From 58bb00041d0f1e54104fbb5aec4c70c29517fc5e Mon Sep 17 00:00:00 2001 From: Adrian Papari Date: Wed, 28 Jan 2026 20:10:37 +0100 Subject: [PATCH 01/12] mouse events with cell coordinates --- Cargo.toml | 1 + README.md | 4 +- examples/minimal/src/main.rs | 36 +++++- examples/shared/src/backend.rs | 68 ++++++++++- src/backend/canvas.rs | 104 +++++++++++++++- src/backend/dom.rs | 97 ++++++++++++++- src/backend/event_callback.rs | 212 +++++++++++++++++++++++++++++++++ src/backend/mod.rs | 19 +++ src/backend/webgl2.rs | 115 ++++++++++++++++++ src/event.rs | 83 +++++-------- src/lib.rs | 2 +- src/render.rs | 151 ++++++++++++++++------- 12 files changed, 782 insertions(+), 110 deletions(-) create mode 100644 src/backend/event_callback.rs 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/minimal/src/main.rs b/examples/minimal/src/main.rs index 216c3a03..6278df36 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -19,7 +19,7 @@ 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() @@ -34,21 +34,38 @@ 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), + MouseEventKind::SingleClick(_) | + MouseEventKind::DoubleClick(_) | + MouseEventKind::Exited | + MouseEventKind::Entered | + MouseEventKind::Unidentified => { + 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 +91,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/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/src/backend/canvas.rs b/src/backend/canvas.rs index ea6432ab..f072d3f2 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::{ @@ -113,7 +118,6 @@ impl Canvas { /// Canvas backend. /// /// This backend renders the buffer onto a HTML canvas element. -#[derive(Debug)] pub struct CanvasBackend { /// Whether the canvas has been initialized. initialized: bool, @@ -136,6 +140,31 @@ 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 std::fmt::Debug for CanvasBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CanvasBackend") + .field("initialized", &self.initialized) + .field("always_clip_cells", &self.always_clip_cells) + .field( + "buffer", + &format!("[{}x{}]", self.buffer[0].len(), self.buffer.len()), + ) + .field("cursor_position", &self.cursor_position) + .field("cursor_shape", &self.cursor_shape) + .field("debug_mode", &self.debug_mode) + .field("mouse_callback", &self.mouse_callback.is_some()) + .field("key_callback", &self.key_callback.is_some()) + .finish() + } } impl CanvasBackend { @@ -175,6 +204,8 @@ impl CanvasBackend { cursor_position: None, cursor_shape: CursorShape::SteadyBlock, debug_mode: None, + mouse_callback: None, + key_callback: None, }) } @@ -548,6 +579,77 @@ 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 diff --git a/src/backend/dom.rs b/src/backend/dom.rs index 08d8f353..0c09de77 100644 --- a/src/backend/dom.rs +++ b/src/backend/dom.rs @@ -17,7 +17,18 @@ use web_sys::{ 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 +75,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 +98,27 @@ pub struct DomBackend {
     size: Size,
     /// Measured cell dimensions in pixels (width, height).
     cell_size: (f64, f64),
+    /// 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("mouse_callback", &self.mouse_callback.is_some())
+            .field("key_callback", &self.key_callback.is_some())
+            .finish()
+    }
 }
 
 impl DomBackend {
@@ -130,6 +161,8 @@ impl DomBackend {
             last_cursor_position: None,
             size,
             cell_size,
+            mouse_callback: None,
+            key_callback: None,
         };
         backend.add_on_resize_listener();
         backend.reset_grid()?;
@@ -393,3 +426,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..83485731
--- /dev/null
+++ b/src/backend/event_callback.rs
@@ -0,0 +1,212 @@
+//! 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 web_sys::{
+    wasm_bindgen::{convert::FromWasmAbi, prelude::Closure, JsCast},
+    Element,
+};
+
+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 element the listeners are attached to.
+    element: Element,
+    /// 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(
+        element: Element,
+        event_types: &'static [&'static str],
+        callback: F,
+    ) -> Result
+    where
+        F: FnMut(T) + 'static,
+        T: JsCast + FromWasmAbi,
+    {
+        let closure = Closure::::new(callback);
+
+        for event_type in event_types {
+            element
+                .add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())?;
+        }
+
+        Ok(Self {
+            event_types,
+            element,
+            closure,
+        })
+    }
+}
+
+impl Drop for EventCallback {
+    fn drop(&mut self) {
+        for event_type in self.event_types {
+            let _ = self.element.remove_event_listener_with_callback(
+                event_type,
+                self.closure.as_ref().unchecked_ref(),
+            );
+        }
+    }
+}
+
+/// 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.
+pub(super) 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.
+pub(super) 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..7088b29a 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -1,6 +1,8 @@
 use crate::{
     backend::{color::to_rgb, utils::*},
     error::Error,
+    event::{KeyEvent, MouseEvent},
+    render::WebEventHandler,
     widgets::hyperlink::HYPERLINK_MODIFIER,
     CursorShape,
 };
