From 70d219c1f3e57dcf2a6044a8b0cbae3d53eaa6e7 Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 18:10:02 -0700 Subject: [PATCH 01/10] Performance improvements --- Cargo.lock | 50 ++- Cargo.toml | 3 + src/backend/canvas.rs | 659 +++++++++++++++++++-------------- src/backend/ratzilla_canvas.js | 29 ++ 4 files changed, 462 insertions(+), 279 deletions(-) create mode 100644 src/backend/ratzilla_canvas.js diff --git a/Cargo.lock b/Cargo.lock index 97ba99e..219a182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ "emojis", "js-sys", "lru", - "rustc-hash", + "rustc-hash 2.1.1", "thiserror", "unicode-width", "wasm-bindgen", @@ -278,6 +278,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.6" @@ -567,13 +577,22 @@ dependencies = [ "bitvec", "compact_str", "console_error_panic_hook", + "indexmap", "ratatui", + "sledgehammer_bindgen", + "sledgehammer_utils", "thiserror", "unicode-width", "wasm-bindgen-test", "web-sys", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -668,6 +687,35 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5758ab4..a722e1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ thiserror = "2.0.18" bitvec = { version = "1.0.1", default-features = false, features = ["alloc", "std"] } beamterm-renderer = "0.15.0" unicode-width = "0.2.2" +sledgehammer_bindgen = { version = "0.6.0", features = ["web"] } +sledgehammer_utils = "0.3.1" +indexmap = "2.13.0" [dev-dependencies] wasm-bindgen-test = "0.3.58" diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 48594ae..063f8ff 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -1,6 +1,11 @@ -use bitvec::{bitvec, prelude::BitVec}; +use indexmap::IndexMap; use ratatui::{backend::ClearType, layout::Rect}; -use std::io::{Error as IoError, Result as IoResult}; +use std::{ + fmt::Debug, + io::{Error as IoError, Result as IoResult}, + iter::Peekable, + vec::Drain, +}; use crate::{ backend::{ @@ -20,24 +25,22 @@ use ratatui::{ buffer::Cell, layout::{Position, Size}, prelude::Backend, - style::{Color, Modifier}, -}; -use web_sys::{ - js_sys::{Boolean, Map}, - wasm_bindgen::{JsCast, JsValue}, + style::Color, }; +use sledgehammer_bindgen::bindgen; +use web_sys::wasm_bindgen::{self, prelude::*}; /// 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; +const CELL_WIDTH: u16 = 10; /// 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; +const CELL_HEIGHT: u16 = 19; /// Options for the [`CanvasBackend`]. #[derive(Debug, Default)] @@ -72,13 +75,154 @@ impl CanvasBackendOptions { } } +// Mirrors usage in https://github.com/DioxusLabs/dioxus/blob/main/packages/interpreter/src/unified_bindings.rs +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen] + /// External JS class for managing the actual HTML canvas, context, + /// and parent element. + pub type RatzillaCanvas; + + #[wasm_bindgen(method)] + /// Does the initial construction of the RatzillaCanvas class + /// + /// `sledgehammer_bindgen` only lets you have an empty constructor, + /// so we must initialize the class after construction + fn create_canvas_in_element( + this: &RatzillaCanvas, + parent: &web_sys::Element, + width: u32, + height: u32, + ); + + #[wasm_bindgen(method)] + /// Initializes the canvas 2D context with the appropriate properties + fn init_ctx(this: &RatzillaCanvas); + + #[wasm_bindgen(method)] + /// Shares the canvas 2D context with the other buffer + fn share_ctx_with_other(this: &RatzillaCanvas, other: &RatzillaCanvas); + + #[wasm_bindgen(method)] + fn get_canvas(this: &RatzillaCanvas) -> web_sys::HtmlCanvasElement; +} + +#[bindgen] +mod js { + #[extends(RatzillaCanvas)] + /// Responsible for buffering the calls to the canvas and + /// canvas context + struct Buffer; + + const BASE: &str = r#"src/backend/ratzilla_canvas.js"#; + + fn clear_rect() { + r#" + this.ctx.fillRect( + 0, 0, this.canvas.width, this.canvas.height + ); + "# + } + + fn save() { + r#" + this.ctx.save(); + "# + } + + fn restore() { + r#" + this.ctx.restore(); + "# + } + + fn fill() { + r#" + this.ctx.fill(); + "# + } + + fn translate(x: u16, y: u16) { + r#" + this.ctx.translate($x$, $y$); + "# + } + + fn translate_neg(x: u16, y: u16) { + r#" + this.ctx.translate(-$x$, -$y$); + "# + } + + fn begin_path() { + r#" + this.ctx.beginPath(); + "# + } + + fn rect(x: u16, y: u16, w: u16, h: u16) { + r#" + this.ctx.rect($x$, $y$, $w$, $h$); + "# + } + + fn clip() { + r#" + this.ctx.clip(); + "# + } + + fn set_fill_style_str(style: &str) { + r#" + this.ctx.fillStyle = $style$; + "# + } + + fn fill_text(text: &str, x: u16, y: u16) { + r#" + this.ctx.fillText($text$, $x$, $y$); + "# + } + + fn fill_rect(x: u16, y: u16, w: u16, h: u16) { + r#" + this.ctx.fillRect($x$, $y$, $w$, $h$); + "# + } + + fn set_stroke_style_str(style: &str) { + r#" + this.ctx.strokeStyle = $style$; + "# + } + + fn stroke_rect(x: u16, y: u16, w: u16, h: u16) { + r#" + this.ctx.strokeRect($x$, $y$, $w$, $h$); + "# + } +} + +impl Buffer { + /// Converts the buffer to its baseclass + pub fn ratzilla_canvas(&self) -> &RatzillaCanvas { + self.js_channel().unchecked_ref() + } +} + +impl Debug for Buffer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Buffer") + } +} + /// Canvas renderer. #[derive(Debug)] struct Canvas { - /// Canvas element. - inner: web_sys::HtmlCanvasElement, - /// Rendering context. - context: web_sys::CanvasRenderingContext2d, + /// Foreground (symbol) Rendering context. + fg_context: Buffer, + /// Background Rendering context. + bg_context: Buffer, /// Background color. background_color: Color, } @@ -91,25 +235,21 @@ impl Canvas { height: u32, background_color: Color, ) -> Result { - let canvas = create_canvas_in_element(&parent_element, width, height)?; - - 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 fg_context = Buffer::default(); + fg_context + .ratzilla_canvas() + .create_canvas_in_element(&parent_element, width, height); + + fg_context.ratzilla_canvas().init_ctx(); + + let bg_context = Buffer::default(); + bg_context + .ratzilla_canvas() + .share_ctx_with_other(fg_context.ratzilla_canvas()); Ok(Self { - inner: canvas, - context, + fg_context, + bg_context, background_color, }) } @@ -127,12 +267,11 @@ pub struct CanvasBackend { /// 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, + width: u32, + height: u32, + /// Groups together and merges rectangles with + /// the same fill color + bg_rect_optimizer: RectangleColorOptimizer, /// Canvas. canvas: Canvas, /// Cursor position. @@ -175,14 +314,12 @@ impl CanvasBackend { .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32)); 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()]; Ok(Self { - prev_buffer: buffer.clone(), always_clip_cells: options.always_clip_cells, - buffer, + width: width / CELL_WIDTH as u32, + height: height / CELL_HEIGHT as u32, initialized: false, - changed_cells, + bg_rect_optimizer: RectangleColorOptimizer::default(), canvas, cursor_position: None, cursor_shape: CursorShape::SteadyBlock, @@ -233,163 +370,22 @@ impl CanvasBackend { // // If `force_redraw` is `true`, the entire canvas will be cleared and redrawn. fn update_grid(&mut self, force_redraw: bool) -> Result<(), Error> { + // bg_context runs first 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.bg_context.clear_rect(); } - self.canvas.context.translate(5_f64, 5_f64)?; // 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_symbols()?; self.draw_cursor()?; if self.debug_mode.is_some() { self.draw_debug()?; } - self.canvas.context.translate(-5_f64, -5_f64)?; - 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; - } - } - } - - /// Draws the text symbols on the canvas. - /// - /// This method renders the textual content of each cell in the buffer, optimizing canvas operations - /// by minimizing state changes across the WebAssembly boundary. - /// - /// # Optimization Strategy - /// - /// 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. - fn draw_symbols(&mut self) -> Result<(), Error> { - 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() { - // Skip empty cells - if !changed_cells[index] || cell.symbol() == " " { - index += 1; - 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(); - - last_color = Some(color); - - let color = get_canvas_color(color, Color::White); - self.canvas.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; - } - } - self.canvas.context.restore(); - - Ok(()) - } - - /// Draws the background of the cells. - /// - /// 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(); - - 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 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; - } - // Flush the remaining region after traversing the row - row_renderer.flush().map(draw_region); - } - - self.canvas.context.restore(); + // fg_context runs last + self.canvas.bg_context.flush(); + self.canvas.fg_context.flush(); Ok(()) } @@ -397,19 +393,17 @@ impl CanvasBackend { /// Draws the cursor on the canvas. fn draw_cursor(&mut self) -> Result<(), Error> { if let Some(pos) = self.cursor_position { - let cell = &self.buffer[pos.y as usize][pos.x as usize]; + // let cell = &self.buffer[pos.y as usize][pos.x as usize]; - if cell.modifier.contains(Modifier::UNDERLINED) { - self.canvas.context.save(); + // if cell.modifier.contains(Modifier::UNDERLINED) { + self.canvas.fg_context.save(); - self.canvas.context.fill_text( - "_", - pos.x as f64 * CELL_WIDTH, - pos.y as f64 * CELL_HEIGHT, - )?; + self.canvas + .fg_context + .fill_text("_", pos.x * CELL_WIDTH, pos.y * CELL_HEIGHT); - self.canvas.context.restore(); - } + self.canvas.fg_context.restore(); + // } } Ok(()) @@ -417,22 +411,22 @@ impl CanvasBackend { /// Draws cell boundaries for debugging. fn draw_debug(&mut self) -> Result<(), Error> { - self.canvas.context.save(); + self.canvas.fg_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, + for y in 0..self.height { + for x in 0..self.width { + self.canvas.fg_context.set_stroke_style_str(color); + self.canvas.fg_context.stroke_rect( + x as u16 * CELL_WIDTH, + y as u16 * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, ); } } - self.canvas.context.restore(); + self.canvas.fg_context.restore(); Ok(()) } @@ -446,23 +440,103 @@ impl Backend for CanvasBackend { where I: Iterator, { + self.canvas.fg_context.save(); + + let mut last_color = None; for (x, y, cell) in content { let y = y as usize; let x = x as usize; - let line = &mut self.buffer[y]; - line.extend(std::iter::repeat_with(Cell::default).take(x.saturating_sub(line.len()))); - line[x] = cell.clone(); + self.bg_rect_optimizer + .process_color((x, y), actual_bg_color(cell)); + + // Draws the text symbols on the canvas. + // + // This method renders the textual content of each cell in the buffer, optimizing canvas operations + // by minimizing state changes across the WebAssembly boundary. + // + // # Optimization Strategy + // + // 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. + 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.fg_context.restore(); + self.canvas.fg_context.save(); + + self.canvas.fg_context.begin_path(); + self.canvas.fg_context.rect( + x as u16 * CELL_WIDTH, + y as u16 * CELL_HEIGHT, + CELL_WIDTH, + CELL_HEIGHT, + ); + self.canvas.fg_context.clip(); + + last_color = None; // reset last color to avoid clipping + let color = get_canvas_color(color, Color::White); + self.canvas.fg_context.set_fill_style_str(&color); + } else if last_color != Some(color) { + self.canvas.fg_context.restore(); + self.canvas.fg_context.save(); + + last_color = Some(color); + + let color = get_canvas_color(color, Color::White); + self.canvas.fg_context.set_fill_style_str(&color); + } + + self.canvas.fg_context.fill_text( + cell.symbol(), + x as u16 * CELL_WIDTH, + y as u16 * CELL_HEIGHT, + ); + } + + self.canvas.fg_context.restore(); + + self.canvas.bg_context.save(); + + for (color, rects) in self.bg_rect_optimizer.finish() { + let color = get_canvas_color(color, self.canvas.background_color); + + self.canvas.bg_context.begin_path(); + for rect in rects { + self.canvas.bg_context.rect( + rect.x * CELL_WIDTH, + rect.y * CELL_HEIGHT, + rect.width * CELL_WIDTH, + rect.height * CELL_HEIGHT, + ); + } + + self.canvas.bg_context.set_fill_style_str(&color); + self.canvas.bg_context.fill(); } + self.canvas.bg_context.restore(); + // Draw the cursor if set if let Some(pos) = self.cursor_position { let y = pos.y as usize; let x = pos.x as usize; - let line = &mut self.buffer[y]; - if x < line.len() { - let cursor_style = self.cursor_shape.show(line[x].style()); - line[x].set_style(cursor_style); - } + // let line = &mut self.buffer[y]; + // if x < line.len() { + // let cursor_style = self.cursor_shape.show(line[x].style()); + // line[x].set_style(cursor_style); + // } } Ok(()) @@ -473,20 +547,11 @@ 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.prev_buffer = self.buffer.clone(); - self.initialized = true; - return Ok(()); - } - - if self.buffer != self.prev_buffer { - self.update_grid(false)?; - } - - self.prev_buffer = self.buffer.clone(); - + self.update_grid( + // Only runs once. + !self.initialized, + )?; + self.initialized = true; Ok(()) } @@ -494,11 +559,11 @@ impl Backend for CanvasBackend { if let Some(pos) = self.cursor_position { let y = pos.y as usize; let x = pos.x as usize; - let line = &mut self.buffer[y]; - if x < line.len() { - let style = self.cursor_shape.hide(line[x].style()); - line[x].set_style(style); - } + // let line = &mut self.buffer[y]; + // if x < line.len() { + // let style = self.cursor_shape.hide(line[x].style()); + // line[x].set_style(style); + // } } self.cursor_position = None; Ok(()) @@ -517,15 +582,13 @@ impl Backend for CanvasBackend { } fn clear(&mut self) -> IoResult<()> { - self.buffer = get_sized_buffer(); + self.canvas.bg_context.clear_rect(); + // self.buffer = get_sized_buffer(); 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, - )) + Ok(Size::new(self.width as u16, self.height as u16)) } fn window_size(&mut self) -> IoResult { @@ -544,11 +607,11 @@ impl Backend for CanvasBackend { if let Some(old_pos) = self.cursor_position { let y = old_pos.y as usize; let x = old_pos.x as usize; - let line = &mut self.buffer[y]; - if x < line.len() && old_pos != new_pos { - let style = self.cursor_shape.hide(line[x].style()); - line[x].set_style(style); - } + // let line = &mut self.buffer[y]; + // if x < line.len() && old_pos != new_pos { + // let style = self.cursor_shape.hide(line[x].style()); + // line[x].set_style(style); + // } } self.cursor_position = Some(new_pos); Ok(()) @@ -571,15 +634,16 @@ impl WebEventHandler for CanvasBackend { 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 grid_width = self.width as u16; + let grid_height = self.height as u16; // Configure coordinate translation for canvas backend let config = MouseConfig::new(grid_width, grid_height) .with_offset(5.0) // Canvas translation offset - .with_cell_dimensions(CELL_WIDTH, CELL_HEIGHT); + .with_cell_dimensions(CELL_WIDTH as f64, CELL_HEIGHT as f64); - let element: web_sys::Element = self.canvas.inner.clone().into(); + let element: web_sys::Element = + self.canvas.fg_context.ratzilla_canvas().get_canvas().into(); let element_for_closure = element.clone(); // Create mouse event callback @@ -609,11 +673,11 @@ impl WebEventHandler for CanvasBackend { // Clear any existing handlers first self.clear_key_events(); - let element: web_sys::Element = self.canvas.inner.clone().into(); + let element: web_sys::Element = + self.canvas.fg_context.ratzilla_canvas().get_canvas().into(); // Make the canvas focusable so it can receive key events - self.canvas - .inner + element .set_attribute("tabindex", "0") .map_err(Error::from)?; @@ -633,49 +697,88 @@ impl WebEventHandler for CanvasBackend { } } -/// Optimizes canvas rendering by batching adjacent cells with the same color into a single rectangle. -/// -/// This reduces the number of draw calls to the canvas API by coalescing adjacent cells -/// with identical colors into larger rectangles, which is particularly beneficial for -/// WASM where calls are quite expensive. -struct RowColorOptimizer { - /// The currently accumulating region and its color - pending_region: Option<(Rect, Color)>, +struct RectColumnMerger<'a> { + iter: Peekable>, +} + +impl Iterator for RectColumnMerger<'_> { + type Item = Rect; + + fn next(&mut self) -> Option { + let mut initial_rect = self.iter.next()?; + + let mut y = initial_rect.y; + while let Some(next_rect) = self.iter.peek() { + if initial_rect.x == next_rect.x + && y + 1 == next_rect.y + && initial_rect.width == next_rect.width + { + self.iter.next(); + y += 1; + initial_rect.height += 1; + } else { + break; + } + } + + Some(initial_rect) + } } -impl RowColorOptimizer { - /// Creates a new empty optimizer with no pending region. - fn new() -> Self { - Self { - pending_region: None, +struct RectColorMerger<'a> { + iter: indexmap::map::IterMut<'a, Color, Vec>, +} + +impl<'a> Iterator for RectColorMerger<'a> { + type Item = (Color, RectColumnMerger<'a>); + + fn next(&mut self) -> Option { + let mut next_item = self.iter.next()?; + + while next_item.1.is_empty() { + next_item = self.iter.next()?; } + + next_item.1.sort_unstable_by_key(|r| (r.x, r.y)); + + Some(( + *next_item.0, + RectColumnMerger { + iter: next_item.1.drain(..).peekable(), + }, + )) } +} + +#[derive(Debug, Default)] +struct RectangleColorOptimizer { + rects: IndexMap>, +} + +impl RectangleColorOptimizer { + fn process_color(&mut self, pos: (usize, usize), color: Color) { + let color_entry = self.rects.entry(color).or_default(); + let pending_region = color_entry.last_mut(); - /// Processes a cell with the given position and color. - 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 + if let Some(active_rect) = pending_region { + if active_rect.right() as usize == pos.0 && active_rect.y as usize == pos.1 { + // Directly next to active_rect: 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); - *active_color = color; - return Some((region, region_color)); + color_entry.push(Rect::new(pos.0 as _, pos.1 as _, 1, 1)); + // 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)); + color_entry.push(rect); } - - None } - /// Finalizes and returns the current pending region, if any. - fn flush(&mut self) -> Option<(Rect, Color)> { - self.pending_region.take() + fn finish(&mut self) -> RectColorMerger<'_> { + RectColorMerger { + iter: self.rects.iter_mut(), + } } } diff --git a/src/backend/ratzilla_canvas.js b/src/backend/ratzilla_canvas.js new file mode 100644 index 0000000..eb26a6f --- /dev/null +++ b/src/backend/ratzilla_canvas.js @@ -0,0 +1,29 @@ +export class RatzillaCanvas { + constructor() {} + + create_canvas_in_element(parent, width, height) { + this.canvas = document.createElement("canvas"); + this.canvas.width = width + this.canvas.height = height + parent.appendChild(this.canvas); + } + + init_ctx() { + this.ctx = this.canvas.getContext("2d", { + desynchronized: true, + alpha: true + }); + this.ctx.font = "16px monospace"; + this.ctx.textBaseline = "top"; + } + + share_ctx_with_other(other) { + this.ctx = other.ctx; + this.canvas = other.canvas; + } + + get_canvas() { + return this.canvas; + } +} + From 7b39d952feda2c44ca65dfa26797a550b1000dd4 Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 21:43:49 -0700 Subject: [PATCH 02/10] Fix cursor --- src/backend/canvas.rs | 234 ++++++++++++++++++--------------- src/backend/ratzilla_canvas.js | 4 + 2 files changed, 133 insertions(+), 105 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 063f8ff..318e999 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -9,7 +9,7 @@ use std::{ use crate::{ backend::{ - color::{actual_bg_color, actual_fg_color}, + color::{actual_bg_color, actual_fg_color, to_rgb}, event_callback::{ create_mouse_event, EventCallback, MouseConfig, KEY_EVENT_TYPES, MOUSE_EVENT_TYPES, }, @@ -105,6 +105,9 @@ extern "C" { #[wasm_bindgen(method)] fn get_canvas(this: &RatzillaCanvas) -> web_sys::HtmlCanvasElement; + + #[wasm_bindgen(method)] + fn get_ctx(this: &RatzillaCanvas) -> web_sys::CanvasRenderingContext2d; } #[bindgen] @@ -178,21 +181,27 @@ mod js { "# } - fn fill_text(text: &str, x: u16, y: u16) { + fn set_fill_style(style: u32) { r#" - this.ctx.fillText($text$, $x$, $y$); + this.ctx.fillStyle = `#\${$style$.toString(16).padStart(6, '0')}`; "# } - fn fill_rect(x: u16, y: u16, w: u16, h: u16) { + fn set_stroke_style_str(style: &str) { r#" - this.ctx.fillRect($x$, $y$, $w$, $h$); + this.ctx.strokeStyle = $style$; "# } - fn set_stroke_style_str(style: &str) { + fn fill_text(text: &str, x: u16, y: u16) { r#" - this.ctx.strokeStyle = $style$; + this.ctx.fillText($text$, $x$, $y$); + "# + } + + fn fill_rect(x: u16, y: u16, w: u16, h: u16) { + r#" + this.ctx.fillRect($x$, $y$, $w$, $h$); "# } @@ -267,15 +276,21 @@ pub struct CanvasBackend { /// this option may cause some performance issues when dealing with large /// numbers of simultaneous changes. always_clip_cells: bool, + /// Current buffer. + buffer: Vec>, + /// The number of cells wide the canvas is width: u32, + /// The number of cells tall the canvas is height: u32, /// Groups together and merges rectangles with /// the same fill color - bg_rect_optimizer: RectangleColorOptimizer, + bg_rect_optimizer: RectColorOptimizer, /// Canvas. canvas: Canvas, + /// Is true if the cursor is currently visible + cursor_shown: bool, /// Cursor position. - cursor_position: Option, + cursor_position: Position, /// The cursor shape. cursor_shape: CursorShape, /// Draw cell boundaries with specified color. @@ -314,15 +329,21 @@ impl CanvasBackend { .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32)); let canvas = Canvas::new(parent, width, height, Color::Black)?; + let width = width / CELL_WIDTH as u32; + let height = height / CELL_HEIGHT as u32; + let buffer = vec![vec![Cell::default(); width as usize]; height as usize]; + Ok(Self { always_clip_cells: options.always_clip_cells, - width: width / CELL_WIDTH as u32, - height: height / CELL_HEIGHT as u32, + buffer, + width, + height, initialized: false, - bg_rect_optimizer: RectangleColorOptimizer::default(), + bg_rect_optimizer: RectColorOptimizer::default(), canvas, - cursor_position: None, + cursor_position: Position::MIN, cursor_shape: CursorShape::SteadyBlock, + cursor_shown: false, debug_mode: None, mouse_callback: None, key_callback: None, @@ -370,15 +391,25 @@ impl CanvasBackend { // // If `force_redraw` is `true`, the entire canvas will be cleared and redrawn. fn update_grid(&mut self, force_redraw: bool) -> Result<(), Error> { - // bg_context runs first + // Happens immediately (unbuffered) if force_redraw { - self.canvas.bg_context.clear_rect(); + let ctx = self.canvas.bg_context.ratzilla_canvas().get_ctx(); + + ctx.set_fill_style_str(&get_canvas_color( + self.canvas.background_color, + self.canvas.background_color, + )); + ctx.fill_rect( + 0.0, + 0.0, + (self.width * CELL_WIDTH as u32) as f64, + (self.height * CELL_HEIGHT as u32) as f64, + ); } // 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.draw_cursor()?; if self.debug_mode.is_some() { self.draw_debug()?; } @@ -390,29 +421,8 @@ impl CanvasBackend { Ok(()) } - /// Draws the cursor on the canvas. - fn draw_cursor(&mut self) -> Result<(), Error> { - if let Some(pos) = self.cursor_position { - // let cell = &self.buffer[pos.y as usize][pos.x as usize]; - - // if cell.modifier.contains(Modifier::UNDERLINED) { - self.canvas.fg_context.save(); - - self.canvas - .fg_context - .fill_text("_", pos.x * CELL_WIDTH, pos.y * CELL_HEIGHT); - - self.canvas.fg_context.restore(); - // } - } - - Ok(()) - } - /// Draws cell boundaries for debugging. fn draw_debug(&mut self) -> Result<(), Error> { - self.canvas.fg_context.save(); - let color = self.debug_mode.as_ref().unwrap(); for y in 0..self.height { for x in 0..self.width { @@ -426,8 +436,6 @@ impl CanvasBackend { } } - self.canvas.fg_context.restore(); - Ok(()) } } @@ -440,14 +448,20 @@ impl Backend for CanvasBackend { where I: Iterator, { + let mut last_color = None; self.canvas.fg_context.save(); - let mut last_color = None; for (x, y, cell) in content { - let y = y as usize; - let x = x as usize; + { + let x = x as usize; + let y = y as usize; + if let Some(line) = self.buffer.get_mut(y) { + line.get_mut(x).map(|c| *c = cell.clone()); + } + } + self.bg_rect_optimizer - .process_color((x, y), actual_bg_color(cell)); + .process_color((x as usize, y as usize), actual_bg_color(cell)); // Draws the text symbols on the canvas. // @@ -456,17 +470,14 @@ impl Backend for CanvasBackend { // // # Optimization Strategy // - // Rather than saving/restoring the canvas context for every cell (which would be expensive), + // Rather than saving/restoring the canvas context forself. { + // cursor_positionvery 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. - if cell.symbol() == " " { - continue; - } - let color = actual_fg_color(cell); // We need to reset the canvas context state in two scenarios: @@ -478,39 +489,43 @@ impl Backend for CanvasBackend { self.canvas.fg_context.begin_path(); self.canvas.fg_context.rect( - x as u16 * CELL_WIDTH, - y as u16 * CELL_HEIGHT, + x * CELL_WIDTH, + y * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, ); self.canvas.fg_context.clip(); last_color = None; // reset last color to avoid clipping - let color = get_canvas_color(color, Color::White); - self.canvas.fg_context.set_fill_style_str(&color); + let color = to_rgb(color, 0xFFFFFFFF); + self.canvas.fg_context.set_fill_style(color); } else if last_color != Some(color) { self.canvas.fg_context.restore(); self.canvas.fg_context.save(); last_color = Some(color); - let color = get_canvas_color(color, Color::White); - self.canvas.fg_context.set_fill_style_str(&color); + let color = to_rgb(color, 0xFFFFFFFF); + self.canvas.fg_context.set_fill_style(color); } - self.canvas.fg_context.fill_text( - cell.symbol(), - x as u16 * CELL_WIDTH, - y as u16 * CELL_HEIGHT, - ); + if cell.symbol() != " " { + self.canvas + .fg_context + .fill_text(cell.symbol(), x * CELL_WIDTH, y * CELL_HEIGHT); + } + + if self.cursor_shown && self.cursor_position == Position::new(x, y) { + self.canvas + .fg_context + .fill_text("_", x * CELL_WIDTH, y * CELL_HEIGHT); + } } self.canvas.fg_context.restore(); - self.canvas.bg_context.save(); - for (color, rects) in self.bg_rect_optimizer.finish() { - let color = get_canvas_color(color, self.canvas.background_color); + let color = to_rgb(color, to_rgb(self.canvas.background_color, 0x00000000)); self.canvas.bg_context.begin_path(); for rect in rects { @@ -522,23 +537,10 @@ impl Backend for CanvasBackend { ); } - self.canvas.bg_context.set_fill_style_str(&color); + self.canvas.bg_context.set_fill_style(color); self.canvas.bg_context.fill(); } - self.canvas.bg_context.restore(); - - // Draw the cursor if set - if let Some(pos) = self.cursor_position { - let y = pos.y as usize; - let x = pos.x as usize; - // let line = &mut self.buffer[y]; - // if x < line.len() { - // let cursor_style = self.cursor_shape.show(line[x].style()); - // line[x].set_style(cursor_style); - // } - } - Ok(()) } @@ -556,34 +558,62 @@ impl Backend for CanvasBackend { } fn hide_cursor(&mut self) -> IoResult<()> { - if let Some(pos) = self.cursor_position { - let y = pos.y as usize; - let x = pos.x as usize; - // let line = &mut self.buffer[y]; - // if x < line.len() { - // let style = self.cursor_shape.hide(line[x].style()); - // line[x].set_style(style); - // } + // Redraw the cell under the cursor, but without + // the cursor style + if self.cursor_shown { + self.flush()?; + self.cursor_shown = false; + let x = self.cursor_position.x as usize; + let y = self.cursor_position.y as usize; + if let Some(line) = self.buffer.get(y) { + if let Some(cell) = line.get(x).cloned() { + self.draw( + [(self.cursor_position.x, self.cursor_position.y, &cell)].into_iter(), + )?; + } + } } - self.cursor_position = None; Ok(()) } fn show_cursor(&mut self) -> IoResult<()> { + // Redraw the new cell under the cursor, but with + // the cursor style + if !self.cursor_shown { + self.flush()?; + self.cursor_shown = true; + let x = self.cursor_position.x as usize; + let y = self.cursor_position.y as usize; + if let Some(line) = self.buffer.get(y) { + if let Some(cell) = line.get(x).cloned() { + self.draw( + [(self.cursor_position.x, self.cursor_position.y, &cell)].into_iter(), + )?; + } + } + } Ok(()) } fn get_cursor(&mut self) -> IoResult<(u16, u16)> { - Ok((0, 0)) + let Position { x, y } = self.get_cursor_position()?; + Ok((x, y)) } - fn set_cursor(&mut self, _x: u16, _y: u16) -> IoResult<()> { - Ok(()) + fn set_cursor(&mut self, x: u16, y: u16) -> IoResult<()> { + self.set_cursor_position(Position::new(x, y)) } fn clear(&mut self) -> IoResult<()> { + self.canvas.bg_context.set_fill_style_str(&get_canvas_color( + self.canvas.background_color, + self.canvas.background_color, + )); self.canvas.bg_context.clear_rect(); - // self.buffer = get_sized_buffer(); + self.buffer + .iter_mut() + .flatten() + .for_each(|c| *c = Cell::default()); Ok(()) } @@ -596,24 +626,18 @@ impl Backend for CanvasBackend { } fn get_cursor_position(&mut self) -> IoResult { - match self.cursor_position { - None => Ok((0, 0).into()), - Some(position) => Ok(position), - } + Ok(self.cursor_position) } fn set_cursor_position>(&mut self, position: P) -> IoResult<()> { - let new_pos = position.into(); - if let Some(old_pos) = self.cursor_position { - let y = old_pos.y as usize; - let x = old_pos.x as usize; - // let line = &mut self.buffer[y]; - // if x < line.len() && old_pos != new_pos { - // let style = self.cursor_shape.hide(line[x].style()); - // line[x].set_style(style); - // } + let new_position = position.into(); + if self.cursor_shown && self.cursor_position != new_position { + self.hide_cursor()?; + self.cursor_position = new_position; + self.show_cursor()?; + } else { + self.cursor_position = new_position; } - self.cursor_position = Some(new_pos); Ok(()) } @@ -751,11 +775,11 @@ impl<'a> Iterator for RectColorMerger<'a> { } #[derive(Debug, Default)] -struct RectangleColorOptimizer { +struct RectColorOptimizer { rects: IndexMap>, } -impl RectangleColorOptimizer { +impl RectColorOptimizer { fn process_color(&mut self, pos: (usize, usize), color: Color) { let color_entry = self.rects.entry(color).or_default(); let pending_region = color_entry.last_mut(); diff --git a/src/backend/ratzilla_canvas.js b/src/backend/ratzilla_canvas.js index eb26a6f..87bcaff 100644 --- a/src/backend/ratzilla_canvas.js +++ b/src/backend/ratzilla_canvas.js @@ -25,5 +25,9 @@ export class RatzillaCanvas { get_canvas() { return this.canvas; } + + get_ctx() { + return this.ctx; + } } From 6749e4d5d55725dd2eb186e5242ad9951d58c418 Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 21:45:25 -0700 Subject: [PATCH 03/10] Fix cursor --- src/backend/canvas.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 318e999..9982f71 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -631,12 +631,10 @@ impl Backend for CanvasBackend { fn set_cursor_position>(&mut self, position: P) -> IoResult<()> { let new_position = position.into(); - if self.cursor_shown && self.cursor_position != new_position { + if self.cursor_position != new_position { self.hide_cursor()?; self.cursor_position = new_position; self.show_cursor()?; - } else { - self.cursor_position = new_position; } Ok(()) } From 180ac4e0372fc15013c4af58b60b2f78e54dd0ba Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 21:49:10 -0700 Subject: [PATCH 04/10] Remove obsolete comment --- src/backend/canvas.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 9982f71..42e243a 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -414,7 +414,6 @@ impl CanvasBackend { self.draw_debug()?; } - // fg_context runs last self.canvas.bg_context.flush(); self.canvas.fg_context.flush(); From 91ab54aff38054f3457b81a503a2dca8a4fc9e8e Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 21:55:29 -0700 Subject: [PATCH 05/10] Put back some old code --- src/backend/canvas.rs | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 42e243a..a527169 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -278,10 +278,6 @@ pub struct CanvasBackend { always_clip_cells: bool, /// Current buffer. buffer: Vec>, - /// The number of cells wide the canvas is - width: u32, - /// The number of cells tall the canvas is - height: u32, /// Groups together and merges rectangles with /// the same fill color bg_rect_optimizer: RectColorOptimizer, @@ -336,8 +332,6 @@ impl CanvasBackend { Ok(Self { always_clip_cells: options.always_clip_cells, buffer, - width, - height, initialized: false, bg_rect_optimizer: RectColorOptimizer::default(), canvas, @@ -399,11 +393,13 @@ impl CanvasBackend { self.canvas.background_color, self.canvas.background_color, )); + // Infallible + let size = self.size().unwrap(); ctx.fill_rect( 0.0, 0.0, - (self.width * CELL_WIDTH as u32) as f64, - (self.height * CELL_HEIGHT as u32) as f64, + (size.width * CELL_WIDTH) as f64, + (size.height * CELL_HEIGHT) as f64, ); } @@ -423,8 +419,8 @@ impl CanvasBackend { /// Draws cell boundaries for debugging. fn draw_debug(&mut self) -> Result<(), Error> { let color = self.debug_mode.as_ref().unwrap(); - for y in 0..self.height { - for x in 0..self.width { + for (y, line) in self.buffer.iter().enumerate() { + for (x, _) in line.iter().enumerate() { self.canvas.fg_context.set_stroke_style_str(color); self.canvas.fg_context.stroke_rect( x as u16 * CELL_WIDTH, @@ -595,12 +591,11 @@ impl Backend for CanvasBackend { } fn get_cursor(&mut self) -> IoResult<(u16, u16)> { - let Position { x, y } = self.get_cursor_position()?; - Ok((x, y)) + Ok((0, 0)) } - fn set_cursor(&mut self, x: u16, y: u16) -> IoResult<()> { - self.set_cursor_position(Position::new(x, y)) + fn set_cursor(&mut self, _x: u16, _y: u16) -> IoResult<()> { + Ok(()) } fn clear(&mut self) -> IoResult<()> { @@ -617,7 +612,14 @@ impl Backend for CanvasBackend { } fn size(&self) -> IoResult { - Ok(Size::new(self.width as u16, self.height as u16)) + Ok(Size::new( + self.buffer + .get(0) + .map(|b| b.len()) + .unwrap_or(0) + .saturating_sub(1) as u16, + self.buffer.len().saturating_sub(1) as u16, + )) } fn window_size(&mut self) -> IoResult { @@ -655,8 +657,8 @@ impl WebEventHandler for CanvasBackend { self.clear_mouse_events(); // Get grid dimensions from the buffer - let grid_width = self.width as u16; - let grid_height = self.height as u16; + 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) From 9dd88ff308d48019a78429149723ab8fd8ca90b4 Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 21:58:06 -0700 Subject: [PATCH 06/10] Create buffer based on canvas client width --- src/backend/canvas.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index a527169..fb26339 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -325,10 +325,8 @@ impl CanvasBackend { .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32)); let canvas = Canvas::new(parent, width, height, Color::Black)?; - let width = width / CELL_WIDTH as u32; - let height = height / CELL_HEIGHT as u32; - let buffer = vec![vec![Cell::default(); width as usize]; height as usize]; - + let buffer = + get_sized_buffer_from_canvas(&canvas.fg_context.ratzilla_canvas().get_canvas()); Ok(Self { always_clip_cells: options.always_clip_cells, buffer, From b41a2604c5c0a9c4eb3e126ada7d2e1bdf63b01c Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 22:02:20 -0700 Subject: [PATCH 07/10] Final code changes before opening PR --- src/backend/canvas.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index fb26339..25388f5 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -228,6 +228,8 @@ impl Debug for Buffer { /// Canvas renderer. #[derive(Debug)] struct Canvas { + /// Canvas element. + inner: web_sys::HtmlCanvasElement, /// Foreground (symbol) Rendering context. fg_context: Buffer, /// Background Rendering context. @@ -257,6 +259,9 @@ impl Canvas { .share_ctx_with_other(fg_context.ratzilla_canvas()); Ok(Self { + // `bg_context` and `fg_context` point to + // the same canvas element + inner: fg_context.ratzilla_canvas().get_canvas(), fg_context, bg_context, background_color, @@ -325,8 +330,7 @@ impl CanvasBackend { .unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32)); let canvas = Canvas::new(parent, width, height, Color::Black)?; - let buffer = - get_sized_buffer_from_canvas(&canvas.fg_context.ratzilla_canvas().get_canvas()); + let buffer = get_sized_buffer_from_canvas(&canvas.inner); Ok(Self { always_clip_cells: options.always_clip_cells, buffer, @@ -391,8 +395,7 @@ impl CanvasBackend { self.canvas.background_color, self.canvas.background_color, )); - // Infallible - let size = self.size().unwrap(); + let size = self.size().expect("Infallible"); ctx.fill_rect( 0.0, 0.0, @@ -663,8 +666,7 @@ impl WebEventHandler for CanvasBackend { .with_offset(5.0) // Canvas translation offset .with_cell_dimensions(CELL_WIDTH as f64, CELL_HEIGHT as f64); - let element: web_sys::Element = - self.canvas.fg_context.ratzilla_canvas().get_canvas().into(); + let element: web_sys::Element = self.canvas.inner.clone().into(); let element_for_closure = element.clone(); // Create mouse event callback @@ -694,8 +696,7 @@ impl WebEventHandler for CanvasBackend { // Clear any existing handlers first self.clear_key_events(); - let element: web_sys::Element = - self.canvas.fg_context.ratzilla_canvas().get_canvas().into(); + let element: web_sys::Element = self.canvas.inner.clone().into(); // Make the canvas focusable so it can receive key events element From 197277142f42454d171e920c3650baff7717045f Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 22:12:33 -0700 Subject: [PATCH 08/10] Improve performance by skipping space cells when it's not the cursor cell --- src/backend/canvas.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 25388f5..95d362e 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -476,6 +476,12 @@ impl Backend for CanvasBackend { // or when `always_clip_cells` is enabled. let color = actual_fg_color(cell); + let is_cursor_cell = self.cursor_shown && self.cursor_position == Position::new(x, y); + + if !is_cursor_cell && cell.symbol() == " " { + continue; + } + // 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 @@ -505,13 +511,11 @@ impl Backend for CanvasBackend { self.canvas.fg_context.set_fill_style(color); } - if cell.symbol() != " " { - self.canvas - .fg_context - .fill_text(cell.symbol(), x * CELL_WIDTH, y * CELL_HEIGHT); - } + self.canvas + .fg_context + .fill_text(cell.symbol(), x * CELL_WIDTH, y * CELL_HEIGHT); - if self.cursor_shown && self.cursor_position == Position::new(x, y) { + if is_cursor_cell { self.canvas .fg_context .fill_text("_", x * CELL_WIDTH, y * CELL_HEIGHT); From f0d191d1d606194994951bcf07f471801b03012a Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 22:27:47 -0700 Subject: [PATCH 09/10] Remove bitvec dependency --- Cargo.lock | 40 ---------------------------------------- Cargo.toml | 1 - 2 files changed, 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 219a182..664586e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,18 +68,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -224,12 +212,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures-core" version = "0.3.31" @@ -513,12 +495,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "ratatui" version = "0.30.0" @@ -574,7 +550,6 @@ name = "ratzilla" version = "0.3.0" dependencies = [ "beamterm-renderer", - "bitvec", "compact_str", "console_error_panic_hook", "indexmap", @@ -760,12 +735,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "thiserror" version = "2.0.18" @@ -981,15 +950,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "zmij" version = "1.0.14" diff --git a/Cargo.toml b/Cargo.toml index a722e1d..4e0750f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,6 @@ compact_str = "0.9.0" ratatui = { version = "0.30", default-features = false, features = ["all-widgets", "layout-cache"] } console_error_panic_hook = "0.1.7" thiserror = "2.0.18" -bitvec = { version = "1.0.1", default-features = false, features = ["alloc", "std"] } beamterm-renderer = "0.15.0" unicode-width = "0.2.2" sledgehammer_bindgen = { version = "0.6.0", features = ["web"] } From 89dd41d570bf7bbc466771deba8e9d54e169aa16 Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Sun, 22 Feb 2026 23:10:01 -0700 Subject: [PATCH 10/10] Put back some old code --- src/backend/canvas.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/canvas.rs b/src/backend/canvas.rs index 95d362e..1b15be7 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -395,12 +395,11 @@ impl CanvasBackend { self.canvas.background_color, self.canvas.background_color, )); - let size = self.size().expect("Infallible"); ctx.fill_rect( 0.0, 0.0, - (size.width * CELL_WIDTH) as f64, - (size.height * CELL_HEIGHT) as f64, + self.canvas.inner.client_width() as f64, + self.canvas.inner.client_height() as f64, ); } @@ -703,7 +702,8 @@ impl WebEventHandler for CanvasBackend { let element: web_sys::Element = self.canvas.inner.clone().into(); // Make the canvas focusable so it can receive key events - element + self.canvas + .inner .set_attribute("tabindex", "0") .map_err(Error::from)?;