From 8d208a20e730897801aaa6236cb3b49820cfcdb6 Mon Sep 17 00:00:00 2001 From: zombkit Date: Tue, 17 Mar 2026 10:45:07 -0700 Subject: [PATCH 01/11] s --- Cargo.toml | 2 + src/backend/canvas.rs | 711 ++++++++++++++++++++++++++-------- src/backend/dom.rs | 36 +- src/backend/event_callback.rs | 31 +- src/backend/utils.rs | 24 +- src/backend/webgl2.rs | 3 +- 6 files changed, 594 insertions(+), 213 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e841249..c772f05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ edition = "2021" [dependencies] web-sys = { version = "0.3.81", features = [ + 'Clipboard', 'console', 'CanvasRenderingContext2d', 'Document', @@ -28,6 +29,7 @@ web-sys = { version = "0.3.81", features = [ 'Node', 'Performance', 'Screen', + 'TextMetrics', 'WebGl2RenderingContext', 'WebGlBuffer', 'WebGlProgram', diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index ff77c78..6fee532 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -1,6 +1,9 @@ -use bitvec::{bitvec, prelude::BitVec}; use ratatui::{backend::ClearType, layout::Rect}; -use std::io::{Error as IoError, Result as IoResult}; +use std::{ + cell::RefCell, + io::{Error as IoError, Result as IoResult}, + rc::Rc, +}; use crate::{ backend::{ @@ -28,17 +31,24 @@ use web_sys::{ wasm_bindgen::{JsCast, JsValue}, }; -/// Width of a single cell. -/// -/// This will be used for multiplying the cell's x position to get the actual pixel -/// position on the canvas. -const CELL_WIDTH: f64 = 10.0; +/// Default width of a single cell when measurement fails. +const DEFAULT_CELL_WIDTH: f64 = 10.0; -/// Height of a single cell. -/// -/// This will be used for multiplying the cell's y position to get the actual pixel -/// position on the canvas. -const CELL_HEIGHT: f64 = 19.0; +/// Default height of a single cell when measurement fails. +const DEFAULT_CELL_HEIGHT: f64 = 19.0; + +/// Padding offset used by the canvas backend. +const CANVAS_PADDING: f64 = 0.0; + +/// Mouse selection mode for the canvas backend. +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] +pub enum SelectionMode { + /// Select text linearly, following text flow. + #[default] + Linear, + /// Select a rectangular block of cells. + Block, +} /// Options for the [`CanvasBackend`]. #[derive(Debug, Default)] @@ -47,11 +57,8 @@ pub struct CanvasBackendOptions { grid_id: Option, /// Override the automatically detected size. size: Option<(u32, u32)>, - /// Always clip foreground drawing to the cell rectangle. Helpful when - /// dealing with out-of-bounds rendering from problematic fonts. Enabling - /// this option may cause some performance issues when dealing with large - /// numbers of simultaneous changes. - always_clip_cells: bool, + /// Optional mouse selection mode. + selection_mode: Option, } impl CanvasBackendOptions { @@ -71,6 +78,80 @@ impl CanvasBackendOptions { self.size = Some(size); self } + + /// Enable mouse selection with the default mode. + pub fn enable_mouse_selection(self) -> Self { + self.enable_mouse_selection_with_mode(SelectionMode::default()) + } + + /// Enable mouse selection with the provided mode. + pub fn enable_mouse_selection_with_mode(mut self, mode: SelectionMode) -> Self { + self.selection_mode = Some(mode); + self + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +struct SelectionPoint { + col: u16, + row: u16, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +struct SelectionRange { + anchor: SelectionPoint, + focus: SelectionPoint, +} + +#[derive(Debug, Default)] +struct SelectionState { + active: Option, + drag_anchor: Option, + dragging: bool, + pending_copy: bool, + revision: u64, +} + +impl SelectionState { + fn bump(&mut self) { + self.revision = self.revision.wrapping_add(1); + } + + fn begin(&mut self, point: SelectionPoint) { + self.drag_anchor = Some(point); + self.dragging = true; + self.pending_copy = false; + if self.active.take().is_some() { + self.bump(); + } + } + + fn update(&mut self, point: SelectionPoint) { + let Some(anchor) = self.drag_anchor else { + return; + }; + + let next = if anchor == point { + None + } else { + Some(SelectionRange { + anchor, + focus: point, + }) + }; + + if self.active != next { + self.active = next; + self.bump(); + } + } + + fn finish(&mut self, point: SelectionPoint) { + self.update(point); + self.dragging = false; + self.drag_anchor = None; + self.pending_copy = self.active.is_some(); + } } /// Canvas renderer. @@ -78,13 +159,39 @@ impl CanvasBackendOptions { struct Canvas { /// Canvas element. inner: web_sys::HtmlCanvasElement, - /// Rendering context. - context: web_sys::CanvasRenderingContext2d, + /// Visible rendering context. + display_context: web_sys::CanvasRenderingContext2d, + /// Offscreen frame canvas. + frame: web_sys::HtmlCanvasElement, + /// Offscreen frame context used for all drawing operations. + frame_context: web_sys::CanvasRenderingContext2d, /// Background color. background_color: Color, } impl Canvas { + fn create_context( + canvas: &web_sys::HtmlCanvasElement, + ) -> Result { + let context_options = Map::new(); + context_options.set(&JsValue::from_str("alpha"), &Boolean::from(JsValue::FALSE)); + + canvas + .get_context_with_context_options("2d", &context_options)? + .ok_or_else(|| Error::UnableToRetrieveCanvasContext)? + .dyn_into::() + .map_err(|_| Error::UnableToRetrieveCanvasContext) + } + + fn configure_text_context(context: &web_sys::CanvasRenderingContext2d) { + context.set_font("16px 'JetBrains Mono', monospace"); + context.set_text_align("left"); + context.set_text_baseline("alphabetic"); + context.set_image_smoothing_enabled(false); + context.set_shadow_blur(0.0); + context.set_global_alpha(1.0); + } + /// Constructs a new [`Canvas`]. fn new( parent_element: web_sys::Element, @@ -93,24 +200,23 @@ impl Canvas { background_color: Color, ) -> Result { let canvas = create_canvas_in_element(&parent_element, width, height)?; + let display_context = Self::create_context(&canvas)?; - let context_options = Map::new(); - context_options.set(&JsValue::from_str("alpha"), &Boolean::from(JsValue::TRUE)); - context_options.set( - &JsValue::from_str("desynchronized"), - &Boolean::from(JsValue::TRUE), - ); - let context = canvas - .get_context_with_context_options("2d", &context_options)? - .ok_or_else(|| Error::UnableToRetrieveCanvasContext)? - .dyn_into::() - .expect("Unable to cast canvas context"); - context.set_font("16px monospace"); - context.set_text_baseline("top"); + let frame = get_document()? + .create_element("canvas")? + .dyn_into::() + .map_err(|_| Error::UnableToRetrieveCanvasContext)?; + frame.set_width(width); + frame.set_height(height); + + let frame_context = Self::create_context(&frame)?; + Self::configure_text_context(&frame_context); Ok(Self { inner: canvas, - context, + display_context, + frame, + frame_context, background_color, }) } @@ -123,25 +229,30 @@ impl Canvas { pub struct CanvasBackend { /// Whether the canvas has been initialized. initialized: bool, - /// Always clip foreground drawing to the cell rectangle. Helpful when - /// dealing with out-of-bounds rendering from problematic fonts. Enabling - /// this option may cause some performance issues when dealing with large - /// numbers of simultaneous changes. - always_clip_cells: bool, /// Current buffer. buffer: Vec>, /// Previous buffer. prev_buffer: Vec>, - /// Changed buffer cells - changed_cells: BitVec, /// Canvas. canvas: Canvas, + /// Measured cell width in CSS pixels. + cell_width: f64, + /// Measured cell height in CSS pixels. + cell_height: f64, + /// Alphabetic baseline offset within a cell. + text_baseline_offset: f64, /// Cursor position. cursor_position: Option, /// The cursor shape. cursor_shape: CursorShape, /// Draw cell boundaries with specified color. debug_mode: Option, + /// Mouse selection mode. + selection_mode: Option, + /// Mouse selection state shared with event handlers. + selection_state: Rc>, + /// Last observed selection state revision. + selection_revision: u64, /// Mouse event callback handler. mouse_callback: Option, /// Key event callback handler. @@ -152,6 +263,236 @@ pub struct CanvasBackend { type MouseCallbackState = EventCallback; impl CanvasBackend { + fn content_draw_size(&self) -> (f64, f64) { + let (grid_width, grid_height) = self.canvas_grid_size(); + let width = (grid_width as f64 * self.cell_width).ceil(); + let height = (grid_height as f64 * self.cell_height).ceil(); + (width, height) + } + + fn content_offset(&self) -> (f64, f64) { + let (content_width, content_height) = self.content_draw_size(); + // Snap the centered origin to whole pixels so adjacent cell backgrounds + // share exact edges instead of landing on half-pixel seams. + let offset_x = + (((self.canvas.inner.client_width() as f64 - content_width) / 2.0).max(0.0)).round(); + let offset_y = (((self.canvas.inner.client_height() as f64 - content_height) / 2.0) + .max(0.0)) + .round(); + (offset_x, offset_y) + } + + fn cell_rect(&self, x: usize, y: usize) -> (f64, f64, f64, f64) { + let left = (x as f64 * self.cell_width).floor(); + let top = (y as f64 * self.cell_height).floor(); + let right = ((x + 1) as f64 * self.cell_width).ceil(); + let bottom = ((y + 1) as f64 * self.cell_height).ceil(); + (left, top, (right - left).max(1.0), (bottom - top).max(1.0)) + } + + fn symbol_position(&self, x: usize, y: usize) -> (f64, f64) { + let (left, top, _, _) = self.cell_rect(x, y); + (left, top + self.text_baseline_offset) + } + + fn selection_range(&self) -> Option { + self.selection_state.borrow().active + } + + fn selection_revision(&self) -> u64 { + self.selection_state.borrow().revision + } + + fn selection_row_bounds( + mode: SelectionMode, + range: SelectionRange, + row: usize, + width: usize, + ) -> Option<(usize, usize)> { + if width == 0 { + return None; + } + + match mode { + SelectionMode::Linear => { + let (start, end) = if (range.anchor.row, range.anchor.col) + <= (range.focus.row, range.focus.col) + { + (range.anchor, range.focus) + } else { + (range.focus, range.anchor) + }; + + if row < start.row as usize || row > end.row as usize { + return None; + } + + let start_col = if row == start.row as usize { + start.col as usize + } else { + 0 + }; + let end_col = if row == end.row as usize { + end.col as usize + } else { + width.saturating_sub(1) + }; + + Some((start_col.min(width), end_col.saturating_add(1).min(width))) + } + SelectionMode::Block => { + let min_col = range.anchor.col.min(range.focus.col) as usize; + let max_col = range.anchor.col.max(range.focus.col) as usize; + let min_row = range.anchor.row.min(range.focus.row) as usize; + let max_row = range.anchor.row.max(range.focus.row) as usize; + + if row < min_row || row > max_row { + return None; + } + + Some((min_col.min(width), max_col.saturating_add(1).min(width))) + } + } + } + + fn selected_text(&self, range: SelectionRange) -> String { + let Some(mode) = self.selection_mode else { + return String::new(); + }; + + let mut lines = Vec::new(); + for (row_idx, row) in self.buffer.iter().enumerate() { + let Some((start, end)) = Self::selection_row_bounds(mode, range, row_idx, row.len()) else { + continue; + }; + + let mut line = String::new(); + for cell in &row[start..end] { + line.push_str(cell.symbol()); + } + while line.ends_with(' ') { + line.pop(); + } + lines.push(line); + } + + lines.join("\n") + } + + fn copy_selection_to_clipboard(&self) { + let Some(range) = self.selection_range() else { + return; + }; + let text = self.selected_text(range); + if text.is_empty() { + return; + } + + if let Some(window) = web_sys::window() { + let clipboard = window.navigator().clipboard(); + let _ = clipboard.write_text(&text); + } + } + + fn measure_text_baseline( + context: &web_sys::CanvasRenderingContext2d, + cell_height: f64, + ) -> f64 { + let metrics = context.measure_text("Mg").ok(); + let ascent = metrics + .as_ref() + .map(|metrics| metrics.actual_bounding_box_ascent()) + .filter(|ascent| *ascent > 0.0) + .unwrap_or(cell_height * 0.75); + let descent = metrics + .as_ref() + .map(|metrics| metrics.actual_bounding_box_descent()) + .filter(|descent| *descent >= 0.0) + .unwrap_or(cell_height * 0.2); + + (ascent + ((cell_height - (ascent + descent)).max(0.0) / 2.0)).round() + } + + fn present(&self) -> Result<(), Error> { + self.canvas.display_context.save(); + self.canvas + .display_context + .set_global_composite_operation("copy")?; + self.canvas + .display_context + .draw_image_with_html_canvas_element(&self.canvas.frame, 0.0, 0.0)?; + self.canvas.display_context.restore(); + Ok(()) + } + + fn canvas_grid_size(&self) -> (usize, usize) { + let width = ((self.canvas.inner.client_width() as f64) / self.cell_width) + .floor() + .max(1.0) as usize; + let height = ((self.canvas.inner.client_height() as f64) / self.cell_height) + .floor() + .max(1.0) as usize; + (width, height) + } + + fn sync_canvas_size(&mut self) { + let width = self.canvas.inner.width(); + let height = self.canvas.inner.height(); + + if self.canvas.frame.width() != width || self.canvas.frame.height() != height { + self.canvas.frame.set_width(width); + self.canvas.frame.set_height(height); + Canvas::configure_text_context(&self.canvas.frame_context); + self.canvas.display_context.set_image_smoothing_enabled(false); + self.initialized = false; + } + + let (grid_width, grid_height) = self.canvas_grid_size(); + let needs_buffer_resize = self.buffer.len() != grid_height + || self + .buffer + .first() + .map(|line| line.len() != grid_width) + .unwrap_or(true); + + if needs_buffer_resize { + self.buffer = vec![vec![Cell::default(); grid_width]; grid_height]; + self.prev_buffer = self.buffer.clone(); + self.initialized = false; + } + } + + fn measure_cell_size(parent: &web_sys::Element) -> Result<(f64, f64), Error> { + let document = get_document()?; + let pre = document.create_element("pre")?; + pre.set_attribute( + "style", + "margin: 0; padding: 0; border: 0; line-height: 1; font: 16px 'JetBrains Mono', monospace;", + )?; + + let span = document.create_element("span")?; + span.set_inner_html("\u{2588}"); + span.set_attribute( + "style", + "display: inline-block; width: 1ch; line-height: 1; font: 16px 'JetBrains Mono', monospace;", + )?; + + pre.append_child(&span)?; + parent.append_child(&pre)?; + + let rect = span.get_bounding_client_rect(); + let width = rect.width(); + let height = rect.height(); + + parent.remove_child(&pre)?; + + if width > 0.0 && height > 0.0 { + Ok((width, height)) + } else { + Ok((DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT)) + } + } + /// Constructs a new [`CanvasBackend`]. pub fn new() -> Result { let (width, height) = get_raw_window_size(); @@ -175,19 +516,24 @@ impl CanvasBackend { .size .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32)); + let cell_size = Self::measure_cell_size(&parent)?; let canvas = Canvas::new(parent, width, height, Color::Black)?; - let buffer = get_sized_buffer_from_canvas(&canvas.inner); - let changed_cells = bitvec![0; buffer.len() * buffer[0].len()]; + let text_baseline_offset = Self::measure_text_baseline(&canvas.frame_context, cell_size.1); + let buffer = get_sized_buffer_from_canvas(&canvas.inner, cell_size.0, cell_size.1); Ok(Self { prev_buffer: buffer.clone(), - always_clip_cells: options.always_clip_cells, buffer, initialized: false, - changed_cells, canvas, + cell_width: cell_size.0, + cell_height: cell_size.1, + text_baseline_offset, cursor_position: None, cursor_shape: CursorShape::SteadyBlock, debug_mode: None, + selection_mode: options.selection_mode, + selection_state: Rc::new(RefCell::new(SelectionState::default())), + selection_revision: 0, mouse_callback: None, key_callback: None, }) @@ -229,50 +575,68 @@ impl CanvasBackend { self.debug_mode = color.map(Into::into); } - // Compare the current buffer to the previous buffer and updates the canvas - // accordingly. - // - // If `force_redraw` is `true`, the entire canvas will be cleared and redrawn. - fn update_grid(&mut self, force_redraw: bool) -> Result<(), Error> { - if force_redraw { - self.canvas.context.clear_rect( - 0.0, - 0.0, - self.canvas.inner.client_width() as f64, - self.canvas.inner.client_height() as f64, - ); - } - self.canvas.context.translate(5_f64, 5_f64)?; + // Redraw the entire offscreen frame, then present it in a single blit. + fn render_frame(&mut self) -> Result<(), Error> { + let background = get_canvas_color(self.canvas.background_color, Color::Black); + self.canvas.frame_context.set_fill_style_str(&background); + self.canvas.frame_context.fill_rect( + 0.0, + 0.0, + self.canvas.frame.width() as f64, + self.canvas.frame.height() as f64, + ); + let (offset_x, offset_y) = self.content_offset(); + self.canvas + .frame_context + .translate(CANVAS_PADDING + offset_x, CANVAS_PADDING + offset_y)?; - // NOTE: The draw_* functions each traverse the buffer once, instead of - // traversing it once per cell; this is done to reduce the number of - // WASM calls per cell. - self.resolve_changed_cells(force_redraw); self.draw_background()?; + self.draw_selection()?; self.draw_symbols()?; self.draw_cursor()?; if self.debug_mode.is_some() { self.draw_debug()?; } - self.canvas.context.translate(-5_f64, -5_f64)?; + self.canvas + .frame_context + .translate(-(CANVAS_PADDING + offset_x), -(CANVAS_PADDING + offset_y))?; + self.present()?; Ok(()) } - /// Updates the representation of the changed cells. - /// - /// This function updates the `changed_cells` vector to indicate which cells - /// have changed. - fn resolve_changed_cells(&mut self, force_redraw: bool) { - let mut index = 0; - for (y, line) in self.buffer.iter().enumerate() { - for (x, cell) in line.iter().enumerate() { - let prev_cell = &self.prev_buffer[y][x]; - self.changed_cells - .set(index, force_redraw || cell != prev_cell); - index += 1; + fn draw_selection(&mut self) -> Result<(), Error> { + let Some(mode) = self.selection_mode else { + return Ok(()); + }; + let Some(range) = self.selection_range() else { + return Ok(()); + }; + + self.canvas.frame_context.save(); + self.canvas + .frame_context + .set_fill_style_str("rgba(170, 190, 230, 0.24)"); + + for (row_idx, row) in self.buffer.iter().enumerate() { + let Some((start, end)) = Self::selection_row_bounds(mode, range, row_idx, row.len()) else { + continue; + }; + if start >= end { + continue; } + + let start_x = (start as f64 * self.cell_width).floor(); + let start_y = (row_idx as f64 * self.cell_height).floor(); + let end_x = (end as f64 * self.cell_width).ceil(); + let end_y = ((row_idx + 1) as f64 * self.cell_height).ceil(); + self.canvas + .frame_context + .fill_rect(start_x, start_y, end_x - start_x, end_y - start_y); } + + self.canvas.frame_context.restore(); + Ok(()) } /// Draws the text symbols on the canvas. @@ -285,64 +649,35 @@ impl CanvasBackend { /// Rather than saving/restoring the canvas context for every cell (which would be expensive), /// this implementation: /// - /// 1. Only processes cells that have changed since the last render. - /// 2. Tracks the last foreground color used to avoid unnecessary style changes - /// 3. Only creates clipping paths for potentially problematic glyphs (non-ASCII) - /// or when `always_clip_cells` is enabled. + /// Tracks the last foreground color used to avoid unnecessary style changes. + /// fn draw_symbols(&mut self) -> Result<(), Error> { - let changed_cells = &self.changed_cells; - let mut index = 0; - - self.canvas.context.save(); + self.canvas.frame_context.save(); let mut last_color = None; for (y, line) in self.buffer.iter().enumerate() { for (x, cell) in line.iter().enumerate() { - // Skip empty cells - if !changed_cells[index] || cell.symbol() == " " { - index += 1; + if cell.symbol() == " " { continue; } let color = actual_fg_color(cell); - // We need to reset the canvas context state in two scenarios: - // 1. When we need to create a clipping path (for potentially problematic glyphs) - // 2. When the text color changes - if self.always_clip_cells || !cell.symbol().is_ascii() { - self.canvas.context.restore(); - self.canvas.context.save(); - - self.canvas.context.begin_path(); - self.canvas.context.rect( - x as f64 * CELL_WIDTH, - y as f64 * CELL_HEIGHT, - CELL_WIDTH, - CELL_HEIGHT, - ); - self.canvas.context.clip(); - - last_color = None; // reset last color to avoid clipping - let color = get_canvas_color(color, Color::White); - self.canvas.context.set_fill_style_str(&color); - } else if last_color != Some(color) { - self.canvas.context.restore(); - self.canvas.context.save(); + if last_color != Some(color) { + self.canvas.frame_context.restore(); + self.canvas.frame_context.save(); last_color = Some(color); let color = get_canvas_color(color, Color::White); - self.canvas.context.set_fill_style_str(&color); + self.canvas.frame_context.set_fill_style_str(&color); } - self.canvas.context.fill_text( - cell.symbol(), - x as f64 * CELL_WIDTH, - y as f64 * CELL_HEIGHT, - )?; - - index += 1; + let (text_x, text_y) = self.symbol_position(x, y); + self.canvas + .frame_context + .fill_text(cell.symbol(), text_x, text_y)?; } } - self.canvas.context.restore(); + self.canvas.frame_context.restore(); Ok(()) } @@ -355,42 +690,32 @@ impl CanvasBackend { /// In other words, it accumulates "what to draw" until it finds a different /// color, and then it draws the accumulated rectangle. fn draw_background(&mut self) -> Result<(), Error> { - let changed_cells = &self.changed_cells; - self.canvas.context.save(); + self.canvas.frame_context.save(); let draw_region = |(rect, color): (Rect, Color)| { let color = get_canvas_color(color, self.canvas.background_color); - - self.canvas.context.set_fill_style_str(&color); - self.canvas.context.fill_rect( - rect.x as f64 * CELL_WIDTH, - rect.y as f64 * CELL_HEIGHT, - rect.width as f64 * CELL_WIDTH, - rect.height as f64 * CELL_HEIGHT, - ); + let start_x = (rect.x as f64 * self.cell_width).floor(); + let start_y = (rect.y as f64 * self.cell_height).floor(); + let end_x = ((rect.x + rect.width) as f64 * self.cell_width).ceil(); + let end_y = ((rect.y + rect.height) as f64 * self.cell_height).ceil(); + + self.canvas.frame_context.set_fill_style_str(&color); + self.canvas + .frame_context + .fill_rect(start_x, start_y, end_x - start_x, end_y - start_y); }; - let mut index = 0; for (y, line) in self.buffer.iter().enumerate() { let mut row_renderer = RowColorOptimizer::new(); for (x, cell) in line.iter().enumerate() { - if changed_cells[index] { - // Only calls `draw_region` if the color is different from the previous one - row_renderer - .process_color((x, y), actual_bg_color(cell)) - .map(draw_region); - } else { - // Cell is unchanged so we must flush any held region - // to avoid clearing the foreground (symbol) of the cell - row_renderer.flush().map(draw_region); - } - index += 1; + row_renderer + .process_color((x, y), actual_bg_color(cell)) + .map(draw_region); } - // Flush the remaining region after traversing the row row_renderer.flush().map(draw_region); } - self.canvas.context.restore(); + self.canvas.frame_context.restore(); Ok(()) } @@ -401,15 +726,15 @@ impl CanvasBackend { let cell = &self.buffer[pos.y as usize][pos.x as usize]; if cell.modifier.contains(Modifier::UNDERLINED) { - self.canvas.context.save(); + self.canvas.frame_context.save(); - self.canvas.context.fill_text( + self.canvas.frame_context.fill_text( "_", - pos.x as f64 * CELL_WIDTH, - pos.y as f64 * CELL_HEIGHT, + pos.x as f64 * self.cell_width, + pos.y as f64 * self.cell_height, )?; - self.canvas.context.restore(); + self.canvas.frame_context.restore(); } } @@ -418,22 +743,22 @@ impl CanvasBackend { /// Draws cell boundaries for debugging. fn draw_debug(&mut self) -> Result<(), Error> { - self.canvas.context.save(); + self.canvas.frame_context.save(); let color = self.debug_mode.as_ref().unwrap(); for (y, line) in self.buffer.iter().enumerate() { for (x, _) in line.iter().enumerate() { - self.canvas.context.set_stroke_style_str(color); - self.canvas.context.stroke_rect( - x as f64 * CELL_WIDTH, - y as f64 * CELL_HEIGHT, - CELL_WIDTH, - CELL_HEIGHT, + self.canvas.frame_context.set_stroke_style_str(color); + self.canvas.frame_context.stroke_rect( + x as f64 * self.cell_width, + y as f64 * self.cell_height, + self.cell_width, + self.cell_height, ); } } - self.canvas.context.restore(); + self.canvas.frame_context.restore(); Ok(()) } @@ -441,12 +766,11 @@ impl CanvasBackend { impl CellSized for CanvasBackend { fn cell_size_px(&self) -> (f32, f32) { - let dpr = get_device_pixel_ratio(); - (CELL_WIDTH as f32 * dpr, CELL_HEIGHT as f32 * dpr) + (self.cell_width as f32, self.cell_height as f32) } fn cell_size_css_px(&self) -> (f32, f32) { - (CELL_WIDTH as f32, CELL_HEIGHT as f32) + (self.cell_width as f32, self.cell_height as f32) } } @@ -458,6 +782,8 @@ impl Backend for CanvasBackend { where I: Iterator, { + self.sync_canvas_size(); + for (x, y, cell) in content { let y = y as usize; let x = x as usize; @@ -485,20 +811,29 @@ impl Backend for CanvasBackend { /// This function is called after the [`CanvasBackend::draw`] function to /// actually render the content to the screen. fn flush(&mut self) -> IoResult<()> { - // Only runs once. - if !self.initialized { - self.update_grid(true)?; + self.sync_canvas_size(); + let selection_revision = self.selection_revision(); + + if !self.initialized + || self.buffer != self.prev_buffer + || self.selection_revision != selection_revision + { + self.render_frame()?; self.prev_buffer = self.buffer.clone(); self.initialized = true; - return Ok(()); + self.selection_revision = selection_revision; } - if self.buffer != self.prev_buffer { - self.update_grid(false)?; + let should_copy = { + let mut selection_state = self.selection_state.borrow_mut(); + let should_copy = selection_state.pending_copy; + selection_state.pending_copy = false; + should_copy + }; + if should_copy { + self.copy_selection_to_clipboard(); } - self.prev_buffer = self.buffer.clone(); - Ok(()) } @@ -529,15 +864,17 @@ impl Backend for CanvasBackend { } fn clear(&mut self) -> IoResult<()> { - self.buffer = get_sized_buffer(); + self.sync_canvas_size(); + self.buffer = + get_sized_buffer_from_canvas(&self.canvas.inner, self.cell_width, self.cell_height); + self.prev_buffer = self.buffer.clone(); + self.initialized = false; Ok(()) } fn size(&self) -> IoResult { - Ok(Size::new( - self.buffer[0].len().saturating_sub(1) as u16, - self.buffer.len().saturating_sub(1) as u16, - )) + let (width, height) = self.canvas_grid_size(); + Ok(Size::new(width as u16, height as u16)) } fn window_size(&mut self) -> IoResult { @@ -588,11 +925,16 @@ impl WebEventHandler for CanvasBackend { // 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); + .with_offsets( + CANVAS_PADDING + self.content_offset().0, + CANVAS_PADDING + self.content_offset().1, + ) + .with_cell_dimensions(self.cell_width, self.cell_height); let element: web_sys::Element = self.canvas.inner.clone().into(); let element_for_closure = element.clone(); + let selection_state = self.selection_state.clone(); + let selection_mode = self.selection_mode; // Create mouse event callback let mouse_callback = EventCallback::new( @@ -600,6 +942,22 @@ impl WebEventHandler for CanvasBackend { MOUSE_EVENT_TYPES, move |event: web_sys::MouseEvent| { let mouse_event = create_mouse_event(&event, &element_for_closure, &config); + if selection_mode.is_some() { + let point = SelectionPoint { + col: mouse_event.col, + row: mouse_event.row, + }; + let mut selection_state = selection_state.borrow_mut(); + match event.type_().as_str() { + "mousedown" if event.button() == 0 => selection_state.begin(point), + "mousemove" if selection_state.dragging => selection_state.update(point), + "mouseup" if event.button() == 0 && selection_state.dragging => { + selection_state.finish(point) + } + "mouseleave" if selection_state.dragging => selection_state.finish(point), + _ => {} + } + } callback(mouse_event); }, )?; @@ -629,10 +987,19 @@ impl WebEventHandler for CanvasBackend { .set_attribute("tabindex", "0") .map_err(Error::from)?; + let selection_state = self.selection_state.clone(); self.key_callback = Some(EventCallback::new( element, KEY_EVENT_TYPES, move |event: web_sys::KeyboardEvent| { + let is_copy = (event.ctrl_key() || event.meta_key()) + && matches!(event.key().as_str(), "c" | "C"); + if is_copy && selection_state.borrow().active.is_some() { + event.prevent_default(); + let mut selection_state = selection_state.borrow_mut(); + selection_state.pending_copy = true; + return; + } callback(event.into()); }, )?); diff --git a/src/backend/dom.rs b/src/backend/dom.rs index f43846d..191acd7 100644 --- a/src/backend/dom.rs +++ b/src/backend/dom.rs @@ -187,11 +187,14 @@ impl DomBackend { let pre = document.create_element("pre")?; pre.set_attribute( "style", - "margin: 0; padding: 0; border: 0; line-height: normal;", + "margin: 0; padding: 0; border: 0; line-height: 1; font: 16px 'JetBrains Mono', monospace;", )?; let span = document.create_element("span")?; span.set_inner_html("\u{2588}"); - span.set_attribute("style", "display: inline-block; width: 1ch;")?; + span.set_attribute( + "style", + "display: inline-block; width: 1ch; line-height: 1; vertical-align: top; box-sizing: border-box; font: 16px 'JetBrains Mono', monospace;", + )?; pre.append_child(&span)?; parent.append_child(&pre)?; @@ -251,7 +254,10 @@ impl DomBackend { // Create a
 element for the line
             let pre = self.document.create_element("pre")?;
-            let line_height = format!("height: {}px;", self.cell_size.1);
+            let line_height = format!(
+                "margin: 0; padding: 0; border: 0; height: {}px; line-height: 1;",
+                self.cell_size.1
+            );
             pre.set_attribute("style", &line_height)?;
 
             // Append all elements (spans and anchors) to the 
@@ -268,8 +274,7 @@ impl DomBackend {
 
 impl CellSized for DomBackend {
     fn cell_size_px(&self) -> (f32, f32) {
-        let dpr = get_device_pixel_ratio();
-        (self.cell_size.0 as f32 * dpr, self.cell_size.1 as f32 * dpr)
+        (self.cell_size.0 as f32, self.cell_size.1 as f32)
     }
 
     fn cell_size_css_px(&self) -> (f32, f32) {
@@ -400,11 +405,7 @@ impl Backend for DomBackend {
     }
 
     fn size(&self) -> IoResult {
-        let size = get_size();
-        Ok(Size::new(
-            size.width.saturating_sub(1),
-            size.height.saturating_sub(1),
-        ))
+        Ok(self.size)
     }
 
     fn window_size(&mut self) -> IoResult {
@@ -448,16 +449,12 @@ impl WebEventHandler for DomBackend {
         // 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();
+        let element = self.grid_parent.clone();
 
         // Create mouse event callback
         let mouse_callback = EventCallback::new(
-            self.grid.clone(),
+            self.grid_parent.clone(),
             MOUSE_EVENT_TYPES,
             move |event: web_sys::MouseEvent| {
                 let mouse_event = create_mouse_event(&event, &element, &config);
@@ -481,11 +478,12 @@ impl WebEventHandler for DomBackend {
         // 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")?;
+        // Make the grid parent focusable so it keeps receiving key events
+        // even when the grid node is recreated on resize.
+        self.grid_parent.set_attribute("tabindex", "0")?;
 
         self.key_callback = Some(EventCallback::new(
-            self.grid.clone(),
+            self.grid_parent.clone(),
             KEY_EVENT_TYPES,
             move |event: web_sys::KeyboardEvent| {
                 callback(event.into());
diff --git a/src/backend/event_callback.rs b/src/backend/event_callback.rs
index 645bc4a..0641153 100644
--- a/src/backend/event_callback.rs
+++ b/src/backend/event_callback.rs
@@ -85,8 +85,10 @@ pub(super) struct MouseConfig {
     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,
+    /// Horizontal pixel offset from the element edge (e.g., canvas translation).
+    pub offset_x: Option,
+    /// Vertical pixel offset from the element edge (e.g., canvas translation).
+    pub offset_y: Option,
     /// Cell dimensions in pixels (width, height).
     /// If provided, used for pixel-perfect coordinate calculation.
     pub cell_dimensions: Option<(f64, f64)>,
@@ -98,14 +100,16 @@ impl MouseConfig {
         Self {
             grid_width,
             grid_height,
-            offset: None,
+            offset_x: None,
+            offset_y: 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);
+    /// Sets independent pixel offsets from the element edge.
+    pub fn with_offsets(mut self, offset_x: f64, offset_y: f64) -> Self {
+        self.offset_x = Some(offset_x);
+        self.offset_y = Some(offset_y);
         self
     }
 
@@ -142,9 +146,10 @@ fn mouse_to_grid_coords(
     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);
+    let offset_x = config.offset_x.unwrap_or(0.0);
+    let offset_y = config.offset_y.unwrap_or(0.0);
+    let relative_x = (event.client_x() as f64 - rect.left() - offset_x).max(0.0);
+    let relative_y = (event.client_y() as f64 - rect.top() - offset_y).max(0.0);
 
     // Calculate drawable area
     let (drawable_width, drawable_height) = match config.cell_dimensions {
@@ -152,7 +157,10 @@ fn mouse_to_grid_coords(
             config.grid_width as f64 * cw,
             config.grid_height as f64 * ch,
         ),
-        None => (rect.width() - 2.0 * offset, rect.height() - 2.0 * offset),
+        None => (
+            rect.width() - offset_x.max(0.0) * 2.0,
+            rect.height() - offset_y.max(0.0) * 2.0,
+        ),
     };
 
     // Avoid division by zero
@@ -217,7 +225,8 @@ mod tests {
 
         assert_eq!(config.grid_width, 80);
         assert_eq!(config.grid_height, 24);
-        assert_eq!(config.offset, Some(5.0));
+        assert_eq!(config.offset_x, Some(5.0));
+        assert_eq!(config.offset_y, Some(5.0));
         assert_eq!(config.cell_dimensions, Some((10.0, 19.0)));
     }
 }
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index 3064b6f..4d9da9b 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -212,6 +212,7 @@ pub(crate) fn get_raw_screen_size() -> (i32, i32) {
     (s.width().unwrap(), s.height().unwrap())
 }
 
+#[allow(dead_code)]
 /// Returns a buffer based on the screen size.
 pub(crate) fn get_sized_buffer() -> Vec> {
     let size = get_size();
@@ -228,10 +229,16 @@ pub(crate) fn get_size() -> Size {
 }
 
 /// Returns a buffer based on the canvas size.
-pub(crate) fn get_sized_buffer_from_canvas(canvas: &HtmlCanvasElement) -> Vec> {
-    let width = canvas.client_width() as u16 / 10_u16;
-    let height = canvas.client_height() as u16 / 19_u16;
-    vec![vec![Cell::default(); width as usize]; height as usize]
+pub(crate) fn get_sized_buffer_from_canvas(
+    canvas: &HtmlCanvasElement,
+    cell_width: f64,
+    cell_height: f64,
+) -> Vec> {
+    let width = ((canvas.client_width() as f64) / cell_width).floor().max(1.0) as usize;
+    let height = ((canvas.client_height() as f64) / cell_height)
+        .floor()
+        .max(1.0) as usize;
+    vec![vec![Cell::default(); width]; height]
 }
 
 /// Returns the document object from the window.
@@ -246,11 +253,6 @@ pub(crate) fn get_window() -> Result {
     window().ok_or(Error::UnableToRetrieveWindow)
 }
 
-/// Returns the device pixel ratio from the window.
-pub(crate) fn get_device_pixel_ratio() -> f32 {
-    get_window().map(|w| w.device_pixel_ratio()).unwrap_or(1.0) as f32
-}
-
 /// Returns an element by its ID or the body element if no ID is provided.
 pub(crate) fn get_element_by_id_or_body(id: Option<&String>) -> Result {
     match id {
@@ -287,6 +289,10 @@ pub(crate) fn create_canvas_in_element(
         .expect("Unable to cast canvas element");
     canvas.set_width(width);
     canvas.set_height(height);
+    canvas.set_attribute(
+        "style",
+        "display: block; width: 100%; height: 100%; touch-action: none; image-rendering: pixelated; image-rendering: crisp-edges; image-rendering: -moz-crisp-edges;",
+    )?;
 
     parent.append_child(&element)?;
 
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index ea31ccb..609e468 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -1,5 +1,6 @@
 use crate::{
     backend::{
+        cell_sized::CellSized,
         color::to_rgb,
         event_callback::{EventCallback, KEY_EVENT_TYPES},
         utils::*,
@@ -29,7 +30,6 @@ use std::{
 };
 use web_sys::{wasm_bindgen::JsCast, Element};
 
-use crate::backend::cell_sized::CellSized;
 /// Re-export beamterm's atlas data type. Used by [`FontAtlasConfig::Static`].
 pub use beamterm_renderer::FontAtlasData;
 
@@ -910,7 +910,6 @@ impl WebEventHandler for WebGl2Backend {
         )?;
 
         self._user_mouse_handler = Some(mouse_handler);
-
         Ok(())
     }
 

From 10191709fd962b5db00e872261848ead40d3acd3 Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 11:00:19 -0700
Subject: [PATCH 02/11] s

---
 src/backend/canvas.rs | 33 +++++++++---------
 src/backend/dom.rs    | 78 +++++++++++++++++++++++++++----------------
 src/backend/utils.rs  | 46 ++++++++++++++++++++-----
 3 files changed, 104 insertions(+), 53 deletions(-)

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index 6fee532..a84e376 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -276,9 +276,8 @@ impl CanvasBackend {
         // share exact edges instead of landing on half-pixel seams.
         let offset_x =
             (((self.canvas.inner.client_width() as f64 - content_width) / 2.0).max(0.0)).round();
-        let offset_y = (((self.canvas.inner.client_height() as f64 - content_height) / 2.0)
-            .max(0.0))
-        .round();
+        let offset_y =
+            (((self.canvas.inner.client_height() as f64 - content_height) / 2.0).max(0.0)).round();
         (offset_x, offset_y)
     }
 
@@ -315,13 +314,12 @@ impl CanvasBackend {
 
         match mode {
             SelectionMode::Linear => {
-                let (start, end) = if (range.anchor.row, range.anchor.col)
-                    <= (range.focus.row, range.focus.col)
-                {
-                    (range.anchor, range.focus)
-                } else {
-                    (range.focus, range.anchor)
-                };
+                let (start, end) =
+                    if (range.anchor.row, range.anchor.col) <= (range.focus.row, range.focus.col) {
+                        (range.anchor, range.focus)
+                    } else {
+                        (range.focus, range.anchor)
+                    };
 
                 if row < start.row as usize || row > end.row as usize {
                     return None;
@@ -362,7 +360,8 @@ impl CanvasBackend {
 
         let mut lines = Vec::new();
         for (row_idx, row) in self.buffer.iter().enumerate() {
-            let Some((start, end)) = Self::selection_row_bounds(mode, range, row_idx, row.len()) else {
+            let Some((start, end)) = Self::selection_row_bounds(mode, range, row_idx, row.len())
+            else {
                 continue;
             };
 
@@ -394,10 +393,7 @@ impl CanvasBackend {
         }
     }
 
-    fn measure_text_baseline(
-        context: &web_sys::CanvasRenderingContext2d,
-        cell_height: f64,
-    ) -> f64 {
+    fn measure_text_baseline(context: &web_sys::CanvasRenderingContext2d, cell_height: f64) -> f64 {
         let metrics = context.measure_text("Mg").ok();
         let ascent = metrics
             .as_ref()
@@ -443,7 +439,9 @@ impl CanvasBackend {
             self.canvas.frame.set_width(width);
             self.canvas.frame.set_height(height);
             Canvas::configure_text_context(&self.canvas.frame_context);
-            self.canvas.display_context.set_image_smoothing_enabled(false);
+            self.canvas
+                .display_context
+                .set_image_smoothing_enabled(false);
             self.initialized = false;
         }
 
@@ -619,7 +617,8 @@ impl CanvasBackend {
             .set_fill_style_str("rgba(170, 190, 230, 0.24)");
 
         for (row_idx, row) in self.buffer.iter().enumerate() {
-            let Some((start, end)) = Self::selection_row_bounds(mode, range, row_idx, row.len()) else {
+            let Some((start, end)) = Self::selection_row_bounds(mode, range, row_idx, row.len())
+            else {
                 continue;
             };
             if start >= end {
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 191acd7..3623aef 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -184,25 +184,36 @@ impl DomBackend {
     /// page's CSS (font-family, font-size, etc.), measures it with
     /// `getBoundingClientRect()`, then removes the probe.
     fn measure_cell_size(document: &Document, parent: &Element) -> Result<(f64, f64), Error> {
-        let pre = document.create_element("pre")?;
-        pre.set_attribute(
+        let probe = document.create_element("div")?;
+        probe.set_attribute(
             "style",
-            "margin: 0; padding: 0; border: 0; line-height: 1; font: 16px 'JetBrains Mono', monospace;",
+            "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'JetBrains Mono', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
         )?;
-        let span = document.create_element("span")?;
-        span.set_inner_html("\u{2588}");
-        span.set_attribute(
+
+        let row = document.create_element("div")?;
+        row.set_attribute(
+            "style",
+            "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;",
+        )?;
+
+        let sample = document.create_element("span")?;
+        let sample_text = "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM";
+        sample.set_text_content(Some(sample_text));
+        sample.set_attribute(
             "style",
-            "display: inline-block; width: 1ch; line-height: 1; vertical-align: top; box-sizing: border-box; font: 16px 'JetBrains Mono', monospace;",
+            "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
         )?;
-        pre.append_child(&span)?;
-        parent.append_child(&pre)?;
 
-        let rect = span.get_bounding_client_rect();
-        let width = rect.width();
-        let height = rect.height();
+        row.append_child(&sample)?;
+        probe.append_child(&row)?;
+        parent.append_child(&probe)?;
 
-        parent.remove_child(&pre)?;
+        let sample_rect = sample.get_bounding_client_rect();
+        let row_rect = row.get_bounding_client_rect();
+        let width = sample_rect.width() / sample_text.chars().count() as f64;
+        let height = row_rect.height().max(sample_rect.height());
+
+        parent.remove_child(&probe)?;
 
         if width > 0.0 && height > 0.0 {
             Ok((width, height))
@@ -235,6 +246,10 @@ impl DomBackend {
     fn reset_grid(&mut self) -> Result<(), Error> {
         self.grid = self.document.create_element("div")?;
         self.grid.set_attribute("id", &self.options.grid_id())?;
+        self.grid.set_attribute(
+            "style",
+            "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'JetBrains Mono', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
+        )?;
         self.cells.clear();
         Ok(())
     }
@@ -247,26 +262,32 @@ impl DomBackend {
         for _y in 0..self.size.height {
             let mut line_cells: Vec = Vec::new();
             for _x in 0..self.size.width {
-                let span = create_span(&self.document, &Cell::default())?;
+                let span = create_span(&self.document, &Cell::default(), self.cell_size)?;
                 self.cells.push(span.clone());
                 line_cells.push(span);
             }
 
-            // Create a 
 element for the line
-            let pre = self.document.create_element("pre")?;
-            let line_height = format!(
-                "margin: 0; padding: 0; border: 0; height: {}px; line-height: 1;",
+            // Create a row element with fixed pixel height so the browser
+            // cannot introduce its own line box spacing between rows.
+            let row = self.document.create_element("div")?;
+            row.set_class_name("ratzilla-dom-row");
+            let row_style = format!(
+                "display: flex; flex: 0 0 {}px; width: 100%; height: {}px; min-height: {}px; max-height: {}px; overflow: hidden; margin: 0; padding: 0; border: 0; line-height: {}px; white-space: pre; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
+                self.cell_size.1,
+                self.cell_size.1,
+                self.cell_size.1,
+                self.cell_size.1,
                 self.cell_size.1
             );
-            pre.set_attribute("style", &line_height)?;
+            row.set_attribute("style", &row_style)?;
 
-            // Append all elements (spans and anchors) to the 
+            // Append all elements (spans and anchors) to the row.
             for elem in line_cells {
-                pre.append_child(&elem)?;
+                row.append_child(&elem)?;
             }
 
-            // Append the 
 to the grid
-            self.grid.append_child(&pre)?;
+            // Append the row to the grid.
+            self.grid.append_child(&row)?;
         }
         Ok(())
     }
@@ -325,17 +346,17 @@ impl Backend for DomBackend {
             let cell_position = (y * self.size.width + x) as usize;
             let elem = &self.cells[cell_position];
 
-            elem.set_inner_html(cell.symbol());
-            elem.set_attribute("style", &get_cell_style_as_css(cell))
+            elem.set_text_content(Some(cell.symbol()));
+            elem.set_attribute("style", &get_cell_style_as_css(cell, self.cell_size))
                 .map_err(Error::from)?;
 
             // don't display the next cell if a fullwidth glyph preceeds it
             if cell.symbol().len() > 1 && cell.symbol().width() == 2 {
                 if (cell_position + 1) < self.cells.len() {
                     let next_elem = &self.cells[cell_position + 1];
-                    next_elem.set_inner_html("");
+                    next_elem.set_text_content(Some(""));
                     next_elem
-                        .set_attribute("style", &get_cell_style_as_css(&Cell::new("")))
+                        .set_attribute("style", &get_hidden_cell_style_as_css(self.cell_size))
                         .map_err(Error::from)?;
                 }
             }
@@ -449,7 +470,8 @@ impl WebEventHandler for DomBackend {
         // Clear any existing handlers first
         self.clear_mouse_events();
 
-        let config = MouseConfig::new(self.size.width, self.size.height);
+        let config = MouseConfig::new(self.size.width, self.size.height)
+            .with_cell_dimensions(self.cell_size.0, self.cell_size.1);
         let element = self.grid_parent.clone();
 
         // Create mouse event callback
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index 4d9da9b..d8014be 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -21,29 +21,39 @@ pub struct CssAttribute {
 }
 
 /// Creates a new `` element with the given cell.
-pub(crate) fn create_span(document: &Document, cell: &Cell) -> Result {
+pub(crate) fn create_span(
+    document: &Document,
+    cell: &Cell,
+    cell_size: (f64, f64),
+) -> Result {
     let span = document.create_element("span")?;
-    span.set_inner_html(cell.symbol());
+    span.set_class_name("ratzilla-dom-cell");
+    span.set_text_content(Some(cell.symbol()));
 
-    let style = get_cell_style_as_css(cell);
+    let style = get_cell_style_as_css(cell, cell_size);
     span.set_attribute("style", &style)?;
     Ok(span)
 }
 
 /// Creates a new `` element with the given cells.
 #[allow(dead_code)]
-pub(crate) fn create_anchor(document: &Document, cells: &[Cell]) -> Result {
+pub(crate) fn create_anchor(
+    document: &Document,
+    cells: &[Cell],
+    cell_size: (f64, f64),
+) -> Result {
     let anchor = document.create_element("a")?;
+    anchor.set_class_name("ratzilla-dom-cell ratzilla-dom-link");
     anchor.set_attribute(
         "href",
         &cells.iter().map(|c| c.symbol()).collect::(),
     )?;
-    anchor.set_attribute("style", &get_cell_style_as_css(&cells[0]))?;
+    anchor.set_attribute("style", &get_cell_style_as_css(&cells[0], cell_size))?;
     Ok(anchor)
 }
 
 /// Converts a cell to a CSS style.
-pub(crate) fn get_cell_style_as_css(cell: &Cell) -> String {
+pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> String {
     let mut fg = ansi_to_rgb(cell.fg);
     let mut bg = ansi_to_rgb(cell.bg);
 
@@ -99,11 +109,29 @@ pub(crate) fn get_cell_style_as_css(cell: &Cell) -> String {
         ""
     };
 
-    let sizing = format!("display: inline-block; width: {}ch;", cell.symbol().width());
+    let width = cell.symbol().width().max(1) as f64 * cell_size.0;
+    let sizing = format!(
+        "display: block; flex: 0 0 {width}px; width: {width}px; min-width: {width}px; max-width: {width}px; height: {}px; min-height: {}px; max-height: {}px; line-height: {}px; margin: 0; padding: 0; border: 0; vertical-align: top; box-sizing: border-box; white-space: pre; overflow: hidden; font-family: inherit; font-size: inherit; text-decoration: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
+        cell_size.1,
+        cell_size.1,
+        cell_size.1,
+        cell_size.1
+    );
 
     format!("{fg_style} {bg_style} {modifier_style} {braille_style} {sizing}")
 }
 
+/// CSS style used for the trailing placeholder cell after a full-width glyph.
+pub(crate) fn get_hidden_cell_style_as_css(cell_size: (f64, f64)) -> String {
+    format!(
+        "display: block; flex: 0 0 0px; width: 0; min-width: 0; max-width: 0; height: {}px; min-height: {}px; max-height: {}px; line-height: {}px; margin: 0; padding: 0; border: 0; overflow: hidden; visibility: hidden; box-sizing: border-box; white-space: pre; font-family: inherit; font-size: inherit; text-decoration: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
+        cell_size.1,
+        cell_size.1,
+        cell_size.1,
+        cell_size.1
+    )
+}
+
 /// Parse an inline CSS style string into a Vec of (property, value) pairs.
 fn parse_inline_style(css: &str) -> Vec<(String, String)> {
     css.split(';')
@@ -234,7 +262,9 @@ pub(crate) fn get_sized_buffer_from_canvas(
     cell_width: f64,
     cell_height: f64,
 ) -> Vec> {
-    let width = ((canvas.client_width() as f64) / cell_width).floor().max(1.0) as usize;
+    let width = ((canvas.client_width() as f64) / cell_width)
+        .floor()
+        .max(1.0) as usize;
     let height = ((canvas.client_height() as f64) / cell_height)
         .floor()
         .max(1.0) as usize;

From 73c937a6ede756ae5e409730341710e1cf7579fc Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 11:10:29 -0700
Subject: [PATCH 03/11] Update canvas.rs

---
 src/backend/canvas.rs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index a84e376..3d12bac 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -184,7 +184,7 @@ impl Canvas {
     }
 
     fn configure_text_context(context: &web_sys::CanvasRenderingContext2d) {
-        context.set_font("16px 'JetBrains Mono', monospace");
+        context.set_font("16px 'Iosevka', monospace");
         context.set_text_align("left");
         context.set_text_baseline("alphabetic");
         context.set_image_smoothing_enabled(false);
@@ -465,14 +465,14 @@ impl CanvasBackend {
         let pre = document.create_element("pre")?;
         pre.set_attribute(
             "style",
-            "margin: 0; padding: 0; border: 0; line-height: 1; font: 16px 'JetBrains Mono', monospace;",
+            "margin: 0; padding: 0; border: 0; line-height: 1; font: 16px 'Iosevka', monospace;",
         )?;
 
         let span = document.create_element("span")?;
         span.set_inner_html("\u{2588}");
         span.set_attribute(
             "style",
-            "display: inline-block; width: 1ch; line-height: 1; font: 16px 'JetBrains Mono', monospace;",
+            "display: inline-block; width: 1ch; line-height: 1; font: 16px 'Iosevka', monospace;",
         )?;
 
         pre.append_child(&span)?;

From 90bda9906ccbf7b7d1422ef910cbf34b1fa06121 Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 11:13:17 -0700
Subject: [PATCH 04/11] draft

---
 src/backend/dom.rs    | 4 ++--
 src/backend/webgl2.rs | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 3623aef..51316ae 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -187,7 +187,7 @@ impl DomBackend {
         let probe = document.create_element("div")?;
         probe.set_attribute(
             "style",
-            "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'JetBrains Mono', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
+            "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
         )?;
 
         let row = document.create_element("div")?;
@@ -248,7 +248,7 @@ impl DomBackend {
         self.grid.set_attribute("id", &self.options.grid_id())?;
         self.grid.set_attribute(
             "style",
-            "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'JetBrains Mono', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
+            "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
         )?;
         self.cells.clear();
         Ok(())
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 609e468..8eb308f 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -172,7 +172,7 @@ impl WebGl2BackendOptions {
     /// let options = WebGl2BackendOptions::new()
     ///     .font_atlas_config(FontAtlasConfig::dynamic(
     ///         // monospace is an implicit fallback font in browsers
-    ///         &["JetBrains Mono"],
+    ///         &["Iosevka"],
     ///         16.0
     ///     ));
     /// ```

From d3b6f4e59877f8c4604893160f6e6e849caf3e8a Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 13:45:16 -0700
Subject: [PATCH 05/11] k

---
 Cargo.toml         |   1 +
 src/backend/dom.rs | 111 ++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 101 insertions(+), 11 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index c772f05..098c35b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,6 +29,7 @@ web-sys = { version = "0.3.81", features = [
     'Node',
     'Performance',
     'Screen',
+    'Selection',
     'TextMetrics',
     'WebGl2RenderingContext',
     'WebGlBuffer',
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 51316ae..54319a5 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -38,6 +38,8 @@ pub struct DomBackendOptions {
     grid_id: Option,
     /// The cursor shape.
     cursor_shape: CursorShape,
+    /// Whether native mouse text selection is enabled.
+    mouse_selection: bool,
 }
 
 impl DomBackendOptions {
@@ -46,6 +48,7 @@ impl DomBackendOptions {
         Self {
             grid_id,
             cursor_shape,
+            mouse_selection: false,
         }
     }
 
@@ -65,6 +68,17 @@ impl DomBackendOptions {
     pub fn cursor_shape(&self) -> &CursorShape {
         &self.cursor_shape
     }
+
+    /// Enables native mouse text selection.
+    pub fn enable_mouse_selection(mut self) -> Self {
+        self.mouse_selection = true;
+        self
+    }
+
+    /// Returns whether native mouse text selection is enabled.
+    pub fn mouse_selection(&self) -> bool {
+        self.mouse_selection
+    }
 }
 
 /// DOM backend.
@@ -96,6 +110,8 @@ pub struct DomBackend {
     cell_size: (f64, f64),
     /// Resize event callback handler.
     _resize_callback: EventCallback,
+    /// Shared mouse coordinate configuration.
+    mouse_config: Rc>,
     /// Mouse event callback handler.
     mouse_callback: Option,
     /// Key event callback handler.
@@ -114,6 +130,7 @@ impl std::fmt::Debug for DomBackend {
             .field("cell_size", &self.cell_size)
             .field("cursor_position", &self.cursor_position)
             .field("resize_callback", &"...")
+            .field("mouse_config", &self.mouse_config)
             .field("mouse_callback", &self.mouse_callback.is_some())
             .field("key_callback", &self.key_callback.is_some())
             .finish()
@@ -158,6 +175,10 @@ impl DomBackend {
                 initialized_cb.replace(false);
             },
         )?;
+        let mouse_config = Rc::new(RefCell::new(
+            MouseConfig::new(size.width, size.height)
+                .with_cell_dimensions(cell_size.0, cell_size.1),
+        ));
 
         let mut backend = Self {
             initialized,
@@ -171,10 +192,12 @@ impl DomBackend {
             size,
             cell_size,
             _resize_callback: resize_callback,
+            mouse_config,
             mouse_callback: None,
             key_callback: None,
         };
         backend.reset_grid()?;
+        backend.sync_mouse_config();
         Ok(backend)
     }
 
@@ -242,14 +265,54 @@ impl DomBackend {
     /// Resize event types.
     const RESIZE_EVENT_TYPES: &[&str] = &["resize"];
 
+    fn current_size(&self) -> Size {
+        Self::calculate_size(&self.grid_parent, self.cell_size)
+    }
+
+    fn sync_mouse_config(&self) {
+        let mut config = self.mouse_config.borrow_mut();
+        config.grid_width = self.size.width;
+        config.grid_height = self.size.height;
+        config.cell_dimensions = Some(self.cell_size);
+        config.offset_x = None;
+        config.offset_y = None;
+    }
+
+    fn selected_text() -> String {
+        window()
+            .and_then(|window| window.get_selection().ok().flatten())
+            .map(|selection| selection.to_string().into())
+            .unwrap_or_default()
+    }
+
+    fn has_selected_text() -> bool {
+        !Self::selected_text().trim().is_empty()
+    }
+
+    fn copy_selected_text_to_clipboard() {
+        let text = Self::selected_text();
+        if text.trim().is_empty() {
+            return;
+        }
+
+        if let Some(window) = window() {
+            let clipboard = window.navigator().clipboard();
+            let _ = clipboard.write_text(&text);
+        }
+    }
+
     /// Reset the grid and clear the cells.
     fn reset_grid(&mut self) -> Result<(), Error> {
         self.grid = self.document.create_element("div")?;
         self.grid.set_attribute("id", &self.options.grid_id())?;
-        self.grid.set_attribute(
-            "style",
-            "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
-        )?;
+        let mut grid_style = "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;".to_string();
+        if self.options.mouse_selection() {
+            self.grid.set_class_name("ratzilla-dom-selection-enabled");
+            grid_style.push_str(
+                " user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;",
+            );
+        }
+        self.grid.set_attribute("style", &grid_style)?;
         self.cells.clear();
         Ok(())
     }
@@ -333,7 +396,8 @@ impl Backend for DomBackend {
                 // re-measure cell size and update grid dimensions
                 self.cell_size = Self::measure_cell_size(&self.document, &self.grid_parent)
                     .unwrap_or(DEFAULT_CELL_SIZE);
-                self.size = Self::calculate_size(&self.grid_parent, self.cell_size);
+                self.size = self.current_size();
+                self.sync_mouse_config();
             }
 
             self.grid_parent
@@ -343,7 +407,13 @@ impl Backend for DomBackend {
         }
 
         for (x, y, cell) in content {
+            if x >= self.size.width || y >= self.size.height {
+                continue;
+            }
             let cell_position = (y * self.size.width + x) as usize;
+            if cell_position >= self.cells.len() {
+                continue;
+            }
             let elem = &self.cells[cell_position];
 
             elem.set_text_content(Some(cell.symbol()));
@@ -426,15 +496,16 @@ impl Backend for DomBackend {
     }
 
     fn size(&self) -> IoResult {
-        Ok(self.size)
+        Ok(self.current_size())
     }
 
     fn window_size(&mut self) -> IoResult {
+        let size = self.current_size();
         Ok(WindowSize {
-            columns_rows: self.size,
+            columns_rows: size,
             pixels: Size::new(
-                (self.size.width as f64 * self.cell_size.0) as u16,
-                (self.size.height as f64 * self.cell_size.1) as u16,
+                (size.width as f64 * self.cell_size.0) as u16,
+                (size.height as f64 * self.cell_size.1) as u16,
             ),
         })
     }
@@ -470,15 +541,25 @@ impl WebEventHandler for DomBackend {
         // Clear any existing handlers first
         self.clear_mouse_events();
 
-        let config = MouseConfig::new(self.size.width, self.size.height)
-            .with_cell_dimensions(self.cell_size.0, self.cell_size.1);
+        self.sync_mouse_config();
+        let config = self.mouse_config.clone();
         let element = self.grid_parent.clone();
 
         // Create mouse event callback
+        let mouse_selection = self.options.mouse_selection();
         let mouse_callback = EventCallback::new(
             self.grid_parent.clone(),
             MOUSE_EVENT_TYPES,
             move |event: web_sys::MouseEvent| {
+                if mouse_selection
+                    && event.type_() == "mouseup"
+                    && event.button() == 0
+                    && DomBackend::has_selected_text()
+                {
+                    DomBackend::copy_selected_text_to_clipboard();
+                }
+
+                let config = config.borrow();
                 let mouse_event = create_mouse_event(&event, &element, &config);
                 callback(mouse_event);
             },
@@ -503,11 +584,19 @@ impl WebEventHandler for DomBackend {
         // Make the grid parent focusable so it keeps receiving key events
         // even when the grid node is recreated on resize.
         self.grid_parent.set_attribute("tabindex", "0")?;
+        let mouse_selection = self.options.mouse_selection();
 
         self.key_callback = Some(EventCallback::new(
             self.grid_parent.clone(),
             KEY_EVENT_TYPES,
             move |event: web_sys::KeyboardEvent| {
+                let is_copy =
+                    (event.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("c");
+                if mouse_selection && is_copy && DomBackend::has_selected_text() {
+                    event.prevent_default();
+                    DomBackend::copy_selected_text_to_clipboard();
+                    return;
+                }
                 callback(event.into());
             },
         )?);

From 4fb5cd0ed3dd7bd300a12bc6bb1d42e41b941e6b Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 15:20:36 -0700
Subject: [PATCH 06/11] trimming LOC

---
 src/backend/canvas.rs         | 60 ++++++++++--------------
 src/backend/dom.rs            | 87 +++++++++++++++++------------------
 src/backend/event_callback.rs |  2 +-
 src/backend/utils.rs          | 73 ++++++++++-------------------
 4 files changed, 92 insertions(+), 130 deletions(-)

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index 3d12bac..a29cf97 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -37,9 +37,6 @@ const DEFAULT_CELL_WIDTH: f64 = 10.0;
 /// Default height of a single cell when measurement fails.
 const DEFAULT_CELL_HEIGHT: f64 = 19.0;
 
-/// Padding offset used by the canvas backend.
-const CANVAS_PADDING: f64 = 0.0;
-
 /// Mouse selection mode for the canvas backend.
 #[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
 pub enum SelectionMode {
@@ -184,7 +181,7 @@ impl Canvas {
     }
 
     fn configure_text_context(context: &web_sys::CanvasRenderingContext2d) {
-        context.set_font("16px 'Iosevka', monospace");
+        context.set_font(TERMINAL_FONT);
         context.set_text_align("left");
         context.set_text_baseline("alphabetic");
         context.set_image_smoothing_enabled(false);
@@ -263,6 +260,14 @@ pub struct CanvasBackend {
 type MouseCallbackState = EventCallback;
 
 impl CanvasBackend {
+    fn grid_rect(&self, x: usize, y: usize, width: usize, height: usize) -> (f64, f64, f64, f64) {
+        let left = (x as f64 * self.cell_width).floor();
+        let top = (y as f64 * self.cell_height).floor();
+        let right = ((x + width) as f64 * self.cell_width).ceil();
+        let bottom = ((y + height) as f64 * self.cell_height).ceil();
+        (left, top, (right - left).max(1.0), (bottom - top).max(1.0))
+    }
+
     fn content_draw_size(&self) -> (f64, f64) {
         let (grid_width, grid_height) = self.canvas_grid_size();
         let width = (grid_width as f64 * self.cell_width).ceil();
@@ -281,16 +286,8 @@ impl CanvasBackend {
         (offset_x, offset_y)
     }
 
-    fn cell_rect(&self, x: usize, y: usize) -> (f64, f64, f64, f64) {
-        let left = (x as f64 * self.cell_width).floor();
-        let top = (y as f64 * self.cell_height).floor();
-        let right = ((x + 1) as f64 * self.cell_width).ceil();
-        let bottom = ((y + 1) as f64 * self.cell_height).ceil();
-        (left, top, (right - left).max(1.0), (bottom - top).max(1.0))
-    }
-
     fn symbol_position(&self, x: usize, y: usize) -> (f64, f64) {
-        let (left, top, _, _) = self.cell_rect(x, y);
+        let (left, top, _, _) = self.grid_rect(x, y, 1, 1);
         (left, top + self.text_baseline_offset)
     }
 
@@ -387,10 +384,7 @@ impl CanvasBackend {
             return;
         }
 
-        if let Some(window) = web_sys::window() {
-            let clipboard = window.navigator().clipboard();
-            let _ = clipboard.write_text(&text);
-        }
+        write_text_to_clipboard(&text);
     }
 
     fn measure_text_baseline(context: &web_sys::CanvasRenderingContext2d, cell_height: f64) -> f64 {
@@ -465,14 +459,14 @@ impl CanvasBackend {
         let pre = document.create_element("pre")?;
         pre.set_attribute(
             "style",
-            "margin: 0; padding: 0; border: 0; line-height: 1; font: 16px 'Iosevka', monospace;",
+            &format!("margin: 0; padding: 0; border: 0; line-height: 1; font: {TERMINAL_FONT};"),
         )?;
 
         let span = document.create_element("span")?;
         span.set_inner_html("\u{2588}");
         span.set_attribute(
             "style",
-            "display: inline-block; width: 1ch; line-height: 1; font: 16px 'Iosevka', monospace;",
+            &format!("display: inline-block; width: 1ch; line-height: 1; font: {TERMINAL_FONT};"),
         )?;
 
         pre.append_child(&span)?;
@@ -586,7 +580,7 @@ impl CanvasBackend {
         let (offset_x, offset_y) = self.content_offset();
         self.canvas
             .frame_context
-            .translate(CANVAS_PADDING + offset_x, CANVAS_PADDING + offset_y)?;
+            .translate(offset_x, offset_y)?;
 
         self.draw_background()?;
         self.draw_selection()?;
@@ -598,7 +592,7 @@ impl CanvasBackend {
 
         self.canvas
             .frame_context
-            .translate(-(CANVAS_PADDING + offset_x), -(CANVAS_PADDING + offset_y))?;
+            .translate(-offset_x, -offset_y)?;
         self.present()?;
         Ok(())
     }
@@ -625,13 +619,10 @@ impl CanvasBackend {
                 continue;
             }
 
-            let start_x = (start as f64 * self.cell_width).floor();
-            let start_y = (row_idx as f64 * self.cell_height).floor();
-            let end_x = (end as f64 * self.cell_width).ceil();
-            let end_y = ((row_idx + 1) as f64 * self.cell_height).ceil();
+            let (start_x, start_y, width, height) = self.grid_rect(start, row_idx, end - start, 1);
             self.canvas
                 .frame_context
-                .fill_rect(start_x, start_y, end_x - start_x, end_y - start_y);
+                .fill_rect(start_x, start_y, width, height);
         }
 
         self.canvas.frame_context.restore();
@@ -693,15 +684,17 @@ impl CanvasBackend {
 
         let draw_region = |(rect, color): (Rect, Color)| {
             let color = get_canvas_color(color, self.canvas.background_color);
-            let start_x = (rect.x as f64 * self.cell_width).floor();
-            let start_y = (rect.y as f64 * self.cell_height).floor();
-            let end_x = ((rect.x + rect.width) as f64 * self.cell_width).ceil();
-            let end_y = ((rect.y + rect.height) as f64 * self.cell_height).ceil();
+            let (start_x, start_y, width, height) = self.grid_rect(
+                rect.x as usize,
+                rect.y as usize,
+                rect.width as usize,
+                rect.height as usize,
+            );
 
             self.canvas.frame_context.set_fill_style_str(&color);
             self.canvas
                 .frame_context
-                .fill_rect(start_x, start_y, end_x - start_x, end_y - start_y);
+                .fill_rect(start_x, start_y, width, height);
         };
 
         for (y, line) in self.buffer.iter().enumerate() {
@@ -924,10 +917,7 @@ impl WebEventHandler for CanvasBackend {
 
         // Configure coordinate translation for canvas backend
         let config = MouseConfig::new(grid_width, grid_height)
-            .with_offsets(
-                CANVAS_PADDING + self.content_offset().0,
-                CANVAS_PADDING + self.content_offset().1,
-            )
+            .with_offsets(self.content_offset().0, self.content_offset().1)
             .with_cell_dimensions(self.cell_width, self.cell_height);
 
         let element: web_sys::Element = self.canvas.inner.clone().into();
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 54319a5..1e73a6d 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -30,6 +30,10 @@ use crate::{
 
 /// Default cell size used as a fallback when measurement fails.
 const DEFAULT_CELL_SIZE: (f64, f64) = (10.0, 20.0);
+const PROBE_ROW_STYLE: &str =
+    "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;";
+const PROBE_SAMPLE_STYLE: &str =
+    "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
 
 /// Options for the [`DomBackend`].
 #[derive(Debug, Default)]
@@ -138,6 +142,24 @@ impl std::fmt::Debug for DomBackend {
 }
 
 impl DomBackend {
+    fn grid_style(mouse_selection: bool) -> String {
+        let mut style = format!(
+            "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; {TERMINAL_FONT_CSS}"
+        );
+        if mouse_selection {
+            style.push_str(
+                " user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;",
+            );
+        }
+        style
+    }
+
+    fn probe_style() -> String {
+        format!(
+            "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; {TERMINAL_FONT_CSS}"
+        )
+    }
+
     /// Constructs a new [`DomBackend`].
     pub fn new() -> Result {
         Self::new_with_options(DomBackendOptions::default())
@@ -208,24 +230,15 @@ impl DomBackend {
     /// `getBoundingClientRect()`, then removes the probe.
     fn measure_cell_size(document: &Document, parent: &Element) -> Result<(f64, f64), Error> {
         let probe = document.create_element("div")?;
-        probe.set_attribute(
-            "style",
-            "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
-        )?;
+        probe.set_attribute("style", &Self::probe_style())?;
 
         let row = document.create_element("div")?;
-        row.set_attribute(
-            "style",
-            "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;",
-        )?;
+        row.set_attribute("style", PROBE_ROW_STYLE)?;
 
         let sample = document.create_element("span")?;
         let sample_text = "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM";
         sample.set_text_content(Some(sample_text));
-        sample.set_attribute(
-            "style",
-            "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
-        )?;
+        sample.set_attribute("style", PROBE_SAMPLE_STYLE)?;
 
         row.append_child(&sample)?;
         probe.append_child(&row)?;
@@ -278,26 +291,21 @@ impl DomBackend {
         config.offset_y = None;
     }
 
-    fn selected_text() -> String {
-        window()
+    fn selected_text() -> Option {
+        let text: String = window()
             .and_then(|window| window.get_selection().ok().flatten())
             .map(|selection| selection.to_string().into())
-            .unwrap_or_default()
-    }
-
-    fn has_selected_text() -> bool {
-        !Self::selected_text().trim().is_empty()
+            .unwrap_or_default();
+        (!text.trim().is_empty()).then_some(text)
     }
 
-    fn copy_selected_text_to_clipboard() {
-        let text = Self::selected_text();
-        if text.trim().is_empty() {
-            return;
-        }
-
-        if let Some(window) = window() {
-            let clipboard = window.navigator().clipboard();
-            let _ = clipboard.write_text(&text);
+    fn copy_selected_text_to_clipboard() -> bool {
+        match Self::selected_text() {
+            Some(text) => {
+                write_text_to_clipboard(&text);
+                true
+            }
+            None => false,
         }
     }
 
@@ -305,14 +313,11 @@ impl DomBackend {
     fn reset_grid(&mut self) -> Result<(), Error> {
         self.grid = self.document.create_element("div")?;
         self.grid.set_attribute("id", &self.options.grid_id())?;
-        let mut grid_style = "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;".to_string();
         if self.options.mouse_selection() {
             self.grid.set_class_name("ratzilla-dom-selection-enabled");
-            grid_style.push_str(
-                " user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;",
-            );
         }
-        self.grid.set_attribute("style", &grid_style)?;
+        self.grid
+            .set_attribute("style", &Self::grid_style(self.options.mouse_selection()))?;
         self.cells.clear();
         Ok(())
     }
@@ -334,15 +339,7 @@ impl DomBackend {
             // cannot introduce its own line box spacing between rows.
             let row = self.document.create_element("div")?;
             row.set_class_name("ratzilla-dom-row");
-            let row_style = format!(
-                "display: flex; flex: 0 0 {}px; width: 100%; height: {}px; min-height: {}px; max-height: {}px; overflow: hidden; margin: 0; padding: 0; border: 0; line-height: {}px; white-space: pre; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
-                self.cell_size.1,
-                self.cell_size.1,
-                self.cell_size.1,
-                self.cell_size.1,
-                self.cell_size.1
-            );
-            row.set_attribute("style", &row_style)?;
+            row.set_attribute("style", &terminal_row_style(self.cell_size.1))?;
 
             // Append all elements (spans and anchors) to the row.
             for elem in line_cells {
@@ -554,9 +551,8 @@ impl WebEventHandler for DomBackend {
                 if mouse_selection
                     && event.type_() == "mouseup"
                     && event.button() == 0
-                    && DomBackend::has_selected_text()
+                    && DomBackend::copy_selected_text_to_clipboard()
                 {
-                    DomBackend::copy_selected_text_to_clipboard();
                 }
 
                 let config = config.borrow();
@@ -592,9 +588,8 @@ impl WebEventHandler for DomBackend {
             move |event: web_sys::KeyboardEvent| {
                 let is_copy =
                     (event.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("c");
-                if mouse_selection && is_copy && DomBackend::has_selected_text() {
+                if mouse_selection && is_copy && DomBackend::copy_selected_text_to_clipboard() {
                     event.prevent_default();
-                    DomBackend::copy_selected_text_to_clipboard();
                     return;
                 }
                 callback(event.into());
diff --git a/src/backend/event_callback.rs b/src/backend/event_callback.rs
index 0641153..fdf4671 100644
--- a/src/backend/event_callback.rs
+++ b/src/backend/event_callback.rs
@@ -220,7 +220,7 @@ mod tests {
     #[test]
     fn test_mouse_config_builder() {
         let config = MouseConfig::new(80, 24)
-            .with_offset(5.0)
+            .with_offsets(5.0, 5.0)
             .with_cell_dimensions(10.0, 19.0);
 
         assert_eq!(config.grid_width, 80);
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index d8014be..65c3ff3 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -1,12 +1,10 @@
 use crate::{
     backend::color::ansi_to_rgb,
     error::Error,
-    utils::{get_screen_size, get_window_size, is_mobile},
 };
 use compact_str::{format_compact, CompactString};
 use ratatui::{
     buffer::Cell,
-    layout::Size,
     style::{Color, Modifier},
 };
 use unicode_width::UnicodeWidthStr;
@@ -15,11 +13,33 @@ use web_sys::{
     window, Document, Element, HtmlCanvasElement, Window,
 };
 
+pub(crate) const TERMINAL_FONT: &str = "16px 'Iosevka', monospace";
+pub(crate) const TERMINAL_FONT_CSS: &str = "font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
+
 pub struct CssAttribute {
     pub field: &'static str,
     pub value: Option<&'static str>,
 }
 
+fn terminal_cell_box_style(width: f64, cell_height: f64) -> CompactString {
+    format_compact!(
+        "display: block; flex: 0 0 {width}px; width: {width}px; min-width: {width}px; max-width: {width}px; height: {cell_height}px; min-height: {cell_height}px; max-height: {cell_height}px; line-height: {cell_height}px; margin: 0; padding: 0; border: 0; vertical-align: top; box-sizing: border-box; white-space: pre; overflow: hidden; font-family: inherit; font-size: inherit; text-decoration: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;"
+    )
+}
+
+pub(crate) fn terminal_row_style(cell_height: f64) -> CompactString {
+    format_compact!(
+        "display: flex; flex: 0 0 {cell_height}px; width: 100%; height: {cell_height}px; min-height: {cell_height}px; max-height: {cell_height}px; overflow: hidden; margin: 0; padding: 0; border: 0; line-height: {cell_height}px; white-space: pre; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;"
+    )
+}
+
+pub(crate) fn write_text_to_clipboard(text: &str) {
+    if let Some(window) = window() {
+        let clipboard = window.navigator().clipboard();
+        let _ = clipboard.write_text(text);
+    }
+}
+
 /// Creates a new `` element with the given cell.
 pub(crate) fn create_span(
     document: &Document,
@@ -35,23 +55,6 @@ pub(crate) fn create_span(
     Ok(span)
 }
 
-/// Creates a new `` element with the given cells.
-#[allow(dead_code)]
-pub(crate) fn create_anchor(
-    document: &Document,
-    cells: &[Cell],
-    cell_size: (f64, f64),
-) -> Result {
-    let anchor = document.create_element("a")?;
-    anchor.set_class_name("ratzilla-dom-cell ratzilla-dom-link");
-    anchor.set_attribute(
-        "href",
-        &cells.iter().map(|c| c.symbol()).collect::(),
-    )?;
-    anchor.set_attribute("style", &get_cell_style_as_css(&cells[0], cell_size))?;
-    Ok(anchor)
-}
-
 /// Converts a cell to a CSS style.
 pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> String {
     let mut fg = ansi_to_rgb(cell.fg);
@@ -109,14 +112,7 @@ pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> Strin
         ""
     };
 
-    let width = cell.symbol().width().max(1) as f64 * cell_size.0;
-    let sizing = format!(
-        "display: block; flex: 0 0 {width}px; width: {width}px; min-width: {width}px; max-width: {width}px; height: {}px; min-height: {}px; max-height: {}px; line-height: {}px; margin: 0; padding: 0; border: 0; vertical-align: top; box-sizing: border-box; white-space: pre; overflow: hidden; font-family: inherit; font-size: inherit; text-decoration: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
-        cell_size.1,
-        cell_size.1,
-        cell_size.1,
-        cell_size.1
-    );
+    let sizing = terminal_cell_box_style(cell.symbol().width().max(1) as f64 * cell_size.0, cell_size.1);
 
     format!("{fg_style} {bg_style} {modifier_style} {braille_style} {sizing}")
 }
@@ -124,11 +120,8 @@ pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> Strin
 /// CSS style used for the trailing placeholder cell after a full-width glyph.
 pub(crate) fn get_hidden_cell_style_as_css(cell_size: (f64, f64)) -> String {
     format!(
-        "display: block; flex: 0 0 0px; width: 0; min-width: 0; max-width: 0; height: {}px; min-height: {}px; max-height: {}px; line-height: {}px; margin: 0; padding: 0; border: 0; overflow: hidden; visibility: hidden; box-sizing: border-box; white-space: pre; font-family: inherit; font-size: inherit; text-decoration: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;",
-        cell_size.1,
-        cell_size.1,
-        cell_size.1,
-        cell_size.1
+        "{} visibility: hidden;",
+        terminal_cell_box_style(0.0, cell_size.1)
     )
 }
 
@@ -240,22 +233,6 @@ pub(crate) fn get_raw_screen_size() -> (i32, i32) {
     (s.width().unwrap(), s.height().unwrap())
 }
 
-#[allow(dead_code)]
-/// Returns a buffer based on the screen size.
-pub(crate) fn get_sized_buffer() -> Vec> {
-    let size = get_size();
-    vec![vec![Cell::default(); size.width as usize]; size.height as usize]
-}
-
-/// Returns a buffer size based on the screen size.
-pub(crate) fn get_size() -> Size {
-    if is_mobile() {
-        get_screen_size()
-    } else {
-        get_window_size()
-    }
-}
-
 /// Returns a buffer based on the canvas size.
 pub(crate) fn get_sized_buffer_from_canvas(
     canvas: &HtmlCanvasElement,

From 08c8947444587d35c6abf197924b5603b9537f03 Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 15:48:29 -0700
Subject: [PATCH 07/11] decent

---
 src/backend/dom.rs   | 46 ++++++++++++++++++--------------------------
 src/backend/utils.rs |  1 -
 2 files changed, 19 insertions(+), 28 deletions(-)

diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 1e73a6d..a531239 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -30,6 +30,12 @@ use crate::{
 
 /// Default cell size used as a fallback when measurement fails.
 const DEFAULT_CELL_SIZE: (f64, f64) = (10.0, 20.0);
+const GRID_STYLE: &str =
+    "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
+const GRID_SELECTION_STYLE: &str =
+    "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0; user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;";
+const PROBE_STYLE: &str =
+    "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
 const PROBE_ROW_STYLE: &str =
     "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;";
 const PROBE_SAMPLE_STYLE: &str =
@@ -142,24 +148,6 @@ impl std::fmt::Debug for DomBackend {
 }
 
 impl DomBackend {
-    fn grid_style(mouse_selection: bool) -> String {
-        let mut style = format!(
-            "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; {TERMINAL_FONT_CSS}"
-        );
-        if mouse_selection {
-            style.push_str(
-                " user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;",
-            );
-        }
-        style
-    }
-
-    fn probe_style() -> String {
-        format!(
-            "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; {TERMINAL_FONT_CSS}"
-        )
-    }
-
     /// Constructs a new [`DomBackend`].
     pub fn new() -> Result {
         Self::new_with_options(DomBackendOptions::default())
@@ -230,7 +218,7 @@ impl DomBackend {
     /// `getBoundingClientRect()`, then removes the probe.
     fn measure_cell_size(document: &Document, parent: &Element) -> Result<(f64, f64), Error> {
         let probe = document.create_element("div")?;
-        probe.set_attribute("style", &Self::probe_style())?;
+        probe.set_attribute("style", PROBE_STYLE)?;
 
         let row = document.create_element("div")?;
         row.set_attribute("style", PROBE_ROW_STYLE)?;
@@ -292,11 +280,10 @@ impl DomBackend {
     }
 
     fn selected_text() -> Option {
-        let text: String = window()
+        window()
             .and_then(|window| window.get_selection().ok().flatten())
             .map(|selection| selection.to_string().into())
-            .unwrap_or_default();
-        (!text.trim().is_empty()).then_some(text)
+            .filter(|text: &String| !text.trim().is_empty())
     }
 
     fn copy_selected_text_to_clipboard() -> bool {
@@ -317,7 +304,14 @@ impl DomBackend {
             self.grid.set_class_name("ratzilla-dom-selection-enabled");
         }
         self.grid
-            .set_attribute("style", &Self::grid_style(self.options.mouse_selection()))?;
+            .set_attribute(
+                "style",
+                if self.options.mouse_selection() {
+                    GRID_SELECTION_STYLE
+                } else {
+                    GRID_STYLE
+                },
+            )?;
         self.cells.clear();
         Ok(())
     }
@@ -548,12 +542,10 @@ impl WebEventHandler for DomBackend {
             self.grid_parent.clone(),
             MOUSE_EVENT_TYPES,
             move |event: web_sys::MouseEvent| {
-                if mouse_selection
+                let _ = mouse_selection
                     && event.type_() == "mouseup"
                     && event.button() == 0
-                    && DomBackend::copy_selected_text_to_clipboard()
-                {
-                }
+                    && DomBackend::copy_selected_text_to_clipboard();
 
                 let config = config.borrow();
                 let mouse_event = create_mouse_event(&event, &element, &config);
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index 65c3ff3..627ea6d 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -14,7 +14,6 @@ use web_sys::{
 };
 
 pub(crate) const TERMINAL_FONT: &str = "16px 'Iosevka', monospace";
-pub(crate) const TERMINAL_FONT_CSS: &str = "font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
 
 pub struct CssAttribute {
     pub field: &'static str,

From 927bfe7fe00b61a27381b295d759713c123d5852 Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 16:11:46 -0700
Subject: [PATCH 08/11] check

---
 src/backend/canvas.rs    | 327 ++++++++++++++++++---------------------
 src/backend/dom.rs       |  32 ++--
 src/backend/mod.rs       |   5 +-
 src/backend/selection.rs |   9 ++
 src/backend/utils.rs     |  49 +++++-
 src/backend/webgl2.rs    |   9 +-
 src/lib.rs               |   3 +-
 7 files changed, 228 insertions(+), 206 deletions(-)
 create mode 100644 src/backend/selection.rs

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index a29cf97..5d457b6 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -1,3 +1,4 @@
+use bitvec::{bitvec, prelude::BitVec};
 use ratatui::{backend::ClearType, layout::Rect};
 use std::{
     cell::RefCell,
@@ -12,6 +13,7 @@ use crate::{
         event_callback::{
             create_mouse_event, EventCallback, MouseConfig, KEY_EVENT_TYPES, MOUSE_EVENT_TYPES,
         },
+        selection::SelectionMode,
         utils::*,
     },
     error::Error,
@@ -37,16 +39,6 @@ const DEFAULT_CELL_WIDTH: f64 = 10.0;
 /// Default height of a single cell when measurement fails.
 const DEFAULT_CELL_HEIGHT: f64 = 19.0;
 
-/// Mouse selection mode for the canvas backend.
-#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
-pub enum SelectionMode {
-    /// Select text linearly, following text flow.
-    #[default]
-    Linear,
-    /// Select a rectangular block of cells.
-    Block,
-}
-
 /// Options for the [`CanvasBackend`].
 #[derive(Debug, Default)]
 pub struct CanvasBackendOptions {
@@ -54,6 +46,11 @@ pub struct CanvasBackendOptions {
     grid_id: Option,
     /// Override the automatically detected size.
     size: Option<(u32, u32)>,
+    /// Always clip foreground drawing to the cell rectangle. Helpful when
+    /// dealing with out-of-bounds rendering from problematic fonts. Enabling
+    /// this option may cause some performance issues when dealing with large
+    /// numbers of simultaneous changes.
+    always_clip_cells: bool,
     /// Optional mouse selection mode.
     selection_mode: Option,
 }
@@ -76,9 +73,9 @@ impl CanvasBackendOptions {
         self
     }
 
-    /// Enable mouse selection with the default mode.
+    /// Enable mouse selection with the canvas backend's default mode.
     pub fn enable_mouse_selection(self) -> Self {
-        self.enable_mouse_selection_with_mode(SelectionMode::default())
+        self.enable_mouse_selection_with_mode(SelectionMode::Linear)
     }
 
     /// Enable mouse selection with the provided mode.
@@ -156,39 +153,13 @@ impl SelectionState {
 struct Canvas {
     /// Canvas element.
     inner: web_sys::HtmlCanvasElement,
-    /// Visible rendering context.
-    display_context: web_sys::CanvasRenderingContext2d,
-    /// Offscreen frame canvas.
-    frame: web_sys::HtmlCanvasElement,
-    /// Offscreen frame context used for all drawing operations.
-    frame_context: web_sys::CanvasRenderingContext2d,
+    /// Rendering context.
+    context: web_sys::CanvasRenderingContext2d,
     /// Background color.
     background_color: Color,
 }
 
 impl Canvas {
-    fn create_context(
-        canvas: &web_sys::HtmlCanvasElement,
-    ) -> Result {
-        let context_options = Map::new();
-        context_options.set(&JsValue::from_str("alpha"), &Boolean::from(JsValue::FALSE));
-
-        canvas
-            .get_context_with_context_options("2d", &context_options)?
-            .ok_or_else(|| Error::UnableToRetrieveCanvasContext)?
-            .dyn_into::()
-            .map_err(|_| Error::UnableToRetrieveCanvasContext)
-    }
-
-    fn configure_text_context(context: &web_sys::CanvasRenderingContext2d) {
-        context.set_font(TERMINAL_FONT);
-        context.set_text_align("left");
-        context.set_text_baseline("alphabetic");
-        context.set_image_smoothing_enabled(false);
-        context.set_shadow_blur(0.0);
-        context.set_global_alpha(1.0);
-    }
-
     /// Constructs a new [`Canvas`].
     fn new(
         parent_element: web_sys::Element,
@@ -197,23 +168,26 @@ impl Canvas {
         background_color: Color,
     ) -> Result {
         let canvas = create_canvas_in_element(&parent_element, width, height)?;
-        let display_context = Self::create_context(&canvas)?;
 
-        let frame = get_document()?
-            .create_element("canvas")?
-            .dyn_into::()
+        let context_options = Map::new();
+        context_options.set(&JsValue::from_str("alpha"), &Boolean::from(JsValue::TRUE));
+        context_options.set(
+            &JsValue::from_str("desynchronized"),
+            &Boolean::from(JsValue::TRUE),
+        );
+        let context = canvas
+            .get_context_with_context_options("2d", &context_options)?
+            .ok_or(Error::UnableToRetrieveCanvasContext)?
+            .dyn_into::()
             .map_err(|_| Error::UnableToRetrieveCanvasContext)?;
-        frame.set_width(width);
-        frame.set_height(height);
-
-        let frame_context = Self::create_context(&frame)?;
-        Self::configure_text_context(&frame_context);
+        context.set_font(TERMINAL_FONT);
+        context.set_text_align("left");
+        context.set_text_baseline("alphabetic");
+        context.set_image_smoothing_enabled(false);
 
         Ok(Self {
             inner: canvas,
-            display_context,
-            frame,
-            frame_context,
+            context,
             background_color,
         })
     }
@@ -226,10 +200,17 @@ impl Canvas {
 pub struct CanvasBackend {
     /// Whether the canvas has been initialized.
     initialized: bool,
+    /// Always clip foreground drawing to the cell rectangle. Helpful when
+    /// dealing with out-of-bounds rendering from problematic fonts. Enabling
+    /// this option may cause some performance issues when dealing with large
+    /// numbers of simultaneous changes.
+    always_clip_cells: bool,
     /// Current buffer.
     buffer: Vec>,
     /// Previous buffer.
     prev_buffer: Vec>,
+    /// Changed buffer cells.
+    changed_cells: BitVec,
     /// Canvas.
     canvas: Canvas,
     /// Measured cell width in CSS pixels.
@@ -268,17 +249,21 @@ impl CanvasBackend {
         (left, top, (right - left).max(1.0), (bottom - top).max(1.0))
     }
 
+    fn symbol_position(&self, x: usize, y: usize) -> (f64, f64) {
+        let (left, top, _, _) = self.grid_rect(x, y, 1, 1);
+        (left, top + self.text_baseline_offset)
+    }
+
     fn content_draw_size(&self) -> (f64, f64) {
         let (grid_width, grid_height) = self.canvas_grid_size();
-        let width = (grid_width as f64 * self.cell_width).ceil();
-        let height = (grid_height as f64 * self.cell_height).ceil();
-        (width, height)
+        (
+            (grid_width as f64 * self.cell_width).ceil(),
+            (grid_height as f64 * self.cell_height).ceil(),
+        )
     }
 
     fn content_offset(&self) -> (f64, f64) {
         let (content_width, content_height) = self.content_draw_size();
-        // Snap the centered origin to whole pixels so adjacent cell backgrounds
-        // share exact edges instead of landing on half-pixel seams.
         let offset_x =
             (((self.canvas.inner.client_width() as f64 - content_width) / 2.0).max(0.0)).round();
         let offset_y =
@@ -286,16 +271,11 @@ impl CanvasBackend {
         (offset_x, offset_y)
     }
 
-    fn symbol_position(&self, x: usize, y: usize) -> (f64, f64) {
-        let (left, top, _, _) = self.grid_rect(x, y, 1, 1);
-        (left, top + self.text_baseline_offset)
-    }
-
     fn selection_range(&self) -> Option {
         self.selection_state.borrow().active
     }
 
-    fn selection_revision(&self) -> u64 {
+    fn selection_state_revision(&self) -> u64 {
         self.selection_state.borrow().revision
     }
 
@@ -403,18 +383,6 @@ impl CanvasBackend {
         (ascent + ((cell_height - (ascent + descent)).max(0.0) / 2.0)).round()
     }
 
-    fn present(&self) -> Result<(), Error> {
-        self.canvas.display_context.save();
-        self.canvas
-            .display_context
-            .set_global_composite_operation("copy")?;
-        self.canvas
-            .display_context
-            .draw_image_with_html_canvas_element(&self.canvas.frame, 0.0, 0.0)?;
-        self.canvas.display_context.restore();
-        Ok(())
-    }
-
     fn canvas_grid_size(&self) -> (usize, usize) {
         let width = ((self.canvas.inner.client_width() as f64) / self.cell_width)
             .floor()
@@ -426,30 +394,18 @@ impl CanvasBackend {
     }
 
     fn sync_canvas_size(&mut self) {
-        let width = self.canvas.inner.width();
-        let height = self.canvas.inner.height();
-
-        if self.canvas.frame.width() != width || self.canvas.frame.height() != height {
-            self.canvas.frame.set_width(width);
-            self.canvas.frame.set_height(height);
-            Canvas::configure_text_context(&self.canvas.frame_context);
-            self.canvas
-                .display_context
-                .set_image_smoothing_enabled(false);
-            self.initialized = false;
-        }
-
         let (grid_width, grid_height) = self.canvas_grid_size();
-        let needs_buffer_resize = self.buffer.len() != grid_height
+        let needs_resize = self.buffer.len() != grid_height
             || self
                 .buffer
                 .first()
                 .map(|line| line.len() != grid_width)
                 .unwrap_or(true);
 
-        if needs_buffer_resize {
+        if needs_resize {
             self.buffer = vec![vec![Cell::default(); grid_width]; grid_height];
             self.prev_buffer = self.buffer.clone();
+            self.changed_cells = bitvec![0; grid_width * grid_height];
             self.initialized = false;
         }
     }
@@ -501,21 +457,22 @@ impl CanvasBackend {
 
     /// Constructs a new [`CanvasBackend`] with the given options.
     pub fn new_with_options(options: CanvasBackendOptions) -> Result {
-        // Parent element of canvas (uses  unless specified)
         let parent = get_element_by_id_or_body(options.grid_id.as_ref())?;
-
         let (width, height) = options
             .size
             .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32));
 
         let cell_size = Self::measure_cell_size(&parent)?;
         let canvas = Canvas::new(parent, width, height, Color::Black)?;
-        let text_baseline_offset = Self::measure_text_baseline(&canvas.frame_context, cell_size.1);
+        let text_baseline_offset = Self::measure_text_baseline(&canvas.context, cell_size.1);
         let buffer = get_sized_buffer_from_canvas(&canvas.inner, cell_size.0, cell_size.1);
+        let changed_cells = bitvec![0; buffer.len() * buffer[0].len()];
         Ok(Self {
             prev_buffer: buffer.clone(),
+            always_clip_cells: options.always_clip_cells,
             buffer,
             initialized: false,
+            changed_cells,
             canvas,
             cell_width: cell_size.0,
             cell_height: cell_size.1,
@@ -567,21 +524,24 @@ impl CanvasBackend {
         self.debug_mode = color.map(Into::into);
     }
 
-    // Redraw the entire offscreen frame, then present it in a single blit.
-    fn render_frame(&mut self) -> Result<(), Error> {
-        let background = get_canvas_color(self.canvas.background_color, Color::Black);
-        self.canvas.frame_context.set_fill_style_str(&background);
-        self.canvas.frame_context.fill_rect(
-            0.0,
-            0.0,
-            self.canvas.frame.width() as f64,
-            self.canvas.frame.height() as f64,
-        );
+    // Compare the current buffer to the previous buffer and updates the canvas
+    // accordingly.
+    //
+    // If `force_redraw` is `true`, the entire canvas will be cleared and redrawn.
+    fn update_grid(&mut self, force_redraw: bool) -> Result<(), Error> {
+        if force_redraw {
+            self.canvas.context.clear_rect(
+                0.0,
+                0.0,
+                self.canvas.inner.client_width() as f64,
+                self.canvas.inner.client_height() as f64,
+            );
+        }
+
         let (offset_x, offset_y) = self.content_offset();
-        self.canvas
-            .frame_context
-            .translate(offset_x, offset_y)?;
+        self.canvas.context.translate(offset_x, offset_y)?;
 
+        self.resolve_changed_cells(force_redraw);
         self.draw_background()?;
         self.draw_selection()?;
         self.draw_symbols()?;
@@ -590,13 +550,23 @@ impl CanvasBackend {
             self.draw_debug()?;
         }
 
-        self.canvas
-            .frame_context
-            .translate(-offset_x, -offset_y)?;
-        self.present()?;
+        self.canvas.context.translate(-offset_x, -offset_y)?;
         Ok(())
     }
 
+    /// Updates the representation of the changed cells.
+    fn resolve_changed_cells(&mut self, force_redraw: bool) {
+        let mut index = 0;
+        for (y, line) in self.buffer.iter().enumerate() {
+            for (x, cell) in line.iter().enumerate() {
+                let prev_cell = &self.prev_buffer[y][x];
+                self.changed_cells
+                    .set(index, force_redraw || cell != prev_cell);
+                index += 1;
+            }
+        }
+    }
+
     fn draw_selection(&mut self) -> Result<(), Error> {
         let Some(mode) = self.selection_mode else {
             return Ok(());
@@ -605,9 +575,9 @@ impl CanvasBackend {
             return Ok(());
         };
 
-        self.canvas.frame_context.save();
+        self.canvas.context.save();
         self.canvas
-            .frame_context
+            .context
             .set_fill_style_str("rgba(170, 190, 230, 0.24)");
 
         for (row_idx, row) in self.buffer.iter().enumerate() {
@@ -621,11 +591,11 @@ impl CanvasBackend {
 
             let (start_x, start_y, width, height) = self.grid_rect(start, row_idx, end - start, 1);
             self.canvas
-                .frame_context
+                .context
                 .fill_rect(start_x, start_y, width, height);
         }
 
-        self.canvas.frame_context.restore();
+        self.canvas.context.restore();
         Ok(())
     }
 
@@ -639,35 +609,55 @@ impl CanvasBackend {
     /// Rather than saving/restoring the canvas context for every cell (which would be expensive),
     /// this implementation:
     ///
-    /// Tracks the last foreground color used to avoid unnecessary style changes.
-    ///
+    /// 1. Only processes cells that have changed since the last render.
+    /// 2. Tracks the last foreground color used to avoid unnecessary style changes
+    /// 3. Only creates clipping paths for potentially problematic glyphs (non-ASCII)
+    /// or when `always_clip_cells` is enabled.
     fn draw_symbols(&mut self) -> Result<(), Error> {
-        self.canvas.frame_context.save();
+        let changed_cells = &self.changed_cells;
+        let mut index = 0;
+
+        self.canvas.context.save();
         let mut last_color = None;
         for (y, line) in self.buffer.iter().enumerate() {
             for (x, cell) in line.iter().enumerate() {
-                if cell.symbol() == " " {
+                if !changed_cells[index] || cell.symbol() == " " {
+                    index += 1;
                     continue;
                 }
                 let color = actual_fg_color(cell);
 
-                if last_color != Some(color) {
-                    self.canvas.frame_context.restore();
-                    self.canvas.frame_context.save();
+                if self.always_clip_cells || !cell.symbol().is_ascii() {
+                    self.canvas.context.restore();
+                    self.canvas.context.save();
+
+                    let (left, top, width, height) = self.grid_rect(x, y, 1, 1);
+                    self.canvas.context.begin_path();
+                    self.canvas.context.rect(left, top, width, height);
+                    self.canvas.context.clip();
+
+                    last_color = None;
+                    let color = get_canvas_color(color, Color::White);
+                    self.canvas.context.set_fill_style_str(&color);
+                } else if last_color != Some(color) {
+                    self.canvas.context.restore();
+                    self.canvas.context.save();
 
                     last_color = Some(color);
 
                     let color = get_canvas_color(color, Color::White);
-                    self.canvas.frame_context.set_fill_style_str(&color);
+                    self.canvas.context.set_fill_style_str(&color);
                 }
 
                 let (text_x, text_y) = self.symbol_position(x, y);
                 self.canvas
-                    .frame_context
+                    .context
                     .fill_text(cell.symbol(), text_x, text_y)?;
+
+                index += 1;
             }
         }
-        self.canvas.frame_context.restore();
+        self.canvas.context.restore();
 
         Ok(())
     }
@@ -676,11 +666,9 @@ impl CanvasBackend {
     ///
     /// This function uses [`RowColorOptimizer`] to optimize the drawing of the background
     /// colors by batching adjacent cells with the same color into a single rectangle.
-    ///
-    /// In other words, it accumulates "what to draw" until it finds a different
-    /// color, and then it draws the accumulated rectangle.
     fn draw_background(&mut self) -> Result<(), Error> {
-        self.canvas.frame_context.save();
+        let changed_cells = &self.changed_cells;
+        self.canvas.context.save();
 
         let draw_region = |(rect, color): (Rect, Color)| {
             let color = get_canvas_color(color, self.canvas.background_color);
@@ -691,23 +679,29 @@ impl CanvasBackend {
                 rect.height as usize,
             );
 
-            self.canvas.frame_context.set_fill_style_str(&color);
+            self.canvas.context.set_fill_style_str(&color);
             self.canvas
-                .frame_context
+                .context
                 .fill_rect(start_x, start_y, width, height);
         };
 
+        let mut index = 0;
         for (y, line) in self.buffer.iter().enumerate() {
             let mut row_renderer = RowColorOptimizer::new();
             for (x, cell) in line.iter().enumerate() {
-                row_renderer
-                    .process_color((x, y), actual_bg_color(cell))
-                    .map(draw_region);
+                if changed_cells[index] {
+                    row_renderer
+                        .process_color((x, y), actual_bg_color(cell))
+                        .map(draw_region);
+                } else {
+                    row_renderer.flush().map(draw_region);
+                }
+                index += 1;
             }
             row_renderer.flush().map(draw_region);
         }
 
-        self.canvas.frame_context.restore();
+        self.canvas.context.restore();
 
         Ok(())
     }
@@ -718,15 +712,12 @@ impl CanvasBackend {
             let cell = &self.buffer[pos.y as usize][pos.x as usize];
 
             if cell.modifier.contains(Modifier::UNDERLINED) {
-                self.canvas.frame_context.save();
+                self.canvas.context.save();
 
-                self.canvas.frame_context.fill_text(
-                    "_",
-                    pos.x as f64 * self.cell_width,
-                    pos.y as f64 * self.cell_height,
-                )?;
+                let (text_x, text_y) = self.symbol_position(pos.x as usize, pos.y as usize);
+                self.canvas.context.fill_text("_", text_x, text_y)?;
 
-                self.canvas.frame_context.restore();
+                self.canvas.context.restore();
             }
         }
 
@@ -735,22 +726,18 @@ impl CanvasBackend {
 
     /// Draws cell boundaries for debugging.
     fn draw_debug(&mut self) -> Result<(), Error> {
-        self.canvas.frame_context.save();
+        self.canvas.context.save();
 
         let color = self.debug_mode.as_ref().unwrap();
         for (y, line) in self.buffer.iter().enumerate() {
             for (x, _) in line.iter().enumerate() {
-                self.canvas.frame_context.set_stroke_style_str(color);
-                self.canvas.frame_context.stroke_rect(
-                    x as f64 * self.cell_width,
-                    y as f64 * self.cell_height,
-                    self.cell_width,
-                    self.cell_height,
-                );
+                let (left, top, width, height) = self.grid_rect(x, y, 1, 1);
+                self.canvas.context.set_stroke_style_str(color);
+                self.canvas.context.stroke_rect(left, top, width, height);
             }
         }
 
-        self.canvas.frame_context.restore();
+        self.canvas.context.restore();
 
         Ok(())
     }
@@ -758,7 +745,8 @@ impl CanvasBackend {
 
 impl CellSized for CanvasBackend {
     fn cell_size_px(&self) -> (f32, f32) {
-        (self.cell_width as f32, self.cell_height as f32)
+        let dpr = get_device_pixel_ratio();
+        (self.cell_width as f32 * dpr, self.cell_height as f32 * dpr)
     }
 
     fn cell_size_css_px(&self) -> (f32, f32) {
@@ -784,7 +772,6 @@ impl Backend for CanvasBackend {
             line[x] = cell.clone();
         }
 
-        // Draw the cursor if set
         if let Some(pos) = self.cursor_position {
             let y = pos.y as usize;
             let x = pos.x as usize;
@@ -804,16 +791,20 @@ impl Backend for CanvasBackend {
     /// actually render the content to the screen.
     fn flush(&mut self) -> IoResult<()> {
         self.sync_canvas_size();
-        let selection_revision = self.selection_revision();
+        let selection_revision = self.selection_state_revision();
 
-        if !self.initialized
-            || self.buffer != self.prev_buffer
-            || self.selection_revision != selection_revision
-        {
-            self.render_frame()?;
+        if !self.initialized {
+            self.update_grid(true)?;
             self.prev_buffer = self.buffer.clone();
             self.initialized = true;
             self.selection_revision = selection_revision;
+        } else if self.selection_revision != selection_revision {
+            self.update_grid(true)?;
+            self.prev_buffer = self.buffer.clone();
+            self.selection_revision = selection_revision;
+        } else if self.buffer != self.prev_buffer {
+            self.update_grid(false)?;
+            self.prev_buffer = self.buffer.clone();
         }
 
         let should_copy = {
@@ -860,6 +851,7 @@ impl Backend for CanvasBackend {
         self.buffer =
             get_sized_buffer_from_canvas(&self.canvas.inner, self.cell_width, self.cell_height);
         self.prev_buffer = self.buffer.clone();
+        self.changed_cells = bitvec![0; self.buffer.len() * self.buffer[0].len()];
         self.initialized = false;
         Ok(())
     }
@@ -908,16 +900,13 @@ impl WebEventHandler for CanvasBackend {
     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 (offset_x, offset_y) = self.content_offset();
         let config = MouseConfig::new(grid_width, grid_height)
-            .with_offsets(self.content_offset().0, self.content_offset().1)
+            .with_offsets(offset_x, offset_y)
             .with_cell_dimensions(self.cell_width, self.cell_height);
 
         let element: web_sys::Element = self.canvas.inner.clone().into();
@@ -925,7 +914,6 @@ impl WebEventHandler for CanvasBackend {
         let selection_state = self.selection_state.clone();
         let selection_mode = self.selection_mode;
 
-        // Create mouse event callback
         let mouse_callback = EventCallback::new(
             element,
             MOUSE_EVENT_TYPES,
@@ -957,7 +945,6 @@ impl WebEventHandler for CanvasBackend {
     }
 
     fn clear_mouse_events(&mut self) {
-        // Drop the callback, which will remove the event listeners
         self.mouse_callback = None;
     }
 
@@ -965,12 +952,9 @@ impl WebEventHandler for CanvasBackend {
     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")
@@ -981,8 +965,8 @@ impl WebEventHandler for CanvasBackend {
             element,
             KEY_EVENT_TYPES,
             move |event: web_sys::KeyboardEvent| {
-                let is_copy = (event.ctrl_key() || event.meta_key())
-                    && matches!(event.key().as_str(), "c" | "C");
+                let is_copy =
+                    (event.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("c");
                 if is_copy && selection_state.borrow().active.is_some() {
                     event.prevent_default();
                     let mut selection_state = selection_state.borrow_mut();
@@ -1023,10 +1007,8 @@ impl RowColorOptimizer {
     fn process_color(&mut self, pos: (usize, usize), color: Color) -> Option<(Rect, Color)> {
         if let Some((active_rect, active_color)) = self.pending_region.as_mut() {
             if active_color == &color {
-                // Same color: extend the rectangle
                 active_rect.width += 1;
             } else {
-                // Different color: flush the previous region and start a new one
                 let region = *active_rect;
                 let region_color = *active_color;
                 *active_rect = Rect::new(pos.0 as _, pos.1 as _, 1, 1);
@@ -1034,7 +1016,6 @@ impl RowColorOptimizer {
                 return Some((region, region_color));
             }
         } else {
-            // First color: create a new rectangle
             let rect = Rect::new(pos.0 as _, pos.1 as _, 1, 1);
             self.pending_region = Some((rect, color));
         }
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index a531239..a34bb91 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -30,16 +30,6 @@ use crate::{
 
 /// Default cell size used as a fallback when measurement fails.
 const DEFAULT_CELL_SIZE: (f64, f64) = (10.0, 20.0);
-const GRID_STYLE: &str =
-    "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
-const GRID_SELECTION_STYLE: &str =
-    "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0; user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;";
-const PROBE_STYLE: &str =
-    "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
-const PROBE_ROW_STYLE: &str =
-    "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;";
-const PROBE_SAMPLE_STYLE: &str =
-    "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
 
 /// Options for the [`DomBackend`].
 #[derive(Debug, Default)]
@@ -218,15 +208,15 @@ impl DomBackend {
     /// `getBoundingClientRect()`, then removes the probe.
     fn measure_cell_size(document: &Document, parent: &Element) -> Result<(f64, f64), Error> {
         let probe = document.create_element("div")?;
-        probe.set_attribute("style", PROBE_STYLE)?;
+        probe.set_attribute("style", &terminal_probe_style())?;
 
         let row = document.create_element("div")?;
-        row.set_attribute("style", PROBE_ROW_STYLE)?;
+        row.set_attribute("style", terminal_probe_row_style())?;
 
         let sample = document.create_element("span")?;
         let sample_text = "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM";
         sample.set_text_content(Some(sample_text));
-        sample.set_attribute("style", PROBE_SAMPLE_STYLE)?;
+        sample.set_attribute("style", &terminal_probe_sample_style())?;
 
         row.append_child(&sample)?;
         probe.append_child(&row)?;
@@ -303,15 +293,10 @@ impl DomBackend {
         if self.options.mouse_selection() {
             self.grid.set_class_name("ratzilla-dom-selection-enabled");
         }
-        self.grid
-            .set_attribute(
-                "style",
-                if self.options.mouse_selection() {
-                    GRID_SELECTION_STYLE
-                } else {
-                    GRID_STYLE
-                },
-            )?;
+        self.grid.set_attribute(
+            "style",
+            &terminal_grid_style(self.options.mouse_selection()),
+        )?;
         self.cells.clear();
         Ok(())
     }
@@ -349,7 +334,8 @@ impl DomBackend {
 
 impl CellSized for DomBackend {
     fn cell_size_px(&self) -> (f32, f32) {
-        (self.cell_size.0 as f32, self.cell_size.1 as f32)
+        let dpr = get_device_pixel_ratio();
+        (self.cell_size.0 as f32 * dpr, self.cell_size.1 as f32 * dpr)
     }
 
     fn cell_size_css_px(&self) -> (f32, f32) {
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index fe22a32..a6d6562 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -23,7 +23,7 @@
 //! | **60fps on large terminals** | ✗          | ✗             | ✓              |
 //! | **Memory Usage**             | Highest    | Medium        | Lowest         |
 //! | **Hyperlinks**               | ✗          | ✗             | ✓              |
-//! | **Text Selection**           | Linear     | ✗             | Linear/Block   |
+//! | **Text Selection**           | Linear     | Linear/Block  | Linear/Block   |
 //! | **Unicode/Emoji Support**    | Full       | Limited²      | Full¹          |
 //! | **Dynamic Characters**       | ✓          | ✓             | ✓¹             |
 //! | **Font Variants**            | ✓          | Regular only  | ✓              |
@@ -70,6 +70,9 @@ pub mod dom;
 /// WebGL2 backend.
 pub mod webgl2;
 
+/// Shared selection types.
+pub mod selection;
+
 /// Color handling.
 mod color;
 /// Event callback management.
diff --git a/src/backend/selection.rs b/src/backend/selection.rs
new file mode 100644
index 0000000..791606e
--- /dev/null
+++ b/src/backend/selection.rs
@@ -0,0 +1,9 @@
+/// Mouse selection mode shared by backends that support text selection.
+#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
+pub enum SelectionMode {
+    /// Select text linearly, following text flow.
+    Linear,
+    /// Select a rectangular block of cells.
+    #[default]
+    Block,
+}
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index 627ea6d..ce66efb 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -1,7 +1,4 @@
-use crate::{
-    backend::color::ansi_to_rgb,
-    error::Error,
-};
+use crate::{backend::color::ansi_to_rgb, error::Error};
 use compact_str::{format_compact, CompactString};
 use ratatui::{
     buffer::Cell,
@@ -15,6 +12,40 @@ use web_sys::{
 
 pub(crate) const TERMINAL_FONT: &str = "16px 'Iosevka', monospace";
 
+fn terminal_font_style() -> &'static str {
+    "font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;"
+}
+
+pub(crate) fn terminal_grid_style(enable_selection: bool) -> CompactString {
+    let mut style = format_compact!(
+        "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; {}",
+        terminal_font_style()
+    );
+    if enable_selection {
+        style.push_str(
+            " user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;",
+        );
+    }
+    style
+}
+
+pub(crate) fn terminal_probe_style() -> CompactString {
+    format_compact!(
+        "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; {}",
+        terminal_font_style()
+    )
+}
+
+pub(crate) fn terminal_probe_row_style() -> &'static str {
+    "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;"
+}
+
+pub(crate) fn terminal_probe_sample_style() -> CompactString {
+    format_compact!(
+        "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;"
+    )
+}
+
 pub struct CssAttribute {
     pub field: &'static str,
     pub value: Option<&'static str>,
@@ -111,7 +142,10 @@ pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> Strin
         ""
     };
 
-    let sizing = terminal_cell_box_style(cell.symbol().width().max(1) as f64 * cell_size.0, cell_size.1);
+    let sizing = terminal_cell_box_style(
+        cell.symbol().width().max(1) as f64 * cell_size.0,
+        cell_size.1,
+    );
 
     format!("{fg_style} {bg_style} {modifier_style} {braille_style} {sizing}")
 }
@@ -259,6 +293,11 @@ pub(crate) fn get_window() -> Result {
     window().ok_or(Error::UnableToRetrieveWindow)
 }
 
+/// Returns the device pixel ratio from the window.
+pub(crate) fn get_device_pixel_ratio() -> f32 {
+    get_window().map(|w| w.device_pixel_ratio()).unwrap_or(1.0) as f32
+}
+
 /// Returns an element by its ID or the body element if no ID is provided.
 pub(crate) fn get_element_by_id_or_body(id: Option<&String>) -> Result {
     match id {
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 8eb308f..65ddbac 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -3,6 +3,7 @@ use crate::{
         cell_sized::CellSized,
         color::to_rgb,
         event_callback::{EventCallback, KEY_EVENT_TYPES},
+        selection::SelectionMode,
         utils::*,
     },
     error::Error,
@@ -10,7 +11,6 @@ use crate::{
     render::WebEventHandler,
     CursorShape,
 };
-pub use beamterm_renderer::SelectionMode;
 use beamterm_renderer::{
     mouse::*, CellData, CursorPosition, GlyphEffect, Terminal as Beamterm, Terminal,
 };
@@ -172,7 +172,7 @@ impl WebGl2BackendOptions {
     /// let options = WebGl2BackendOptions::new()
     ///     .font_atlas_config(FontAtlasConfig::dynamic(
     ///         // monospace is an implicit fallback font in browsers
-    ///         &["Iosevka"],
+    ///         &["JetBrains Mono"],
     ///         16.0
     ///     ));
     /// ```
@@ -659,7 +659,10 @@ impl WebGl2Backend {
         let beamterm = if let Some(mode) = options.mouse_selection_mode {
             beamterm.mouse_selection_handler(
                 MouseSelectOptions::new()
-                    .selection_mode(mode)
+                    .selection_mode(match mode {
+                        SelectionMode::Linear => beamterm_renderer::SelectionMode::Linear,
+                        SelectionMode::Block => beamterm_renderer::SelectionMode::Block,
+                    })
                     .trim_trailing_whitespace(true),
             )
         } else {
diff --git a/src/lib.rs b/src/lib.rs
index 3c66396..ba9a81f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -30,6 +30,7 @@ pub use backend::{
     cell_sized::CellSized,
     cursor::CursorShape,
     dom::DomBackend,
-    webgl2::{FontAtlasConfig, SelectionMode, WebGl2Backend},
+    selection::SelectionMode,
+    webgl2::{FontAtlasConfig, WebGl2Backend},
 };
 pub use render::{WebEventHandler, WebRenderer};

From 4c9b3e0537415f9631a4c12d388055f2c209ea6e Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 16:19:12 -0700
Subject: [PATCH 09/11] Revert "check"

This reverts commit 927bfe7fe00b61a27381b295d759713c123d5852.
---
 src/backend/canvas.rs    | 327 +++++++++++++++++++++------------------
 src/backend/dom.rs       |  32 ++--
 src/backend/mod.rs       |   5 +-
 src/backend/selection.rs |   9 --
 src/backend/utils.rs     |  49 +-----
 src/backend/webgl2.rs    |   9 +-
 src/lib.rs               |   3 +-
 7 files changed, 206 insertions(+), 228 deletions(-)
 delete mode 100644 src/backend/selection.rs

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index 5d457b6..a29cf97 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -1,4 +1,3 @@
-use bitvec::{bitvec, prelude::BitVec};
 use ratatui::{backend::ClearType, layout::Rect};
 use std::{
     cell::RefCell,
@@ -13,7 +12,6 @@ use crate::{
         event_callback::{
             create_mouse_event, EventCallback, MouseConfig, KEY_EVENT_TYPES, MOUSE_EVENT_TYPES,
         },
-        selection::SelectionMode,
         utils::*,
     },
     error::Error,
@@ -39,6 +37,16 @@ const DEFAULT_CELL_WIDTH: f64 = 10.0;
 /// Default height of a single cell when measurement fails.
 const DEFAULT_CELL_HEIGHT: f64 = 19.0;
 
+/// Mouse selection mode for the canvas backend.
+#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
+pub enum SelectionMode {
+    /// Select text linearly, following text flow.
+    #[default]
+    Linear,
+    /// Select a rectangular block of cells.
+    Block,
+}
+
 /// Options for the [`CanvasBackend`].
 #[derive(Debug, Default)]
 pub struct CanvasBackendOptions {
@@ -46,11 +54,6 @@ pub struct CanvasBackendOptions {
     grid_id: Option,
     /// Override the automatically detected size.
     size: Option<(u32, u32)>,
-    /// Always clip foreground drawing to the cell rectangle. Helpful when
-    /// dealing with out-of-bounds rendering from problematic fonts. Enabling
-    /// this option may cause some performance issues when dealing with large
-    /// numbers of simultaneous changes.
-    always_clip_cells: bool,
     /// Optional mouse selection mode.
     selection_mode: Option,
 }
@@ -73,9 +76,9 @@ impl CanvasBackendOptions {
         self
     }
 
-    /// Enable mouse selection with the canvas backend's default mode.
+    /// Enable mouse selection with the default mode.
     pub fn enable_mouse_selection(self) -> Self {
-        self.enable_mouse_selection_with_mode(SelectionMode::Linear)
+        self.enable_mouse_selection_with_mode(SelectionMode::default())
     }
 
     /// Enable mouse selection with the provided mode.
@@ -153,13 +156,39 @@ impl SelectionState {
 struct Canvas {
     /// Canvas element.
     inner: web_sys::HtmlCanvasElement,
-    /// Rendering context.
-    context: web_sys::CanvasRenderingContext2d,
+    /// Visible rendering context.
+    display_context: web_sys::CanvasRenderingContext2d,
+    /// Offscreen frame canvas.
+    frame: web_sys::HtmlCanvasElement,
+    /// Offscreen frame context used for all drawing operations.
+    frame_context: web_sys::CanvasRenderingContext2d,
     /// Background color.
     background_color: Color,
 }
 
 impl Canvas {
+    fn create_context(
+        canvas: &web_sys::HtmlCanvasElement,
+    ) -> Result {
+        let context_options = Map::new();
+        context_options.set(&JsValue::from_str("alpha"), &Boolean::from(JsValue::FALSE));
+
+        canvas
+            .get_context_with_context_options("2d", &context_options)?
+            .ok_or_else(|| Error::UnableToRetrieveCanvasContext)?
+            .dyn_into::()
+            .map_err(|_| Error::UnableToRetrieveCanvasContext)
+    }
+
+    fn configure_text_context(context: &web_sys::CanvasRenderingContext2d) {
+        context.set_font(TERMINAL_FONT);
+        context.set_text_align("left");
+        context.set_text_baseline("alphabetic");
+        context.set_image_smoothing_enabled(false);
+        context.set_shadow_blur(0.0);
+        context.set_global_alpha(1.0);
+    }
+
     /// Constructs a new [`Canvas`].
     fn new(
         parent_element: web_sys::Element,
@@ -168,26 +197,23 @@ impl Canvas {
         background_color: Color,
     ) -> Result {
         let canvas = create_canvas_in_element(&parent_element, width, height)?;
+        let display_context = Self::create_context(&canvas)?;
 
-        let context_options = Map::new();
-        context_options.set(&JsValue::from_str("alpha"), &Boolean::from(JsValue::TRUE));
-        context_options.set(
-            &JsValue::from_str("desynchronized"),
-            &Boolean::from(JsValue::TRUE),
-        );
-        let context = canvas
-            .get_context_with_context_options("2d", &context_options)?
-            .ok_or(Error::UnableToRetrieveCanvasContext)?
-            .dyn_into::()
+        let frame = get_document()?
+            .create_element("canvas")?
+            .dyn_into::()
             .map_err(|_| Error::UnableToRetrieveCanvasContext)?;
-        context.set_font(TERMINAL_FONT);
-        context.set_text_align("left");
-        context.set_text_baseline("alphabetic");
-        context.set_image_smoothing_enabled(false);
+        frame.set_width(width);
+        frame.set_height(height);
+
+        let frame_context = Self::create_context(&frame)?;
+        Self::configure_text_context(&frame_context);
 
         Ok(Self {
             inner: canvas,
-            context,
+            display_context,
+            frame,
+            frame_context,
             background_color,
         })
     }
@@ -200,17 +226,10 @@ impl Canvas {
 pub struct CanvasBackend {
     /// Whether the canvas has been initialized.
     initialized: bool,
-    /// Always clip foreground drawing to the cell rectangle. Helpful when
-    /// dealing with out-of-bounds rendering from problematic fonts. Enabling
-    /// this option may cause some performance issues when dealing with large
-    /// numbers of simultaneous changes.
-    always_clip_cells: bool,
     /// Current buffer.
     buffer: Vec>,
     /// Previous buffer.
     prev_buffer: Vec>,
-    /// Changed buffer cells.
-    changed_cells: BitVec,
     /// Canvas.
     canvas: Canvas,
     /// Measured cell width in CSS pixels.
@@ -249,21 +268,17 @@ impl CanvasBackend {
         (left, top, (right - left).max(1.0), (bottom - top).max(1.0))
     }
 
-    fn symbol_position(&self, x: usize, y: usize) -> (f64, f64) {
-        let (left, top, _, _) = self.grid_rect(x, y, 1, 1);
-        (left, top + self.text_baseline_offset)
-    }
-
     fn content_draw_size(&self) -> (f64, f64) {
         let (grid_width, grid_height) = self.canvas_grid_size();
-        (
-            (grid_width as f64 * self.cell_width).ceil(),
-            (grid_height as f64 * self.cell_height).ceil(),
-        )
+        let width = (grid_width as f64 * self.cell_width).ceil();
+        let height = (grid_height as f64 * self.cell_height).ceil();
+        (width, height)
     }
 
     fn content_offset(&self) -> (f64, f64) {
         let (content_width, content_height) = self.content_draw_size();
+        // Snap the centered origin to whole pixels so adjacent cell backgrounds
+        // share exact edges instead of landing on half-pixel seams.
         let offset_x =
             (((self.canvas.inner.client_width() as f64 - content_width) / 2.0).max(0.0)).round();
         let offset_y =
@@ -271,11 +286,16 @@ impl CanvasBackend {
         (offset_x, offset_y)
     }
 
+    fn symbol_position(&self, x: usize, y: usize) -> (f64, f64) {
+        let (left, top, _, _) = self.grid_rect(x, y, 1, 1);
+        (left, top + self.text_baseline_offset)
+    }
+
     fn selection_range(&self) -> Option {
         self.selection_state.borrow().active
     }
 
-    fn selection_state_revision(&self) -> u64 {
+    fn selection_revision(&self) -> u64 {
         self.selection_state.borrow().revision
     }
 
@@ -383,6 +403,18 @@ impl CanvasBackend {
         (ascent + ((cell_height - (ascent + descent)).max(0.0) / 2.0)).round()
     }
 
+    fn present(&self) -> Result<(), Error> {
+        self.canvas.display_context.save();
+        self.canvas
+            .display_context
+            .set_global_composite_operation("copy")?;
+        self.canvas
+            .display_context
+            .draw_image_with_html_canvas_element(&self.canvas.frame, 0.0, 0.0)?;
+        self.canvas.display_context.restore();
+        Ok(())
+    }
+
     fn canvas_grid_size(&self) -> (usize, usize) {
         let width = ((self.canvas.inner.client_width() as f64) / self.cell_width)
             .floor()
@@ -394,18 +426,30 @@ impl CanvasBackend {
     }
 
     fn sync_canvas_size(&mut self) {
+        let width = self.canvas.inner.width();
+        let height = self.canvas.inner.height();
+
+        if self.canvas.frame.width() != width || self.canvas.frame.height() != height {
+            self.canvas.frame.set_width(width);
+            self.canvas.frame.set_height(height);
+            Canvas::configure_text_context(&self.canvas.frame_context);
+            self.canvas
+                .display_context
+                .set_image_smoothing_enabled(false);
+            self.initialized = false;
+        }
+
         let (grid_width, grid_height) = self.canvas_grid_size();
-        let needs_resize = self.buffer.len() != grid_height
+        let needs_buffer_resize = self.buffer.len() != grid_height
             || self
                 .buffer
                 .first()
                 .map(|line| line.len() != grid_width)
                 .unwrap_or(true);
 
-        if needs_resize {
+        if needs_buffer_resize {
             self.buffer = vec![vec![Cell::default(); grid_width]; grid_height];
             self.prev_buffer = self.buffer.clone();
-            self.changed_cells = bitvec![0; grid_width * grid_height];
             self.initialized = false;
         }
     }
@@ -457,22 +501,21 @@ impl CanvasBackend {
 
     /// Constructs a new [`CanvasBackend`] with the given options.
     pub fn new_with_options(options: CanvasBackendOptions) -> Result {
+        // Parent element of canvas (uses  unless specified)
         let parent = get_element_by_id_or_body(options.grid_id.as_ref())?;
+
         let (width, height) = options
             .size
             .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32));
 
         let cell_size = Self::measure_cell_size(&parent)?;
         let canvas = Canvas::new(parent, width, height, Color::Black)?;
-        let text_baseline_offset = Self::measure_text_baseline(&canvas.context, cell_size.1);
+        let text_baseline_offset = Self::measure_text_baseline(&canvas.frame_context, cell_size.1);
         let buffer = get_sized_buffer_from_canvas(&canvas.inner, cell_size.0, cell_size.1);
-        let changed_cells = bitvec![0; buffer.len() * buffer[0].len()];
         Ok(Self {
             prev_buffer: buffer.clone(),
-            always_clip_cells: options.always_clip_cells,
             buffer,
             initialized: false,
-            changed_cells,
             canvas,
             cell_width: cell_size.0,
             cell_height: cell_size.1,
@@ -524,24 +567,21 @@ impl CanvasBackend {
         self.debug_mode = color.map(Into::into);
     }
 
-    // Compare the current buffer to the previous buffer and updates the canvas
-    // accordingly.
-    //
-    // If `force_redraw` is `true`, the entire canvas will be cleared and redrawn.
-    fn update_grid(&mut self, force_redraw: bool) -> Result<(), Error> {
-        if force_redraw {
-            self.canvas.context.clear_rect(
-                0.0,
-                0.0,
-                self.canvas.inner.client_width() as f64,
-                self.canvas.inner.client_height() as f64,
-            );
-        }
-
+    // Redraw the entire offscreen frame, then present it in a single blit.
+    fn render_frame(&mut self) -> Result<(), Error> {
+        let background = get_canvas_color(self.canvas.background_color, Color::Black);
+        self.canvas.frame_context.set_fill_style_str(&background);
+        self.canvas.frame_context.fill_rect(
+            0.0,
+            0.0,
+            self.canvas.frame.width() as f64,
+            self.canvas.frame.height() as f64,
+        );
         let (offset_x, offset_y) = self.content_offset();
-        self.canvas.context.translate(offset_x, offset_y)?;
+        self.canvas
+            .frame_context
+            .translate(offset_x, offset_y)?;
 
-        self.resolve_changed_cells(force_redraw);
         self.draw_background()?;
         self.draw_selection()?;
         self.draw_symbols()?;
@@ -550,23 +590,13 @@ impl CanvasBackend {
             self.draw_debug()?;
         }
 
-        self.canvas.context.translate(-offset_x, -offset_y)?;
+        self.canvas
+            .frame_context
+            .translate(-offset_x, -offset_y)?;
+        self.present()?;
         Ok(())
     }
 
-    /// Updates the representation of the changed cells.
-    fn resolve_changed_cells(&mut self, force_redraw: bool) {
-        let mut index = 0;
-        for (y, line) in self.buffer.iter().enumerate() {
-            for (x, cell) in line.iter().enumerate() {
-                let prev_cell = &self.prev_buffer[y][x];
-                self.changed_cells
-                    .set(index, force_redraw || cell != prev_cell);
-                index += 1;
-            }
-        }
-    }
-
     fn draw_selection(&mut self) -> Result<(), Error> {
         let Some(mode) = self.selection_mode else {
             return Ok(());
@@ -575,9 +605,9 @@ impl CanvasBackend {
             return Ok(());
         };
 
-        self.canvas.context.save();
+        self.canvas.frame_context.save();
         self.canvas
-            .context
+            .frame_context
             .set_fill_style_str("rgba(170, 190, 230, 0.24)");
 
         for (row_idx, row) in self.buffer.iter().enumerate() {
@@ -591,11 +621,11 @@ impl CanvasBackend {
 
             let (start_x, start_y, width, height) = self.grid_rect(start, row_idx, end - start, 1);
             self.canvas
-                .context
+                .frame_context
                 .fill_rect(start_x, start_y, width, height);
         }
 
-        self.canvas.context.restore();
+        self.canvas.frame_context.restore();
         Ok(())
     }
 
@@ -609,55 +639,35 @@ impl CanvasBackend {
     /// Rather than saving/restoring the canvas context for every cell (which would be expensive),
     /// this implementation:
     ///
-    /// 1. Only processes cells that have changed since the last render.
-    /// 2. Tracks the last foreground color used to avoid unnecessary style changes
-    /// 3. Only creates clipping paths for potentially problematic glyphs (non-ASCII)
-    /// or when `always_clip_cells` is enabled.
+    /// Tracks the last foreground color used to avoid unnecessary style changes.
+    ///
     fn draw_symbols(&mut self) -> Result<(), Error> {
-        let changed_cells = &self.changed_cells;
-        let mut index = 0;
-
-        self.canvas.context.save();
+        self.canvas.frame_context.save();
         let mut last_color = None;
         for (y, line) in self.buffer.iter().enumerate() {
             for (x, cell) in line.iter().enumerate() {
-                if !changed_cells[index] || cell.symbol() == " " {
-                    index += 1;
+                if cell.symbol() == " " {
                     continue;
                 }
                 let color = actual_fg_color(cell);
 
-                if self.always_clip_cells || !cell.symbol().is_ascii() {
-                    self.canvas.context.restore();
-                    self.canvas.context.save();
-
-                    let (left, top, width, height) = self.grid_rect(x, y, 1, 1);
-                    self.canvas.context.begin_path();
-                    self.canvas.context.rect(left, top, width, height);
-                    self.canvas.context.clip();
-
-                    last_color = None;
-                    let color = get_canvas_color(color, Color::White);
-                    self.canvas.context.set_fill_style_str(&color);
-                } else if last_color != Some(color) {
-                    self.canvas.context.restore();
-                    self.canvas.context.save();
+                if last_color != Some(color) {
+                    self.canvas.frame_context.restore();
+                    self.canvas.frame_context.save();
 
                     last_color = Some(color);
 
                     let color = get_canvas_color(color, Color::White);
-                    self.canvas.context.set_fill_style_str(&color);
+                    self.canvas.frame_context.set_fill_style_str(&color);
                 }
 
                 let (text_x, text_y) = self.symbol_position(x, y);
                 self.canvas
-                    .context
+                    .frame_context
                     .fill_text(cell.symbol(), text_x, text_y)?;
-
-                index += 1;
             }
         }
-        self.canvas.context.restore();
+        self.canvas.frame_context.restore();
 
         Ok(())
     }
@@ -666,9 +676,11 @@ impl CanvasBackend {
     ///
     /// This function uses [`RowColorOptimizer`] to optimize the drawing of the background
     /// colors by batching adjacent cells with the same color into a single rectangle.
+    ///
+    /// In other words, it accumulates "what to draw" until it finds a different
+    /// color, and then it draws the accumulated rectangle.
     fn draw_background(&mut self) -> Result<(), Error> {
-        let changed_cells = &self.changed_cells;
-        self.canvas.context.save();
+        self.canvas.frame_context.save();
 
         let draw_region = |(rect, color): (Rect, Color)| {
             let color = get_canvas_color(color, self.canvas.background_color);
@@ -679,29 +691,23 @@ impl CanvasBackend {
                 rect.height as usize,
             );
 
-            self.canvas.context.set_fill_style_str(&color);
+            self.canvas.frame_context.set_fill_style_str(&color);
             self.canvas
-                .context
+                .frame_context
                 .fill_rect(start_x, start_y, width, height);
         };
 
-        let mut index = 0;
         for (y, line) in self.buffer.iter().enumerate() {
             let mut row_renderer = RowColorOptimizer::new();
             for (x, cell) in line.iter().enumerate() {
-                if changed_cells[index] {
-                    row_renderer
-                        .process_color((x, y), actual_bg_color(cell))
-                        .map(draw_region);
-                } else {
-                    row_renderer.flush().map(draw_region);
-                }
-                index += 1;
+                row_renderer
+                    .process_color((x, y), actual_bg_color(cell))
+                    .map(draw_region);
             }
             row_renderer.flush().map(draw_region);
         }
 
-        self.canvas.context.restore();
+        self.canvas.frame_context.restore();
 
         Ok(())
     }
@@ -712,12 +718,15 @@ impl CanvasBackend {
             let cell = &self.buffer[pos.y as usize][pos.x as usize];
 
             if cell.modifier.contains(Modifier::UNDERLINED) {
-                self.canvas.context.save();
+                self.canvas.frame_context.save();
 
-                let (text_x, text_y) = self.symbol_position(pos.x as usize, pos.y as usize);
-                self.canvas.context.fill_text("_", text_x, text_y)?;
+                self.canvas.frame_context.fill_text(
+                    "_",
+                    pos.x as f64 * self.cell_width,
+                    pos.y as f64 * self.cell_height,
+                )?;
 
-                self.canvas.context.restore();
+                self.canvas.frame_context.restore();
             }
         }
 
@@ -726,18 +735,22 @@ impl CanvasBackend {
 
     /// Draws cell boundaries for debugging.
     fn draw_debug(&mut self) -> Result<(), Error> {
-        self.canvas.context.save();
+        self.canvas.frame_context.save();
 
         let color = self.debug_mode.as_ref().unwrap();
         for (y, line) in self.buffer.iter().enumerate() {
             for (x, _) in line.iter().enumerate() {
-                let (left, top, width, height) = self.grid_rect(x, y, 1, 1);
-                self.canvas.context.set_stroke_style_str(color);
-                self.canvas.context.stroke_rect(left, top, width, height);
+                self.canvas.frame_context.set_stroke_style_str(color);
+                self.canvas.frame_context.stroke_rect(
+                    x as f64 * self.cell_width,
+                    y as f64 * self.cell_height,
+                    self.cell_width,
+                    self.cell_height,
+                );
             }
         }
 
-        self.canvas.context.restore();
+        self.canvas.frame_context.restore();
 
         Ok(())
     }
@@ -745,8 +758,7 @@ impl CanvasBackend {
 
 impl CellSized for CanvasBackend {
     fn cell_size_px(&self) -> (f32, f32) {
-        let dpr = get_device_pixel_ratio();
-        (self.cell_width as f32 * dpr, self.cell_height as f32 * dpr)
+        (self.cell_width as f32, self.cell_height as f32)
     }
 
     fn cell_size_css_px(&self) -> (f32, f32) {
@@ -772,6 +784,7 @@ impl Backend for CanvasBackend {
             line[x] = cell.clone();
         }
 
+        // Draw the cursor if set
         if let Some(pos) = self.cursor_position {
             let y = pos.y as usize;
             let x = pos.x as usize;
@@ -791,20 +804,16 @@ impl Backend for CanvasBackend {
     /// actually render the content to the screen.
     fn flush(&mut self) -> IoResult<()> {
         self.sync_canvas_size();
-        let selection_revision = self.selection_state_revision();
+        let selection_revision = self.selection_revision();
 
-        if !self.initialized {
-            self.update_grid(true)?;
+        if !self.initialized
+            || self.buffer != self.prev_buffer
+            || self.selection_revision != selection_revision
+        {
+            self.render_frame()?;
             self.prev_buffer = self.buffer.clone();
             self.initialized = true;
             self.selection_revision = selection_revision;
-        } else if self.selection_revision != selection_revision {
-            self.update_grid(true)?;
-            self.prev_buffer = self.buffer.clone();
-            self.selection_revision = selection_revision;
-        } else if self.buffer != self.prev_buffer {
-            self.update_grid(false)?;
-            self.prev_buffer = self.buffer.clone();
         }
 
         let should_copy = {
@@ -851,7 +860,6 @@ impl Backend for CanvasBackend {
         self.buffer =
             get_sized_buffer_from_canvas(&self.canvas.inner, self.cell_width, self.cell_height);
         self.prev_buffer = self.buffer.clone();
-        self.changed_cells = bitvec![0; self.buffer.len() * self.buffer[0].len()];
         self.initialized = false;
         Ok(())
     }
@@ -900,13 +908,16 @@ impl WebEventHandler for CanvasBackend {
     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;
-        let (offset_x, offset_y) = self.content_offset();
+
+        // Configure coordinate translation for canvas backend
         let config = MouseConfig::new(grid_width, grid_height)
-            .with_offsets(offset_x, offset_y)
+            .with_offsets(self.content_offset().0, self.content_offset().1)
             .with_cell_dimensions(self.cell_width, self.cell_height);
 
         let element: web_sys::Element = self.canvas.inner.clone().into();
@@ -914,6 +925,7 @@ impl WebEventHandler for CanvasBackend {
         let selection_state = self.selection_state.clone();
         let selection_mode = self.selection_mode;
 
+        // Create mouse event callback
         let mouse_callback = EventCallback::new(
             element,
             MOUSE_EVENT_TYPES,
@@ -945,6 +957,7 @@ impl WebEventHandler for CanvasBackend {
     }
 
     fn clear_mouse_events(&mut self) {
+        // Drop the callback, which will remove the event listeners
         self.mouse_callback = None;
     }
 
@@ -952,9 +965,12 @@ impl WebEventHandler for CanvasBackend {
     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")
@@ -965,8 +981,8 @@ impl WebEventHandler for CanvasBackend {
             element,
             KEY_EVENT_TYPES,
             move |event: web_sys::KeyboardEvent| {
-                let is_copy =
-                    (event.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("c");
+                let is_copy = (event.ctrl_key() || event.meta_key())
+                    && matches!(event.key().as_str(), "c" | "C");
                 if is_copy && selection_state.borrow().active.is_some() {
                     event.prevent_default();
                     let mut selection_state = selection_state.borrow_mut();
@@ -1007,8 +1023,10 @@ impl RowColorOptimizer {
     fn process_color(&mut self, pos: (usize, usize), color: Color) -> Option<(Rect, Color)> {
         if let Some((active_rect, active_color)) = self.pending_region.as_mut() {
             if active_color == &color {
+                // Same color: extend the rectangle
                 active_rect.width += 1;
             } else {
+                // Different color: flush the previous region and start a new one
                 let region = *active_rect;
                 let region_color = *active_color;
                 *active_rect = Rect::new(pos.0 as _, pos.1 as _, 1, 1);
@@ -1016,6 +1034,7 @@ impl RowColorOptimizer {
                 return Some((region, region_color));
             }
         } else {
+            // First color: create a new rectangle
             let rect = Rect::new(pos.0 as _, pos.1 as _, 1, 1);
             self.pending_region = Some((rect, color));
         }
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index a34bb91..a531239 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -30,6 +30,16 @@ use crate::{
 
 /// Default cell size used as a fallback when measurement fails.
 const DEFAULT_CELL_SIZE: (f64, f64) = (10.0, 20.0);
+const GRID_STYLE: &str =
+    "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
+const GRID_SELECTION_STYLE: &str =
+    "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0; user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;";
+const PROBE_STYLE: &str =
+    "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
+const PROBE_ROW_STYLE: &str =
+    "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;";
+const PROBE_SAMPLE_STYLE: &str =
+    "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;";
 
 /// Options for the [`DomBackend`].
 #[derive(Debug, Default)]
@@ -208,15 +218,15 @@ impl DomBackend {
     /// `getBoundingClientRect()`, then removes the probe.
     fn measure_cell_size(document: &Document, parent: &Element) -> Result<(f64, f64), Error> {
         let probe = document.create_element("div")?;
-        probe.set_attribute("style", &terminal_probe_style())?;
+        probe.set_attribute("style", PROBE_STYLE)?;
 
         let row = document.create_element("div")?;
-        row.set_attribute("style", terminal_probe_row_style())?;
+        row.set_attribute("style", PROBE_ROW_STYLE)?;
 
         let sample = document.create_element("span")?;
         let sample_text = "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM";
         sample.set_text_content(Some(sample_text));
-        sample.set_attribute("style", &terminal_probe_sample_style())?;
+        sample.set_attribute("style", PROBE_SAMPLE_STYLE)?;
 
         row.append_child(&sample)?;
         probe.append_child(&row)?;
@@ -293,10 +303,15 @@ impl DomBackend {
         if self.options.mouse_selection() {
             self.grid.set_class_name("ratzilla-dom-selection-enabled");
         }
-        self.grid.set_attribute(
-            "style",
-            &terminal_grid_style(self.options.mouse_selection()),
-        )?;
+        self.grid
+            .set_attribute(
+                "style",
+                if self.options.mouse_selection() {
+                    GRID_SELECTION_STYLE
+                } else {
+                    GRID_STYLE
+                },
+            )?;
         self.cells.clear();
         Ok(())
     }
@@ -334,8 +349,7 @@ impl DomBackend {
 
 impl CellSized for DomBackend {
     fn cell_size_px(&self) -> (f32, f32) {
-        let dpr = get_device_pixel_ratio();
-        (self.cell_size.0 as f32 * dpr, self.cell_size.1 as f32 * dpr)
+        (self.cell_size.0 as f32, self.cell_size.1 as f32)
     }
 
     fn cell_size_css_px(&self) -> (f32, f32) {
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index a6d6562..fe22a32 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -23,7 +23,7 @@
 //! | **60fps on large terminals** | ✗          | ✗             | ✓              |
 //! | **Memory Usage**             | Highest    | Medium        | Lowest         |
 //! | **Hyperlinks**               | ✗          | ✗             | ✓              |
-//! | **Text Selection**           | Linear     | Linear/Block  | Linear/Block   |
+//! | **Text Selection**           | Linear     | ✗             | Linear/Block   |
 //! | **Unicode/Emoji Support**    | Full       | Limited²      | Full¹          |
 //! | **Dynamic Characters**       | ✓          | ✓             | ✓¹             |
 //! | **Font Variants**            | ✓          | Regular only  | ✓              |
@@ -70,9 +70,6 @@ pub mod dom;
 /// WebGL2 backend.
 pub mod webgl2;
 
-/// Shared selection types.
-pub mod selection;
-
 /// Color handling.
 mod color;
 /// Event callback management.
diff --git a/src/backend/selection.rs b/src/backend/selection.rs
deleted file mode 100644
index 791606e..0000000
--- a/src/backend/selection.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-/// Mouse selection mode shared by backends that support text selection.
-#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
-pub enum SelectionMode {
-    /// Select text linearly, following text flow.
-    Linear,
-    /// Select a rectangular block of cells.
-    #[default]
-    Block,
-}
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index ce66efb..627ea6d 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -1,4 +1,7 @@
-use crate::{backend::color::ansi_to_rgb, error::Error};
+use crate::{
+    backend::color::ansi_to_rgb,
+    error::Error,
+};
 use compact_str::{format_compact, CompactString};
 use ratatui::{
     buffer::Cell,
@@ -12,40 +15,6 @@ use web_sys::{
 
 pub(crate) const TERMINAL_FONT: &str = "16px 'Iosevka', monospace";
 
-fn terminal_font_style() -> &'static str {
-    "font-family: 'Iosevka', monospace; font-size: 16px; line-height: 1; white-space: pre; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;"
-}
-
-pub(crate) fn terminal_grid_style(enable_selection: bool) -> CompactString {
-    let mut style = format_compact!(
-        "display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; width: 100%; height: 100%; overflow: hidden; {}",
-        terminal_font_style()
-    );
-    if enable_selection {
-        style.push_str(
-            " user-select: text; -webkit-user-select: text; cursor: text; touch-action: auto;",
-        );
-    }
-    style
-}
-
-pub(crate) fn terminal_probe_style() -> CompactString {
-    format_compact!(
-        "position: absolute; left: -10000px; top: 0; visibility: hidden; pointer-events: none; display: flex; flex-direction: column; margin: 0; padding: 0; border: 0; {}",
-        terminal_font_style()
-    )
-}
-
-pub(crate) fn terminal_probe_row_style() -> &'static str {
-    "display: flex; flex: 0 0 auto; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1;"
-}
-
-pub(crate) fn terminal_probe_sample_style() -> CompactString {
-    format_compact!(
-        "display: block; margin: 0; padding: 0; border: 0; white-space: pre; line-height: 1; font-family: inherit; font-size: inherit; letter-spacing: 0; word-spacing: 0; font-kerning: none; font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;"
-    )
-}
-
 pub struct CssAttribute {
     pub field: &'static str,
     pub value: Option<&'static str>,
@@ -142,10 +111,7 @@ pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> Strin
         ""
     };
 
-    let sizing = terminal_cell_box_style(
-        cell.symbol().width().max(1) as f64 * cell_size.0,
-        cell_size.1,
-    );
+    let sizing = terminal_cell_box_style(cell.symbol().width().max(1) as f64 * cell_size.0, cell_size.1);
 
     format!("{fg_style} {bg_style} {modifier_style} {braille_style} {sizing}")
 }
@@ -293,11 +259,6 @@ pub(crate) fn get_window() -> Result {
     window().ok_or(Error::UnableToRetrieveWindow)
 }
 
-/// Returns the device pixel ratio from the window.
-pub(crate) fn get_device_pixel_ratio() -> f32 {
-    get_window().map(|w| w.device_pixel_ratio()).unwrap_or(1.0) as f32
-}
-
 /// Returns an element by its ID or the body element if no ID is provided.
 pub(crate) fn get_element_by_id_or_body(id: Option<&String>) -> Result {
     match id {
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 65ddbac..8eb308f 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -3,7 +3,6 @@ use crate::{
         cell_sized::CellSized,
         color::to_rgb,
         event_callback::{EventCallback, KEY_EVENT_TYPES},
-        selection::SelectionMode,
         utils::*,
     },
     error::Error,
@@ -11,6 +10,7 @@ use crate::{
     render::WebEventHandler,
     CursorShape,
 };
+pub use beamterm_renderer::SelectionMode;
 use beamterm_renderer::{
     mouse::*, CellData, CursorPosition, GlyphEffect, Terminal as Beamterm, Terminal,
 };
@@ -172,7 +172,7 @@ impl WebGl2BackendOptions {
     /// let options = WebGl2BackendOptions::new()
     ///     .font_atlas_config(FontAtlasConfig::dynamic(
     ///         // monospace is an implicit fallback font in browsers
-    ///         &["JetBrains Mono"],
+    ///         &["Iosevka"],
     ///         16.0
     ///     ));
     /// ```
@@ -659,10 +659,7 @@ impl WebGl2Backend {
         let beamterm = if let Some(mode) = options.mouse_selection_mode {
             beamterm.mouse_selection_handler(
                 MouseSelectOptions::new()
-                    .selection_mode(match mode {
-                        SelectionMode::Linear => beamterm_renderer::SelectionMode::Linear,
-                        SelectionMode::Block => beamterm_renderer::SelectionMode::Block,
-                    })
+                    .selection_mode(mode)
                     .trim_trailing_whitespace(true),
             )
         } else {
diff --git a/src/lib.rs b/src/lib.rs
index ba9a81f..3c66396 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -30,7 +30,6 @@ pub use backend::{
     cell_sized::CellSized,
     cursor::CursorShape,
     dom::DomBackend,
-    selection::SelectionMode,
-    webgl2::{FontAtlasConfig, WebGl2Backend},
+    webgl2::{FontAtlasConfig, SelectionMode, WebGl2Backend},
 };
 pub use render::{WebEventHandler, WebRenderer};

From 9d139b8e3d0122e412779a1e83af7c3eaf544fcb Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 16:25:27 -0700
Subject: [PATCH 10/11] dom and canvas copy selection behavior same as webgl

---
 src/backend/canvas.rs | 45 ++++++++++++++++++++++++-------------------
 src/backend/dom.rs    | 24 ++++++++++++++---------
 src/backend/utils.rs  | 10 +++++-----
 3 files changed, 45 insertions(+), 34 deletions(-)

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index a29cf97..8295da8 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -149,6 +149,15 @@ impl SelectionState {
         self.drag_anchor = None;
         self.pending_copy = self.active.is_some();
     }
+
+    fn clear(&mut self) {
+        self.dragging = false;
+        self.drag_anchor = None;
+        self.pending_copy = false;
+        if self.active.take().is_some() {
+            self.bump();
+        }
+    }
 }
 
 /// Canvas renderer.
@@ -380,11 +389,9 @@ impl CanvasBackend {
             return;
         };
         let text = self.selected_text(range);
-        if text.is_empty() {
-            return;
+        if !text.is_empty() {
+            write_text_to_clipboard(&text);
         }
-
-        write_text_to_clipboard(&text);
     }
 
     fn measure_text_baseline(context: &web_sys::CanvasRenderingContext2d, cell_height: f64) -> f64 {
@@ -578,9 +585,7 @@ impl CanvasBackend {
             self.canvas.frame.height() as f64,
         );
         let (offset_x, offset_y) = self.content_offset();
-        self.canvas
-            .frame_context
-            .translate(offset_x, offset_y)?;
+        self.canvas.frame_context.translate(offset_x, offset_y)?;
 
         self.draw_background()?;
         self.draw_selection()?;
@@ -590,9 +595,7 @@ impl CanvasBackend {
             self.draw_debug()?;
         }
 
-        self.canvas
-            .frame_context
-            .translate(-offset_x, -offset_y)?;
+        self.canvas.frame_context.translate(-offset_x, -offset_y)?;
         self.present()?;
         Ok(())
     }
@@ -804,6 +807,18 @@ impl Backend for CanvasBackend {
     /// actually render the content to the screen.
     fn flush(&mut self) -> IoResult<()> {
         self.sync_canvas_size();
+
+        let should_copy = {
+            let mut selection_state = self.selection_state.borrow_mut();
+            let should_copy = selection_state.pending_copy;
+            selection_state.pending_copy = false;
+            should_copy
+        };
+        if should_copy {
+            self.copy_selection_to_clipboard();
+            self.selection_state.borrow_mut().clear();
+        }
+
         let selection_revision = self.selection_revision();
 
         if !self.initialized
@@ -816,16 +831,6 @@ impl Backend for CanvasBackend {
             self.selection_revision = selection_revision;
         }
 
-        let should_copy = {
-            let mut selection_state = self.selection_state.borrow_mut();
-            let should_copy = selection_state.pending_copy;
-            selection_state.pending_copy = false;
-            should_copy
-        };
-        if should_copy {
-            self.copy_selection_to_clipboard();
-        }
-
         Ok(())
     }
 
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index a531239..c80dc55 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -286,10 +286,17 @@ impl DomBackend {
             .filter(|text: &String| !text.trim().is_empty())
     }
 
+    fn clear_selected_text() {
+        if let Some(selection) = window().and_then(|window| window.get_selection().ok().flatten()) {
+            let _ = selection.remove_all_ranges();
+        }
+    }
+
     fn copy_selected_text_to_clipboard() -> bool {
         match Self::selected_text() {
             Some(text) => {
                 write_text_to_clipboard(&text);
+                Self::clear_selected_text();
                 true
             }
             None => false,
@@ -303,15 +310,14 @@ impl DomBackend {
         if self.options.mouse_selection() {
             self.grid.set_class_name("ratzilla-dom-selection-enabled");
         }
-        self.grid
-            .set_attribute(
-                "style",
-                if self.options.mouse_selection() {
-                    GRID_SELECTION_STYLE
-                } else {
-                    GRID_STYLE
-                },
-            )?;
+        self.grid.set_attribute(
+            "style",
+            if self.options.mouse_selection() {
+                GRID_SELECTION_STYLE
+            } else {
+                GRID_STYLE
+            },
+        )?;
         self.cells.clear();
         Ok(())
     }
diff --git a/src/backend/utils.rs b/src/backend/utils.rs
index 627ea6d..76bb6fb 100644
--- a/src/backend/utils.rs
+++ b/src/backend/utils.rs
@@ -1,7 +1,4 @@
-use crate::{
-    backend::color::ansi_to_rgb,
-    error::Error,
-};
+use crate::{backend::color::ansi_to_rgb, error::Error};
 use compact_str::{format_compact, CompactString};
 use ratatui::{
     buffer::Cell,
@@ -111,7 +108,10 @@ pub(crate) fn get_cell_style_as_css(cell: &Cell, cell_size: (f64, f64)) -> Strin
         ""
     };
 
-    let sizing = terminal_cell_box_style(cell.symbol().width().max(1) as f64 * cell_size.0, cell_size.1);
+    let sizing = terminal_cell_box_style(
+        cell.symbol().width().max(1) as f64 * cell_size.0,
+        cell_size.1,
+    );
 
     format!("{fg_style} {bg_style} {modifier_style} {braille_style} {sizing}")
 }

From ec0fa13a27ba796b5f8b5af78bbd6cade32b342a Mon Sep 17 00:00:00 2001
From: zombkit 
Date: Tue, 17 Mar 2026 17:12:34 -0700
Subject: [PATCH 11/11] DOM backend preserves behavior after resize

---
 src/backend/canvas.rs         | 38 +++++++++++++++++++++++++----------
 src/backend/dom.rs            | 13 +++++++-----
 src/backend/event_callback.rs | 13 +++---------
 src/backend/webgl2.rs         | 14 +++++++++++++
 src/utils.rs                  |  4 +---
 5 files changed, 53 insertions(+), 29 deletions(-)

diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs
index 8295da8..740f41e 100644
--- a/src/backend/canvas.rs
+++ b/src/backend/canvas.rs
@@ -259,6 +259,8 @@ pub struct CanvasBackend {
     selection_state: Rc>,
     /// Last observed selection state revision.
     selection_revision: u64,
+    /// Shared mouse coordinate configuration.
+    mouse_config: Rc>,
     /// Mouse event callback handler.
     mouse_callback: Option,
     /// Key event callback handler.
@@ -269,6 +271,17 @@ pub struct CanvasBackend {
 type MouseCallbackState = EventCallback;
 
 impl CanvasBackend {
+    fn sync_mouse_config(&self) {
+        let (grid_width, grid_height) = self.canvas_grid_size();
+        let (offset_x, offset_y) = self.content_offset();
+        let mut config = self.mouse_config.borrow_mut();
+        config.grid_width = grid_width as u16;
+        config.grid_height = grid_height as u16;
+        config.offset_x = Some(offset_x);
+        config.offset_y = Some(offset_y);
+        config.cell_dimensions = Some((self.cell_width, self.cell_height));
+    }
+
     fn grid_rect(&self, x: usize, y: usize, width: usize, height: usize) -> (f64, f64, f64, f64) {
         let left = (x as f64 * self.cell_width).floor();
         let top = (y as f64 * self.cell_height).floor();
@@ -459,6 +472,8 @@ impl CanvasBackend {
             self.prev_buffer = self.buffer.clone();
             self.initialized = false;
         }
+
+        self.sync_mouse_config();
     }
 
     fn measure_cell_size(parent: &web_sys::Element) -> Result<(f64, f64), Error> {
@@ -519,7 +534,11 @@ impl CanvasBackend {
         let canvas = Canvas::new(parent, width, height, Color::Black)?;
         let text_baseline_offset = Self::measure_text_baseline(&canvas.frame_context, cell_size.1);
         let buffer = get_sized_buffer_from_canvas(&canvas.inner, cell_size.0, cell_size.1);
-        Ok(Self {
+        let mouse_config = Rc::new(RefCell::new(MouseConfig::new(
+            buffer.first().map(|line| line.len()).unwrap_or(0) as u16,
+            buffer.len() as u16,
+        )));
+        let backend = Self {
             prev_buffer: buffer.clone(),
             buffer,
             initialized: false,
@@ -533,9 +552,12 @@ impl CanvasBackend {
             selection_mode: options.selection_mode,
             selection_state: Rc::new(RefCell::new(SelectionState::default())),
             selection_revision: 0,
+            mouse_config,
             mouse_callback: None,
             key_callback: None,
-        })
+        };
+        backend.sync_mouse_config();
+        Ok(backend)
     }
 
     /// Sets the background color of the canvas.
@@ -916,15 +938,8 @@ impl WebEventHandler for CanvasBackend {
         // 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_offsets(self.content_offset().0, self.content_offset().1)
-            .with_cell_dimensions(self.cell_width, self.cell_height);
-
+        self.sync_mouse_config();
+        let config = self.mouse_config.clone();
         let element: web_sys::Element = self.canvas.inner.clone().into();
         let element_for_closure = element.clone();
         let selection_state = self.selection_state.clone();
@@ -935,6 +950,7 @@ impl WebEventHandler for CanvasBackend {
             element,
             MOUSE_EVENT_TYPES,
             move |event: web_sys::MouseEvent| {
+                let config = config.borrow();
                 let mouse_event = create_mouse_event(&event, &element_for_closure, &config);
                 if selection_mode.is_some() {
                     let point = SelectionPoint {
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index c80dc55..c0acee7 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -305,10 +305,11 @@ impl DomBackend {
 
     /// Reset the grid and clear the cells.
     fn reset_grid(&mut self) -> Result<(), Error> {
-        self.grid = self.document.create_element("div")?;
         self.grid.set_attribute("id", &self.options.grid_id())?;
         if self.options.mouse_selection() {
             self.grid.set_class_name("ratzilla-dom-selection-enabled");
+        } else {
+            self.grid.set_class_name("");
         }
         self.grid.set_attribute(
             "style",
@@ -318,6 +319,7 @@ impl DomBackend {
                 GRID_STYLE
             },
         )?;
+        self.grid.set_inner_html("");
         self.cells.clear();
         Ok(())
     }
@@ -387,7 +389,6 @@ impl Backend for DomBackend {
                 .get_element_by_id(&self.options.grid_id())
                 .is_some()
             {
-                self.grid_parent.set_inner_html("");
                 self.reset_grid()?;
 
                 // re-measure cell size and update grid dimensions
@@ -397,9 +398,11 @@ impl Backend for DomBackend {
                 self.sync_mouse_config();
             }
 
-            self.grid_parent
-                .append_child(&self.grid)
-                .map_err(Error::from)?;
+            if self.grid.parent_element().is_none() {
+                self.grid_parent
+                    .append_child(&self.grid)
+                    .map_err(Error::from)?;
+            }
             self.populate()?;
         }
 
diff --git a/src/backend/event_callback.rs b/src/backend/event_callback.rs
index fdf4671..9664c13 100644
--- a/src/backend/event_callback.rs
+++ b/src/backend/event_callback.rs
@@ -106,13 +106,6 @@ impl MouseConfig {
         }
     }
 
-    /// Sets independent pixel offsets from the element edge.
-    pub fn with_offsets(mut self, offset_x: f64, offset_y: f64) -> Self {
-        self.offset_x = Some(offset_x);
-        self.offset_y = Some(offset_y);
-        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));
@@ -219,9 +212,9 @@ mod tests {
 
     #[test]
     fn test_mouse_config_builder() {
-        let config = MouseConfig::new(80, 24)
-            .with_offsets(5.0, 5.0)
-            .with_cell_dimensions(10.0, 19.0);
+        let mut config = MouseConfig::new(80, 24).with_cell_dimensions(10.0, 19.0);
+        config.offset_x = Some(5.0);
+        config.offset_y = Some(5.0);
 
         assert_eq!(config.grid_width, 80);
         assert_eq!(config.grid_height, 24);
diff --git a/src/backend/webgl2.rs b/src/backend/webgl2.rs
index 8eb308f..20e488e 100644
--- a/src/backend/webgl2.rs
+++ b/src/backend/webgl2.rs
@@ -61,6 +61,7 @@ impl FontAtlasConfig {
 struct PendingHyperlinkEvent {
     hover: Option<(u16, u16)>,
     click: Option<(u16, u16)>,
+    reset_cursor: bool,
 }
 
 // Labels used by the Performance API
@@ -583,6 +584,11 @@ impl WebGl2Backend {
                     }
                     MouseEventType::MouseMove => {
                         state.hover = Some((event.col, event.row));
+                        state.reset_cursor = false;
+                    }
+                    MouseEventType::MouseLeave => {
+                        state.hover = None;
+                        state.reset_cursor = true;
                     }
                     _ => return,
                 }
@@ -616,6 +622,14 @@ impl WebGl2Backend {
             }
         }
 
+        if pending.reset_cursor {
+            pending.reset_cursor = false;
+            if self.cursor_over_hyperlink {
+                self.cursor_over_hyperlink = false;
+                Self::update_canvas_cursor_style(&self.beamterm.canvas(), false);
+            }
+        }
+
         // Update cursor style on hover
         if let Some((col, row)) = pending.hover {
             let is_over = self
diff --git a/src/utils.rs b/src/utils.rs
index 7782080..79e83e0 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -26,9 +26,7 @@ pub fn open_url(url: &str, new_tab: bool) -> Result<(), Error> {
     if new_tab {
         window.open_with_url(url)?;
     } else {
-        let location = window.location();
-        location.set_href(url)?;
-        location.replace(url)?;
+        window.location().set_href(url)?;
     }
     Ok(())
 }