diff --git a/Cargo.lock b/Cargo.lock index 97ba99e..664586e 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", @@ -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" @@ -278,6 +260,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" @@ -503,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" @@ -564,16 +550,24 @@ name = "ratzilla" version = "0.3.0" dependencies = [ "beamterm-renderer", - "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 +662,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" @@ -712,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" @@ -933,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 5758ab4..4e0750f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,9 +43,11 @@ 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"] } +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..1b15be7 100644 --- a/src/backend/canvas.rs +++ b/src/backend/canvas.rs @@ -1,10 +1,15 @@ -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::{ - 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, }, @@ -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,165 @@ 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; + + #[wasm_bindgen(method)] + fn get_ctx(this: &RatzillaCanvas) -> web_sys::CanvasRenderingContext2d; +} + +#[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 set_fill_style(style: u32) { + r#" + this.ctx.fillStyle = `#\${$style$.toString(16).padStart(6, '0')}`; + "# + } + + fn set_stroke_style_str(style: &str) { + r#" + this.ctx.strokeStyle = $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 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 +246,24 @@ 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, + // `bg_context` and `fg_context` point to + // the same canvas element + inner: fg_context.ratzilla_canvas().get_canvas(), + fg_context, + bg_context, background_color, }) } @@ -129,14 +283,15 @@ pub struct CanvasBackend { always_clip_cells: bool, /// Current buffer. buffer: Vec>, - /// Previous buffer. - prev_buffer: Vec>, - /// Changed buffer cells - changed_cells: BitVec, + /// Groups together and merges rectangles with + /// the same fill color + 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. @@ -176,16 +331,15 @@ impl CanvasBackend { 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, initialized: false, - changed_cells, + 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, @@ -233,207 +387,50 @@ 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> { + // Happens immediately (unbuffered) if force_redraw { - self.canvas.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.canvas.inner.client_width() as f64, self.canvas.inner.client_height() as f64, ); } - 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(); - - 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.context.save(); - - self.canvas.context.fill_text( - "_", - pos.x as f64 * CELL_WIDTH, - pos.y as f64 * CELL_HEIGHT, - )?; - - self.canvas.context.restore(); - } - } + self.canvas.bg_context.flush(); + self.canvas.fg_context.flush(); Ok(()) } /// Draws cell boundaries for debugging. fn draw_debug(&mut self) -> Result<(), Error> { - 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.context.set_stroke_style_str(color); - self.canvas.context.stroke_rect( - x as f64 * CELL_WIDTH, - y as f64 * CELL_HEIGHT, + 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(); - Ok(()) } } @@ -446,23 +443,101 @@ impl Backend for CanvasBackend { where I: Iterator, { + let mut last_color = None; + self.canvas.fg_context.save(); + 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(); + { + 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 as usize, y as usize), 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 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. + 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 + 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 * 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 = 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 = to_rgb(color, 0xFFFFFFFF); + self.canvas.fg_context.set_fill_style(color); + } + + self.canvas + .fg_context + .fill_text(cell.symbol(), x * CELL_WIDTH, y * CELL_HEIGHT); + + if is_cursor_cell { + self.canvas + .fg_context + .fill_text("_", x * CELL_WIDTH, y * CELL_HEIGHT); + } } - // 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); + self.canvas.fg_context.restore(); + + for (color, rects) in self.bg_rect_optimizer.finish() { + let color = to_rgb(color, to_rgb(self.canvas.background_color, 0x00000000)); + + 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(color); + self.canvas.bg_context.fill(); } Ok(()) @@ -473,38 +548,49 @@ 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(()) } 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(()) } @@ -517,13 +603,25 @@ impl Backend for CanvasBackend { } fn clear(&mut self) -> IoResult<()> { - self.buffer = get_sized_buffer(); + 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 + .iter_mut() + .flatten() + .for_each(|c| *c = Cell::default()); Ok(()) } fn size(&self) -> IoResult { Ok(Size::new( - self.buffer[0].len().saturating_sub(1) as u16, + self.buffer + .get(0) + .map(|b| b.len()) + .unwrap_or(0) + .saturating_sub(1) as u16, self.buffer.len().saturating_sub(1) as u16, )) } @@ -533,24 +631,16 @@ 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_position != new_position { + self.hide_cursor()?; + self.cursor_position = new_position; + self.show_cursor()?; } - self.cursor_position = Some(new_pos); Ok(()) } @@ -577,7 +667,7 @@ 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_cell_dimensions(CELL_WIDTH as f64, CELL_HEIGHT as f64); let element: web_sys::Element = self.canvas.inner.clone().into(); let element_for_closure = element.clone(); @@ -633,49 +723,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 RowColorOptimizer { - /// Creates a new empty optimizer with no pending region. - fn new() -> Self { - Self { - pending_region: None, +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) } +} + +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()?; - /// 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 + 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 RectColorOptimizer { + rects: IndexMap>, +} + +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(); + + 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..87bcaff --- /dev/null +++ b/src/backend/ratzilla_canvas.js @@ -0,0 +1,33 @@ +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; + } + + get_ctx() { + return this.ctx; + } +} +