@@ -297,6 +299,8 @@ pub struct WebGl2Backend {
     cursor_over_hyperlink: Option>>,
     /// Hyperlink click callback.
     _hyperlink_callback: Option,
+    /// User-provided mouse event handler.
+    _user_mouse_handler: Option,
 }
 
 impl WebGl2Backend {
@@ -371,6 +375,7 @@ impl WebGl2Backend {
             performance,
             cursor_over_hyperlink,
             _hyperlink_callback: hyperlink_callback,
+            _user_mouse_handler: None,
         })
     }
 
@@ -929,6 +934,116 @@ impl std::fmt::Debug for HyperlinkCallback {
     }
 }
 
+/// Mouse event handling for [`WebGl2Backend`].
+///
+/// This implementation delegates 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`].
+///
+/// **Note**: Keyboard events are not supported by this backend.
+///
+/// [`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 = beamterm_event_to_mouse_event(&event);
+                if let Ok(mut cb) = callback_clone.try_borrow_mut() {
+                    cb(mouse_event);
+                }
+            },
+        )?;
+
+        self._user_mouse_handler = Some(mouse_handler);
+
+        Ok(())
+    }
+
+    fn clear_mouse_events(&mut self) {
+        self._user_mouse_handler = None;
+    }
+
+    fn on_key_event(&mut self, _callback: F) -> Result<(), Error>
+    where
+        F: FnMut(KeyEvent) + 'static,
+    {
+        // Key events are not supported for WebGl2Backend.
+        // The canvas would need to be made focusable and handle keyboard events.
+        // We silently succeed here so apps can use the same code for all backends.
+        Ok(())
+    }
+
+    fn clear_key_events(&mut self) {
+        // No-op for WebGl2Backend since key events aren't supported
+    }
+}
+
+/// Converts a beamterm `TerminalMouseEvent` to our `MouseEvent` type.
+fn beamterm_event_to_mouse_event(event: &TerminalMouseEvent) -> MouseEvent {
+    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..622ef4d8 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,88 @@ 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
+/// use ratzilla::{CanvasBackend, WebRenderer};
+/// use ratatui::Terminal;
+///
+/// let mut terminal = Terminal::new(CanvasBackend::new().unwrap()).unwrap();
+///
+/// // 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);
+/// }).unwrap();
+/// ```
+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);
 }

From b9ec1b8d38596c157ef0ac9e71c71b622323003a Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Thu, 29 Jan 2026 20:09:42 +0100
Subject: [PATCH 02/12] derive Debug

---
 examples/minimal/src/main.rs  |  6 ++----
 src/backend/canvas.rs         | 21 ++-------------------
 src/backend/event_callback.rs | 10 ++++++++++
 3 files changed, 14 insertions(+), 23 deletions(-)

diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs
index 6278df36..7a8896c2 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;
@@ -22,7 +20,7 @@ fn main() -> io::Result<()> {
     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()?;
 
diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index f072d3f2..48594ae2 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -118,6 +118,7 @@ impl Canvas {
 /// Canvas backend.
 ///
 /// This backend renders the buffer onto a HTML canvas element.
+#[derive(Debug)]
 pub struct CanvasBackend {
     /// Whether the canvas has been initialized.
     initialized: bool,
@@ -149,24 +150,6 @@ pub struct CanvasBackend {
 /// Type alias for mouse event callback state.
 type MouseCallbackState = EventCallback;
 
-impl std::fmt::Debug for CanvasBackend {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_struct("CanvasBackend")
-            .field("initialized", &self.initialized)
-            .field("always_clip_cells", &self.always_clip_cells)
-            .field(
-                "buffer",
-                &format!("[{}x{}]", self.buffer[0].len(), self.buffer.len()),
-            )
-            .field("cursor_position", &self.cursor_position)
-            .field("cursor_shape", &self.cursor_shape)
-            .field("debug_mode", &self.debug_mode)
-            .field("mouse_callback", &self.mouse_callback.is_some())
-            .field("key_callback", &self.key_callback.is_some())
-            .finish()
-    }
-}
-
 impl CanvasBackend {
     /// Constructs a new [`CanvasBackend`].
     pub fn new() -> Result {
@@ -654,7 +637,7 @@ impl WebEventHandler for CanvasBackend {
 ///
 /// 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/event_callback.rs b/src/backend/event_callback.rs
index 83485731..b6498126 100644
--- a/src/backend/event_callback.rs
+++ b/src/backend/event_callback.rs
@@ -3,6 +3,7 @@
 //! 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,
@@ -64,6 +65,15 @@ impl Drop for EventCallback {
     }
 }
 
+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("element", &self.element)
+            .finish()
+    }
+}
+
 /// Configuration for mouse coordinate transformation.
 ///
 /// This struct holds the information needed to translate raw pixel coordinates

From 913b5fff33478a931201383514f7a5154206b75d Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Thu, 29 Jan 2026 20:13:45 +0100
Subject: [PATCH 03/12] fix examples

---
 examples/canvas_stress_test/src/main.rs |  4 ++--
 examples/clipboard/src/main.rs          |  6 +++---
 examples/demo/src/main.rs               |  8 ++++----
 examples/demo2/src/main.rs              |  8 ++++----
 examples/demo2/src/tabs/weather.rs      |  2 +-
 examples/pong/src/main.rs               |  8 ++++----
 examples/tauri/src/main.rs              |  1 -
 examples/text_area/src/main.rs          |  4 ++--
 examples/user_input/src/main.rs         |  8 ++++----
 examples/website/src/main.rs            | 10 +++++-----
 examples/world_map/src/main.rs          |  1 -
 11 files changed, 29 insertions(+), 31 deletions(-)

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..c82d3bb1 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,12 +15,12 @@ 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());
     let event_state = Rc::clone(&state);
-    terminal.on_key_event(move |key_event| {
+    let _ = terminal.on_key_event(move |key_event| {
         let event_state = event_state.clone();
         wasm_bindgen_futures::spawn_local(
             async move { event_state.handle_events(key_event).await },
diff --git a/examples/demo/src/main.rs b/examples/demo/src/main.rs
index a42a70a1..127aaa0a 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,15 +28,15 @@ 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()?;
 
-    terminal.on_key_event({
+    let _ = terminal.on_key_event({
         let app_state_cloned = app_state.clone();
         move |event| {
             let mut app_state = app_state_cloned.borrow_mut();
diff --git a/examples/demo2/src/main.rs b/examples/demo2/src/main.rs
index 1c12abf0..8ea2b599 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,17 +47,17 @@ 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 })
         .build_terminal()?;
     
     let app = Rc::new(RefCell::new(App::default()));
-    terminal.on_key_event({
+    let _ = terminal.on_key_event({
         let app = app.clone();
         move |key_event| {
             app.borrow_mut().handle_key_press(key_event);
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/pong/src/main.rs b/examples/pong/src/main.rs
index d480a4f8..1f93839f 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")
@@ -77,7 +77,7 @@ fn main() -> std::io::Result<()> {
         .dom_options(DomBackendOptions::new(Some("container".into()), CursorShape::SteadyBlock))
         .build_terminal()?;
 
-    terminal.on_key_event({
+    let _ = terminal.on_key_event({
         let app_state_cloned = app_state.clone();
         move |event| {
             let mut app_state = app_state_cloned.borrow_mut();
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..ea4f2ce4 100644
--- a/examples/text_area/src/main.rs
+++ b/examples/text_area/src/main.rs
@@ -13,11 +13,11 @@ 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()));
 
-    terminal.on_key_event({
+    let _ = terminal.on_key_event({
         let event_state = app.clone();
         move |key_event| {
             let mut state = event_state.borrow_mut();
diff --git a/examples/user_input/src/main.rs b/examples/user_input/src/main.rs
index 0dc305bf..16c40e86 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,16 +22,16 @@ 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()?;
 
     let app = Rc::new(RefCell::new(App::new()));
 
-    terminal.on_key_event({
+    let _ = terminal.on_key_event({
         let event_state = app.clone();
         move |key_event| {
             let mut state = event_state.borrow_mut();
diff --git a/examples/website/src/main.rs b/examples/website/src/main.rs
index 39382d22..98628154 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));
+    let _ = 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};

From 12d05c0de1635f87c0d45445562bdd53fc58107b Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Thu, 29 Jan 2026 20:24:43 +0100
Subject: [PATCH 04/12] exit with a bang

---
 examples/clipboard/src/main.rs  | 4 ++--
 examples/demo/src/main.rs       | 4 ++--
 examples/demo2/src/main.rs      | 4 ++--
 examples/minimal/src/main.rs    | 8 +-------
 examples/pong/src/main.rs       | 4 ++--
 examples/text_area/src/main.rs  | 4 ++--
 examples/user_input/src/main.rs | 4 ++--
 examples/website/src/main.rs    | 2 +-
 8 files changed, 14 insertions(+), 20 deletions(-)

diff --git a/examples/clipboard/src/main.rs b/examples/clipboard/src/main.rs
index c82d3bb1..5857aa4b 100644
--- a/examples/clipboard/src/main.rs
+++ b/examples/clipboard/src/main.rs
@@ -20,12 +20,12 @@ fn main() -> io::Result<()> {
 
     let state = Rc::new(App::default());
     let event_state = Rc::clone(&state);
-    let _ = terminal.on_key_event(move |key_event| {
+    terminal.on_key_event(move |key_event| {
         let event_state = event_state.clone();
         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 127aaa0a..37e4c794 100644
--- a/examples/demo/src/main.rs
+++ b/examples/demo/src/main.rs
@@ -36,7 +36,7 @@ fn main() -> Result<()> {
         .webgl2_options(webgl2_options)
         .build_terminal()?;
 
-    let _ = terminal.on_key_event({
+    terminal.on_key_event({
         let app_state_cloned = app_state.clone();
         move |event| {
             let mut app_state = app_state_cloned.borrow_mut();
@@ -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 8ea2b599..a344960c 100644
--- a/examples/demo2/src/main.rs
+++ b/examples/demo2/src/main.rs
@@ -57,12 +57,12 @@ fn main() -> std::io::Result<()> {
         .build_terminal()?;
     
     let app = Rc::new(RefCell::new(App::default()));
-    let _ = terminal.on_key_event({
+    terminal.on_key_event({
         let app = app.clone();
         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/minimal/src/main.rs b/examples/minimal/src/main.rs
index 7a8896c2..8a1b7cc6 100644
--- a/examples/minimal/src/main.rs
+++ b/examples/minimal/src/main.rs
@@ -43,13 +43,7 @@ fn main() -> io::Result<()> {
                 MouseEventKind::Moved => None,
                 MouseEventKind::ButtonDown(btn) => Some(btn),
                 MouseEventKind::ButtonUp(btn) => Some(btn),
-                MouseEventKind::SingleClick(_) |
-                MouseEventKind::DoubleClick(_) |
-                MouseEventKind::Exited  |
-                MouseEventKind::Entered |
-                MouseEventKind::Unidentified => {
-                    return;
-                }
+                _ => return
             };
 
 
diff --git a/examples/pong/src/main.rs b/examples/pong/src/main.rs
index 1f93839f..6f7d9deb 100644
--- a/examples/pong/src/main.rs
+++ b/examples/pong/src/main.rs
@@ -77,7 +77,7 @@ fn main() -> std::io::Result<()> {
         .dom_options(DomBackendOptions::new(Some("container".into()), CursorShape::SteadyBlock))
         .build_terminal()?;
 
-    let _ = terminal.on_key_event({
+    terminal.on_key_event({
         let app_state_cloned = app_state.clone();
         move |event| {
             let mut app_state = app_state_cloned.borrow_mut();
@@ -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/text_area/src/main.rs b/examples/text_area/src/main.rs
index ea4f2ce4..483f57fc 100644
--- a/examples/text_area/src/main.rs
+++ b/examples/text_area/src/main.rs
@@ -17,13 +17,13 @@ fn main() -> io::Result<()> {
 
     let app = Rc::new(RefCell::new(App::new()));
 
-    let _ = terminal.on_key_event({
+    terminal.on_key_event({
         let event_state = app.clone();
         move |key_event| {
             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 16c40e86..9f1f4b4b 100644
--- a/examples/user_input/src/main.rs
+++ b/examples/user_input/src/main.rs
@@ -31,13 +31,13 @@ fn main() -> io::Result<()> {
 
     let app = Rc::new(RefCell::new(App::new()));
 
-    let _ = terminal.on_key_event({
+    terminal.on_key_event({
         let event_state = app.clone();
         move |key_event| {
             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 98628154..230df4c1 100644
--- a/examples/website/src/main.rs
+++ b/examples/website/src/main.rs
@@ -64,7 +64,7 @@ fn main() -> io::Result<()> {
         .build_terminal()?;
 
     let mut state = State::default();
-    let _ = 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(())
 }

From 794ce887455ed691fa4c26f34a725566cd7339b6 Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Thu, 29 Jan 2026 20:45:47 +0100
Subject: [PATCH 05/12] webgl2 handle on_key_event

---
 src/backend/webgl2.rs | 43 ++++++++++++++++++++++++++++++++-----------
 1 file changed, 32 insertions(+), 11 deletions(-)

diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 7088b29a..96a1ba77 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -1,5 +1,9 @@
 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,
@@ -301,6 +305,8 @@ pub struct WebGl2Backend {
     _hyperlink_callback: Option,
     /// User-provided mouse event handler.
     _user_mouse_handler: Option,
+    /// User-provided key event handler.
+    _user_key_handler: Option>,
 }
 
 impl WebGl2Backend {
@@ -376,6 +382,7 @@ impl WebGl2Backend {
             cursor_over_hyperlink,
             _hyperlink_callback: hyperlink_callback,
             _user_mouse_handler: None,
+            _user_key_handler: None,
         })
     }
 
@@ -934,11 +941,11 @@ impl std::fmt::Debug for HyperlinkCallback {
     }
 }
 
-/// Mouse event handling for [`WebGl2Backend`].
+/// Event handling for [`WebGl2Backend`].
 ///
-/// This implementation delegates to beamterm's [`TerminalMouseHandler`], which provides
-/// native grid coordinate translation. However, beamterm only supports a subset of
-/// mouse events:
+/// 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 |
 /// |-----------|------------|
@@ -952,7 +959,7 @@ impl std::fmt::Debug for HyperlinkCallback {
 ///
 /// For full mouse event support, consider using [`CanvasBackend`] or [`DomBackend`].
 ///
-/// **Note**: Keyboard events are not supported by this backend.
+/// Keyboard events are supported by making the canvas focusable with `tabindex="0"`.
 ///
 /// [`CanvasBackend`]: crate::CanvasBackend
 /// [`DomBackend`]: crate::DomBackend
@@ -999,18 +1006,32 @@ impl WebEventHandler for WebGl2Backend {
         self._user_mouse_handler = None;
     }
 
-    fn on_key_event(&mut self, _callback: F) -> Result<(), Error>
+    fn on_key_event(&mut self, mut callback: F) -> Result<(), Error>
     where
         F: FnMut(KeyEvent) + 'static,
     {
-        // Key events are not supported for WebGl2Backend.
-        // The canvas would need to be made focusable and handle keyboard events.
-        // We silently succeed here so apps can use the same code for all backends.
+        // 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) {
-        // No-op for WebGl2Backend since key events aren't supported
+        self._user_key_handler = None;
     }
 }
 

From c21f0ea149cd0ee9660f4a43cd47efb9fcdb22e7 Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 8 Feb 2026 12:59:55 +0100
Subject: [PATCH 06/12] fix dom resize panic

---
 src/backend/dom.rs            | 40 +++++++++++++++++++----------------
 src/backend/event_callback.rs | 17 ++++++++-------
 2 files changed, 31 insertions(+), 26 deletions(-)

diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 0c09de77..bc32abb5 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -10,10 +10,7 @@ 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;
 
@@ -98,6 +95,8 @@ 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.
@@ -115,6 +114,7 @@ impl std::fmt::Debug for DomBackend {
             .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()
@@ -149,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,
@@ -161,10 +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)
     }
@@ -216,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> {
@@ -388,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),
         ))
     }
 
diff --git a/src/backend/event_callback.rs b/src/backend/event_callback.rs
index b6498126..784e4854 100644
--- a/src/backend/event_callback.rs
+++ b/src/backend/event_callback.rs
@@ -6,7 +6,7 @@
 use std::fmt::Formatter;
 use web_sys::{
     wasm_bindgen::{convert::FromWasmAbi, prelude::Closure, JsCast},
-    Element,
+    Element, EventTarget,
 };
 
 use crate::{
@@ -21,8 +21,8 @@ use crate::{
 pub(super) struct EventCallback {
     /// The event types this callback is registered for.
     event_types: &'static [&'static str],
-    /// The element the listeners are attached to.
-    element: Element,
+    /// The event target the listeners are attached to.
+    target: EventTarget,
     /// The closure that handles the events.
     #[allow(dead_code)]
     closure: Closure,
@@ -31,7 +31,7 @@ pub(super) struct EventCallback {
 impl EventCallback {
     /// Creates a new [`EventCallback`] and attaches listeners to the element.
     pub fn new(
-        element: Element,
+        target: impl Into,
         event_types: &'static [&'static str],
         callback: F,
     ) -> Result
@@ -39,16 +39,17 @@ impl EventCallback {
         F: FnMut(T) + 'static,
         T: JsCast + FromWasmAbi,
     {
+        let target = target.into();
         let closure = Closure::::new(callback);
 
         for event_type in event_types {
-            element
+            target
                 .add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())?;
         }
 
         Ok(Self {
             event_types,
-            element,
+            target,
             closure,
         })
     }
@@ -57,7 +58,7 @@ impl EventCallback {
 impl Drop for EventCallback {
     fn drop(&mut self) {
         for event_type in self.event_types {
-            let _ = self.element.remove_event_listener_with_callback(
+            let _ = self.target.remove_event_listener_with_callback(
                 event_type,
                 self.closure.as_ref().unchecked_ref(),
             );
@@ -69,7 +70,7 @@ 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("element", &self.element)
+            .field("target", &self.target)
             .finish()
     }
 }

From 589f1209acf4414ee331e48f0f90ab88bba5ca4a Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 15 Feb 2026 19:16:09 +0100
Subject: [PATCH 07/12] Update src/render.rs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Orhun Parmaksız 
---
 src/render.rs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/render.rs b/src/render.rs
index 622ef4d8..b5dc2bbd 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -100,6 +100,7 @@ where
 /// 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)

From f414c220fab12947f11d83a828f32e85de17db41 Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 15 Feb 2026 19:16:32 +0100
Subject: [PATCH 08/12] Update src/render.rs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Orhun Parmaksız 
---
 src/render.rs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/render.rs b/src/render.rs
index b5dc2bbd..7d268cbd 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -149,7 +149,9 @@ pub trait WebEventHandler {
     /// 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
+    /// # Note
+    ///
+    ///  Some backends (e.g., [`WebGl2Backend`]) do not support key events
     /// and will silently succeed without registering any handlers.
     ///
     /// # Errors

From 96a267d65deaef7fda8178a8ee949f29d1671850 Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 15 Feb 2026 19:16:52 +0100
Subject: [PATCH 09/12] Update src/backend/webgl2.rs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Orhun Parmaksız 
---
 src/backend/webgl2.rs | 18 +++++++++---------
 1 file changed, 9 insertions(+), 9 deletions(-)

diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 96a1ba77..be17d16c 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -947,15 +947,15 @@ impl std::fmt::Debug for HyperlinkCallback {
 /// 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`] |
+/// | 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`].
 ///

From c2df3e409969dbb4da2438b29942d90728a9e9f7 Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 15 Feb 2026 19:25:06 +0100
Subject: [PATCH 10/12] address feedback

---
 src/backend/event_callback.rs |  4 +--
 src/backend/webgl2.rs         | 57 ++++++++++++++++++-----------------
 src/render.rs                 |  4 +--
 3 files changed, 33 insertions(+), 32 deletions(-)

diff --git a/src/backend/event_callback.rs b/src/backend/event_callback.rs
index 784e4854..645bc4ad 100644
--- a/src/backend/event_callback.rs
+++ b/src/backend/event_callback.rs
@@ -134,7 +134,7 @@ pub(super) const MOUSE_EVENT_TYPES: &[&str] = &[
 ///
 /// This function calculates the grid position (col, row) from raw pixel
 /// coordinates, taking into account element positioning and optional offsets.
-pub(super) fn mouse_to_grid_coords(
+fn mouse_to_grid_coords(
     event: &web_sys::MouseEvent,
     element: &Element,
     config: &MouseConfig,
@@ -172,7 +172,7 @@ pub(super) fn mouse_to_grid_coords(
 }
 
 /// Converts a web_sys::MouseEvent type string to a MouseEventKind.
-pub(super) fn event_type_to_kind(event_type: &str, button: MouseButton) -> MouseEventKind {
+fn event_type_to_kind(event_type: &str, button: MouseButton) -> MouseEventKind {
     match event_type {
         "mousemove" => MouseEventKind::Moved,
         "mousedown" => MouseEventKind::ButtonDown(button),
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index be17d16c..9dcfe103 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -990,7 +990,7 @@ impl WebEventHandler for WebGl2Backend {
             canvas,
             grid,
             move |event: TerminalMouseEvent, _grid: &beamterm_renderer::TerminalGrid| {
-                let mouse_event = beamterm_event_to_mouse_event(&event);
+                let mouse_event = MouseEvent::from(&event);
                 if let Ok(mut cb) = callback_clone.try_borrow_mut() {
                     cb(mouse_event);
                 }
@@ -1035,33 +1035,34 @@ impl WebEventHandler for WebGl2Backend {
     }
 }
 
-/// Converts a beamterm `TerminalMouseEvent` to our `MouseEvent` type.
-fn beamterm_event_to_mouse_event(event: &TerminalMouseEvent) -> MouseEvent {
-    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(),
+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(),
+        }
     }
 }
 
diff --git a/src/render.rs b/src/render.rs
index 7d268cbd..9b73912a 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -111,13 +111,13 @@ where
 /// use ratzilla::{CanvasBackend, WebRenderer};
 /// use ratatui::Terminal;
 ///
-/// let mut terminal = Terminal::new(CanvasBackend::new().unwrap()).unwrap();
+/// 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);
-/// }).unwrap();
+/// })?;
 /// ```
 pub trait WebEventHandler {
     /// Sets up mouse event handlers with coordinate translation.

From 5e78ba559d4db287936922bcf772cfdfe4bab83c Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 15 Feb 2026 19:28:47 +0100
Subject: [PATCH 11/12] make doctest happy

---
 src/render.rs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/render.rs b/src/render.rs
index 9b73912a..a24c2c71 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -108,6 +108,7 @@ where
 /// # Example
 ///
 /// ```no_run
+/// # fn main() -> Result<(), Box> {
 /// use ratzilla::{CanvasBackend, WebRenderer};
 /// use ratatui::Terminal;
 ///
@@ -118,6 +119,8 @@ where
 ///     // 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.

From 40025461962fb93aca5634befd99d3afd529cce5 Mon Sep 17 00:00:00 2001
From: Adrian Papari 
Date: Sun, 15 Feb 2026 19:46:12 +0100
Subject: [PATCH 12/12] actually keep mouse-handler metrics up-to-date

---
 src/backend/webgl2.rs | 37 +++++++++++++++++++++++++++++++++++--
 1 file changed, 35 insertions(+), 2 deletions(-)

diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 9dcfe103..42b8b398 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -372,7 +372,7 @@ impl WebGl2Backend {
             None
         };
 
-        Ok(Self {
+        let mut backend = Self {
             beamterm,
             cursor_position: None,
             options,
@@ -383,7 +383,12 @@ impl WebGl2Backend {
             _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.
@@ -425,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();
@@ -999,6 +1028,10 @@ impl WebEventHandler for WebGl2Backend {
 
         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(())
     }