diff --git a/Cargo.toml b/Cargo.toml index a2bc5362..0a7e7b16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ web-sys = { version = "0.3.81", features = [ 'console', 'CanvasRenderingContext2d', 'Document', + 'DomRect', 'Element', 'HtmlCanvasElement', 'HtmlElement', diff --git a/README.md b/README.md index 1e250514..acd08f46 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ use ratzilla::{event::KeyCode, DomBackend, WebRenderer}; fn main() -> io::Result<()> { let counter = Rc::new(RefCell::new(0)); let backend = DomBackend::new()?; - let terminal = Terminal::new(backend)?; + let mut terminal = Terminal::new(backend)?; terminal.on_key_event({ let counter_cloned = counter.clone(); @@ -72,7 +72,7 @@ fn main() -> io::Result<()> { *counter += 1; } } - }); + })?; terminal.draw_web(move |f| { let counter = counter.borrow(); diff --git a/examples/canvas_stress_test/src/main.rs b/examples/canvas_stress_test/src/main.rs index 602540fa..22fc4978 100644 --- a/examples/canvas_stress_test/src/main.rs +++ b/examples/canvas_stress_test/src/main.rs @@ -20,7 +20,7 @@ use std::{cell::RefCell, rc::Rc}; fn main() -> std::io::Result<()> { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::WebGl2) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::WebGl2) .webgl2_options(WebGl2BackendOptions::new().measure_performance(true)) .build_terminal()?; @@ -35,7 +35,7 @@ fn main() -> std::io::Result<()> { let current = text_style_key_event.as_ref(); let next = current.borrow().clone() + 1; *current.borrow_mut() = next % WidgetCache::SCREEN_TYPES; - }); + })?; // Pre-generate widgets for better performance; in particular, // this avoids excessive GC pressure in the JS heap. diff --git a/examples/clipboard/src/main.rs b/examples/clipboard/src/main.rs index 70f186d5..5857aa4b 100644 --- a/examples/clipboard/src/main.rs +++ b/examples/clipboard/src/main.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::Alignment, style::{Color, Stylize}, widgets::{Block, BorderType, Paragraph}, - Frame, Terminal, + Frame, }; use ratzilla::{ @@ -15,7 +15,7 @@ use examples_shared::backend::{BackendType, MultiBackendBuilder}; fn main() -> io::Result<()> { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) .build_terminal()?; let state = Rc::new(App::default()); @@ -25,7 +25,7 @@ fn main() -> io::Result<()> { wasm_bindgen_futures::spawn_local( async move { event_state.handle_events(key_event).await }, ); - }); + })?; let render_state = Rc::clone(&state); terminal.draw_web(move |frame| { diff --git a/examples/demo/src/main.rs b/examples/demo/src/main.rs index a42a70a1..37e4c794 100644 --- a/examples/demo/src/main.rs +++ b/examples/demo/src/main.rs @@ -12,7 +12,7 @@ use app::App; use examples_shared::backend::{BackendType, MultiBackendBuilder}; use ratzilla::event::KeyCode; use ratzilla::WebRenderer; -use ratzilla::{backend::canvas::CanvasBackendOptions, backend::webgl2::WebGl2BackendOptions}; +use ratzilla::{backend::canvas::CanvasBackendOptions, backend::webgl2::{SelectionMode, WebGl2BackendOptions}}; mod app; @@ -28,10 +28,10 @@ fn main() -> Result<()> { let webgl2_options = WebGl2BackendOptions::new() .measure_performance(true) .enable_console_debug_api() - .enable_mouse_selection() + .enable_mouse_selection_with_mode(SelectionMode::default()) .size((1600, 900)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::WebGl2) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::WebGl2) .canvas_options(canvas_options) .webgl2_options(webgl2_options) .build_terminal()?; @@ -57,7 +57,7 @@ fn main() -> Result<()> { _ => {} } } - }); + })?; terminal.draw_web(move |f| { let mut app_state = app_state.borrow_mut(); diff --git a/examples/demo2/src/main.rs b/examples/demo2/src/main.rs index 1c12abf0..a344960c 100644 --- a/examples/demo2/src/main.rs +++ b/examples/demo2/src/main.rs @@ -29,7 +29,7 @@ use std::{cell::RefCell, rc::Rc}; use app::App; use ratzilla::{ - backend::webgl2::WebGl2BackendOptions, + backend::webgl2::{SelectionMode, WebGl2BackendOptions}, ratatui::{layout::Rect, TerminalOptions, Viewport}, WebRenderer, }; @@ -47,10 +47,10 @@ fn main() -> std::io::Result<()> { // using vhs in a 1280x640 sized window (github social preview size) let viewport = Viewport::Fixed(Rect::new(0, 0, 81, 18)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Canvas) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Canvas) .webgl2_options(WebGl2BackendOptions::new() .measure_performance(true) - .enable_mouse_selection() + .enable_mouse_selection_with_mode(SelectionMode::default()) .enable_console_debug_api() ) .terminal_options(TerminalOptions { viewport }) @@ -62,7 +62,7 @@ fn main() -> std::io::Result<()> { move |key_event| { app.borrow_mut().handle_key_press(key_event); } - }); + })?; terminal.draw_web(move |f| { let app = app.borrow_mut(); app.draw(f); diff --git a/examples/demo2/src/tabs/weather.rs b/examples/demo2/src/tabs/weather.rs index 5dbea9a4..3c988740 100644 --- a/examples/demo2/src/tabs/weather.rs +++ b/examples/demo2/src/tabs/weather.rs @@ -3,7 +3,7 @@ use palette::Okhsv; use ratzilla::ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Margin, Rect}, - style::{Color, Style, Stylize}, + style::{Color, Style}, symbols, widgets::{ calendar::{CalendarEventStore, Monthly}, diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index 216c3a03..8a1b7cc6 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -6,9 +6,7 @@ use ratzilla::ratatui::{ widgets::{Block, Paragraph}, }; -use ratzilla::{ - event::KeyCode, event::MouseButton, event::MouseEventKind, WebRenderer, -}; +use ratzilla::{event::KeyCode, event::MouseButton, event::MouseEventKind, SelectionMode, WebRenderer}; use examples_shared::backend::{BackendType, MultiBackendBuilder}; use ratzilla::backend::webgl2::WebGl2BackendOptions; @@ -19,10 +17,10 @@ fn main() -> io::Result<()> { let mouse_button = Rc::new(RefCell::new(None::)); let mouse_event_kind = Rc::new(RefCell::new(None::)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) .webgl2_options(WebGl2BackendOptions::new() .enable_console_debug_api() - .enable_mouse_selection() + .enable_mouse_selection_with_mode(SelectionMode::Block) ) .build_terminal()?; @@ -34,21 +32,32 @@ fn main() -> io::Result<()> { *counter += 1; } } - }); + })?; terminal.on_mouse_event({ let mouse_position_cloned = mouse_position.clone(); let mouse_button_cloned = mouse_button.clone(); let mouse_event_kind_cloned = mouse_event_kind.clone(); move |mouse_event| { + let btn = match mouse_event.kind { + MouseEventKind::Moved => None, + MouseEventKind::ButtonDown(btn) => Some(btn), + MouseEventKind::ButtonUp(btn) => Some(btn), + _ => return + }; + + let mut mouse_position = mouse_position_cloned.borrow_mut(); - *mouse_position = (mouse_event.x, mouse_event.y); + *mouse_position = (mouse_event.col, mouse_event.row); let mut mouse_button = mouse_button_cloned.borrow_mut(); - *mouse_button = Some(mouse_event.button); + *mouse_button = btn; let mut mouse_event_kind = mouse_event_kind_cloned.borrow_mut(); - *mouse_event_kind = Some(mouse_event.event); + *mouse_event_kind = Some(mouse_event.kind); } - }); + })?; + + // Gruvbox bright orange + const HOVER_BG: Color = Color::Rgb(254, 128, 25); terminal.draw_web(move |f| { let counter = counter.borrow(); @@ -74,6 +83,13 @@ fn main() -> io::Result<()> { ), f.area(), ); + + // Highlight the hovered cell + let (col, row) = *mouse_position; + let area = f.area(); + if col < area.width && row < area.height { + f.buffer_mut()[(col, row)].set_bg(HOVER_BG); + } }); Ok(()) diff --git a/examples/pong/src/main.rs b/examples/pong/src/main.rs index d480a4f8..6f7d9deb 100644 --- a/examples/pong/src/main.rs +++ b/examples/pong/src/main.rs @@ -14,7 +14,7 @@ use ratzilla::ratatui::{ use examples_shared::backend::{BackendType, MultiBackendBuilder}; use ratzilla::backend::canvas::CanvasBackendOptions; use ratzilla::backend::dom::DomBackendOptions; -use ratzilla::backend::webgl2::WebGl2BackendOptions; +use ratzilla::backend::webgl2::{SelectionMode, WebGl2BackendOptions}; struct App { count: u64, @@ -65,11 +65,11 @@ fn main() -> std::io::Result<()> { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); let app_state = Rc::new(RefCell::new(App::new())); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) .webgl2_options(WebGl2BackendOptions::new() .grid_id("container") .enable_hyperlinks() - .enable_mouse_selection() + .enable_mouse_selection_with_mode(SelectionMode::default()) ) .canvas_options(CanvasBackendOptions::new() .grid_id("container") @@ -96,7 +96,7 @@ fn main() -> std::io::Result<()> { _ => {} } } - }); + })?; terminal.draw_web(move |f| { let mut app_state = app_state.borrow_mut(); app_state.count += 1; diff --git a/examples/shared/src/backend.rs b/examples/shared/src/backend.rs index 5f40810e..c8ccbde1 100644 --- a/examples/shared/src/backend.rs +++ b/examples/shared/src/backend.rs @@ -1,8 +1,10 @@ use crate::{fps, utils::inject_backend_footer}; use ratzilla::{ backend::{canvas::CanvasBackendOptions, dom::DomBackendOptions, webgl2::WebGl2BackendOptions}, + error::Error, + event::{KeyEvent, MouseEvent}, ratatui::{backend::Backend, prelude::backend::ClearType, Terminal, TerminalOptions}, - CanvasBackend, DomBackend, WebGl2Backend, + CanvasBackend, DomBackend, WebEventHandler, WebGl2Backend, }; use std::{convert::TryFrom, fmt, io}; use web_sys::{window, Url}; @@ -173,6 +175,46 @@ impl Backend for RatzillaBackend { } } +impl WebEventHandler for RatzillaBackend { + fn on_mouse_event(&mut self, callback: F) -> Result<(), Error> + where + F: FnMut(MouseEvent) + 'static, + { + match self { + RatzillaBackend::Dom(backend) => backend.on_mouse_event(callback), + RatzillaBackend::Canvas(backend) => backend.on_mouse_event(callback), + RatzillaBackend::WebGl2(backend) => backend.on_mouse_event(callback), + } + } + + fn clear_mouse_events(&mut self) { + match self { + RatzillaBackend::Dom(backend) => backend.clear_mouse_events(), + RatzillaBackend::Canvas(backend) => backend.clear_mouse_events(), + RatzillaBackend::WebGl2(backend) => backend.clear_mouse_events(), + } + } + + fn on_key_event(&mut self, callback: F) -> Result<(), Error> + where + F: FnMut(KeyEvent) + 'static, + { + match self { + RatzillaBackend::Dom(backend) => backend.on_key_event(callback), + RatzillaBackend::Canvas(backend) => backend.on_key_event(callback), + RatzillaBackend::WebGl2(backend) => backend.on_key_event(callback), + } + } + + fn clear_key_events(&mut self) { + match self { + RatzillaBackend::Dom(backend) => backend.clear_key_events(), + RatzillaBackend::Canvas(backend) => backend.clear_key_events(), + RatzillaBackend::WebGl2(backend) => backend.clear_key_events(), + } + } +} + /// Backend wrapper that automatically tracks FPS by recording frames on each flush. /// /// This wrapper delegates all Backend trait methods to the inner RatzillaBackend @@ -264,6 +306,30 @@ impl Backend for FpsTrackingBackend { } } +impl WebEventHandler for FpsTrackingBackend { + fn on_mouse_event(&mut self, callback: F) -> Result<(), Error> + where + F: FnMut(MouseEvent) + 'static, + { + self.inner.on_mouse_event(callback) + } + + fn clear_mouse_events(&mut self) { + self.inner.clear_mouse_events() + } + + fn on_key_event(&mut self, callback: F) -> Result<(), Error> + where + F: FnMut(KeyEvent) + 'static, + { + self.inner.on_key_event(callback) + } + + fn clear_key_events(&mut self) { + self.inner.clear_key_events() + } +} + /// Builder for creating terminals with different backend types and configuration options. /// /// This builder provides a fluent API for configuring terminal and backend options diff --git a/examples/tauri/src/main.rs b/examples/tauri/src/main.rs index fd92a2a8..640c3c0d 100644 --- a/examples/tauri/src/main.rs +++ b/examples/tauri/src/main.rs @@ -10,7 +10,6 @@ use ratzilla::{ use examples_shared::backend::{BackendType, MultiBackendBuilder}; use tachyonfx::{ fx, CenteredShrink, Duration, Effect, EffectRenderer, EffectTimer, Interpolation, Motion, - Shader, }; fn main() -> io::Result<()> { diff --git a/examples/text_area/src/main.rs b/examples/text_area/src/main.rs index 3366fcc5..483f57fc 100644 --- a/examples/text_area/src/main.rs +++ b/examples/text_area/src/main.rs @@ -13,7 +13,7 @@ use ratzilla::{ fn main() -> io::Result<()> { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom).build_terminal()?; + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Dom).build_terminal()?; let app = Rc::new(RefCell::new(App::new())); @@ -23,7 +23,7 @@ fn main() -> io::Result<()> { let mut state = event_state.borrow_mut(); state.handle_events(key_event); } - }); + })?; terminal.draw_web({ let render_state = app.clone(); diff --git a/examples/user_input/src/main.rs b/examples/user_input/src/main.rs index 0dc305bf..9f1f4b4b 100644 --- a/examples/user_input/src/main.rs +++ b/examples/user_input/src/main.rs @@ -14,7 +14,7 @@ use ratzilla::ratatui::{ use ratzilla::{event::KeyCode, WebRenderer}; use examples_shared::backend::{BackendType, MultiBackendBuilder}; use ratzilla::backend::dom::DomBackendOptions; -use ratzilla::backend::webgl2::WebGl2BackendOptions; +use ratzilla::backend::webgl2::{SelectionMode, WebGl2BackendOptions}; fn main() -> io::Result<()> { let dom_options = DomBackendOptions::new(None, CursorShape::SteadyUnderScore); @@ -22,9 +22,9 @@ fn main() -> io::Result<()> { let webgl2_options = WebGl2BackendOptions::new() .cursor_shape(CursorShape::SteadyUnderScore) .enable_console_debug_api() - .enable_mouse_selection(); + .enable_mouse_selection_with_mode(SelectionMode::default()); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) .dom_options(dom_options) .webgl2_options(webgl2_options) .build_terminal()?; @@ -37,7 +37,7 @@ fn main() -> io::Result<()> { let mut state = event_state.borrow_mut(); state.handle_events(key_event); } - }); + })?; terminal.draw_web({ let render_state = app.clone(); diff --git a/examples/website/src/main.rs b/examples/website/src/main.rs index 39382d22..230df4c1 100644 --- a/examples/website/src/main.rs +++ b/examples/website/src/main.rs @@ -16,7 +16,7 @@ use tachyonfx::{ fx::{self, RepeatMode}, CenteredShrink, Duration, Effect, EffectRenderer, EffectTimer, Interpolation, Motion, }; -use ratzilla::backend::webgl2::WebGl2BackendOptions; +use ratzilla::backend::webgl2::{SelectionMode, WebGl2BackendOptions}; struct State { intro_effect: Effect, @@ -56,15 +56,15 @@ impl Default for State { fn main() -> io::Result<()> { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); - let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) + let mut terminal = MultiBackendBuilder::with_fallback(BackendType::Dom) .webgl2_options(WebGl2BackendOptions::new() .enable_hyperlinks() - .enable_mouse_selection() + .enable_mouse_selection_with_mode(SelectionMode::default()) ) .build_terminal()?; - + let mut state = State::default(); - terminal.on_key_event(move |key| handle_key_event(key)); + terminal.on_key_event(move |key| handle_key_event(key))?; terminal.draw_web(move |f| ui(f, &mut state)); Ok(()) } diff --git a/examples/world_map/src/main.rs b/examples/world_map/src/main.rs index 2548dac0..a1933c59 100644 --- a/examples/world_map/src/main.rs +++ b/examples/world_map/src/main.rs @@ -5,7 +5,6 @@ use ratzilla::ratatui::{ widgets, widgets::canvas, style::Color, - Terminal, }; use ratzilla::{WebRenderer}; diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index ea6432ab..48594ae2 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -5,9 +5,14 @@ use std::io::{Error as IoError, Result as IoResult}; use crate::{ backend::{ color::{actual_bg_color, actual_fg_color}, + event_callback::{ + create_mouse_event, EventCallback, MouseConfig, KEY_EVENT_TYPES, MOUSE_EVENT_TYPES, + }, utils::*, }, error::Error, + event::{KeyEvent, MouseEvent}, + render::WebEventHandler, CursorShape, }; use ratatui::{ @@ -136,8 +141,15 @@ pub struct CanvasBackend { cursor_shape: CursorShape, /// Draw cell boundaries with specified color. debug_mode: Option, + /// Mouse event callback handler. + mouse_callback: Option, + /// Key event callback handler. + key_callback: Option>, } +/// Type alias for mouse event callback state. +type MouseCallbackState = EventCallback; + impl CanvasBackend { /// Constructs a new [`CanvasBackend`]. pub fn new() -> Result { @@ -175,6 +187,8 @@ impl CanvasBackend { cursor_position: None, cursor_shape: CursorShape::SteadyBlock, debug_mode: None, + mouse_callback: None, + key_callback: None, }) } @@ -548,11 +562,82 @@ impl Backend for CanvasBackend { } } +impl WebEventHandler for CanvasBackend { + fn on_mouse_event(&mut self, mut callback: F) -> Result<(), Error> + where + F: FnMut(MouseEvent) + 'static, + { + // Clear any existing handlers first + self.clear_mouse_events(); + + // Get grid dimensions from the buffer + let grid_width = self.buffer[0].len() as u16; + let grid_height = self.buffer.len() as u16; + + // Configure coordinate translation for canvas backend + let config = MouseConfig::new(grid_width, grid_height) + .with_offset(5.0) // Canvas translation offset + .with_cell_dimensions(CELL_WIDTH, CELL_HEIGHT); + + let element: web_sys::Element = self.canvas.inner.clone().into(); + let element_for_closure = element.clone(); + + // Create mouse event callback + let mouse_callback = EventCallback::new( + element, + MOUSE_EVENT_TYPES, + move |event: web_sys::MouseEvent| { + let mouse_event = create_mouse_event(&event, &element_for_closure, &config); + callback(mouse_event); + }, + )?; + + self.mouse_callback = Some(mouse_callback); + + Ok(()) + } + + fn clear_mouse_events(&mut self) { + // Drop the callback, which will remove the event listeners + self.mouse_callback = None; + } + + fn on_key_event(&mut self, mut callback: F) -> Result<(), Error> + where + F: FnMut(KeyEvent) + 'static, + { + // Clear any existing handlers first + self.clear_key_events(); + + let element: web_sys::Element = self.canvas.inner.clone().into(); + + // Make the canvas focusable so it can receive key events + self.canvas + .inner + .set_attribute("tabindex", "0") + .map_err(Error::from)?; + + self.key_callback = Some(EventCallback::new( + element, + KEY_EVENT_TYPES, + move |event: web_sys::KeyboardEvent| { + callback(event.into()); + }, + )?); + + Ok(()) + } + + fn clear_key_events(&mut self) { + self.key_callback = None; + } +} + /// Optimizes canvas rendering by batching adjacent cells with the same color into a single rectangle. /// /// This reduces the number of draw calls to the canvas API by coalescing adjacent cells /// with identical colors into larger rectangles, which is particularly beneficial for -/// WASM where calls are quiteexpensive. +/// WASM where calls are quite expensive. struct RowColorOptimizer { /// The currently accumulating region and its color pending_region: Option<(Rect, Color)>, diff --git a/src/backend/dom.rs b/src/backend/dom.rs index 08d8f353..bc32abb5 100644 --- a/src/backend/dom.rs +++ b/src/backend/dom.rs @@ -10,14 +10,22 @@ use ratatui::{ layout::{Position, Size}, prelude::{backend::ClearType, Backend}, }; -use web_sys::{ - wasm_bindgen::{prelude::Closure, JsCast}, - window, Document, Element, Window, -}; +use web_sys::{window, Document, Element, Window}; use unicode_width::UnicodeWidthStr; -use crate::{backend::utils::*, error::Error, CursorShape}; +use crate::{ + backend::{ + event_callback::{ + create_mouse_event, EventCallback, MouseConfig, KEY_EVENT_TYPES, MOUSE_EVENT_TYPES, + }, + utils::*, + }, + error::Error, + event::{KeyEvent, MouseEvent}, + render::WebEventHandler, + CursorShape, +}; /// Default cell size used as a fallback when measurement fails. const DEFAULT_CELL_SIZE: (f64, f64) = (10.0, 20.0); @@ -64,7 +72,6 @@ impl DomBackendOptions { /// /// In other words, it transforms the [`Cell`]s into ``s which are then /// appended to a `
` element.
-#[derive(Debug)]
 pub struct DomBackend {
     /// Whether the backend has been initialized.
     initialized: Rc>,
@@ -88,6 +95,30 @@ pub struct DomBackend {
     size: Size,
     /// Measured cell dimensions in pixels (width, height).
     cell_size: (f64, f64),
+    /// Resize event callback handler.
+    resize_callback: EventCallback,
+    /// Mouse event callback handler.
+    mouse_callback: Option,
+    /// Key event callback handler.
+    key_callback: Option>,
+}
+
+/// Type alias for mouse event callback state.
+type DomMouseCallbackState = EventCallback;
+
+impl std::fmt::Debug for DomBackend {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("DomBackend")
+            .field("initialized", &self.initialized)
+            .field("cells", &format!("[{} cells]", self.cells.len()))
+            .field("size", &self.size)
+            .field("cell_size", &self.cell_size)
+            .field("cursor_position", &self.cursor_position)
+            .field("resize_callback", &"...")
+            .field("mouse_callback", &self.mouse_callback.is_some())
+            .field("key_callback", &self.key_callback.is_some())
+            .finish()
+    }
 }
 
 impl DomBackend {
@@ -118,8 +149,19 @@ impl DomBackend {
         let cell_size =
             Self::measure_cell_size(&document, &grid_parent).unwrap_or(DEFAULT_CELL_SIZE);
         let size = Self::calculate_size(&grid_parent, cell_size);
+
+        let initialized = Rc::new(RefCell::new(false));
+        let initialized_cb = initialized.clone();
+        let resize_callback = EventCallback::new(
+            window.clone(),
+            Self::RESIZE_EVENT_TYPES,
+            move |_: web_sys::Event| {
+                initialized_cb.replace(false);
+            },
+        )?;
+
         let mut backend = Self {
-            initialized: Rc::new(RefCell::new(false)),
+            initialized,
             cells: vec![],
             grid: document.create_element("div")?,
             grid_parent,
@@ -130,8 +172,10 @@ impl DomBackend {
             last_cursor_position: None,
             size,
             cell_size,
+            resize_callback,
+            mouse_callback: None,
+            key_callback: None,
         };
-        backend.add_on_resize_listener();
         backend.reset_grid()?;
         Ok(backend)
     }
@@ -183,16 +227,8 @@ impl DomBackend {
         Size::new((w / cell_size.0) as u16, (h / cell_size.1) as u16)
     }
 
-    /// Add a listener to the window resize event.
-    fn add_on_resize_listener(&mut self) {
-        let initialized = self.initialized.clone();
-        let closure = Closure::::new(move |_: web_sys::Event| {
-            initialized.replace(false);
-        });
-        self.window
-            .set_onresize(Some(closure.as_ref().unchecked_ref()));
-        closure.forget();
-    }
+    /// Resize event types.
+    const RESIZE_EVENT_TYPES: &[&str] = &["resize"];
 
     /// Reset the grid and clear the cells.
     fn reset_grid(&mut self) -> Result<(), Error> {
@@ -355,9 +391,10 @@ impl Backend for DomBackend {
     }
 
     fn size(&self) -> IoResult {
+        let size = get_size();
         Ok(Size::new(
-            self.size.width.saturating_sub(1),
-            self.size.height.saturating_sub(1),
+            size.width.saturating_sub(1),
+            size.height.saturating_sub(1),
         ))
     }
 
@@ -393,3 +430,63 @@ impl Backend for DomBackend {
         }
     }
 }
+
+impl WebEventHandler for DomBackend {
+    fn on_mouse_event(&mut self, mut callback: F) -> Result<(), Error>
+    where
+        F: FnMut(MouseEvent) + 'static,
+    {
+        // Clear any existing handlers first
+        self.clear_mouse_events();
+
+        // Configure coordinate translation for DOM backend
+        // Cell dimensions are derived from element dimensions / grid size
+        let config = MouseConfig::new(self.size.width, self.size.height);
+
+        // Use the grid element for coordinate calculation
+        let element = self.grid.clone();
+
+        // Create mouse event callback
+        let mouse_callback = EventCallback::new(
+            self.grid.clone(),
+            MOUSE_EVENT_TYPES,
+            move |event: web_sys::MouseEvent| {
+                let mouse_event = create_mouse_event(&event, &element, &config);
+                callback(mouse_event);
+            },
+        )?;
+
+        self.mouse_callback = Some(mouse_callback);
+
+        Ok(())
+    }
+
+    fn clear_mouse_events(&mut self) {
+        self.mouse_callback = None;
+    }
+
+    fn on_key_event(&mut self, mut callback: F) -> Result<(), Error>
+    where
+        F: FnMut(KeyEvent) + 'static,
+    {
+        // Clear any existing handlers first
+        self.clear_key_events();
+
+        // Make the grid element focusable so it can receive key events
+        self.grid.set_attribute("tabindex", "0")?;
+
+        self.key_callback = Some(EventCallback::new(
+            self.grid.clone(),
+            KEY_EVENT_TYPES,
+            move |event: web_sys::KeyboardEvent| {
+                callback(event.into());
+            },
+        )?);
+
+        Ok(())
+    }
+
+    fn clear_key_events(&mut self) {
+        self.key_callback = None;
+    }
+}
diff --git a/src/backend/event_callback.rs b/src/backend/event_callback.rs
new file mode 100644
index 00000000..645bc4ad
--- /dev/null
+++ b/src/backend/event_callback.rs
@@ -0,0 +1,223 @@
+//! Event callback management with automatic cleanup.
+//!
+//! This module provides utilities for managing web event listeners with proper
+//! lifecycle management and coordinate translation for mouse events.
+
+use std::fmt::Formatter;
+use web_sys::{
+    wasm_bindgen::{convert::FromWasmAbi, prelude::Closure, JsCast},
+    Element, EventTarget,
+};
+
+use crate::{
+    error::Error,
+    event::{MouseButton, MouseEvent, MouseEventKind},
+};
+
+/// Manages web event listeners with automatic cleanup.
+///
+/// When this struct is dropped, all registered event listeners are removed
+/// from the element, preventing memory leaks.
+pub(super) struct EventCallback {
+    /// The event types this callback is registered for.
+    event_types: &'static [&'static str],
+    /// The event target the listeners are attached to.
+    target: EventTarget,
+    /// The closure that handles the events.
+    #[allow(dead_code)]
+    closure: Closure,
+}
+
+impl EventCallback {
+    /// Creates a new [`EventCallback`] and attaches listeners to the element.
+    pub fn new(
+        target: impl Into,
+        event_types: &'static [&'static str],
+        callback: F,
+    ) -> Result
+    where
+        F: FnMut(T) + 'static,
+        T: JsCast + FromWasmAbi,
+    {
+        let target = target.into();
+        let closure = Closure::::new(callback);
+
+        for event_type in event_types {
+            target
+                .add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())?;
+        }
+
+        Ok(Self {
+            event_types,
+            target,
+            closure,
+        })
+    }
+}
+
+impl Drop for EventCallback {
+    fn drop(&mut self) {
+        for event_type in self.event_types {
+            let _ = self.target.remove_event_listener_with_callback(
+                event_type,
+                self.closure.as_ref().unchecked_ref(),
+            );
+        }
+    }
+}
+
+impl std::fmt::Debug for EventCallback {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("EventCallback")
+            .field("event_types", &self.event_types)
+            .field("target", &self.target)
+            .finish()
+    }
+}
+
+/// Configuration for mouse coordinate transformation.
+///
+/// This struct holds the information needed to translate raw pixel coordinates
+/// from mouse events into terminal grid coordinates.
+#[derive(Debug, Clone)]
+pub(super) struct MouseConfig {
+    /// Terminal grid width in characters.
+    pub grid_width: u16,
+    /// Terminal grid height in characters.
+    pub grid_height: u16,
+    /// Pixel offset from the element edge (e.g., canvas padding/translation).
+    pub offset: Option,
+    /// Cell dimensions in pixels (width, height).
+    /// If provided, used for pixel-perfect coordinate calculation.
+    pub cell_dimensions: Option<(f64, f64)>,
+}
+
+impl MouseConfig {
+    /// Creates a new [`MouseConfig`] with the given grid dimensions.
+    pub fn new(grid_width: u16, grid_height: u16) -> Self {
+        Self {
+            grid_width,
+            grid_height,
+            offset: None,
+            cell_dimensions: None,
+        }
+    }
+
+    /// Sets the pixel offset from the element edge.
+    pub fn with_offset(mut self, offset: f64) -> Self {
+        self.offset = Some(offset);
+        self
+    }
+
+    /// Sets the cell dimensions in pixels.
+    pub fn with_cell_dimensions(mut self, width: f64, height: f64) -> Self {
+        self.cell_dimensions = Some((width, height));
+        self
+    }
+}
+
+/// The event types for keyboard events.
+pub(super) const KEY_EVENT_TYPES: &[&str] = &["keydown"];
+
+/// Mouse event types (excluding wheel which needs special handling).
+pub(super) const MOUSE_EVENT_TYPES: &[&str] = &[
+    "mousemove",
+    "mousedown",
+    "mouseup",
+    "click",
+    "dblclick",
+    "mouseenter",
+    "mouseleave",
+];
+
+/// Translates mouse event pixel coordinates to terminal grid coordinates.
+///
+/// This function calculates the grid position (col, row) from raw pixel
+/// coordinates, taking into account element positioning and optional offsets.
+fn mouse_to_grid_coords(
+    event: &web_sys::MouseEvent,
+    element: &Element,
+    config: &MouseConfig,
+) -> (u16, u16) {
+    let rect = element.get_bounding_client_rect();
+
+    // Calculate relative position within element
+    let offset = config.offset.unwrap_or(0.0);
+    let relative_x = (event.client_x() as f64 - rect.left() - offset).max(0.0);
+    let relative_y = (event.client_y() as f64 - rect.top() - offset).max(0.0);
+
+    // Calculate drawable area
+    let (drawable_width, drawable_height) = match config.cell_dimensions {
+        Some((cw, ch)) => (
+            config.grid_width as f64 * cw,
+            config.grid_height as f64 * ch,
+        ),
+        None => (rect.width() - 2.0 * offset, rect.height() - 2.0 * offset),
+    };
+
+    // Avoid division by zero
+    if drawable_width <= 0.0 || drawable_height <= 0.0 {
+        return (0, 0);
+    }
+
+    // Map to grid coordinates
+    let col = ((relative_x / drawable_width) * config.grid_width as f64) as u16;
+    let row = ((relative_y / drawable_height) * config.grid_height as f64) as u16;
+
+    // Clamp to bounds
+    (
+        col.min(config.grid_width.saturating_sub(1)),
+        row.min(config.grid_height.saturating_sub(1)),
+    )
+}
+
+/// Converts a web_sys::MouseEvent type string to a MouseEventKind.
+fn event_type_to_kind(event_type: &str, button: MouseButton) -> MouseEventKind {
+    match event_type {
+        "mousemove" => MouseEventKind::Moved,
+        "mousedown" => MouseEventKind::ButtonDown(button),
+        "mouseup" => MouseEventKind::ButtonUp(button),
+        "click" => MouseEventKind::SingleClick(button),
+        "dblclick" => MouseEventKind::DoubleClick(button),
+        "mouseenter" => MouseEventKind::Entered,
+        "mouseleave" => MouseEventKind::Exited,
+        _ => MouseEventKind::Unidentified,
+    }
+}
+
+/// Creates a MouseEvent from web_sys events with coordinate translation.
+pub(super) fn create_mouse_event(
+    event: &web_sys::MouseEvent,
+    element: &Element,
+    config: &MouseConfig,
+) -> MouseEvent {
+    let (col, row) = mouse_to_grid_coords(event, element, config);
+    let button: MouseButton = event.button().into();
+    let event_type = event.type_();
+
+    MouseEvent {
+        kind: event_type_to_kind(&event_type, button),
+        col,
+        row,
+        ctrl: event.ctrl_key(),
+        alt: event.alt_key(),
+        shift: event.shift_key(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_mouse_config_builder() {
+        let config = MouseConfig::new(80, 24)
+            .with_offset(5.0)
+            .with_cell_dimensions(10.0, 19.0);
+
+        assert_eq!(config.grid_width, 80);
+        assert_eq!(config.grid_height, 24);
+        assert_eq!(config.offset, Some(5.0));
+        assert_eq!(config.cell_dimensions, Some((10.0, 19.0)));
+    }
+}
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 75d12a92..617856ee 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -30,6 +30,7 @@
 //! | **Underline**                | ✓          | ✗             | ✓              |
 //! | **Strikethrough**            | ✓          | ✗             | ✓              |
 //! | **Browser Support**          | All        | All           | Modern (2017+) |
+//! | **Mouse Events**             | Full       | Full          | Basic          |
 //!
 //! ¹: The [dynamic font atlas](webgl2::FontAtlasConfig::Dynamic) rasterizes
 //!    glyphs on demand with full Unicode/emoji and font variant support. The
@@ -38,6 +39,22 @@
 //! ²: Unicode is supported, but emoji only render correctly when it spans one cell.
 //!    Most emoji occupy two cells.
 //!
+//! ### Mouse Event Support
+//!
+//! All backends support [`WebEventHandler`] for mouse events with grid coordinate translation.
+//!
+//! | Event Type      | DomBackend | CanvasBackend | WebGl2Backend |
+//! |-----------------|------------|---------------|---------------|
+//! | `Moved`         | ✓          | ✓             | ✓             |
+//! | `ButtonDown`    | ✓          | ✓             | ✓             |
+//! | `ButtonUp`      | ✓          | ✓             | ✓             |
+//! | `SingleClick`   | ✓          | ✓             | ✗             |
+//! | `DoubleClick`   | ✓          | ✓             | ✗             |
+//! | `Entered`       | ✓          | ✓             | ✗             |
+//! | `Exited`        | ✓          | ✓             | ✗             |
+//!
+//! [`WebEventHandler`]: crate::WebEventHandler
+//!
 //! ## Choosing a Backend
 //!
 //! - **WebGl2Backend**: Preferred for most applications - consumes the least amount of resources
@@ -55,6 +72,8 @@ pub mod webgl2;
 
 /// Color handling.
 mod color;
+/// Event callback management.
+pub(super) mod event_callback;
 /// Backend utilities.
 pub(crate) mod utils;
 
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index b688cb86..42b8b398 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -1,6 +1,12 @@
 use crate::{
-    backend::{color::to_rgb, utils::*},
+    backend::{
+        color::to_rgb,
+        event_callback::{EventCallback, KEY_EVENT_TYPES},
+        utils::*,
+    },
     error::Error,
+    event::{KeyEvent, MouseEvent},
+    render::WebEventHandler,
     widgets::hyperlink::HYPERLINK_MODIFIER,
     CursorShape,
 };
@@ -297,6 +303,10 @@ pub struct WebGl2Backend {
     cursor_over_hyperlink: Option>>,
     /// Hyperlink click callback.
     _hyperlink_callback: Option,
+    /// User-provided mouse event handler.
+    _user_mouse_handler: Option,
+    /// User-provided key event handler.
+    _user_key_handler: Option>,
 }
 
 impl WebGl2Backend {
@@ -362,7 +372,7 @@ impl WebGl2Backend {
             None
         };
 
-        Ok(Self {
+        let mut backend = Self {
             beamterm,
             cursor_position: None,
             options,
@@ -371,7 +381,14 @@ impl WebGl2Backend {
             performance,
             cursor_over_hyperlink,
             _hyperlink_callback: hyperlink_callback,
-        })
+            _user_mouse_handler: None,
+            _user_key_handler: None,
+        };
+
+        // Convert handler metrics from physical pixels to CSS pixels
+        backend.update_mouse_handler_metrics();
+
+        Ok(backend)
     }
 
     /// Returns the options objects used to create this backend.
@@ -413,9 +430,33 @@ impl WebGl2Backend {
             }
         }
 
+        self.update_mouse_handler_metrics();
+
         Ok(())
     }
 
+    /// Updates metrics on externally-managed mouse handlers after resize or DPR changes.
+    ///
+    /// Beamterm's `Terminal::resize()` only updates its own internal mouse handler.
+    /// The user and hyperlink handlers created by ratzilla need their metrics updated
+    /// separately.
+    fn update_mouse_handler_metrics(&mut self) {
+        let (cols, rows) = self.beamterm.terminal_size();
+        let (phys_w, phys_h) = self.beamterm.cell_size();
+        let dpr = window()
+            .map(|w| w.device_pixel_ratio() as f32)
+            .unwrap_or(1.0);
+        let cell_width = phys_w as f32 / dpr;
+        let cell_height = phys_h as f32 / dpr;
+
+        if let Some(handler) = &mut self._user_mouse_handler {
+            handler.update_metrics(cols, rows, cell_width, cell_height);
+        }
+        if let Some(handler) = &mut self._hyperlink_mouse_handler {
+            handler.update_metrics(cols, rows, cell_width, cell_height);
+        }
+    }
+
     /// Checks if the canvas size matches the display size and resizes it if necessary.
     fn check_canvas_resize(&mut self) -> Result<(), Error> {
         let canvas = self.beamterm.canvas();
@@ -929,6 +970,135 @@ impl std::fmt::Debug for HyperlinkCallback {
     }
 }
 
+/// Event handling for [`WebGl2Backend`].
+///
+/// This implementation delegates mouse events to beamterm's [`TerminalMouseHandler`],
+/// which provides native grid coordinate translation. However, beamterm only supports
+/// a subset of mouse events:
+///
+/// | Supported | Event Type                      |
+/// | --------- | ------------------------------- |
+/// | ✓         | [`MouseEventKind::Moved`]       |
+/// | ✓         | [`MouseEventKind::ButtonDown`]  |
+/// | ✓         | [`MouseEventKind::ButtonUp`]    |
+/// | ✗         | [`MouseEventKind::SingleClick`] |
+/// | ✗         | [`MouseEventKind::DoubleClick`] |
+/// | ✗         | [`MouseEventKind::Entered`]     |
+/// | ✗         | [`MouseEventKind::Exited`]      |
+///
+/// For full mouse event support, consider using [`CanvasBackend`] or [`DomBackend`].
+///
+/// Keyboard events are supported by making the canvas focusable with `tabindex="0"`.
+///
+/// [`CanvasBackend`]: crate::CanvasBackend
+/// [`DomBackend`]: crate::DomBackend
+/// [`MouseEventKind::Moved`]: crate::event::MouseEventKind::Moved
+/// [`MouseEventKind::ButtonDown`]: crate::event::MouseEventKind::ButtonDown
+/// [`MouseEventKind::ButtonUp`]: crate::event::MouseEventKind::ButtonUp
+/// [`MouseEventKind::SingleClick`]: crate::event::MouseEventKind::SingleClick
+/// [`MouseEventKind::DoubleClick`]: crate::event::MouseEventKind::DoubleClick
+/// [`MouseEventKind::Entered`]: crate::event::MouseEventKind::Entered
+/// [`MouseEventKind::Exited`]: crate::event::MouseEventKind::Exited
+impl WebEventHandler for WebGl2Backend {
+    fn on_mouse_event(&mut self, callback: F) -> Result<(), Error>
+    where
+        F: FnMut(MouseEvent) + 'static,
+    {
+        // Clear any existing handlers first
+        self.clear_mouse_events();
+
+        let grid = self.beamterm.grid();
+        let canvas = self.beamterm.canvas();
+
+        // Wrap the callback in Rc for sharing
+        let callback = Rc::new(RefCell::new(callback));
+        let callback_clone = callback.clone();
+
+        // Create a TerminalMouseHandler that delegates to our callback
+        let mouse_handler = TerminalMouseHandler::new(
+            canvas,
+            grid,
+            move |event: TerminalMouseEvent, _grid: &beamterm_renderer::TerminalGrid| {
+                let mouse_event = MouseEvent::from(&event);
+                if let Ok(mut cb) = callback_clone.try_borrow_mut() {
+                    cb(mouse_event);
+                }
+            },
+        )?;
+
+        self._user_mouse_handler = Some(mouse_handler);
+
+        // TerminalMouseHandler is constructed with physical pixel metrics;
+        // convert to CSS pixels so coordinate translation is correct on HiDPI.
+        self.update_mouse_handler_metrics();
+
+        Ok(())
+    }
+
+    fn clear_mouse_events(&mut self) {
+        self._user_mouse_handler = None;
+    }
+
+    fn on_key_event(&mut self, mut callback: F) -> Result<(), Error>
+    where
+        F: FnMut(KeyEvent) + 'static,
+    {
+        // Clear any existing handlers first
+        self.clear_key_events();
+
+        let canvas = self.beamterm.canvas();
+        let element: web_sys::Element = canvas.clone().into();
+
+        // Make the canvas focusable so it can receive key events
+        canvas.set_attribute("tabindex", "0").map_err(Error::from)?;
+
+        self._user_key_handler = Some(EventCallback::new(
+            element,
+            KEY_EVENT_TYPES,
+            move |event: web_sys::KeyboardEvent| {
+                callback(event.into());
+            },
+        )?);
+
+        Ok(())
+    }
+
+    fn clear_key_events(&mut self) {
+        self._user_key_handler = None;
+    }
+}
+
+impl From<&TerminalMouseEvent> for MouseEvent {
+    fn from(event: &TerminalMouseEvent) -> Self {
+        use crate::event::{MouseButton, MouseEventKind};
+
+        let button = match event.button() {
+            0 => MouseButton::Left,
+            1 => MouseButton::Middle,
+            2 => MouseButton::Right,
+            3 => MouseButton::Back,
+            4 => MouseButton::Forward,
+            _ => MouseButton::Unidentified,
+        };
+
+        // beamterm only provides MouseMove, MouseDown, and MouseUp events
+        let kind = match event.event_type {
+            MouseEventType::MouseMove => MouseEventKind::Moved,
+            MouseEventType::MouseDown => MouseEventKind::ButtonDown(button),
+            MouseEventType::MouseUp => MouseEventKind::ButtonUp(button),
+        };
+
+        MouseEvent {
+            kind,
+            col: event.col,
+            row: event.row,
+            ctrl: event.ctrl_key(),
+            alt: event.alt_key(),
+            shift: event.shift_key(),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/src/event.rs b/src/event.rs
index 07f9590e..029c551e 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -11,17 +11,19 @@ pub struct KeyEvent {
     pub shift: bool,
 }
 
-/// A mouse movement event.
+/// A mouse event with terminal grid coordinates.
+///
+/// Coordinates are reported as terminal cell positions (`col`, `row`),
+/// not raw pixel coordinates. The origin (0, 0) is the top-left cell
+/// of the terminal grid.
 #[derive(Debug, Clone, Eq, PartialEq)]
 pub struct MouseEvent {
-    /// The mouse button that was pressed.
-    pub button: MouseButton,
-    /// The triggered event.
-    pub event: MouseEventKind,
-    /// The x coordinate of the mouse.
-    pub x: u32,
-    /// The y coordinate of the mouse.
-    pub y: u32,
+    /// The type of mouse event that occurred.
+    pub kind: MouseEventKind,
+    /// The column (x-coordinate) in the terminal grid.
+    pub col: u16,
+    /// The row (y-coordinate) in the terminal grid.
+    pub row: u16,
     /// Whether the control key is pressed.
     pub ctrl: bool,
     /// Whether the alt key is pressed.
@@ -126,7 +128,7 @@ impl From for KeyCode {
 }
 
 /// A mouse button.
-#[derive(Debug, Clone, Eq, PartialEq)]
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
 pub enum MouseButton {
     /// Left mouse button
     Left,
@@ -142,43 +144,27 @@ pub enum MouseButton {
     Unidentified,
 }
 
-/// A mouse event.
-#[derive(Debug, Clone, Eq, PartialEq)]
+/// The type of mouse event that occurred.
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
 pub enum MouseEventKind {
-    /// Mouse moved
+    /// Mouse cursor moved.
     Moved,
-    /// Mouse button pressed
-    Pressed,
-    /// Mouse button released
-    Released,
-    /// Unidentified mouse event
+    /// Mouse button was pressed down.
+    ButtonDown(MouseButton),
+    /// Mouse button was released.
+    ButtonUp(MouseButton),
+    /// Mouse button was clicked (pressed and released).
+    SingleClick(MouseButton),
+    /// Mouse button was double-clicked.
+    DoubleClick(MouseButton),
+    /// Mouse cursor entered the terminal area.
+    Entered,
+    /// Mouse cursor left the terminal area.
+    Exited,
+    /// Unidentified mouse event.
     Unidentified,
 }
 
-/// Convert a [`web_sys::MouseEvent`] to a [`MouseEvent`].
-impl From for MouseEvent {
-    fn from(event: web_sys::MouseEvent) -> Self {
-        let ctrl = event.ctrl_key();
-        let alt = event.alt_key();
-        let shift = event.shift_key();
-        let event_type = event.type_().into();
-        MouseEvent {
-            // Button is only valid if it is a mousedown or mouseup event.
-            button: if event_type == MouseEventKind::Moved {
-                MouseButton::Unidentified
-            } else {
-                event.button().into()
-            },
-            event: event_type,
-            x: event.client_x() as u32,
-            y: event.client_y() as u32,
-            ctrl,
-            alt,
-            shift,
-        }
-    }
-}
-
 /// Convert a [`web_sys::MouseEvent`] to a [`MouseButton`].
 impl From for MouseButton {
     fn from(button: i16) -> Self {
@@ -192,16 +178,3 @@ impl From for MouseButton {
         }
     }
 }
-
-/// Convert a [`web_sys::MouseEvent`] to a [`MouseEventKind`].
-impl From for MouseEventKind {
-    fn from(event: String) -> Self {
-        let event = event.as_str();
-        match event {
-            "mousemove" => MouseEventKind::Moved,
-            "mousedown" => MouseEventKind::Pressed,
-            "mouseup" => MouseEventKind::Released,
-            _ => MouseEventKind::Unidentified,
-        }
-    }
-}
diff --git a/src/lib.rs b/src/lib.rs
index f254e9d1..5c3b3be5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -31,4 +31,4 @@ pub use backend::{
     dom::DomBackend,
     webgl2::{FontAtlasConfig, SelectionMode, WebGl2Backend},
 };
-pub use render::WebRenderer;
+pub use render::{WebEventHandler, WebRenderer};
diff --git a/src/render.rs b/src/render.rs
index bc559ff8..a24c2c71 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -2,20 +2,21 @@ use ratatui::{prelude::Backend, Frame, Terminal};
 use std::{cell::RefCell, rc::Rc};
 use web_sys::{wasm_bindgen::prelude::*, window};
 
-use crate::event::{KeyEvent, MouseEvent};
+use crate::{
+    error::Error,
+    event::{KeyEvent, MouseEvent},
+};
 
 /// Trait for rendering on the web.
 ///
 /// It provides all the necessary methods to render the terminal on the web
-/// and also interact with the browser such as handling key events.
+/// and also interact with the browser such as handling key and mouse events.
 pub trait WebRenderer {
     /// Renders the terminal on the web.
     ///
     /// This method takes a closure that will be called on every update
     /// that the browser makes during [`requestAnimationFrame`] calls.
     ///
-    /// TODO: Clarify and validate this.
-    ///
     /// [`requestAnimationFrame`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
     fn draw_web(self, render_callback: F)
     where
@@ -23,47 +24,28 @@ pub trait WebRenderer {
 
     /// Handles key events.
     ///
-    /// This method takes a closure that will be called on every `keydown`
-    /// event.
-    fn on_key_event(&self, mut callback: F)
+    /// This method takes a closure that will be called on every `keydown` event.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the backend does not support key events or if
+    /// event listener attachment fails.
+    fn on_key_event(&mut self, callback: F) -> Result<(), Error>
     where
-        F: FnMut(KeyEvent) + 'static,
-    {
-        let closure = Closure::::new(move |event: web_sys::KeyboardEvent| {
-            callback(event.into());
-        });
-        let window = window().unwrap();
-        let document = window.document().unwrap();
-        document
-            .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
-            .unwrap();
-        closure.forget();
-    }
+        F: FnMut(KeyEvent) + 'static;
 
     /// Handles mouse events.
     ///
-    /// This method takes a closure that will be called on every `mousemove`, 'mousedown', and `mouseup`
-    /// event.
-    fn on_mouse_event(&self, mut callback: F)
+    /// This method takes a closure that will be called on mouse events.
+    /// The callback receives [`MouseEvent`]s with terminal grid coordinates
+    /// (`col`, `row`) instead of raw pixel coordinates.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if event listener attachment fails.
+    fn on_mouse_event(&mut self, callback: F) -> Result<(), Error>
     where
-        F: FnMut(MouseEvent) + 'static,
-    {
-        let closure = Closure::::new(move |event: web_sys::MouseEvent| {
-            callback(event.into());
-        });
-        let window = window().unwrap();
-        let document = window.document().unwrap();
-        document
-            .add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())
-            .unwrap();
-        document
-            .add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())
-            .unwrap();
-        document
-            .add_event_listener_with_callback("mouseup", closure.as_ref().unchecked_ref())
-            .unwrap();
-        closure.forget();
-    }
+        F: FnMut(MouseEvent) + 'static;
 
     /// Requests an animation frame.
     fn request_animation_frame(f: &Closure) {
@@ -76,10 +58,11 @@ pub trait WebRenderer {
 
 /// Implement [`WebRenderer`] for Ratatui's [`Terminal`].
 ///
-/// This implementation creates a loop that calls the [`Terminal::draw`] method.
+/// This implementation delegates event handling to the backend's
+/// [`WebEventHandler`] implementation.
 impl WebRenderer for Terminal
 where
-    T: Backend + 'static,
+    T: Backend + WebEventHandler + 'static,
 {
     fn draw_web(mut self, mut render_callback: F)
     where
@@ -98,4 +81,94 @@ where
         }) as Box));
         Self::request_animation_frame(callback.borrow().as_ref().unwrap());
     }
+
+    fn on_key_event(&mut self, callback: F) -> Result<(), Error>
+    where
+        F: FnMut(KeyEvent) + 'static,
+    {
+        self.backend_mut().on_key_event(callback)
+    }
+
+    fn on_mouse_event(&mut self, callback: F) -> Result<(), Error>
+    where
+        F: FnMut(MouseEvent) + 'static,
+    {
+        self.backend_mut().on_mouse_event(callback)
+    }
+}
+
+/// Backend-specific event handling with lifecycle management.
+///
+/// This trait provides proper event handling for terminal backends, including:
+///
+/// - Coordinate translation from pixels to terminal grid positions
+/// - Automatic cleanup of event listeners when replaced or dropped
+/// - Extended mouse event support (enter/leave, click/dblclick)
+///
+/// # Example
+///
+/// ```no_run
+/// # fn main() -> Result<(), Box> {
+/// use ratzilla::{CanvasBackend, WebRenderer};
+/// use ratatui::Terminal;
+///
+/// let mut terminal = Terminal::new(CanvasBackend::new()?)?;
+///
+/// // Set up mouse events with grid coordinate translation
+/// terminal.on_mouse_event(|event| {
+///     // event.col and event.row are terminal grid coordinates
+///     println!("Mouse at ({}, {})", event.col, event.row);
+/// })?;
+/// # Ok(())
+/// # }
+/// ```
+pub trait WebEventHandler {
+    /// Sets up mouse event handlers with coordinate translation.
+    ///
+    /// The callback receives [`MouseEvent`]s with terminal grid coordinates
+    /// (`col`, `row`) instead of raw pixel coordinates. Coordinates are
+    /// relative to the terminal element, not the viewport.
+    ///
+    /// Calling this method again will automatically clean up the previous
+    /// event listeners before setting up new ones.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if event listener attachment fails.
+    fn on_mouse_event(&mut self, callback: F) -> Result<(), Error>
+    where
+        F: FnMut(MouseEvent) + 'static;
+
+    /// Removes all mouse event handlers.
+    ///
+    /// This is automatically called when new handlers are set up, but can be
+    /// called manually to stop receiving mouse events.
+    fn clear_mouse_events(&mut self);
+
+    /// Sets up keyboard event handlers.
+    ///
+    /// The callback receives [`KeyEvent`]s for `keydown` events.
+    ///
+    /// Calling this method again will automatically clean up the previous
+    /// event listeners before setting up new ones.
+    ///
+    /// # Note
+    ///
+    ///  Some backends (e.g., [`WebGl2Backend`]) do not support key events
+    /// and will silently succeed without registering any handlers.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if event listener attachment fails.
+    ///
+    /// [`WebGl2Backend`]: crate::WebGl2Backend
+    fn on_key_event(&mut self, callback: F) -> Result<(), Error>
+    where
+        F: FnMut(KeyEvent) + 'static;
+
+    /// Removes all keyboard event handlers.
+    ///
+    /// This is automatically called when new handlers are set up, but can be
+    /// called manually to stop receiving key events.
+    fn clear_key_events(&mut self);
 }