From 48aadfd22788a8621b34cbad554333a2dfde9d54 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Mar 2026 15:53:10 +0900 Subject: [PATCH 01/14] refactor: enhance layout engine integration and session management - Updated the `TextEditSession` to be generic over the layout engine, allowing for more flexible integration of different layout implementations, such as `SkiaLayoutEngine` and `SimpleLayoutEngine`. - Introduced the `ManagedTextLayout` trait to encapsulate layout lifecycle management, providing methods for layout invalidation and updates. - Refactored the `Cargo.toml` to make the `skia-safe` dependency optional and organized feature flags for better modularity. - Adjusted the `wd_text_editor` example to utilize the new layout engine structure, improving clarity and maintainability. These changes aim to streamline the text editing architecture, enhancing flexibility and performance in the grida-text-edit project. --- crates/grida-text-edit/Cargo.toml | 6 +- .../examples/wd_text_editor.rs | 13 +-- crates/grida-text-edit/src/layout.rs | 39 ++++++++ crates/grida-text-edit/src/lib.rs | 33 ++++++- crates/grida-text-edit/src/session_tests.rs | 8 +- crates/grida-text-edit/src/simple_layout.rs | 30 +++++- crates/grida-text-edit/src/skia_layout.rs | 30 ++++++ .../grida-text-edit/src/text_edit_session.rs | 94 +++++++++++++------ 8 files changed, 208 insertions(+), 45 deletions(-) diff --git a/crates/grida-text-edit/Cargo.toml b/crates/grida-text-edit/Cargo.toml index 6abf8e17f4..0d8c29014c 100644 --- a/crates/grida-text-edit/Cargo.toml +++ b/crates/grida-text-edit/Cargo.toml @@ -10,7 +10,11 @@ description = "Platform-agnostic text editing engine." unicode-segmentation = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -skia-safe = { version = "0.91.0", features = ["textlayout"] } +skia-safe = { version = "0.91.0", features = ["textlayout"], optional = true } + +[features] +default = ["skia"] +skia = ["dep:skia-safe"] [dev-dependencies] arboard = "3" diff --git a/crates/grida-text-edit/examples/wd_text_editor.rs b/crates/grida-text-edit/examples/wd_text_editor.rs index 1f445c2499..0852425026 100644 --- a/crates/grida-text-edit/examples/wd_text_editor.rs +++ b/crates/grida-text-edit/examples/wd_text_editor.rs @@ -47,7 +47,7 @@ use winit::{ }; use grida_text_edit::{ - utf8_to_utf16_offset, TextLayoutEngine, + utf8_to_utf16_offset, TextLayoutEngine, SkiaLayoutEngine, selection_rects::{ EmptyLineSelectionPolicy, selection_rects_with_policy, skia_line_index_for_u16_offset, }, @@ -66,7 +66,7 @@ const WINDOW_W: u32 = 800; const WINDOW_H: u32 = 600; const PADDING: f32 = 24.0; const FONT_SIZE: f32 = 18.0; -const CURSOR_WIDTH: f32 = 2.0; +const CURSOR_WIDTH: f32 = 1.0; // --------------------------------------------------------------------------- // GL + Skia surface helpers (purely windowing boilerplate) @@ -115,7 +115,7 @@ impl GlSkiaSurface { fn draw_session( canvas: &skia_safe::Canvas, - session: &mut TextEditSession, + session: &mut TextEditSession, policy: EmptyLineSelectionPolicy, ) { canvas.clear(Color::WHITE); @@ -251,7 +251,7 @@ fn draw_session( .paint_paragraph_at(canvas, &session.state.text, origin); // Cursor - if session.cursor_visible() && !session.has_selection() { + if session.should_show_caret() { let cr = session.caret_rect(); let cursor_rect = Rect::from_xywh( cr.x + origin.x - CURSOR_WIDTH / 2.0, @@ -285,7 +285,7 @@ struct TextEditorApp { struct AppInner { window: Window, gl_skia: GlSkiaSurface, - session: TextEditSession, + session: TextEditSession, } impl TextEditorApp { @@ -437,7 +437,8 @@ impl ApplicationHandler for TextEditorApp { let layout_w = (w as f32) - PADDING * 2.0; let layout_h = (h as f32) - PADDING * 2.0; - let mut session = TextEditSession::new(layout_w, layout_h, default_style.clone()); + let layout = SkiaLayoutEngine::new(layout_w, layout_h); + let mut session = TextEditSession::new(layout, default_style.clone()); // Load fonts let inter_upright = include_bytes!( diff --git a/crates/grida-text-edit/src/layout.rs b/crates/grida-text-edit/src/layout.rs index 7270cfd584..5ee1f63e1b 100644 --- a/crates/grida-text-edit/src/layout.rs +++ b/crates/grida-text-edit/src/layout.rs @@ -118,6 +118,45 @@ pub trait TextLayoutEngine { fn viewport_height(&self) -> f32; } +// --------------------------------------------------------------------------- +// ManagedTextLayout — layout lifecycle trait +// --------------------------------------------------------------------------- + +/// Extended layout engine trait that adds lifecycle management. +/// +/// `TextLayoutEngine` provides read-only geometry queries (caret position, +/// selection rects, etc.). `ManagedTextLayout` extends it with layout +/// invalidation, rebuild, and sizing — everything `TextEditSession` needs +/// to orchestrate the full editing loop. +/// +/// Implementors: +/// - `SimpleLayoutEngine` — trivial no-ops (test-only, monospace). +/// - `SkiaLayoutEngine` — Skia Paragraph per-block layout (behind `skia` feature). +/// - Canvas-side adapters — delegate to the scene's paragraph cache. +pub trait ManagedTextLayout: TextLayoutEngine { + /// Ensure layout is up-to-date for the given attributed text. + /// + /// Implementations may cache and skip rebuild if content hasn't changed + /// (e.g. by checking `AttributedText::generation()`). + fn ensure_layout(&mut self, content: &crate::attributed_text::AttributedText); + + /// Invalidate all cached layout. The next `ensure_layout` call will + /// rebuild from scratch. + fn invalidate(&mut self); + + /// The current layout width (the wrap boundary). + fn layout_width(&self) -> f32; + + /// The current layout height (viewport/container height). + fn layout_height(&self) -> f32; + + /// Update layout width. Implementations should invalidate if changed. + fn set_layout_width(&mut self, width: f32); + + /// Update layout height. + fn set_layout_height(&mut self, height: f32); +} + // --------------------------------------------------------------------------- // Utility: find the line index for a UTF-8 offset // --------------------------------------------------------------------------- diff --git a/crates/grida-text-edit/src/lib.rs b/crates/grida-text-edit/src/lib.rs index fe0b40cfca..a083c18a39 100644 --- a/crates/grida-text-edit/src/lib.rs +++ b/crates/grida-text-edit/src/lib.rs @@ -1,24 +1,30 @@ pub mod attributed_text; pub mod history; pub mod layout; -pub mod selection_rects; pub mod simple_layout; -pub mod skia_layout; pub mod text_edit_session; pub mod time; +#[cfg(feature = "skia")] +pub mod selection_rects; +#[cfg(feature = "skia")] +pub mod skia_layout; + #[cfg(test)] mod tests; #[cfg(test)] mod session_tests; pub use history::{EditHistory, EditKind, GenericEditHistory}; -pub use layout::{line_index_for_offset, CaretRect, LineMetrics, SelectionRect, TextLayoutEngine}; -pub use selection_rects::{EmptyLineSelectionPolicy, selection_rects_with_policy, skia_line_index_for_u16_offset}; +pub use layout::{line_index_for_offset, CaretRect, LineMetrics, ManagedTextLayout, SelectionRect, TextLayoutEngine}; pub use simple_layout::SimpleLayoutEngine; -pub use skia_layout::SkiaLayoutEngine; pub use text_edit_session::{ClickTracker, KeyAction, KeyName, TextEditSession}; +#[cfg(feature = "skia")] +pub use selection_rects::{EmptyLineSelectionPolicy, selection_rects_with_policy, skia_line_index_for_u16_offset}; +#[cfg(feature = "skia")] +pub use skia_layout::SkiaLayoutEngine; + use unicode_segmentation::UnicodeSegmentation; // --------------------------------------------------------------------------- @@ -262,6 +268,23 @@ impl TextEditorState { self.anchor.map_or(false, |a| a != self.cursor) } + /// Whether the caret should be shown. + /// + /// Returns `true` when there is **no** active selection (caret mode). + /// When the user has selected text, the caret is hidden — only the + /// selection highlight is visible. This is the standard behaviour of + /// every major OS text editor (macOS, Windows, Linux). + /// + /// Consumers should combine this with the blink state to decide whether + /// to actually paint the caret: + /// + /// ```text + /// let paint = state.should_show_caret() && blink_visible; + /// ``` + pub fn should_show_caret(&self) -> bool { + !self.has_selection() + } + pub fn selection_range(&self) -> Option<(usize, usize)> { self.anchor.map(|a| { let lo = a.min(self.cursor); diff --git a/crates/grida-text-edit/src/session_tests.rs b/crates/grida-text-edit/src/session_tests.rs index 259a1a26f5..589fd9c038 100644 --- a/crates/grida-text-edit/src/session_tests.rs +++ b/crates/grida-text-edit/src/session_tests.rs @@ -21,6 +21,7 @@ use crate::attributed_text::{ AttributedText, TextDecorationLine, TextFill, TextStyle as AttrTextStyle, RGBA, }; use crate::layout::TextLayoutEngine; +use crate::simple_layout::SimpleLayoutEngine; use crate::text_edit_session::{ClickTracker, KeyAction, KeyName, TextEditSession}; // --------------------------------------------------------------------------- @@ -36,9 +37,10 @@ fn default_style() -> AttrTextStyle { } /// Create a session with some text, cursor at end. -fn session(text: &str) -> TextEditSession { +fn session(text: &str) -> TextEditSession { let style = default_style(); - let mut s = TextEditSession::new(400.0, 300.0, style.clone()); + let layout = SimpleLayoutEngine::new(300.0, 24.0, 10.0); + let mut s = TextEditSession::new(layout, style.clone()); if !text.is_empty() { s.content = AttributedText::new(text, style); s.state.text = text.to_string(); @@ -48,7 +50,7 @@ fn session(text: &str) -> TextEditSession { } /// Assert the critical invariant: content.text() == state.text. -fn assert_content_synced(s: &TextEditSession) { +fn assert_content_synced(s: &TextEditSession) { assert_eq!( s.content.text(), &s.state.text, diff --git a/crates/grida-text-edit/src/simple_layout.rs b/crates/grida-text-edit/src/simple_layout.rs index d480fc6577..09e702ce96 100644 --- a/crates/grida-text-edit/src/simple_layout.rs +++ b/crates/grida-text-edit/src/simple_layout.rs @@ -10,7 +10,7 @@ //! and wrong for real rendering — its only purpose is to produce deterministic, //! inspectable results for unit tests. -use crate::layout::{CaretRect, LineMetrics, SelectionRect, TextLayoutEngine}; +use crate::layout::{CaretRect, LineMetrics, ManagedTextLayout, SelectionRect, TextLayoutEngine}; use unicode_segmentation::UnicodeSegmentation; @@ -221,6 +221,34 @@ impl TextLayoutEngine for SimpleLayoutEngine { } } +impl ManagedTextLayout for SimpleLayoutEngine { + fn ensure_layout(&mut self, _content: &crate::attributed_text::AttributedText) { + // No-op: monospace layout needs no rebuild. + } + + fn invalidate(&mut self) { + // No-op: stateless. + } + + fn layout_width(&self) -> f32 { + // SimpleLayoutEngine has no wrapping, but we provide a nominal width. + // This is only used for scroll calculations. + f32::MAX + } + + fn layout_height(&self) -> f32 { + self.viewport_height + } + + fn set_layout_width(&mut self, _width: f32) { + // No-op: monospace layout doesn't wrap. + } + + fn set_layout_height(&mut self, height: f32) { + self.viewport_height = height; + } +} + /// Helper: test-friendly constructor with typical values (18px font, 8px char). impl SimpleLayoutEngine { pub fn default_test() -> Self { diff --git a/crates/grida-text-edit/src/skia_layout.rs b/crates/grida-text-edit/src/skia_layout.rs index d2c95965a7..6fd5dd6d8e 100644 --- a/crates/grida-text-edit/src/skia_layout.rs +++ b/crates/grida-text-edit/src/skia_layout.rs @@ -1538,3 +1538,33 @@ impl TextLayoutEngine for SkiaLayoutEngine { self.layout_height } } + +impl crate::layout::ManagedTextLayout for SkiaLayoutEngine { + fn ensure_layout(&mut self, content: &crate::attributed_text::AttributedText) { + self.ensure_layout_attributed(content); + } + + fn invalidate(&mut self) { + // Delegate to the existing invalidate method. + self.paragraph = None; + self.blocks.clear(); + self.cached_text.clear(); + self.cached_line_metrics = None; + } + + fn layout_width(&self) -> f32 { + self.layout_width + } + + fn layout_height(&self) -> f32 { + self.layout_height + } + + fn set_layout_width(&mut self, w: f32) { + SkiaLayoutEngine::set_layout_width(self, w); + } + + fn set_layout_height(&mut self, h: f32) { + SkiaLayoutEngine::set_layout_height(self, h); + } +} diff --git a/crates/grida-text-edit/src/text_edit_session.rs b/crates/grida-text-edit/src/text_edit_session.rs index 8f4e5f3ad2..8346768724 100644 --- a/crates/grida-text-edit/src/text_edit_session.rs +++ b/crates/grida-text-edit/src/text_edit_session.rs @@ -1,10 +1,18 @@ //! Rich text editing session. //! //! [`TextEditSession`] bundles the pure editing state ([`TextEditorState`]), -//! a Skia-backed layout engine ([`SkiaLayoutEngine`]), and an +//! a layout engine implementing [`ManagedTextLayout`], and an //! [`AttributedText`] content model into a single, self-contained editing //! session. //! +//! The session is generic over the layout backend `L: ManagedTextLayout`. +//! Built-in implementations: +//! - `SkiaLayoutEngine` — real shaping via Skia Paragraph (behind `skia` feature). +//! - `SimpleLayoutEngine` — monospace, no wrapping; for deterministic tests. +//! +//! External consumers (e.g. `grida-canvas`) can provide their own implementation +//! that delegates to the host's paragraph cache. +//! //! This is the primary integration point that hosts (WASM canvas, winit //! desktop, headless tests) consume. The session does **not** own any //! rendering surface or window — drawing is the host's responsibility. @@ -101,9 +109,8 @@ use crate::{ AttributedText, TextDecorationLine, TextFill, TextStyle as AttrTextStyle, RGBA, }, history::{EditKind, GenericEditHistory}, - layout::{line_index_for_offset, CaretRect}, - skia_layout::SkiaLayoutEngine, - EditDelta, EditingCommand, TextEditorState, TextLayoutEngine, + layout::{line_index_for_offset, CaretRect, ManagedTextLayout}, + EditDelta, EditingCommand, TextEditorState, }; // --------------------------------------------------------------------------- @@ -397,14 +404,18 @@ struct ScrollAnchor { /// Bundles editing state, layout engine, attributed content, history, /// cursor blink, pointer tracking, IME composition, and scroll. /// +/// Generic over the layout backend `L`. Built-in options: +/// - `SkiaLayoutEngine` (behind `skia` feature) — real shaping. +/// - `SimpleLayoutEngine` — monospace, for tests. +/// /// Hosts create a session, feed events into it, and use the exposed /// layout engine + geometry queries to render. -pub struct TextEditSession { +pub struct TextEditSession { /// Pure editing state: text, cursor, anchor. pub state: TextEditorState, - /// Skia-backed layout engine. - pub layout: SkiaLayoutEngine, + /// Layout engine (generic — could be Skia, simple, or host-provided). + pub layout: L, /// Attributed text model (runs of styled text). pub content: AttributedText, @@ -436,16 +447,13 @@ pub struct TextEditSession { cached_caret_rect: Option, } -impl TextEditSession { - /// Create a new empty editing session with the given default style. - pub fn new( - layout_width: f32, - layout_height: f32, - default_style: AttrTextStyle, - ) -> Self { +impl TextEditSession { + /// Create a new empty editing session with the given layout engine and + /// default style. + pub fn new(layout: L, default_style: AttrTextStyle) -> Self { Self { state: TextEditorState::with_cursor(String::new(), 0), - layout: SkiaLayoutEngine::new(layout_width, layout_height), + layout, content: AttributedText::empty(default_style), caret_style_override: None, cursor_visible: true, @@ -460,16 +468,12 @@ impl TextEditSession { } /// Create a session pre-loaded with text content. - pub fn with_content( - layout_width: f32, - layout_height: f32, - content: AttributedText, - ) -> Self { + pub fn with_content(layout: L, content: AttributedText) -> Self { let text = content.text().to_owned(); let cursor = text.len(); Self { state: TextEditorState::with_cursor(text, cursor), - layout: SkiaLayoutEngine::new(layout_width, layout_height), + layout, content, caret_style_override: None, cursor_visible: true, @@ -497,11 +501,25 @@ impl TextEditSession { self.caret_style_override = style; } - /// Whether the cursor is currently visible (for blink rendering). + /// Whether the cursor is currently in its visible blink phase. + /// + /// **Prefer [`should_show_caret`](Self::should_show_caret)** for + /// rendering decisions — it combines blink state with selection state. pub fn cursor_visible(&self) -> bool { self.cursor_visible } + /// Whether the caret should be painted this frame. + /// + /// Combines the blink phase (`cursor_visible`) with the selection + /// state: when text is selected, the caret is hidden regardless of + /// the blink timer. This is the single authority for "should I draw + /// the caret?" — callers should not need to check `has_selection()` + /// separately. + pub fn should_show_caret(&self) -> bool { + self.state.should_show_caret() && self.cursor_visible + } + /// Whether a mouse drag is in progress. pub fn is_mouse_down(&self) -> bool { self.mouse_down @@ -573,7 +591,7 @@ impl TextEditSession { self.caret_style_override = None; self.cached_caret_rect = None; self.layout.invalidate(); - self.layout.ensure_layout_attributed(&self.content); + self.layout.ensure_layout(&self.content); self.ensure_cursor_visible(); } @@ -607,7 +625,7 @@ impl TextEditSession { self.caret_style_override = None; } self.reset_blink(); - self.layout.ensure_layout_attributed(&self.content); + self.layout.ensure_layout(&self.content); self.ensure_cursor_visible(); } @@ -633,7 +651,7 @@ impl TextEditSession { self.caret_style_override = None; } self.reset_blink(); - self.layout.ensure_layout_attributed(&self.content); + self.layout.ensure_layout(&self.content); self.ensure_cursor_visible(); } @@ -1182,7 +1200,7 @@ impl TextEditSession { // 2. Rebuild layout at the new width. self.layout.set_layout_width(w.max(1.0)); - self.layout.ensure_layout_attributed(&self.content); + self.layout.ensure_layout(&self.content); self.cached_caret_rect = None; // 3. Restore scroll position from anchor, then clamp. @@ -1211,7 +1229,7 @@ impl TextEditSession { /// Maximum scroll offset (content may be shorter than viewport). pub fn max_scroll_y(&mut self) -> f32 { - (self.content_height() - self.layout.layout_height).max(0.0) + (self.content_height() - self.layout.layout_height()).max(0.0) } /// Clamp scroll_y to valid range. @@ -1278,11 +1296,11 @@ impl TextEditSession { /// Adjust scroll so the caret is within the visible viewport. /// - /// **Ordering constraint:** `ensure_layout_attributed` must be called + /// **Ordering constraint:** `ensure_layout` must be called /// before this method when editing rich text. pub fn ensure_cursor_visible(&mut self) { let cr = self.caret_rect(); - let viewport_height = self.layout.layout_height; + let viewport_height = self.layout.layout_height(); let margin = cr.height; if cr.y < self.scroll_y + margin { @@ -1308,7 +1326,25 @@ impl TextEditSession { } /// Advance the blink timer. Returns `true` if visibility changed. + /// + /// When a selection is active the caret is unconditionally hidden, so + /// the timer is not toggled — this avoids unnecessary redraws and + /// ensures the caret appears immediately when the selection is + /// collapsed. pub fn tick_blink(&mut self) -> bool { + // No blinking while text is selected — the caret is not shown. + if self.state.has_selection() { + // Keep the phase "visible" so that collapsing the selection + // will show the caret immediately without waiting for a full + // blink interval. + if !self.cursor_visible { + self.cursor_visible = true; + self.last_blink = Instant::now(); + return true; + } + return false; + } + if self.last_blink.elapsed() >= BLINK_INTERVAL { self.cursor_visible = !self.cursor_visible; self.last_blink = Instant::now(); From 660f2a4c5c72b56fa3f352c18b703d83fe43507e Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Mar 2026 18:59:36 +0900 Subject: [PATCH 02/14] refactor: improve caret positioning and history management in text editing - Removed unnecessary canvas clipping and saving in the `wd_text_editor` example, simplifying the drawing logic. - Enhanced the `TextEditSession` to correctly position the caret when an IME preedit is active, ensuring it reflects the end of the preedit text. - Added new methods to `GenericEditHistory` and `TextEditSession` for better access to undo states and history texts, facilitating integration with external history systems. These changes aim to streamline caret management and enhance the overall functionality of the text editing experience in the grida-text-edit project. --- .../examples/wd_text_editor.rs | 13 --- crates/grida-text-edit/src/history.rs | 12 +++ crates/grida-text-edit/src/session_tests.rs | 82 +++++++++++++++++++ .../grida-text-edit/src/text_edit_session.rs | 57 ++++++++++++- 4 files changed, 150 insertions(+), 14 deletions(-) diff --git a/crates/grida-text-edit/examples/wd_text_editor.rs b/crates/grida-text-edit/examples/wd_text_editor.rs index 0852425026..059f2dcfc6 100644 --- a/crates/grida-text-edit/examples/wd_text_editor.rs +++ b/crates/grida-text-edit/examples/wd_text_editor.rs @@ -120,17 +120,6 @@ fn draw_session( ) { canvas.clear(Color::WHITE); - canvas.save(); - canvas.clip_rect( - Rect::from_xywh( - PADDING, - PADDING, - session.layout.layout_width, - session.layout.layout_height, - ), - None, - None, - ); let origin = Point::new(PADDING, PADDING - session.scroll_y()); let preedit = session @@ -265,8 +254,6 @@ fn draw_session( canvas.draw_rect(cursor_rect, &cp); } } - - canvas.restore(); } // --------------------------------------------------------------------------- diff --git a/crates/grida-text-edit/src/history.rs b/crates/grida-text-edit/src/history.rs index 11673a6789..cefdd0f602 100644 --- a/crates/grida-text-edit/src/history.rs +++ b/crates/grida-text-edit/src/history.rs @@ -76,6 +76,18 @@ impl GenericEditHistory { !self.redo_stack.is_empty() } + /// Returns references to the states in the undo stack, oldest first. + /// + /// Each entry represents the state **before** an edit. Combined with + /// the current session state, this gives the full sequence of undo + /// boundaries the user can step through. + /// + /// Used by hosts to replay session-level history into their own history + /// system (e.g., document-level undo after exiting a text editing session). + pub fn undo_states(&self) -> impl Iterator { + self.undo_stack.iter().map(|e| &e.state) + } + pub fn clear(&mut self) { self.undo_stack.clear(); self.redo_stack.clear(); diff --git a/crates/grida-text-edit/src/session_tests.rs b/crates/grida-text-edit/src/session_tests.rs index 589fd9c038..c406817a26 100644 --- a/crates/grida-text-edit/src/session_tests.rs +++ b/crates/grida-text-edit/src/session_tests.rs @@ -704,6 +704,88 @@ fn drain_empty_preedit_only_clears_empty() { assert_eq!(s.preedit(), Some("ko")); } +// =========================================================================== +// IME caret positioning — preedit-aware caret_rect +// =========================================================================== + +#[test] +fn preedit_caret_advances_past_composed_text() { + let mut s = session("abc"); + // Cursor at end → offset 3 + assert_eq!(s.state.cursor, 3); + let cr_before = s.caret_rect(); + + // Simulate Korean IME: preedit "한" (3 UTF-8 bytes) + s.update_preedit("\u{D55C}".into()); + let cr_preedit = s.caret_rect(); + + // Caret must advance past the preedit text. + assert!( + cr_preedit.x > cr_before.x, + "caret should advance past preedit: before.x={}, preedit.x={}", + cr_before.x, + cr_preedit.x, + ); + + // Same line — y should not change. + assert_eq!(cr_preedit.y, cr_before.y); +} + +#[test] +fn preedit_caret_mid_text() { + let mut s = session("abcd"); + // Place cursor between 'b' and 'c' → offset 2 + s.state.cursor = 2; + let cr_before = s.caret_rect(); + + // Preedit inserted at cursor position + s.update_preedit("XY".into()); + let cr_preedit = s.caret_rect(); + + // Caret should be further right (past the preedit). + assert!( + cr_preedit.x > cr_before.x, + "mid-text preedit caret should advance: before.x={}, preedit.x={}", + cr_before.x, + cr_preedit.x, + ); +} + +#[test] +fn preedit_cancel_restores_caret() { + let mut s = session("abc"); + let cr_before = s.caret_rect(); + + s.update_preedit("XY".into()); + let cr_preedit = s.caret_rect(); + assert!(cr_preedit.x > cr_before.x); + + // Cancel preedit → caret returns to the committed cursor position. + s.cancel_preedit(); + let cr_after = s.caret_rect(); + assert_eq!(cr_after.x, cr_before.x); + assert_eq!(cr_after.y, cr_before.y); +} + +#[test] +fn preedit_caret_cache_invalidated_on_update() { + let mut s = session("abc"); + let _cr1 = s.caret_rect(); // populate cache + + s.update_preedit("X".into()); + let cr2 = s.caret_rect(); + + // Update preedit with longer text → cache must be invalidated. + s.update_preedit("XYZ".into()); + let cr3 = s.caret_rect(); + assert!( + cr3.x > cr2.x, + "caret should advance with longer preedit: cr2.x={}, cr3.x={}", + cr2.x, + cr3.x, + ); +} + // =========================================================================== // Scroll management // =========================================================================== diff --git a/crates/grida-text-edit/src/text_edit_session.rs b/crates/grida-text-edit/src/text_edit_session.rs index 8346768724..439ac4ff0f 100644 --- a/crates/grida-text-edit/src/text_edit_session.rs +++ b/crates/grida-text-edit/src/text_edit_session.rs @@ -558,11 +558,28 @@ impl TextEditSession { // ----------------------------------------------------------------------- /// Return the caret rectangle, using a per-frame cache. + /// + /// When an IME preedit is active, the caret is positioned at the + /// **end** of the preedit text (not the committed cursor offset). + /// This is computed by laying out the display text (committed text + /// with preedit spliced in) and querying the caret at + /// `cursor + preedit.len()`. pub fn caret_rect(&mut self) -> CaretRect { if let Some(ref cr) = self.cached_caret_rect { return cr.clone(); } - let cr = self.layout.caret_rect_at(&self.state.text, self.state.cursor); + let cr = match self.preedit.as_deref() { + Some(preedit) if !preedit.is_empty() => { + let cursor = self.state.cursor; + let text = &self.state.text; + let mut display = String::with_capacity(text.len() + preedit.len()); + display.push_str(&text[..cursor]); + display.push_str(preedit); + display.push_str(&text[cursor..]); + self.layout.caret_rect_at(&display, cursor + preedit.len()) + } + _ => self.layout.caret_rect_at(&self.state.text, self.state.cursor), + }; self.cached_caret_rect = Some(cr.clone()); cr } @@ -705,6 +722,42 @@ impl TextEditSession { } } + /// Returns the text at each undo boundary, from oldest to current. + /// + /// The first element is the oldest snapshot (the text before the first + /// recorded edit). The last element is the current text. Each consecutive + /// pair `(texts[i], texts[i+1])` represents one undo step. + /// + /// Returns an empty `Vec` if the session has no history (no edits were + /// made, or all edits were undone back to the original state). + /// + /// Used by hosts to replay session-level undo boundaries into their own + /// history system — for example, injecting fine-grained document-level + /// undo entries when exiting a text editing session. + /// + /// # Future direction + /// + /// A more principled approach would be a drain-based event system where + /// the session emits `HistoryEvent { previous_text, current_text, kind }` + /// as undo boundaries are created, and the host polls them per-frame + /// (similar to ProseMirror's transaction dispatch or CodeMirror's change + /// listener). This would keep the host's history in real-time sync during + /// editing, rather than batch-replaying at exit. The current batch + /// approach is chosen for simplicity and minimal FFI surface. + pub fn history_texts(&self) -> Vec { + let snapshots: Vec = self + .history + .undo_states() + .map(|snap| snap.state.text.clone()) + .collect(); + if snapshots.is_empty() { + return Vec::new(); + } + let mut texts = snapshots; + texts.push(self.state.text.clone()); + texts + } + // ----------------------------------------------------------------------- // Rich text: style toggles // ----------------------------------------------------------------------- @@ -1366,12 +1419,14 @@ impl TextEditSession { /// Update the IME preedit string. pub fn update_preedit(&mut self, text: String) { self.preedit = Some(text); + self.cached_caret_rect = None; self.reset_blink(); } /// Cancel/clear the IME preedit. pub fn cancel_preedit(&mut self) { self.preedit = None; + self.cached_caret_rect = None; self.reset_blink(); } From 1f7f03d26faec3e5952cfe4c07578240c92b4f42 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Mar 2026 19:42:22 +0900 Subject: [PATCH 03/14] refactor: enhance IME preedit handling and layout stability - Removed unused imports and streamlined the `draw_session` function in the `wd_text_editor` example for clarity. - Improved the `SkiaLayoutEngine` to support a consistent layout during IME preedit by implementing a strut style that prevents visual jumps. - Added a new method to rebuild the layout with preedit text, ensuring accurate metrics and selection handling. - Updated the cursor positioning logic to reflect the end of the preedit text correctly. These changes aim to enhance the text editing experience by improving layout stability and handling of IME preedit events in the grida-text-edit project. --- .../examples/wd_text_editor.rs | 123 +++++++++--------- crates/grida-text-edit/src/skia_layout.rs | 50 +++++++ 2 files changed, 110 insertions(+), 63 deletions(-) diff --git a/crates/grida-text-edit/examples/wd_text_editor.rs b/crates/grida-text-edit/examples/wd_text_editor.rs index 059f2dcfc6..f917dd557c 100644 --- a/crates/grida-text-edit/examples/wd_text_editor.rs +++ b/crates/grida-text-edit/examples/wd_text_editor.rs @@ -34,7 +34,6 @@ use glutin_winit::DisplayBuilder; use raw_window_handle::HasRawWindowHandle; use skia_safe::{ gpu::{self, backend_render_targets, gl::FramebufferInfo, surfaces::wrap_backend_render_target}, - textlayout::{RectHeightStyle, RectWidthStyle}, Color, ColorType, Paint, Point, Rect, Surface, }; use winit::{ @@ -47,10 +46,8 @@ use winit::{ }; use grida_text_edit::{ - utf8_to_utf16_offset, TextLayoutEngine, SkiaLayoutEngine, - selection_rects::{ - EmptyLineSelectionPolicy, selection_rects_with_policy, skia_line_index_for_u16_offset, - }, + TextLayoutEngine, SkiaLayoutEngine, + selection_rects::EmptyLineSelectionPolicy, text_edit_session::{ClickTracker, KeyAction, KeyName, TextEditSession}, attributed_text::{ AttributedText, TextStyle as AttrTextStyle, RGBA, @@ -116,7 +113,7 @@ impl GlSkiaSurface { fn draw_session( canvas: &skia_safe::Canvas, session: &mut TextEditSession, - policy: EmptyLineSelectionPolicy, + _policy: EmptyLineSelectionPolicy, ) { canvas.clear(Color::WHITE); @@ -128,40 +125,33 @@ fn draw_session( .map(str::to_owned); if let Some(ref p) = preedit { - // ---- preedit mode ---- - let (display_text, dp, preedit_range) = - session.layout.build_preedit_paragraph( + // ---- preedit mode (per-block, same path as normal) ---- + // Build per-block layout with preedit text spliced in and + // underlined, producing identical metrics to the normal path. + let (display_text, preedit_range) = + session.layout.rebuild_blocks_with_preedit( &session.content, session.state.cursor, p, ); - let preedit_end_u16 = utf8_to_utf16_offset(&display_text, preedit_range.end); - - // Selection + // Selection (using the display_text which includes preedit) if let Some((lo, hi)) = session.selection_range() { if lo < hi { - let u16_lo = utf8_to_utf16_offset(&display_text, lo); - let u16_hi = utf8_to_utf16_offset(&display_text, hi); - let rects = selection_rects_with_policy( - &dp, - &display_text, - u16_lo, - u16_hi, - session.layout.layout_width, - FONT_SIZE, - policy, - ); + let sel_rects = + session + .layout + .selection_rects_for_range(&display_text, lo, hi); let mut sp = Paint::default(); sp.set_color(Color::from_argb(80, 66, 133, 244)); sp.set_anti_alias(true); - for r in &rects { + for r in &sel_rects { canvas.draw_rect( Rect::from_ltrb( - r.left + origin.x, - r.top + origin.y, - r.right + origin.x, - r.bottom + origin.y, + r.x + origin.x, + r.y + origin.y, + r.x + r.width + origin.x, + r.y + r.height + origin.y, ), &sp, ); @@ -169,41 +159,24 @@ fn draw_session( } } - dp.paint(canvas, origin); - - // Cursor at end of preedit - let preedit_start_u16 = utf8_to_utf16_offset(&display_text, preedit_range.start); - let cx = if preedit_start_u16 < preedit_end_u16 { - let rects = dp.get_rects_for_range( - (preedit_end_u16 - 1)..preedit_end_u16, - RectHeightStyle::Max, - RectWidthStyle::Tight, - ); - rects.last().map(|tb| tb.rect.right()).unwrap_or(0.0) - } else { - 0.0 - }; - let cy = { - let skia_metrics = dp.get_line_metrics(); - if skia_metrics.is_empty() { - FONT_SIZE - } else { - let idx = skia_line_index_for_u16_offset(&skia_metrics, preedit_end_u16); - skia_metrics[idx].baseline as f32 - } - }; + // Text (per-block paint, same as normal mode) + session + .layout + .paint_paragraph_at(canvas, &display_text, origin); + + // Cursor at end of preedit (use caret_rect_at with display text) + let preedit_end = preedit_range.end; + let cr = session.layout.caret_rect_at(&display_text, preedit_end); + let cursor_rect = Rect::from_xywh( + cr.x + origin.x - CURSOR_WIDTH / 2.0, + cr.y + origin.y, + CURSOR_WIDTH, + cr.height, + ); let mut cp = Paint::default(); cp.set_color(Color::BLACK); cp.set_anti_alias(false); - canvas.draw_rect( - Rect::from_xywh( - cx + origin.x - CURSOR_WIDTH / 2.0, - cy - FONT_SIZE + origin.y, - CURSOR_WIDTH, - FONT_SIZE * 1.2, - ), - &cp, - ); + canvas.draw_rect(cursor_rect, &cp); } else { // ---- normal mode (rich text, per-block layout) ---- session @@ -551,6 +524,23 @@ impl ApplicationHandler for TextEditorApp { self.modifiers = m.state(); } + // --------------------------------------------------------- + // IME (Input Method Editor) composition events + // --------------------------------------------------------- + // On macOS, winit 0.30 suppresses KeyboardInput during active + // IME composition at the platform level (view.rs key_down). + // We only receive Preedit / Commit here; no duplicate + // KeyboardInput for composed characters. + // + // Known issue (winit 0.30, macOS): when a non-composable + // character (e.g. ".", "?", symbols) finalizes an active + // composition, winit's insertText handler commits the + // composed text but silently drops the finalizing character. + // Neither Ime::Commit nor KeyboardInput is emitted for it, + // so it is lost. The user must press the key a second time. + // This is a winit bug — our handler is correct. + // --------------------------------------------------------- + WindowEvent::Ime(Ime::Preedit(text, _cursor_range)) => { if inner.session.handle_key_action(KeyAction::ImePreedit(text)) { inner.window.request_redraw(); @@ -562,9 +552,13 @@ impl ApplicationHandler for TextEditorApp { } } WindowEvent::Ime(Ime::Enabled) => { - inner.session.cancel_preedit(); + // No-op. On macOS, Ime::Enabled fires when the first + // preedit begins — cancelling preedit here would race + // with the Preedit event and drop initial characters. } WindowEvent::Ime(Ime::Disabled) => { + // Input source switched away from CJK. Clear any + // in-progress composition. inner.session.cancel_preedit(); inner.window.request_redraw(); } @@ -572,6 +566,11 @@ impl ApplicationHandler for TextEditorApp { WindowEvent::KeyboardInput { event: ke, .. } if ke.state == ElementState::Pressed => { + // Drain the empty-preedit sentinel *before* processing + // keys so that the session's preedit.is_some() guard + // doesn't suppress the next insertion. + inner.session.drain_empty_preedit(); + let meta = self.modifiers.super_key(); let alt = self.modifiers.alt_key(); let ctrl = self.modifiers.control_key(); @@ -645,8 +644,6 @@ impl ApplicationHandler for TextEditorApp { } } } - - inner.session.drain_empty_preedit(); } WindowEvent::MouseWheel { delta, .. } => { diff --git a/crates/grida-text-edit/src/skia_layout.rs b/crates/grida-text-edit/src/skia_layout.rs index 6fd5dd6d8e..c71bee7fee 100644 --- a/crates/grida-text-edit/src/skia_layout.rs +++ b/crates/grida-text-edit/src/skia_layout.rs @@ -1024,6 +1024,24 @@ impl SkiaLayoutEngine { para_style.set_apply_rounding_hack(false); para_style.set_text_align(self.config.text_align.to_skia()); + // Use a strut style based on the first run's font in this block + // (or the config defaults). This prevents font-fallback characters + // (e.g. CJK glyphs rendered by a system font) from changing the + // line height, which causes a visual "jump" during IME preedit. + { + let (strut_size, strut_family) = if let Some(run) = runs.first() { + (run.style.font_size, run.style.font_family.as_str()) + } else { + (self.config.font_size, self.config.font_families.first().map(|s| s.as_str()).unwrap_or("sans-serif")) + }; + let mut strut = skia_safe::textlayout::StrutStyle::new(); + strut.set_strut_enabled(true); + strut.set_force_strut_height(true); + strut.set_font_size(strut_size); + strut.set_font_families(&[strut_family]); + para_style.set_strut_style(strut); + } + let mut builder = ParagraphBuilder::new(¶_style, &self.font_collection); let fallback_families: Vec<&str> = self.config.font_families.iter().map(|s| s.as_str()).collect(); @@ -1227,6 +1245,38 @@ impl SkiaLayoutEngine { // Preedit (IME composition) support // ------------------------------------------------------------------- + /// Rebuild per-block layout with the preedit text spliced in at `cursor`. + /// + /// This uses the same per-block approach as + /// [`ensure_layout_attributed`](Self::ensure_layout_attributed) so the + /// resulting layout metrics are identical — avoiding the visual "jump" + /// that occurs when switching between a single-paragraph preedit layout + /// and a per-block normal layout. + /// + /// Returns `(display_text, preedit_byte_range)` so the caller can + /// position the cursor and draw selection rects using the display text. + pub fn rebuild_blocks_with_preedit( + &mut self, + content: &crate::attributed_text::AttributedText, + cursor: usize, + preedit: &str, + ) -> (String, std::ops::Range) { + use crate::attributed_text::TextDecorationLine; + + let mut display_content = content.clone(); + let mut preedit_style = content.caret_style_at(cursor as u32).clone(); + preedit_style.text_decoration_line = TextDecorationLine::Underline; + display_content.insert_with_style(cursor, preedit, preedit_style); + + let preedit_range = cursor..(cursor + preedit.len()); + + // Full per-block rebuild using the spliced content. + self.rebuild_blocks_attributed(&display_content); + + let display_text = display_content.text().to_owned(); + (display_text, preedit_range) + } + /// Build a display string and a laid-out `Paragraph` with the preedit /// text spliced in at `cursor` and styled with an underline. /// From 96adb7882d9f5e5fbc8fcbad4bbde57021a6c856 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Mar 2026 01:15:18 +0900 Subject: [PATCH 04/14] feat: integrate text editing features into grida-canvas - Added support for text editing sessions, allowing users to enter and exit editing mode for text nodes. - Implemented a new `ActiveTextEdit` structure to manage the state of text editing, including cursor positioning and selection handling. - Introduced a `ParagraphCacheLayout` to ensure consistent layout during editing, matching the rendering behavior of the Painter. - Enhanced the `Renderer` to update text content dynamically during editing, invalidating caches as necessary. - Added new methods for handling text editing commands, including undo and redo functionality. - Created a `TextEditDecorationOverlay` for rendering caret and selection highlights in the devtools overlay. These changes aim to provide a robust text editing experience within the grida-canvas framework, enhancing usability and functionality. --- Cargo.lock | 1 + crates/grida-canvas-wasm/lib/index.ts | 2 + .../lib/modules/canvas-bindings.d.ts | 81 +++ .../grida-canvas-wasm/lib/modules/canvas.ts | 290 +++++++- crates/grida-canvas-wasm/src/main.rs | 1 + .../grida-canvas-wasm/src/wasm_text_edit.rs | 406 ++++++++++++ crates/grida-canvas/Cargo.toml | 14 +- crates/grida-canvas/src/cache/paragraph.rs | 5 + crates/grida-canvas/src/cache/picture.rs | 11 + crates/grida-canvas/src/devtools/mod.rs | 1 + .../devtools/text_edit_decoration_overlay.rs | 150 +++++ crates/grida-canvas/src/lib.rs | 1 + crates/grida-canvas/src/runtime/scene.rs | 60 +- .../src/text/attributed_text_conv.rs | 421 ++++++++++++ crates/grida-canvas/src/text/mod.rs | 2 + .../src/text/paragraph_cache_layout.rs | 543 +++++++++++++++ crates/grida-canvas/src/text_edit_session.rs | 128 ++++ crates/grida-canvas/src/window/application.rs | 449 ++++++++++++- crates/grida-text-edit/src/history.rs | 12 - crates/grida-text-edit/src/lib.rs | 2 +- crates/grida-text-edit/src/tests.rs | 2 +- .../grida-text-edit/src/text_edit_session.rs | 36 - .../grida-canvas-react/viewport/surface.tsx | 8 +- .../viewport/ui/surface-text-editor.tsx | 627 ++++++++++++++++++ .../viewport/ui/text-editor.tsx | 118 ---- editor/grida-canvas/commands/text-edit.ts | 116 ++++ editor/grida-canvas/editor.ts | 12 + 27 files changed, 3318 insertions(+), 181 deletions(-) create mode 100644 crates/grida-canvas-wasm/src/wasm_text_edit.rs create mode 100644 crates/grida-canvas/src/devtools/text_edit_decoration_overlay.rs create mode 100644 crates/grida-canvas/src/text/attributed_text_conv.rs create mode 100644 crates/grida-canvas/src/text/paragraph_cache_layout.rs create mode 100644 crates/grida-canvas/src/text_edit_session.rs create mode 100644 editor/grida-canvas-react/viewport/ui/surface-text-editor.tsx delete mode 100644 editor/grida-canvas-react/viewport/ui/text-editor.tsx create mode 100644 editor/grida-canvas/commands/text-edit.ts diff --git a/Cargo.lock b/Cargo.lock index ea245d0154..80a7a3ebdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -497,6 +497,7 @@ dependencies = [ "figma-api", "futures", "gl", + "grida-text-edit", "imgref", "json-patch", "math2", diff --git a/crates/grida-canvas-wasm/lib/index.ts b/crates/grida-canvas-wasm/lib/index.ts index c408c6dbf5..882d516d1d 100644 --- a/crates/grida-canvas-wasm/lib/index.ts +++ b/crates/grida-canvas-wasm/lib/index.ts @@ -4,6 +4,7 @@ import { Scene, type CreateImageResourceResult, type AddImageWithIdResult, + type TextEditCommand, } from "./modules/canvas"; import { svgtypes } from "./modules/svg-bindings"; export { @@ -11,6 +12,7 @@ export { type svgtypes, type CreateImageResourceResult, type AddImageWithIdResult, + type TextEditCommand, }; export const version = _version; diff --git a/crates/grida-canvas-wasm/lib/modules/canvas-bindings.d.ts b/crates/grida-canvas-wasm/lib/modules/canvas-bindings.d.ts index 1bd1dca308..f000e71acf 100644 --- a/crates/grida-canvas-wasm/lib/modules/canvas-bindings.d.ts +++ b/crates/grida-canvas-wasm/lib/modules/canvas-bindings.d.ts @@ -196,5 +196,86 @@ declare namespace canvas { state: GridaCanvasApplicationPtr, enable: boolean ): void; + + // ==================================================================================================== + // TEXT EDITING + // ==================================================================================================== + _text_edit_enter( + state: GridaCanvasApplicationPtr, + node_id_ptr: number, + node_id_len: number + ): boolean; + _text_edit_exit( + state: GridaCanvasApplicationPtr, + commit: boolean + ): Ptr; + _text_edit_is_active(state: GridaCanvasApplicationPtr): boolean; + _text_edit_get_text(state: GridaCanvasApplicationPtr): Ptr; + _text_edit_undo(state: GridaCanvasApplicationPtr): boolean; + _text_edit_redo(state: GridaCanvasApplicationPtr): boolean; + _text_edit_command( + state: GridaCanvasApplicationPtr, + json_ptr: number, + json_len: number + ): void; + _text_edit_pointer_down( + state: GridaCanvasApplicationPtr, + x: number, + y: number, + shift: boolean, + click_count: number + ): void; + _text_edit_pointer_move( + state: GridaCanvasApplicationPtr, + x: number, + y: number + ): void; + _text_edit_pointer_up(state: GridaCanvasApplicationPtr): void; + _text_edit_ime_set_preedit( + state: GridaCanvasApplicationPtr, + text_ptr: number, + text_len: number + ): void; + _text_edit_ime_commit( + state: GridaCanvasApplicationPtr, + text_ptr: number, + text_len: number + ): void; + _text_edit_ime_cancel(state: GridaCanvasApplicationPtr): void; + _text_edit_get_selected_text(state: GridaCanvasApplicationPtr): Ptr; + _text_edit_get_selected_html(state: GridaCanvasApplicationPtr): Ptr; + _text_edit_paste_text( + state: GridaCanvasApplicationPtr, + text_ptr: number, + text_len: number + ): void; + _text_edit_paste_html( + state: GridaCanvasApplicationPtr, + html_ptr: number, + html_len: number + ): void; + _text_edit_get_caret_rect(state: GridaCanvasApplicationPtr): Ptr; + _text_edit_get_selection_rects(state: GridaCanvasApplicationPtr): Ptr; + _text_edit_toggle_bold(state: GridaCanvasApplicationPtr): void; + _text_edit_toggle_italic(state: GridaCanvasApplicationPtr): void; + _text_edit_toggle_underline(state: GridaCanvasApplicationPtr): void; + _text_edit_toggle_strikethrough(state: GridaCanvasApplicationPtr): void; + _text_edit_set_font_size( + state: GridaCanvasApplicationPtr, + size: number + ): void; + _text_edit_set_font_family( + state: GridaCanvasApplicationPtr, + family_ptr: number, + family_len: number + ): void; + _text_edit_set_color( + state: GridaCanvasApplicationPtr, + r: number, + g: number, + b: number, + a: number + ): void; + _text_edit_tick(state: GridaCanvasApplicationPtr): boolean; } } diff --git a/crates/grida-canvas-wasm/lib/modules/canvas.ts b/crates/grida-canvas-wasm/lib/modules/canvas.ts index f547645dae..0937e0b6aa 100644 --- a/crates/grida-canvas-wasm/lib/modules/canvas.ts +++ b/crates/grida-canvas-wasm/lib/modules/canvas.ts @@ -165,10 +165,7 @@ export class Scene { } } - addImageWithId( - data: Uint8Array, - rid: string - ): AddImageWithIdResult | false { + addImageWithId(data: Uint8Array, rid: string): AddImageWithIdResult | false { this._assertAlive(); const [dataPtr, dataLen] = ffi.allocBytes(this.module, data); const [ridPtr, ridLen] = this._alloc_string(rid); @@ -350,9 +347,7 @@ export class Scene { * network has straight segments and corner radius should be applied as * a rendering effect. When absent, curves are baked into the geometry. */ - toVectorNetwork( - id: string - ): types.FlattenResult | null { + toVectorNetwork(id: string): types.FlattenResult | null { this._assertAlive(); const [ptr, len] = this._alloc_string(id); const outptr = this.module._to_vector_network(this.appptr, ptr, len - 1); @@ -516,4 +511,285 @@ export class Scene { this._assertAlive(); this.module._devtools_rendering_set_show_ruler(this.appptr, show); } + + // ========================================================================== + // Text editing + // ========================================================================== + + /** + * Enter text editing mode for a node. + * + * The engine reads all text properties (font, size, width, alignment) + * directly from the scene node, so only the node ID is needed. + */ + textEditEnter(nodeId: string): boolean { + this._assertAlive(); + const [ptr, len] = this._alloc_string(nodeId); + const result = this.module._text_edit_enter(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + return !!result; + } + + /** + * Exit text editing mode. + * @param commit - If true, returns the final text. If false, cancels. + * @returns The committed text, or null if cancelled / no session. + */ + textEditExit(commit: boolean): string | null { + this._assertAlive(); + const outptr = this.module._text_edit_exit(this.appptr, commit) as number; + if (!outptr) return null; + return ffi.readLenPrefixedString(this.module, outptr); + } + + /** Check if a text editing session is active. */ + textEditIsActive(): boolean { + this._assertAlive(); + return !!this.module._text_edit_is_active(this.appptr); + } + + /** + * Returns the current text of the active editing session, or null if + * no session is active. + */ + textEditGetText(): string | null { + this._assertAlive(); + const outptr = this.module._text_edit_get_text(this.appptr) as number; + if (!outptr) return null; + return ffi.readLenPrefixedString(this.module, outptr); + } + + /** + * Undo within the text editing session. + * + * The session owns all undo during editing. Document-level undo is not + * involved until the session exits. + */ + textEditUndo(): boolean { + this._assertAlive(); + return !!this.module._text_edit_undo(this.appptr); + } + + /** + * Redo within the text editing session. + */ + textEditRedo(): boolean { + this._assertAlive(); + return !!this.module._text_edit_redo(this.appptr); + } + + /** + * Dispatch an editing command. + * @param cmd - The command object (JSON-serializable WasmEditCommand). + */ + textEditCommand(cmd: TextEditCommand) { + this._assertAlive(); + const json = JSON.stringify(cmd); + const [ptr, len] = this._alloc_string(json); + this.module._text_edit_command(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + } + + /** Pointer down in layout-local coordinates. */ + textEditPointerDown( + x: number, + y: number, + shift: boolean, + clickCount: number + ) { + this._assertAlive(); + this.module._text_edit_pointer_down(this.appptr, x, y, shift, clickCount); + } + + /** Pointer move in layout-local coordinates (during drag). */ + textEditPointerMove(x: number, y: number) { + this._assertAlive(); + this.module._text_edit_pointer_move(this.appptr, x, y); + } + + /** Pointer up. */ + textEditPointerUp() { + this._assertAlive(); + this.module._text_edit_pointer_up(this.appptr); + } + + /** Set IME preedit string. */ + textEditImeSetPreedit(text: string) { + this._assertAlive(); + const [ptr, len] = this._alloc_string(text); + this.module._text_edit_ime_set_preedit(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + } + + /** Commit IME composition. */ + textEditImeCommit(text: string) { + this._assertAlive(); + const [ptr, len] = this._alloc_string(text); + this.module._text_edit_ime_commit(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + } + + /** Cancel IME composition. */ + textEditImeCancel() { + this._assertAlive(); + this.module._text_edit_ime_cancel(this.appptr); + } + + /** Get selected text as plain text. */ + textEditGetSelectedText(): string | null { + this._assertAlive(); + const outptr = this.module._text_edit_get_selected_text( + this.appptr + ) as number; + if (!outptr) return null; + return ffi.readLenPrefixedString(this.module, outptr); + } + + /** Get selected text as HTML. */ + textEditGetSelectedHtml(): string | null { + this._assertAlive(); + const outptr = this.module._text_edit_get_selected_html( + this.appptr + ) as number; + if (!outptr) return null; + return ffi.readLenPrefixedString(this.module, outptr); + } + + /** Paste plain text. */ + textEditPasteText(text: string) { + this._assertAlive(); + const [ptr, len] = this._alloc_string(text); + this.module._text_edit_paste_text(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + } + + /** Paste HTML with formatting. */ + textEditPasteHtml(html: string) { + this._assertAlive(); + const [ptr, len] = this._alloc_string(html); + this.module._text_edit_paste_html(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + } + + /** + * Get the caret rectangle in layout-local coordinates. + * @returns {x, y, w, h} or null if no active session. + */ + textEditGetCaretRect(): { + x: number; + y: number; + w: number; + h: number; + } | null { + this._assertAlive(); + const outptr = this.module._text_edit_get_caret_rect(this.appptr) as number; + if (!outptr) return null; + const bytes = ffi.readLenPrefixedBytes(this.module, outptr); + if (bytes.length < 16) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + x: view.getFloat32(0, true), + y: view.getFloat32(4, true), + w: view.getFloat32(8, true), + h: view.getFloat32(12, true), + }; + } + + /** + * Get selection rectangles in layout-local coordinates. + * @returns Array of {x, y, w, h} or null if no selection. + */ + textEditGetSelectionRects(): Array<{ + x: number; + y: number; + w: number; + h: number; + }> | null { + this._assertAlive(); + const outptr = this.module._text_edit_get_selection_rects( + this.appptr + ) as number; + if (!outptr) return null; + const json = ffi.readLenPrefixedString(this.module, outptr); + try { + return JSON.parse(json); + } catch { + return null; + } + } + + // --- Style commands --- + + textEditToggleBold() { + this._assertAlive(); + this.module._text_edit_toggle_bold(this.appptr); + } + + textEditToggleItalic() { + this._assertAlive(); + this.module._text_edit_toggle_italic(this.appptr); + } + + textEditToggleUnderline() { + this._assertAlive(); + this.module._text_edit_toggle_underline(this.appptr); + } + + textEditToggleStrikethrough() { + this._assertAlive(); + this.module._text_edit_toggle_strikethrough(this.appptr); + } + + textEditSetFontSize(size: number) { + this._assertAlive(); + this.module._text_edit_set_font_size(this.appptr, size); + } + + textEditSetFontFamily(family: string) { + this._assertAlive(); + const [ptr, len] = this._alloc_string(family); + this.module._text_edit_set_font_family(this.appptr, ptr, len - 1); + this._free_string(ptr, len); + } + + textEditSetColor(r: number, g: number, b: number, a: number) { + this._assertAlive(); + this.module._text_edit_set_color(this.appptr, r, g, b, a); + } + + /** + * Tick the blink timer. Returns true if cursor visibility changed. + */ + textEditTick(): boolean { + this._assertAlive(); + return !!this.module._text_edit_tick(this.appptr); + } } + +// --------------------------------------------------------------------------- +// Text editing command types (mirrors Rust WasmEditCommand) +// --------------------------------------------------------------------------- + +export type TextEditCommand = + | { type: "Insert"; text: string } + | { type: "Backspace" } + | { type: "BackspaceWord" } + | { type: "BackspaceLine" } + | { type: "Delete" } + | { type: "DeleteWord" } + | { type: "DeleteLine" } + | { type: "MoveLeft"; extend: boolean } + | { type: "MoveRight"; extend: boolean } + | { type: "MoveUp"; extend: boolean } + | { type: "MoveDown"; extend: boolean } + | { type: "MoveHome"; extend: boolean } + | { type: "MoveEnd"; extend: boolean } + | { type: "MoveDocStart"; extend: boolean } + | { type: "MoveDocEnd"; extend: boolean } + | { type: "MovePageUp"; extend: boolean } + | { type: "MovePageDown"; extend: boolean } + | { type: "MoveWordLeft"; extend: boolean } + | { type: "MoveWordRight"; extend: boolean } + | { type: "SelectAll" } + | { type: "Undo" } + | { type: "Redo" }; diff --git a/crates/grida-canvas-wasm/src/main.rs b/crates/grida-canvas-wasm/src/main.rs index a2f841b82b..f537de61ab 100644 --- a/crates/grida-canvas-wasm/src/main.rs +++ b/crates/grida-canvas-wasm/src/main.rs @@ -5,6 +5,7 @@ mod wasm_application; mod wasm_fonts; mod wasm_markdown; mod wasm_svg; +mod wasm_text_edit; #[cfg(not(target_arch = "wasm32"))] fn main() {} diff --git a/crates/grida-canvas-wasm/src/wasm_text_edit.rs b/crates/grida-canvas-wasm/src/wasm_text_edit.rs new file mode 100644 index 0000000000..da46842a33 --- /dev/null +++ b/crates/grida-canvas-wasm/src/wasm_text_edit.rs @@ -0,0 +1,406 @@ +//! WASM C ABI for text editing operations. +//! +//! Thin delegates to `UnknownTargetApplication` methods. All orchestration +//! logic (session creation, layout, decoration updates, frame queuing) +//! lives in `grida-canvas`'s Application — this file is purely the C ABI +//! boundary. + +use crate::_internal::*; +use cg::window::application::UnknownTargetApplication; +use serde::Deserialize; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn alloc_len_prefixed(bytes: &[u8]) -> *const u8 { + let Ok(len_u32) = u32::try_from(bytes.len()) else { + return std::ptr::null(); + }; + let total = 4 + bytes.len(); + let out = allocate(total); + let len_bytes = len_u32.to_le_bytes(); + unsafe { + std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out, 4); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out.add(4), bytes.len()); + } + out +} + +/// Encode `f32` values as little-endian bytes (safe, no alignment concerns). +fn f32_slice_to_le_bytes(floats: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(floats.len() * 4); + for &f in floats { + bytes.extend_from_slice(&f.to_le_bytes()); + } + bytes +} + +use cg::text_edit_session::EditCommand; + +/// JSON-serializable editing command from TS. +#[derive(Deserialize)] +#[serde(tag = "type")] +enum WasmEditCommand { + Insert { text: String }, + Backspace, + BackspaceWord, + BackspaceLine, + Delete, + DeleteWord, + DeleteLine, + MoveLeft { extend: bool }, + MoveRight { extend: bool }, + MoveUp { extend: bool }, + MoveDown { extend: bool }, + MoveHome { extend: bool }, + MoveEnd { extend: bool }, + MoveDocStart { extend: bool }, + MoveDocEnd { extend: bool }, + MovePageUp { extend: bool }, + MovePageDown { extend: bool }, + MoveWordLeft { extend: bool }, + MoveWordRight { extend: bool }, + SelectAll, + Undo, + Redo, +} + +fn wasm_cmd_to_editing(cmd: WasmEditCommand) -> Option { + Some(match cmd { + WasmEditCommand::Insert { text } => EditCommand::Insert(text), + WasmEditCommand::Backspace => EditCommand::Backspace, + WasmEditCommand::BackspaceWord => EditCommand::BackspaceWord, + WasmEditCommand::BackspaceLine => EditCommand::BackspaceLine, + WasmEditCommand::Delete => EditCommand::Delete, + WasmEditCommand::DeleteWord => EditCommand::DeleteWord, + WasmEditCommand::DeleteLine => EditCommand::DeleteLine, + WasmEditCommand::MoveLeft { extend } => EditCommand::MoveLeft { extend }, + WasmEditCommand::MoveRight { extend } => EditCommand::MoveRight { extend }, + WasmEditCommand::MoveUp { extend } => EditCommand::MoveUp { extend }, + WasmEditCommand::MoveDown { extend } => EditCommand::MoveDown { extend }, + WasmEditCommand::MoveHome { extend } => EditCommand::MoveHome { extend }, + WasmEditCommand::MoveEnd { extend } => EditCommand::MoveEnd { extend }, + WasmEditCommand::MoveDocStart { extend } => EditCommand::MoveDocStart { extend }, + WasmEditCommand::MoveDocEnd { extend } => EditCommand::MoveDocEnd { extend }, + WasmEditCommand::MovePageUp { extend } => EditCommand::MovePageUp { extend }, + WasmEditCommand::MovePageDown { extend } => EditCommand::MovePageDown { extend }, + WasmEditCommand::MoveWordLeft { extend } => EditCommand::MoveWordLeft { extend }, + WasmEditCommand::MoveWordRight { extend } => EditCommand::MoveWordRight { extend }, + WasmEditCommand::SelectAll => EditCommand::SelectAll, + WasmEditCommand::Undo | WasmEditCommand::Redo => return None, + }) +} + +// --------------------------------------------------------------------------- +// Session lifecycle +// --------------------------------------------------------------------------- + +/// Enter text editing mode for a node. +/// +/// The Application reads all text properties directly from the scene node +/// to guarantee the editing layout matches the Painter exactly. +#[no_mangle] +pub unsafe extern "C" fn text_edit_enter( + app: *mut UnknownTargetApplication, + node_id_ptr: *const u8, + node_id_len: usize, +) -> bool { + let Some(app) = app.as_mut() else { return false }; + let Some(node_id) = __str_from_ptr_len(node_id_ptr, node_id_len) else { return false }; + app.text_edit_enter(&node_id) +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_exit( + app: *mut UnknownTargetApplication, + commit: bool, +) -> *const u8 { + let Some(app) = app.as_mut() else { return std::ptr::null() }; + match app.text_edit_exit(commit) { + Some(text) => alloc_len_prefixed(text.as_bytes()), + None => std::ptr::null(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_is_active( + app: *mut UnknownTargetApplication, +) -> bool { + app.as_ref().map(|a| a.text_edit_is_active()).unwrap_or(false) +} + +/// Returns the current text of the active editing session. +/// +/// Returns a length-prefixed UTF-8 string, or null if no session is active. +#[no_mangle] +pub unsafe extern "C" fn text_edit_get_text( + app: *mut UnknownTargetApplication, +) -> *const u8 { + let Some(app) = app.as_ref() else { return std::ptr::null() }; + match app.text_edit_get_text() { + Some(text) => alloc_len_prefixed(text.as_bytes()), + None => std::ptr::null(), + } +} + +// --------------------------------------------------------------------------- +// Command dispatch +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_command( + app: *mut UnknownTargetApplication, + json_ptr: *const u8, + json_len: usize, +) { + let Some(app) = app.as_mut() else { return }; + let Some(json) = __str_from_ptr_len(json_ptr, json_len) else { return }; + + let cmd: WasmEditCommand = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => { eprintln!("text_edit_command: {e}"); return; } + }; + + match cmd { + WasmEditCommand::Undo => { app.text_edit_undo(); } + WasmEditCommand::Redo => { app.text_edit_redo(); } + other => { + if let Some(c) = wasm_cmd_to_editing(other) { + app.text_edit_command(c); + } + } + } +} + +// --------------------------------------------------------------------------- +// Undo / Redo +// --------------------------------------------------------------------------- + +/// Undo within the text editing session. +/// +/// The session owns all undo during editing. Document-level undo is not +/// involved until the session exits. +#[no_mangle] +pub unsafe extern "C" fn text_edit_undo( + app: *mut UnknownTargetApplication, +) -> bool { + let Some(app) = app.as_mut() else { return false }; + app.text_edit_undo() +} + +/// Redo within the text editing session. +#[no_mangle] +pub unsafe extern "C" fn text_edit_redo( + app: *mut UnknownTargetApplication, +) -> bool { + let Some(app) = app.as_mut() else { return false }; + app.text_edit_redo() +} + +// --------------------------------------------------------------------------- +// Pointer events +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_pointer_down( + app: *mut UnknownTargetApplication, + x: f32, y: f32, shift: bool, click_count: u32, +) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_pointer_down(x, y, shift, click_count); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_pointer_move( + app: *mut UnknownTargetApplication, + x: f32, y: f32, +) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_pointer_move(x, y); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_pointer_up( + app: *mut UnknownTargetApplication, +) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_pointer_up(); +} + +// --------------------------------------------------------------------------- +// IME +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_ime_set_preedit( + app: *mut UnknownTargetApplication, + text_ptr: *const u8, text_len: usize, +) { + let Some(app) = app.as_mut() else { return }; + let text = __str_from_ptr_len(text_ptr, text_len).unwrap_or_default(); + app.text_edit_ime_set_preedit(text); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_ime_commit( + app: *mut UnknownTargetApplication, + text_ptr: *const u8, text_len: usize, +) { + let Some(app) = app.as_mut() else { return }; + let text = __str_from_ptr_len(text_ptr, text_len).unwrap_or_default(); + app.text_edit_ime_commit(&text); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_ime_cancel( + app: *mut UnknownTargetApplication, +) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_ime_cancel(); +} + +// --------------------------------------------------------------------------- +// Clipboard +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_get_selected_text( + app: *mut UnknownTargetApplication, +) -> *const u8 { + let Some(app) = app.as_ref() else { return std::ptr::null() }; + match app.text_edit_get_selected_text() { + Some(text) => alloc_len_prefixed(text.as_bytes()), + None => std::ptr::null(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_get_selected_html( + app: *mut UnknownTargetApplication, +) -> *const u8 { + let Some(app) = app.as_ref() else { return std::ptr::null() }; + match app.text_edit_get_selected_html() { + Some(html) => alloc_len_prefixed(html.as_bytes()), + None => std::ptr::null(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_paste_text( + app: *mut UnknownTargetApplication, + text_ptr: *const u8, text_len: usize, +) { + let Some(app) = app.as_mut() else { return }; + let Some(text) = __str_from_ptr_len(text_ptr, text_len) else { return }; + app.text_edit_paste_text(&text); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_paste_html( + app: *mut UnknownTargetApplication, + html_ptr: *const u8, html_len: usize, +) { + let Some(app) = app.as_mut() else { return }; + let Some(html) = __str_from_ptr_len(html_ptr, html_len) else { return }; + app.text_edit_paste_html(&html); +} + +// --------------------------------------------------------------------------- +// Geometry queries +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_get_caret_rect( + app: *mut UnknownTargetApplication, +) -> *const u8 { + let Some(app) = app.as_mut() else { return std::ptr::null() }; + let Some(cr) = app.text_edit_get_caret_rect() else { return std::ptr::null() }; + let floats: [f32; 4] = [cr.x, cr.y, 1.0, cr.height]; + alloc_len_prefixed(&f32_slice_to_le_bytes(&floats)) +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_get_selection_rects( + app: *mut UnknownTargetApplication, +) -> *const u8 { + let Some(app) = app.as_mut() else { return std::ptr::null() }; + let Some(rects) = app.text_edit_get_selection_rects() else { return std::ptr::null() }; + + #[derive(serde::Serialize)] + struct R { x: f32, y: f32, w: f32, h: f32 } + + let json_rects: Vec = rects.iter().map(|r| R { + x: r.x, y: r.y, w: r.width, h: r.height, + }).collect(); + + match serde_json::to_string(&json_rects) { + Ok(json) => alloc_len_prefixed(json.as_bytes()), + Err(_) => std::ptr::null(), + } +} + +// --------------------------------------------------------------------------- +// Style commands +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_toggle_bold(app: *mut UnknownTargetApplication) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_toggle_bold(); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_toggle_italic(app: *mut UnknownTargetApplication) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_toggle_italic(); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_toggle_underline(app: *mut UnknownTargetApplication) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_toggle_underline(); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_toggle_strikethrough(app: *mut UnknownTargetApplication) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_toggle_strikethrough(); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_set_font_size(app: *mut UnknownTargetApplication, size: f32) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_set_font_size(size); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_set_font_family( + app: *mut UnknownTargetApplication, + family_ptr: *const u8, family_len: usize, +) { + let Some(app) = app.as_mut() else { return }; + let Some(family) = __str_from_ptr_len(family_ptr, family_len) else { return }; + app.text_edit_set_font_family(&family); +} + +#[no_mangle] +pub unsafe extern "C" fn text_edit_set_color( + app: *mut UnknownTargetApplication, + r: f32, g: f32, b: f32, a: f32, +) { + let Some(app) = app.as_mut() else { return }; + app.text_edit_set_color(r, g, b, a); +} + +// --------------------------------------------------------------------------- +// Blink tick +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn text_edit_tick( + app: *mut UnknownTargetApplication, +) -> bool { + let Some(app) = app.as_mut() else { return false }; + app.text_edit_tick() +} diff --git a/crates/grida-canvas/Cargo.toml b/crates/grida-canvas/Cargo.toml index 875edc8073..cbd34a0584 100644 --- a/crates/grida-canvas/Cargo.toml +++ b/crates/grida-canvas/Cargo.toml @@ -8,7 +8,14 @@ crate-type = ["cdylib", "rlib"] [dependencies] # skia -skia-safe = { version = "0.91.0", features = ["gpu", "gl", "textlayout", "pdf", "svg", "webp"] } +skia-safe = { version = "0.91.0", features = [ + "gpu", + "gl", + "textlayout", + "pdf", + "svg", + "webp", +] } gl = "0.14.0" # io @@ -16,6 +23,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" json-patch = "4.1.0" +# text editing +grida-text-edit = { path = "../grida-text-edit" } + # geo math2 = { path = "../math2" } rstar = "0.12" @@ -26,7 +36,7 @@ seahash = "4.1.0" taffy = "0.9.1" # svg parsing # (+2mb wasm32-unknown-emscripten@opt-level=3) -usvg = { path = "../../third_party/usvg" } +usvg = { path = "../../third_party/usvg" } # markdown parsing # (+0.25mb wasm32-unknown-emscripten@opt-level=3) pulldown-cmark = "0.13.0" diff --git a/crates/grida-canvas/src/cache/paragraph.rs b/crates/grida-canvas/src/cache/paragraph.rs index 14ac50eb9b..6662dfda5b 100644 --- a/crates/grida-canvas/src/cache/paragraph.rs +++ b/crates/grida-canvas/src/cache/paragraph.rs @@ -480,6 +480,11 @@ impl ParagraphCache { self.entries_measurement_by_shapekey_unstable.clear(); } + /// Invalidate the cached paragraph for a single node. + pub fn invalidate_by_id(&mut self, id: NodeId) { + self.entries_measurement_by_id.remove(&id); + } + pub fn len(&self) -> usize { self.entries_measurement_by_id.len() + self.entries_measurement_by_shapekey_unstable.len() } diff --git a/crates/grida-canvas/src/cache/picture.rs b/crates/grida-canvas/src/cache/picture.rs index 6158e6db8d..6384202690 100644 --- a/crates/grida-canvas/src/cache/picture.rs +++ b/crates/grida-canvas/src/cache/picture.rs @@ -86,4 +86,15 @@ impl PictureCache { self.default_store.clear(); self.variant_store.clear(); } + + /// Invalidate cached pictures for a single node (all variants). + /// + /// Note: `variant_store.retain()` is O(n) over all entries. + /// Called on each keystroke during text editing. If profiling shows + /// this is hot, consider a `HashMap>` + /// to make per-node invalidation O(1). + pub fn invalidate_node(&mut self, id: NodeId) { + self.default_store.remove(&id); + self.variant_store.retain(|&(nid, _), _| nid != id); + } } diff --git a/crates/grida-canvas/src/devtools/mod.rs b/crates/grida-canvas/src/devtools/mod.rs index 887c1ae45a..4361f9d6bb 100644 --- a/crates/grida-canvas/src/devtools/mod.rs +++ b/crates/grida-canvas/src/devtools/mod.rs @@ -3,5 +3,6 @@ pub mod hit_overlay; pub mod ruler_overlay; pub mod stats_overlay; pub mod stroke_overlay; +pub mod text_edit_decoration_overlay; pub mod text_overlay; pub mod tile_overlay; diff --git a/crates/grida-canvas/src/devtools/text_edit_decoration_overlay.rs b/crates/grida-canvas/src/devtools/text_edit_decoration_overlay.rs new file mode 100644 index 0000000000..8ff4975788 --- /dev/null +++ b/crates/grida-canvas/src/devtools/text_edit_decoration_overlay.rs @@ -0,0 +1,150 @@ +//! Text editing decoration overlay (caret + selection highlights). +//! +//! Drawn in the **devtools overlay pass** — after the scene is flushed and +//! composited. This means the caret and selection highlights are: +//! +//! - **Not clipped** by parent containers (visible even when the text +//! overflows its bounds — matching standard OS text editor behavior). +//! - **Zoom-independent**: the caret is always 1 screen-pixel wide, +//! regardless of the canvas zoom level. +//! +//! The overlay transforms layout-local decoration geometry to screen space +//! using the node's world transform and the camera view matrix, following +//! the same pattern as [`super::stroke_overlay::StrokeOverlay`]. + +use crate::cache::scene::SceneCache; +use crate::node::schema::NodeId; +use crate::painter::layer::Layer; +use crate::runtime::camera::Camera2D; +use crate::sk; +use grida_text_edit::{CaretRect, SelectionRect}; +use skia_safe::{Canvas, Color, Matrix, Paint, PaintStyle, Rect}; + +// --------------------------------------------------------------------------- +// Decoration data (computed per-frame by the editing session) +// --------------------------------------------------------------------------- + +/// Caret visual state. +#[derive(Clone, Debug)] +pub struct CaretDecoration { + /// Caret position and size in layout-local coordinates. + /// `x` is the logical caret position — the caret is drawn **centered** + /// on this x coordinate. + pub rect: CaretRect, + /// Whether the caret is currently visible (blink + selection state). + pub visible: bool, +} + +/// Visual decorations for the text node being edited. +/// +/// All geometry is in **layout-local** coordinates. The overlay applies +/// the node world transform + camera view matrix at draw time. +#[derive(Clone, Debug)] +pub struct TextEditingDecorations { + /// The node being edited. + pub node_id: NodeId, + /// Caret decoration (None before the first layout pass). + pub caret: Option, + /// Selection highlight rectangles. + pub selection_rects: Vec, + /// Vertical offset from text vertical alignment (top/center/bottom). + /// Applied as a Y translation before the node transform. + pub y_offset: f32, +} + +// --------------------------------------------------------------------------- +// Overlay renderer +// --------------------------------------------------------------------------- + +/// Selection highlight color: semi-transparent blue (matches OS selection). +const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244); + +/// Caret color: opaque black. +const CARET_COLOR: Color = Color::BLACK; + +/// Caret width in screen pixels (zoom-independent). +const CARET_SCREEN_WIDTH: f32 = 1.0; + +pub struct TextEditDecorationOverlay; + +impl TextEditDecorationOverlay { + /// Draw caret and selection decorations for the active text editing session. + /// + /// The canvas is in **screen space** (no camera transform applied). + /// The overlay computes the full transform chain itself: + /// + /// ```text + /// screen = view_matrix × node_transform × translate(0, y_offset) + /// ``` + pub fn draw( + canvas: &Canvas, + deco: &TextEditingDecorations, + camera: &Camera2D, + cache: &SceneCache, + ) { + // Look up the node's world transform from the layer list. + let entry = cache + .layers + .layers + .iter() + .find(|e| e.id == deco.node_id); + let Some(entry) = entry else { + return; + }; + + let node_matrix = sk::sk_matrix(entry.layer.transform().matrix); + let view_matrix = sk::sk_matrix(camera.view_matrix().matrix); + + // Combined transform: layout-local (with y_offset) → screen. + let y_offset_matrix = Matrix::translate((0.0, deco.y_offset)); + let combined = view_matrix * node_matrix * y_offset_matrix; + + // --- Selection highlights (drawn first, behind the caret) --- + if !deco.selection_rects.is_empty() { + canvas.save(); + canvas.concat(&combined); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(SELECTION_COLOR); + paint.set_style(PaintStyle::Fill); + + for sr in &deco.selection_rects { + let rect = Rect::from_xywh(sr.x, sr.y, sr.width, sr.height); + canvas.draw_rect(rect, &paint); + } + + canvas.restore(); + } + + // --- Caret --- + if let Some(ref caret) = deco.caret { + if caret.visible { + // Transform the caret center-line to screen space, then + // draw a fixed-width rect in screen pixels. + let caret_top = skia_safe::Point::new(caret.rect.x, caret.rect.y); + let caret_bottom = + skia_safe::Point::new(caret.rect.x, caret.rect.y + caret.rect.height); + + let screen_top = combined.map_point(caret_top); + let screen_bottom = combined.map_point(caret_bottom); + + // The caret is a vertical line centered on the logical x. + let half_w = CARET_SCREEN_WIDTH / 2.0; + let rect = Rect::from_xywh( + screen_top.x - half_w, + screen_top.y, + CARET_SCREEN_WIDTH, + screen_bottom.y - screen_top.y, + ); + + let mut paint = Paint::default(); + paint.set_anti_alias(false); + paint.set_color(CARET_COLOR); + paint.set_style(PaintStyle::Fill); + + canvas.draw_rect(rect, &paint); + } + } + } +} diff --git a/crates/grida-canvas/src/lib.rs b/crates/grida-canvas/src/lib.rs index 47df42955c..1f7dd03480 100644 --- a/crates/grida-canvas/src/lib.rs +++ b/crates/grida-canvas/src/lib.rs @@ -19,5 +19,6 @@ pub mod sk_tiny; pub mod svg; pub mod sys; pub mod text; +pub mod text_edit_session; pub mod vectornetwork; pub mod window; diff --git a/crates/grida-canvas/src/runtime/scene.rs b/crates/grida-canvas/src/runtime/scene.rs index e46d12ac46..384547c1cf 100644 --- a/crates/grida-canvas/src/runtime/scene.rs +++ b/crates/grida-canvas/src/runtime/scene.rs @@ -313,6 +313,64 @@ impl Renderer { &self.scene_cache } + /// Update the text content for a node in the layer list and render + /// command tree. + /// + /// Used during text editing to keep the rendered text in sync with the + /// editing session without rebuilding the full layer list. Also + /// invalidates the paragraph cache and picture cache for this node so + /// the next draw uses the fresh text. + /// + /// **Fragility note**: This walks both the flat layer list and the + /// render command tree to patch text in-place. If new + /// `PainterRenderCommand` variants are added, the inner + /// `update_commands` function must be updated to traverse them. + pub fn update_layer_text(&mut self, node_id: NodeId, text: &str) { + use crate::painter::layer::{PainterPictureLayer, PainterRenderCommand}; + + // Update text in the flat layer entries. + for entry in &mut self.scene_cache.layers.layers { + if let PainterPictureLayer::Text(ref mut tl) = entry.layer { + if tl.base.id == node_id { + tl.text = text.to_owned(); + break; + } + } + } + + // Update text in the render command tree (this is what the Painter + // actually draws via draw_render_commands). + fn update_commands(commands: &mut [PainterRenderCommand], node_id: NodeId, text: &str) { + for cmd in commands.iter_mut() { + match cmd { + PainterRenderCommand::Draw(ref mut layer) => { + if let PainterPictureLayer::Text(ref mut tl) = layer { + if tl.base.id == node_id { + tl.text = text.to_owned(); + } + } + } + PainterRenderCommand::MaskGroup(ref mut group) => { + update_commands(&mut group.mask_commands, node_id, text); + update_commands(&mut group.content_commands, node_id, text); + } + } + } + } + update_commands(&mut self.scene_cache.layers.commands, node_id, text); + + // Invalidate the paragraph cache for this node so the Painter + // rebuilds the paragraph with the new text. + self.scene_cache + .paragraph + .borrow_mut() + .invalidate_by_id(node_id); + + // Invalidate the picture cache for this node so the Painter + // doesn't use a stale cached picture. + self.scene_cache.picture.invalidate_node(node_id); + } + pub fn canvas(&self) -> &Canvas { let surface = unsafe { &mut *self.backend.get_surface() }; surface.canvas() @@ -891,7 +949,7 @@ impl Renderer { // Apply camera transform canvas.concat(&sk::sk_matrix(self.camera.view_matrix().matrix)); - // Always use the command pipeline for export to ensure masks are applied + // Always use the command pipeline for export to ensure masks are applied. let painter = Painter::new_with_scene_cache( canvas, &self.fonts, diff --git a/crates/grida-canvas/src/text/attributed_text_conv.rs b/crates/grida-canvas/src/text/attributed_text_conv.rs new file mode 100644 index 0000000000..3fd0dd81f1 --- /dev/null +++ b/crates/grida-canvas/src/text/attributed_text_conv.rs @@ -0,0 +1,421 @@ +//! Bidirectional conversion between `cg` canvas types and +//! `grida_text_edit::attributed_text` types. +//! +//! The two type systems are structurally aligned by design (see +//! `docs/wg/feat-text-editing/attributed-text.md`). This module provides +//! lossless `From`/`Into` implementations so the canvas can construct +//! `AttributedText` from its `TextSpanNodeRec` and vice-versa. + +use crate::cg::types::{ + FontFeature as CgFontFeature, FontOpticalSizing as CgFontOpticalSizing, + FontVariation as CgFontVariation, FontWeight, TextDecorationLine as CgTextDecorationLine, + TextDecorationRec, TextDecorationStyle as CgTextDecorationStyle, + TextLetterSpacing, TextLineHeight, TextStyleRec, TextTransform as CgTextTransform, + TextWordSpacing, +}; + +use grida_text_edit::attributed_text::{ + FontFeature as AttrFontFeature, FontOpticalSizing as AttrFontOpticalSizing, + FontVariation as AttrFontVariation, TextDecorationLine as AttrTextDecorationLine, + TextDecorationStyle as AttrTextDecorationStyle, TextDimension, TextFill, TextStyle, + TextTransform as AttrTextTransform, RGBA, +}; + +// --------------------------------------------------------------------------- +// TextTransform +// --------------------------------------------------------------------------- + +impl From for AttrTextTransform { + fn from(v: CgTextTransform) -> Self { + match v { + CgTextTransform::None => AttrTextTransform::None, + CgTextTransform::Uppercase => AttrTextTransform::Uppercase, + CgTextTransform::Lowercase => AttrTextTransform::Lowercase, + CgTextTransform::Capitalize => AttrTextTransform::Capitalize, + } + } +} + +impl From for CgTextTransform { + fn from(v: AttrTextTransform) -> Self { + match v { + AttrTextTransform::None => CgTextTransform::None, + AttrTextTransform::Uppercase => CgTextTransform::Uppercase, + AttrTextTransform::Lowercase => CgTextTransform::Lowercase, + AttrTextTransform::Capitalize => CgTextTransform::Capitalize, + } + } +} + +// --------------------------------------------------------------------------- +// TextDecorationLine +// --------------------------------------------------------------------------- + +impl From for AttrTextDecorationLine { + fn from(v: CgTextDecorationLine) -> Self { + match v { + CgTextDecorationLine::None => AttrTextDecorationLine::None, + CgTextDecorationLine::Underline => AttrTextDecorationLine::Underline, + CgTextDecorationLine::Overline => AttrTextDecorationLine::Overline, + CgTextDecorationLine::LineThrough => AttrTextDecorationLine::LineThrough, + } + } +} + +impl From for CgTextDecorationLine { + fn from(v: AttrTextDecorationLine) -> Self { + match v { + AttrTextDecorationLine::None => CgTextDecorationLine::None, + AttrTextDecorationLine::Underline => CgTextDecorationLine::Underline, + AttrTextDecorationLine::Overline => CgTextDecorationLine::Overline, + AttrTextDecorationLine::LineThrough => CgTextDecorationLine::LineThrough, + } + } +} + +// --------------------------------------------------------------------------- +// TextDecorationStyle +// --------------------------------------------------------------------------- + +impl From for AttrTextDecorationStyle { + fn from(v: CgTextDecorationStyle) -> Self { + match v { + CgTextDecorationStyle::Solid => AttrTextDecorationStyle::Solid, + CgTextDecorationStyle::Double => AttrTextDecorationStyle::Double, + CgTextDecorationStyle::Dotted => AttrTextDecorationStyle::Dotted, + CgTextDecorationStyle::Dashed => AttrTextDecorationStyle::Dashed, + CgTextDecorationStyle::Wavy => AttrTextDecorationStyle::Wavy, + } + } +} + +impl From for CgTextDecorationStyle { + fn from(v: AttrTextDecorationStyle) -> Self { + match v { + AttrTextDecorationStyle::Solid => CgTextDecorationStyle::Solid, + AttrTextDecorationStyle::Double => CgTextDecorationStyle::Double, + AttrTextDecorationStyle::Dotted => CgTextDecorationStyle::Dotted, + AttrTextDecorationStyle::Dashed => CgTextDecorationStyle::Dashed, + AttrTextDecorationStyle::Wavy => CgTextDecorationStyle::Wavy, + } + } +} + +// --------------------------------------------------------------------------- +// FontOpticalSizing +// --------------------------------------------------------------------------- + +impl From for AttrFontOpticalSizing { + fn from(v: CgFontOpticalSizing) -> Self { + match v { + CgFontOpticalSizing::Auto => AttrFontOpticalSizing::Auto, + CgFontOpticalSizing::None => AttrFontOpticalSizing::None, + CgFontOpticalSizing::Fixed(f) => AttrFontOpticalSizing::Fixed(f), + } + } +} + +impl From for CgFontOpticalSizing { + fn from(v: AttrFontOpticalSizing) -> Self { + match v { + AttrFontOpticalSizing::Auto => CgFontOpticalSizing::Auto, + AttrFontOpticalSizing::None => CgFontOpticalSizing::None, + AttrFontOpticalSizing::Fixed(f) => CgFontOpticalSizing::Fixed(f), + } + } +} + +// --------------------------------------------------------------------------- +// FontFeature +// --------------------------------------------------------------------------- + +impl From for AttrFontFeature { + fn from(v: CgFontFeature) -> Self { + AttrFontFeature { + tag: v.tag, + value: v.value, + } + } +} + +impl From for CgFontFeature { + fn from(v: AttrFontFeature) -> Self { + CgFontFeature { + tag: v.tag, + value: v.value, + } + } +} + +// --------------------------------------------------------------------------- +// FontVariation +// --------------------------------------------------------------------------- + +impl From for AttrFontVariation { + fn from(v: CgFontVariation) -> Self { + AttrFontVariation { + axis: v.axis, + value: v.value, + } + } +} + +impl From for CgFontVariation { + fn from(v: AttrFontVariation) -> Self { + CgFontVariation { + axis: v.axis, + value: v.value, + } + } +} + +// --------------------------------------------------------------------------- +// Spacing / dimension types +// +// cg uses separate enums per dimension; attributed_text uses a unified +// `TextDimension` enum. The mapping is straightforward. +// --------------------------------------------------------------------------- + +impl From for TextDimension { + fn from(v: TextLetterSpacing) -> Self { + match v { + TextLetterSpacing::Fixed(f) => TextDimension::Fixed(f), + TextLetterSpacing::Factor(f) => TextDimension::Factor(f), + } + } +} + +impl From for TextDimension { + fn from(v: TextWordSpacing) -> Self { + match v { + TextWordSpacing::Fixed(f) => TextDimension::Fixed(f), + TextWordSpacing::Factor(f) => TextDimension::Factor(f), + } + } +} + +impl From for TextDimension { + fn from(v: TextLineHeight) -> Self { + match v { + TextLineHeight::Normal => TextDimension::Normal, + TextLineHeight::Fixed(f) => TextDimension::Fixed(f), + TextLineHeight::Factor(f) => TextDimension::Factor(f), + } + } +} + +/// Convert `TextDimension` back to `TextLetterSpacing`. +/// +/// `TextDimension::Normal` maps to `Fixed(0.0)` since `TextLetterSpacing` +/// has no `Normal` variant. +impl From for TextLetterSpacing { + fn from(v: TextDimension) -> Self { + match v { + TextDimension::Normal => TextLetterSpacing::Fixed(0.0), + TextDimension::Fixed(f) => TextLetterSpacing::Fixed(f), + TextDimension::Factor(f) => TextLetterSpacing::Factor(f), + } + } +} + +/// Convert `TextDimension` back to `TextWordSpacing`. +/// +/// `TextDimension::Normal` maps to `Fixed(0.0)`. +impl From for TextWordSpacing { + fn from(v: TextDimension) -> Self { + match v { + TextDimension::Normal => TextWordSpacing::Fixed(0.0), + TextDimension::Fixed(f) => TextWordSpacing::Fixed(f), + TextDimension::Factor(f) => TextWordSpacing::Factor(f), + } + } +} + +impl From for TextLineHeight { + fn from(v: TextDimension) -> Self { + match v { + TextDimension::Normal => TextLineHeight::Normal, + TextDimension::Fixed(f) => TextLineHeight::Fixed(f), + TextDimension::Factor(f) => TextLineHeight::Factor(f), + } + } +} + +// --------------------------------------------------------------------------- +// TextStyleRec <-> TextStyle (the main conversion) +// --------------------------------------------------------------------------- + +/// Convert a `TextStyleRec` (canvas uniform style) into an attributed +/// `TextStyle`. The `fill` field defaults to solid black since +/// `TextStyleRec` does not carry fill — fills live on the node. +/// +/// To provide a per-run fill, set the `fill` field on the returned +/// `TextStyle` after conversion, or use [`text_style_rec_to_attr_with_fill`]. +impl From<&TextStyleRec> for TextStyle { + fn from(rec: &TextStyleRec) -> Self { + let (deco_line, deco_style, deco_color, deco_skip_ink, deco_thickness) = + if let Some(ref d) = rec.text_decoration { + ( + d.text_decoration_line.into(), + d.text_decoration_style + .map(Into::into) + .unwrap_or(AttrTextDecorationStyle::Solid), + d.text_decoration_color.map(|c| RGBA { + r: c.r as f32 / 255.0, + g: c.g as f32 / 255.0, + b: c.b as f32 / 255.0, + a: c.a as f32 / 255.0, + }), + d.text_decoration_skip_ink.unwrap_or(true), + d.text_decoration_thinkness.unwrap_or(1.0), + ) + } else { + ( + AttrTextDecorationLine::None, + AttrTextDecorationStyle::Solid, + None, + true, + 1.0, + ) + }; + + TextStyle { + font_family: rec.font_family.clone(), + font_size: rec.font_size, + font_weight: rec.font_weight.0, + font_width: rec.font_width.unwrap_or(100.0), + font_style_italic: rec.font_style_italic, + font_kerning: rec.font_kerning, + font_optical_sizing: rec.font_optical_sizing.into(), + font_features: rec + .font_features + .as_ref() + .map(|v| v.iter().cloned().map(Into::into).collect()) + .unwrap_or_default(), + font_variations: rec + .font_variations + .as_ref() + .map(|v| v.iter().cloned().map(Into::into).collect()) + .unwrap_or_default(), + letter_spacing: rec.letter_spacing.into(), + word_spacing: rec.word_spacing.into(), + line_height: rec.line_height.clone().into(), + text_decoration_line: deco_line, + text_decoration_style: deco_style, + text_decoration_color: deco_color, + text_decoration_skip_ink: deco_skip_ink, + text_decoration_thickness: deco_thickness, + text_transform: rec.text_transform.into(), + fill: TextFill::default(), // caller must set from node fills + hyperlink: None, + } + } +} + +/// Convert an attributed `TextStyle` back into a canvas `TextStyleRec`. +/// +/// The `fill` and `hyperlink` fields are discarded since `TextStyleRec` +/// does not carry them (fill lives on the node; hyperlink is not yet +/// supported in the canvas schema). +impl From<&TextStyle> for TextStyleRec { + fn from(s: &TextStyle) -> Self { + use crate::cg::color::CGColor; + + let text_decoration = if s.text_decoration_line == AttrTextDecorationLine::None { + None + } else { + Some(TextDecorationRec { + text_decoration_line: s.text_decoration_line.into(), + text_decoration_style: Some(s.text_decoration_style.into()), + text_decoration_color: s.text_decoration_color.map(|c| { + CGColor::from_rgba( + (c.r * 255.0) as u8, + (c.g * 255.0) as u8, + (c.b * 255.0) as u8, + (c.a * 255.0) as u8, + ) + }), + text_decoration_skip_ink: Some(s.text_decoration_skip_ink), + text_decoration_thinkness: Some(s.text_decoration_thickness), + }) + }; + + TextStyleRec { + text_decoration, + font_family: s.font_family.clone(), + font_size: s.font_size, + font_weight: FontWeight(s.font_weight), + font_width: if (s.font_width - 100.0).abs() < f32::EPSILON { + None + } else { + Some(s.font_width) + }, + font_style_italic: s.font_style_italic, + font_kerning: s.font_kerning, + font_optical_sizing: s.font_optical_sizing.into(), + font_features: if s.font_features.is_empty() { + None + } else { + Some(s.font_features.iter().cloned().map(Into::into).collect()) + }, + font_variations: if s.font_variations.is_empty() { + None + } else { + Some(s.font_variations.iter().cloned().map(Into::into).collect()) + }, + letter_spacing: s.letter_spacing.into(), + word_spacing: s.word_spacing.into(), + line_height: s.line_height.into(), + text_transform: s.text_transform.into(), + } + } +} + +/// Convert a `TextStyleRec` to a `TextStyle` with an explicit fill color. +/// +/// This is the preferred conversion when the node's fill paint is known +/// (e.g. the first active solid fill from the node's paint stack). +pub fn text_style_rec_to_attr_with_fill(rec: &TextStyleRec, fill: TextFill) -> TextStyle { + let mut style: TextStyle = rec.into(); + style.fill = fill; + style +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_text_style_rec() { + let rec = TextStyleRec::from_font("Inter", 16.0); + let attr: TextStyle = (&rec).into(); + let rec2: TextStyleRec = (&attr).into(); + + assert_eq!(rec.font_family, rec2.font_family); + assert_eq!(rec.font_size, rec2.font_size); + assert_eq!(rec.font_weight.0, rec2.font_weight.0); + assert_eq!(rec.font_style_italic, rec2.font_style_italic); + } + + #[test] + fn text_dimension_roundtrip_letter_spacing() { + let ls = TextLetterSpacing::Fixed(1.5); + let dim: TextDimension = ls.into(); + let ls2: TextLetterSpacing = dim.into(); + match ls2 { + TextLetterSpacing::Fixed(f) => assert!((f - 1.5).abs() < f32::EPSILON), + _ => panic!("expected Fixed"), + } + } + + #[test] + fn text_dimension_line_height_normal_roundtrip() { + let lh = TextLineHeight::Normal; + let dim: TextDimension = lh.into(); + let lh2: TextLineHeight = dim.into(); + match lh2 { + TextLineHeight::Normal => {} + _ => panic!("expected Normal"), + } + } +} diff --git a/crates/grida-canvas/src/text/mod.rs b/crates/grida-canvas/src/text/mod.rs index 2e93757033..d715de30da 100644 --- a/crates/grida-canvas/src/text/mod.rs +++ b/crates/grida-canvas/src/text/mod.rs @@ -1,3 +1,5 @@ +pub mod attributed_text_conv; +pub mod paragraph_cache_layout; pub mod text_style; pub mod text_transform; diff --git a/crates/grida-canvas/src/text/paragraph_cache_layout.rs b/crates/grida-canvas/src/text/paragraph_cache_layout.rs new file mode 100644 index 0000000000..ac0b66d56b --- /dev/null +++ b/crates/grida-canvas/src/text/paragraph_cache_layout.rs @@ -0,0 +1,543 @@ +//! `ParagraphCacheLayout` — an adapter that implements +//! [`grida_text_edit::ManagedTextLayout`] using the same paragraph-building +//! code path as the scene's [`ParagraphCache`]. +//! +//! ## Why this exists +//! +//! The text editing session needs geometry queries (caret position, selection +//! rects, hit testing, word boundaries) that match the **exact** paragraph +//! the Painter renders. Previously, `SkiaLayoutEngine` (from `grida-text-edit`) +//! built its *own* paragraphs with its *own* font configuration — a completely +//! separate code path from `ParagraphCache`. This caused: +//! +//! - **Font fallback mismatch**: CJK text renders correctly (Painter has +//! fallbacks) but caret positions are wrong (SkiaLayoutEngine measures tofu). +//! - **Layout width divergence**: Painter uses `width: None` for auto-width +//! nodes (intrinsic sizing); SkiaLayoutEngine used a fixed width. +//! - **Style divergence**: Painter uses `textstyle()` + +//! `TextStyleRecBuildContext`; SkiaLayoutEngine used `TextConfig` + +//! `attr_style_to_skia()`. +//! +//! `ParagraphCacheLayout` eliminates all three by building paragraphs with +//! the **same** `textstyle()` function, `FontRepository::font_collection()`, +//! `TextStyleRecBuildContext` (with `user_fallback_fonts`), and intrinsic +//! width logic that `ParagraphCache::measure()` uses. +//! +//! ## Architecture +//! +//! The adapter holds: +//! - Node properties: `TextStyleRec`, `TextAlign`, `node_width`. +//! - A clone of the `FontCollection` from `FontRepository`. +//! - The user fallback font families from `FontRepository`. +//! - The current Skia `Paragraph` (built with the same code as the Painter). +//! +//! When `ensure_layout()` is called, if text or generation changed, the +//! adapter rebuilds the paragraph using `textstyle()` — identical to +//! `ParagraphCache::measure()`. All geometry queries then hit this paragraph. +//! +//! ## Note on `TextTransform` +//! +//! During editing, `text_transform` is **not** applied. The editing engine +//! works with raw text and raw byte offsets. `ParagraphCache::measure()` +//! applies `transform_text()`, but the editing adapter does not, because +//! transforms can change byte lengths (e.g. ß → SS) which would break +//! cursor offset mapping. When the user commits the edit, the scene re-renders +//! the node with `text_transform` applied as normal. + +use skia_safe::textlayout; + +use grida_text_edit::{ + layout::{CaretRect, LineMetrics, ManagedTextLayout, SelectionRect, TextLayoutEngine}, + prev_grapheme_boundary, snap_grapheme_boundary, utf16_to_utf8_offset, utf8_to_utf16_offset, +}; + +use crate::cg::prelude::*; +use crate::runtime::font_repository::FontRepository; +use crate::text::text_style::textstyle; + +// --------------------------------------------------------------------------- +// ParagraphCacheLayout +// --------------------------------------------------------------------------- + +/// Adapter that implements [`ManagedTextLayout`] by building paragraphs with +/// the same code path as `ParagraphCache::measure()` — same fonts, same +/// fallback chain, same `TextStyleRecBuildContext`. +/// +/// Created once per text editing session (one per `text_edit_enter` call). +pub struct ParagraphCacheLayout { + // -- Node properties (immutable for the session's lifetime) -- + text_style: TextStyleRec, + text_align: TextAlign, + /// `None` = auto-width (intrinsic sizing). + node_width: Option, + + // -- Font resources (cloned from FontRepository at session start) -- + font_collection: textlayout::FontCollection, + /// User fallback font families (e.g. CJK coverage fonts). + user_fallback_families: Vec, + + // -- Cached paragraph state -- + /// The Skia paragraph built with the same code as the Painter. + /// Owned exclusively by this adapter — no sharing needed. + paragraph: Option, + /// Text content at the time of the last build. + cached_text: String, + /// `AttributedText::generation()` seen by the last `ensure_layout`. + cached_generation: u64, + /// The layout width currently applied. + layout_width: f32, + /// Viewport height (for PageUp/PageDown). + layout_height: f32, + + // -- Cached line metrics -- + cached_line_metrics: Option>, +} + +impl ParagraphCacheLayout { + /// Create a new adapter for the given text node. + /// + /// The `FontRepository` is used to clone the font collection and extract + /// fallback families. The adapter does NOT hold a reference to the + /// repository — it takes a snapshot at construction time. + /// + /// # Arguments + /// + /// * `text_style` — The node's `TextStyleRec` (from the scene graph). + /// * `text_align` — The node's text alignment. + /// * `node_width` — The node's explicit width (`None` = auto-width). + /// * `layout_height` — Container/viewport height. + /// * `fonts` — The scene's font repository (borrowed for cloning). + pub fn new( + text_style: TextStyleRec, + text_align: TextAlign, + node_width: Option, + layout_height: f32, + fonts: &FontRepository, + ) -> Self { + let layout_width = node_width.unwrap_or(10000.0).max(1.0); + Self { + text_style, + text_align, + node_width, + font_collection: fonts.font_collection().clone(), + user_fallback_families: fonts.user_fallback_families(), + paragraph: None, + cached_text: String::new(), + cached_generation: 0, + layout_width, + layout_height: layout_height.max(1.0), + cached_line_metrics: None, + } + } + + /// Build a Skia paragraph using the **same** code path as + /// `ParagraphCache::measure()`. + /// + /// Key properties that match the Painter: + /// - `textstyle()` with `TextStyleRecBuildContext` (user fallback fonts) + /// - `FontRepository::font_collection()` + /// - `set_apply_rounding_hack(false)` + /// - Intrinsic width handling for `node_width: None` + fn build_paragraph(&self, text: &str) -> textlayout::Paragraph { + let mut paragraph_style = textlayout::ParagraphStyle::new(); + paragraph_style.set_text_direction(textlayout::TextDirection::LTR); + paragraph_style.set_text_align(self.text_align.into()); + paragraph_style.set_apply_rounding_hack(false); + // No max_lines or ellipsis during editing. + + let ctx = TextStyleRecBuildContext { + color: CGColor::TRANSPARENT, // No paint for measurement + user_fallback_fonts: self.user_fallback_families.clone(), + }; + let mut builder = + textlayout::ParagraphBuilder::new(¶graph_style, &self.font_collection); + let ts = textstyle(&self.text_style, &Some(ctx)); + builder.push_style(&ts); + // NOTE: We do NOT apply text_transform here. During editing, the + // engine works with raw text. See module-level doc for rationale. + builder.add_text(text); + let para = builder.build(); + builder.pop(); + para + } + + /// Build and layout the paragraph, handling intrinsic width for + /// auto-width nodes (same logic as `ParagraphCache::compute_measurements`). + fn rebuild(&mut self, text: &str) { + let mut para = self.build_paragraph(text); + + let layout_width = if let Some(width) = self.node_width { + width + } else { + // Auto-width: layout at infinity to get intrinsic width, + // then re-layout at that width. Same as ParagraphCache. + para.layout(f32::INFINITY); + let intrinsic = para.max_intrinsic_width(); + // Empty text returns 0 intrinsic width — use a minimum so + // the caret renders at the correct alignment offset. + if intrinsic < 1.0 { 1.0 } else { intrinsic } + }; + + para.layout(layout_width); + self.layout_width = layout_width.max(1.0); + self.paragraph = Some(para); + self.cached_text = text.to_owned(); + self.cached_line_metrics = None; + } + + /// Ensure the paragraph is built for the given text. + fn ensure_paragraph(&mut self, text: &str) { + if self.paragraph.is_some() && self.cached_text == text { + return; + } + self.rebuild(text); + } + + /// Convert Skia's UTF-16 line metrics to UTF-8. + fn compute_line_metrics(&self, text: &str) -> Vec { + let para = match &self.paragraph { + Some(p) => p, + None => return vec![], + }; + let skia_metrics = para.get_line_metrics(); + + if skia_metrics.is_empty() { + return vec![LineMetrics { + start_index: 0, + end_index: 0, + baseline: self.text_style.font_size, + ascent: self.text_style.font_size, + descent: self.text_style.font_size * 0.2, + left: 0.0, + }]; + } + + let mut result = Vec::with_capacity(skia_metrics.len()); + let mut run_u16: usize = 0; + let mut run_byte: usize = 0; + let mut char_iter = text.char_indices().peekable(); + + for lm in &skia_metrics { + let start_u8 = incremental_u16_to_u8( + lm.start_index, + text, + &mut run_u16, + &mut run_byte, + &mut char_iter, + ); + let end_u8 = incremental_u16_to_u8( + lm.end_including_newline, + text, + &mut run_u16, + &mut run_byte, + &mut char_iter, + ) + .min(text.len()); + + result.push(LineMetrics { + start_index: start_u8, + end_index: end_u8, + baseline: lm.baseline as f32, + ascent: lm.ascent as f32, + descent: lm.descent as f32, + left: lm.left as f32, + }); + } + + // Trailing newline phantom line. + if text.ends_with('\n') && !text.is_empty() { + let needs_phantom = result + .last() + .map(|last| last.start_index < text.len()) + .unwrap_or(true); + if needs_phantom { + let (ascent, descent) = result + .last() + .map(|last| (last.ascent, last.descent)) + .unwrap_or((self.text_style.font_size, self.text_style.font_size * 0.2)); + let baseline = result + .last() + .map(|last| last.baseline + last.descent + ascent) + .unwrap_or(ascent); + result.push(LineMetrics { + start_index: text.len(), + end_index: text.len(), + baseline, + ascent, + descent, + left: empty_line_left(&self.text_align, self.layout_width), + }); + } + } + + result + } + + /// Height of the laid-out paragraph content (in layout-local pixels). + /// + /// This is the *content* height (from `Paragraph::height()`), NOT the + /// container height. Use this for vertical alignment offset calculations. + pub fn paragraph_height(&self) -> f32 { + self.paragraph + .as_ref() + .map(|p| p.height()) + .unwrap_or(self.text_style.font_size) + } + + /// Get or compute cached line metrics. + fn line_metrics_cached(&mut self, text: &str) -> Vec { + self.ensure_paragraph(text); + if let Some(ref cached) = self.cached_line_metrics { + return cached.clone(); + } + let metrics = self.compute_line_metrics(text); + self.cached_line_metrics = Some(metrics.clone()); + metrics + } +} + +// --------------------------------------------------------------------------- +// TextLayoutEngine +// --------------------------------------------------------------------------- + +impl TextLayoutEngine for ParagraphCacheLayout { + fn line_metrics(&mut self, text: &str) -> Vec { + self.line_metrics_cached(text) + } + + fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { + // Check empty lines first. + let metrics = self.line_metrics_cached(text); + for lm in &metrics { + let top = lm.baseline - lm.ascent; + let bot = lm.baseline + lm.descent; + if y >= top - 0.5 && y <= bot + 0.5 && lm.is_empty_line(text) { + return lm.start_index; + } + } + + let para = match &self.paragraph { + Some(p) => p, + None => return 0, + }; + let pwa = + para.get_glyph_position_at_coordinate(skia_safe::Point::new(x, y)); + let raw_u16 = pwa.position.max(0) as usize; + let raw_u8 = utf16_to_utf8_offset(text, raw_u16).min(text.len()); + snap_grapheme_boundary(text, raw_u8) + } + + fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect { + let metrics = self.line_metrics_cached(text); + if metrics.is_empty() { + return CaretRect { + x: 0.0, + y: 0.0, + height: self.text_style.font_size, + }; + } + + let idx = metrics + .iter() + .position(|lm| offset < lm.end_index) + .unwrap_or(metrics.len() - 1); + let lm = &metrics[idx]; + + let y = lm.baseline - lm.ascent; + let height = lm.ascent + lm.descent; + + let x = if offset <= lm.start_index { + lm.left + } else { + let para = match &self.paragraph { + Some(p) => p, + None => return CaretRect { x: lm.left, y, height }, + }; + + let u16_end = utf8_to_utf16_offset(text, offset); + let cluster_start = if offset > 0 { + prev_grapheme_boundary(text, offset) + } else { + 0 + }; + let u16_start = utf8_to_utf16_offset(text, cluster_start); + let rects = para.get_rects_for_range( + u16_start..u16_end, + textlayout::RectHeightStyle::Max, + textlayout::RectWidthStyle::Tight, + ); + rects + .iter() + .map(|tb| tb.rect.right()) + .fold(0.0_f32, f32::max) + }; + + CaretRect { x, y, height } + } + + fn word_boundary_at(&mut self, text: &str, offset: usize) -> (usize, usize) { + self.ensure_paragraph(text); + let para = match &self.paragraph { + Some(p) => p, + None => return (0, 0), + }; + let u16_pos = utf8_to_utf16_offset(text, offset) as u32; + let range = para.get_word_boundary(u16_pos); + let start = utf16_to_utf8_offset(text, range.start); + let end = utf16_to_utf8_offset(text, range.end); + (start, end) + } + + fn selection_rects_for_range( + &mut self, + text: &str, + start: usize, + end: usize, + ) -> Vec { + if start >= end { + return Vec::new(); + } + let metrics = self.line_metrics_cached(text); + if metrics.is_empty() { + return Vec::new(); + } + + self.ensure_paragraph(text); + let para = match &self.paragraph { + Some(p) => p, + None => return Vec::new(), + }; + + let u16_lo = utf8_to_utf16_offset(text, start); + let u16_hi = utf8_to_utf16_offset(text, end); + let raw = para.get_rects_for_range( + u16_lo..u16_hi, + textlayout::RectHeightStyle::Max, + textlayout::RectWidthStyle::Tight, + ); + + let mut rects: Vec = raw + .iter() + .map(|tb| SelectionRect { + x: tb.rect.left(), + y: tb.rect.top(), + width: (tb.rect.right() - tb.rect.left()).max(0.0), + height: (tb.rect.bottom() - tb.rect.top()).max(0.0), + }) + .collect(); + + // Empty-line invariant. + let first_line = grida_text_edit::line_index_for_offset_utf8(&metrics, start); + let last_line = grida_text_edit::line_index_for_offset_utf8( + &metrics, + end.saturating_sub(1).max(start), + ); + + for lm in metrics.iter().take(last_line + 1).skip(first_line) { + if !lm.is_empty_line(text) { + continue; + } + let mid_y = lm.baseline - lm.ascent * 0.5; + let already = rects + .iter() + .any(|r| r.y <= mid_y && mid_y <= r.y + r.height); + if !already { + rects.push(SelectionRect { + x: lm.left, + y: lm.baseline - lm.ascent, + width: self.text_style.font_size * 0.5, + height: lm.ascent + lm.descent, + }); + } + } + + rects + } + + fn viewport_height(&self) -> f32 { + self.layout_height + } +} + +// --------------------------------------------------------------------------- +// ManagedTextLayout +// --------------------------------------------------------------------------- + +impl ManagedTextLayout for ParagraphCacheLayout { + fn ensure_layout(&mut self, content: &grida_text_edit::attributed_text::AttributedText) { + let gen = content.generation(); + let text = content.text(); + if self.paragraph.is_some() + && self.cached_text == text + && self.cached_generation == gen + { + return; + } + self.cached_generation = gen; + self.rebuild(text); + } + + fn invalidate(&mut self) { + self.paragraph = None; + self.cached_text.clear(); + self.cached_generation = 0; + self.cached_line_metrics = None; + } + + fn layout_width(&self) -> f32 { + self.layout_width + } + + fn layout_height(&self) -> f32 { + self.layout_height + } + + fn set_layout_width(&mut self, width: f32) { + let new_w = width.max(1.0); + if (new_w - self.layout_width).abs() > 0.5 { + self.layout_width = new_w; + self.invalidate(); + } + } + + fn set_layout_height(&mut self, height: f32) { + let new_h = height.max(1.0); + if (new_h - self.layout_height).abs() > 0.5 { + self.layout_height = new_h; + } + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +/// Advance a running UTF-16/UTF-8 cursor to the target UTF-16 offset. +fn incremental_u16_to_u8( + target_u16: usize, + text: &str, + run_u16: &mut usize, + run_byte: &mut usize, + iter: &mut std::iter::Peekable, +) -> usize { + while *run_u16 < target_u16 { + if let Some(&(byte_idx, ch)) = iter.peek() { + *run_u16 += ch.len_utf16(); + *run_byte = byte_idx + ch.len_utf8(); + iter.next(); + } else { + break; + } + } + (*run_byte).min(text.len()) +} + +/// X offset for an empty line given alignment and layout width. +fn empty_line_left(align: &TextAlign, layout_width: f32) -> f32 { + match align { + TextAlign::Left => 0.0, + TextAlign::Center => layout_width / 2.0, + TextAlign::Right => layout_width, + _ => 0.0, + } +} diff --git a/crates/grida-canvas/src/text_edit_session.rs b/crates/grida-canvas/src/text_edit_session.rs new file mode 100644 index 0000000000..d02f2fe8da --- /dev/null +++ b/crates/grida-canvas/src/text_edit_session.rs @@ -0,0 +1,128 @@ +//! Text editing session for the canvas. +//! +//! This module provides the canvas-specific text editing integration by +//! re-using the generic [`grida_text_edit::TextEditSession`] with +//! [`ParagraphCacheLayout`] as the layout backend. +//! +//! The generic session already handles all editing concerns: +//! - Text buffer, cursor, selection, attributed text +//! - Undo/redo history with merge grouping +//! - Rich text style toggles (bold, italic, underline, etc.) +//! - Clipboard (copy/cut/paste with HTML formatting) +//! - IME composition (preedit rendering) +//! - Cursor blink timer +//! - Pointer events (click, drag, multi-click) +//! - Geometry queries (caret rect, selection rects) +//! - Scroll management +//! +//! This module adds only canvas-specific concerns: +//! - Constructing the session from a canvas text node's properties +//! - Capturing original text for commit/cancel semantics +//! - The `ActiveTextEdit` bundle that lives on the Application + +use crate::cg::types::TextStyleRec; +use crate::node::schema::NodeId; +use crate::text::attributed_text_conv::text_style_rec_to_attr_with_fill; +use crate::text::paragraph_cache_layout::ParagraphCacheLayout; + +use grida_text_edit::{ + attributed_text::{AttributedText, ParagraphStyle, TextFill}, + text_edit_session::TextEditSession, +}; + +// Re-export for WASM layer (so it doesn't depend on grida-text-edit directly) +pub use grida_text_edit::EditingCommand as EditCommand; + +// Re-export the session type for convenience +pub type CanvasTextEditSession = TextEditSession; + +// --------------------------------------------------------------------------- +// ActiveTextEdit — session bundle with canvas-specific lifecycle +// --------------------------------------------------------------------------- + +/// An active text editing session bound to a canvas text node. +/// +/// Bundles the generic [`TextEditSession`] (which owns both the editing +/// state and the layout engine) with canvas-specific lifecycle data +/// (original text for commit/cancel, node ID). +/// +/// The generic session handles all editing, styling, history, blink, IME, +/// pointer events, and geometry queries internally. The canvas layer only +/// needs to: +/// 1. Forward events (commands, pointer, IME) to the session +/// 2. Read decoration data (caret rect, selection rects) for overlay rendering +/// 3. Commit/cancel the session on exit +pub struct ActiveTextEdit { + /// The generic text editing session with ParagraphCacheLayout. + pub session: CanvasTextEditSession, + + /// The canvas node being edited. + pub node_id: NodeId, + + /// Original text at session start (for commit comparison). + original_text: String, +} + +impl ActiveTextEdit { + /// Create a new editing session from a text node's current state. + /// + /// # Arguments + /// + /// * `node_id` — The internal node ID being edited. + /// * `text` — The node's current plain text. + /// * `text_style_rec` — The node's uniform `TextStyleRec`. + /// * `fill` — The resolved text fill (from the node's paint stack). + /// * `paragraph_style` — Paragraph-level attributes. + /// * `layout` — The pre-configured `ParagraphCacheLayout`. + pub fn new( + node_id: NodeId, + text: &str, + text_style_rec: &TextStyleRec, + fill: TextFill, + paragraph_style: ParagraphStyle, + layout: ParagraphCacheLayout, + ) -> Self { + let attr_style = text_style_rec_to_attr_with_fill(text_style_rec, fill); + let mut content = AttributedText::new(text, attr_style); + *content.paragraph_style_mut() = paragraph_style; + + let mut session = TextEditSession::with_content(layout, content); + + // Select all text when entering edit mode so the user can + // immediately start typing to replace the existing content. + session.select_all(); + + Self { + session, + node_id, + original_text: text.to_owned(), + } + } + + /// The canvas node being edited. + pub fn node_id(&self) -> NodeId { + self.node_id + } + + /// Whether the text has been modified from its original state. + pub fn is_modified(&self) -> bool { + self.session.state.text != self.original_text + } + + /// Commit the session and return the final text (if modified). + /// + /// Returns `Some(text)` if the text was modified, `None` otherwise. + pub fn commit(self) -> Option { + let final_text = self.session.state.text.clone(); + if final_text != self.original_text { + Some(final_text) + } else { + None + } + } + + /// Cancel the session, discarding all changes. + pub fn cancel(self) -> String { + self.original_text + } +} diff --git a/crates/grida-canvas/src/window/application.rs b/crates/grida-canvas/src/window/application.rs index cca24e1028..c816f0511f 100644 --- a/crates/grida-canvas/src/window/application.rs +++ b/crates/grida-canvas/src/window/application.rs @@ -15,6 +15,8 @@ use crate::sys::scheduler; use crate::sys::timer::TimerMgr; use crate::text; use crate::vectornetwork::VectorNetwork; +use grida_text_edit::layout::ManagedTextLayout; +use grida_text_edit::TextLayoutEngine; use crate::window::command::ApplicationCommand; #[cfg(not(target_arch = "wasm32"))] use futures::channel::mpsc; @@ -188,6 +190,17 @@ pub struct UnknownTargetApplication { /// When `true`, this application is driven by a platform-managed tick loop /// and should be freed by that loop after `running` becomes `false`. auto_tick: bool, + + /// Active text editing session and its layout engine, if any. + /// + /// The session and layout are stored together so the overlay renderer + /// can access both during `draw_and_flush_devtools_overlay`. + pub text_edit: Option, + + /// Text editing decorations (caret + selection) for the overlay pass. + /// `None` when no text editing session is active. + text_edit_decorations: + Option, } impl ApplicationApi for UnknownTargetApplication { @@ -196,6 +209,13 @@ impl ApplicationApi for UnknownTargetApplication { fn tick(&mut self, time: f64) { self.clock.tick(time); self.timer.tick(self.clock.now()); + + // Drive the text-edit clock from the host's wall time. + // `time` is milliseconds (from performance.now()); the text-edit + // Instant uses microseconds. On native builds this is a no-op + // (native Instant uses std::time::Instant directly). + #[cfg(target_arch = "wasm32")] + grida_text_edit::time::Instant::set_micros((time * 1000.0) as u64); } /// Update backing resources after a window resize. @@ -616,6 +636,8 @@ impl UnknownTargetApplication { id_mapping_reverse: std::collections::HashMap::new(), running: true, auto_tick: false, + text_edit: None, + text_edit_decorations: None, } } @@ -692,7 +714,7 @@ impl UnknownTargetApplication { } /// Convert user string ID to internal u64 ID - fn user_id_to_internal(&self, user_id: &str) -> Option { + pub fn user_id_to_internal(&self, user_id: &str) -> Option { self.id_mapping.get(user_id).copied() } @@ -1013,6 +1035,17 @@ impl UnknownTargetApplication { self.highlight_stroke_style.as_ref(), ); } + // Text editing decorations (caret + selection) are rendered as + // an overlay — unclipped by parent containers and with a + // zoom-independent caret width. + if let Some(ref deco) = self.text_edit_decorations { + crate::devtools::text_edit_decoration_overlay::TextEditDecorationOverlay::draw( + &canvas, + deco, + &self.renderer.camera, + self.renderer.get_cache(), + ); + } if self.devtools_rendering_show_tiles { tile_overlay::TileOverlay::draw( &canvas, @@ -1100,4 +1133,418 @@ impl UnknownTargetApplication { pub fn get_image_size(&self, id: &str) -> Option<(u32, u32)> { self.renderer.get_image_size(id) } + + // ----------------------------------------------------------------------- + // Text editing — first-class engine feature + // ----------------------------------------------------------------------- + + /// Enter text editing mode for a node. + /// + /// Reads all text properties directly from the scene node to ensure + /// the editing layout engine uses exactly the same configuration as + /// the Painter. Returns `true` on success. + /// + /// The layout adapter (`ParagraphCacheLayout`) builds paragraphs with + /// the **same** `textstyle()`, `FontCollection`, and + /// `TextStyleRecBuildContext` that `ParagraphCache::measure()` uses, + /// eliminating font fallback mismatches and layout divergence. + pub fn text_edit_enter(&mut self, user_node_id: &str) -> bool { + use crate::node::schema::Node; + use crate::text::paragraph_cache_layout::ParagraphCacheLayout; + use crate::text_edit_session::ActiveTextEdit; + + let node_id = self.user_id_to_internal(user_node_id).unwrap_or(0); + + // Look up the text node from the scene to get the authoritative + // properties — same data the Painter and ParagraphCache use. + let scene = match self.renderer.scene.as_ref() { + Some(s) => s, + None => return false, + }; + let node = match scene.graph.get_node(&node_id) { + Ok(n) => n, + Err(_) => return false, + }; + let tspan = match node { + Node::TextSpan(t) => t, + _ => return false, + }; + + let text = &tspan.text; + let text_style_rec = &tspan.text_style; + let text_align = &tspan.text_align; + let layout_height = tspan.height.unwrap_or(10000.0); + + let fill = grida_text_edit::attributed_text::TextFill::default(); + let paragraph_style = grida_text_edit::attributed_text::ParagraphStyle::default(); + + // Build the layout adapter using the same font collection and style + // code path as ParagraphCache::measure(). + let layout = ParagraphCacheLayout::new( + text_style_rec.clone(), + *text_align, + tspan.width, // None = auto-width (intrinsic sizing) + layout_height, + &self.renderer.fonts, + ); + + let te = ActiveTextEdit::new( + node_id, + text, + text_style_rec, + fill, + paragraph_style, + layout, + ); + + self.text_edit = Some(te); + self.text_edit_refresh_decorations(); + true + } + + /// Exit text editing mode. + /// + /// If `commit`, returns the final text (if modified). Otherwise cancels. + pub fn text_edit_exit(&mut self, commit: bool) -> Option { + let te = self.text_edit.take()?; + self.text_edit_decorations = None; + self.renderer.queue_unstable(); + + if commit { + te.commit() + } else { + te.cancel(); + None + } + } + + /// Whether a text editing session is active. + pub fn text_edit_is_active(&self) -> bool { + self.text_edit.is_some() + } + + /// Returns the current text of the active editing session, or `None` + /// if no session is active. + pub fn text_edit_get_text(&self) -> Option<&str> { + self.text_edit.as_ref().map(|te| te.session.state.text.as_str()) + } + + /// Dispatch an editing command. + pub fn text_edit_command(&mut self, cmd: grida_text_edit::EditingCommand) { + if let Some(te) = self.text_edit.as_mut() { + te.session.apply(cmd); + } + self.text_edit_refresh_decorations(); + } + + /// Undo within the text editing session. + /// + /// Returns `true` if the session had something to undo. + pub fn text_edit_undo(&mut self) -> bool { + let performed = self + .text_edit + .as_mut() + .is_some_and(|te| te.session.undo()); + self.text_edit_refresh_decorations(); + performed + } + + /// Redo within the text editing session. + /// + /// Returns `true` if the session had something to redo. + pub fn text_edit_redo(&mut self) -> bool { + let performed = self + .text_edit + .as_mut() + .is_some_and(|te| te.session.redo()); + self.text_edit_refresh_decorations(); + performed + } + + /// Pointer down in layout-local coordinates. + pub fn text_edit_pointer_down(&mut self, x: f32, y: f32, shift: bool, click_count: u32) { + if let Some(te) = self.text_edit.as_mut() { + te.session.handle_click(x, y, click_count, shift); + } + self.text_edit_refresh_decorations(); + } + + /// Pointer move during drag (layout-local coordinates). + pub fn text_edit_pointer_move(&mut self, x: f32, y: f32) { + if let Some(te) = self.text_edit.as_mut() { + te.session.on_pointer_move(x, y); + } + self.text_edit_refresh_decorations(); + } + + /// Pointer up. + pub fn text_edit_pointer_up(&mut self) { + if let Some(te) = self.text_edit.as_mut() { + te.session.on_pointer_up(); + } + } + + /// Set IME preedit string. + pub fn text_edit_ime_set_preedit(&mut self, text: String) { + if let Some(te) = self.text_edit.as_mut() { + te.session.update_preedit(text); + } + self.text_edit_sync_display_text(); + self.text_edit_refresh_decorations(); + } + + /// Commit IME composition. + pub fn text_edit_ime_commit(&mut self, text: &str) { + if let Some(te) = self.text_edit.as_mut() { + te.session.apply_with_kind( + grida_text_edit::EditingCommand::Insert(text.to_owned()), + grida_text_edit::EditKind::ImeCommit, + ); + te.session.cancel_preedit(); + } + self.text_edit_refresh_decorations(); + } + + /// Cancel IME composition. + pub fn text_edit_ime_cancel(&mut self) { + if let Some(te) = self.text_edit.as_mut() { + te.session.cancel_preedit(); + } + self.text_edit_sync_display_text(); + self.text_edit_refresh_decorations(); + } + + /// Get selected text (plain). + pub fn text_edit_get_selected_text(&self) -> Option { + let te = self.text_edit.as_ref()?; + te.session.selected_text().map(|s| s.to_owned()) + } + + /// Get selected text as HTML. + pub fn text_edit_get_selected_html(&self) -> Option { + let te = self.text_edit.as_ref()?; + te.session.selected_html() + } + + /// Paste plain text. + pub fn text_edit_paste_text(&mut self, text: &str) { + if text.is_empty() { return; } + if let Some(te) = self.text_edit.as_mut() { + te.session.apply_with_kind( + grida_text_edit::EditingCommand::Insert(text.to_owned()), + grida_text_edit::EditKind::Paste, + ); + } + self.text_edit_refresh_decorations(); + } + + /// Paste HTML with formatting. + pub fn text_edit_paste_html(&mut self, html: &str) { + if html.is_empty() { return; } + if let Some(te) = self.text_edit.as_mut() { + let base_style = te.session.content.default_style().clone(); + match grida_text_edit::attributed_text::html::html_to_attributed_text(html, base_style) { + Ok(pasted) if !pasted.is_empty() => { + te.session.paste_attributed(&pasted); + } + _ => {} // malformed HTML — ignore silently + } + } + self.text_edit_refresh_decorations(); + } + + /// Get caret rect in layout-local coordinates. + pub fn text_edit_get_caret_rect(&mut self) -> Option { + let te = self.text_edit.as_mut()?; + Some(te.session.caret_rect()) + } + + /// Get selection rects in layout-local coordinates. + pub fn text_edit_get_selection_rects(&mut self) -> Option> { + let te = self.text_edit.as_mut()?; + let (lo, hi) = te.session.selection_range()?; + let rects = te.session.layout.selection_rects_for_range(&te.session.state.text, lo, hi); + if rects.is_empty() { None } else { Some(rects) } + } + + /// Toggle bold on selection/caret. + pub fn text_edit_toggle_bold(&mut self) { + if let Some(te) = self.text_edit.as_mut() { te.session.toggle_bold(); } + self.text_edit_after_style_change(); + } + + /// Toggle italic on selection/caret. + pub fn text_edit_toggle_italic(&mut self) { + if let Some(te) = self.text_edit.as_mut() { te.session.toggle_italic(); } + self.text_edit_after_style_change(); + } + + /// Toggle underline on selection/caret. + pub fn text_edit_toggle_underline(&mut self) { + if let Some(te) = self.text_edit.as_mut() { te.session.toggle_underline(); } + self.text_edit_after_style_change(); + } + + /// Toggle strikethrough on selection/caret. + pub fn text_edit_toggle_strikethrough(&mut self) { + if let Some(te) = self.text_edit.as_mut() { te.session.toggle_strikethrough(); } + self.text_edit_after_style_change(); + } + + /// Set font size on selection/caret. + pub fn text_edit_set_font_size(&mut self, size: f32) { + if let Some(te) = self.text_edit.as_mut() { te.session.set_font_size(size); } + self.text_edit_after_style_change(); + } + + /// Set font family on selection/caret. + pub fn text_edit_set_font_family(&mut self, family: &str) { + if let Some(te) = self.text_edit.as_mut() { te.session.set_font_family(family); } + self.text_edit_after_style_change(); + } + + /// Set fill color on selection/caret. + pub fn text_edit_set_color(&mut self, r: f32, g: f32, b: f32, a: f32) { + use grida_text_edit::attributed_text::RGBA; + if let Some(te) = self.text_edit.as_mut() { + te.session.set_color(RGBA { r, g, b, a }); + } + self.text_edit_after_style_change(); + } + + /// Tick the blink timer. Returns `true` if visibility changed. + pub fn text_edit_tick(&mut self) -> bool { + let changed = self + .text_edit + .as_mut() + .map(|te| te.session.tick_blink()) + .unwrap_or(false); + if changed { + if let Some(te) = self.text_edit.as_ref() { + let visible = te.session.should_show_caret(); + if let Some(ref mut deco) = self.text_edit_decorations { + if let Some(ref mut caret) = deco.caret { + caret.visible = visible; + } + } + } + self.renderer.queue_unstable(); + } + changed + } + + // -- Internal helpers -- + + /// Build the display text (committed text + preedit at cursor) and + /// sync it to the layer. Called during IME composition so the user + /// sees each intermediate syllable. + fn text_edit_sync_display_text(&mut self) { + if let Some(te) = self.text_edit.as_ref() { + let node_id = te.node_id(); + let display_text = match te.session.preedit() { + Some(preedit) if !preedit.is_empty() => { + let committed = &te.session.state.text; + let cursor = te.session.state.cursor; + let mut buf = String::with_capacity(committed.len() + preedit.len()); + buf.push_str(&committed[..cursor]); + buf.push_str(preedit); + buf.push_str(&committed[cursor..]); + buf + } + _ => te.session.state.text.clone(), + }; + self.renderer.update_layer_text(node_id, &display_text); + } + self.renderer.queue_unstable(); + } + + fn text_edit_after_style_change(&mut self) { + if let Some(te) = self.text_edit.as_mut() { + te.session.layout.invalidate(); + te.session.layout.ensure_layout(&te.session.content); + } + self.text_edit_refresh_decorations(); + } + + fn text_edit_refresh_decorations(&mut self) { + // The generic session's apply() already calls ensure_layout internally, + // but we still need to compute decoration data for the overlay. + + // Split borrows: extract data from `text_edit`, then access `renderer`. + let deco_data = self.text_edit.as_mut().map(|te| { + // Ensure layout is up to date. + te.session.layout.ensure_layout(&te.session.content); + let node_id = te.node_id(); + let paragraph_height = te.session.layout.paragraph_height(); + // Use display text (committed + preedit) so intermediate IME + // syllables remain visible when decorations are refreshed. + let display_text = match te.session.preedit() { + Some(preedit) if !preedit.is_empty() => { + let committed = &te.session.state.text; + let cursor = te.session.state.cursor; + let mut buf = String::with_capacity(committed.len() + preedit.len()); + buf.push_str(&committed[..cursor]); + buf.push_str(preedit); + buf.push_str(&committed[cursor..]); + buf + } + _ => te.session.state.text.clone(), + }; + let caret = te.session.caret_rect(); + let visible = te.session.should_show_caret(); + let selection_rects = te.session.selection_range().map(|(lo, hi)| { + te.session.layout.selection_rects_for_range(&te.session.state.text, lo, hi) + }).unwrap_or_default(); + (node_id, paragraph_height, display_text, caret, visible, selection_rects) + }); + + if let Some((node_id, paragraph_height, display_text, caret, visible, selection_rects)) = + deco_data + { + self.renderer.update_layer_text(node_id, &display_text); + + let y_offset = + Self::compute_text_y_offset(&self.renderer, node_id, paragraph_height); + + use crate::devtools::text_edit_decoration_overlay::{ + CaretDecoration, TextEditingDecorations, + }; + + self.text_edit_decorations = Some(TextEditingDecorations { + node_id, + caret: Some(CaretDecoration { rect: caret, visible }), + selection_rects, + y_offset, + }); + } + self.renderer.queue_unstable(); + } + + /// Compute the text vertical alignment offset for a text node. + fn compute_text_y_offset( + renderer: &Renderer, + node_id: NodeId, + paragraph_height: f32, + ) -> f32 { + use crate::node::schema::Node; + let scene = match renderer.scene.as_ref() { + Some(s) => s, + None => return 0.0, + }; + let node = match scene.graph.get_node(&node_id) { + Ok(n) => n, + Err(_) => return 0.0, + }; + match node { + Node::TextSpan(t) => match t.height { + Some(h) => match t.text_align_vertical { + TextAlignVertical::Top => 0.0, + TextAlignVertical::Center => (h - paragraph_height) / 2.0, + TextAlignVertical::Bottom => h - paragraph_height, + }, + None => 0.0, + }, + _ => 0.0, + } + } } diff --git a/crates/grida-text-edit/src/history.rs b/crates/grida-text-edit/src/history.rs index cefdd0f602..11673a6789 100644 --- a/crates/grida-text-edit/src/history.rs +++ b/crates/grida-text-edit/src/history.rs @@ -76,18 +76,6 @@ impl GenericEditHistory { !self.redo_stack.is_empty() } - /// Returns references to the states in the undo stack, oldest first. - /// - /// Each entry represents the state **before** an edit. Combined with - /// the current session state, this gives the full sequence of undo - /// boundaries the user can step through. - /// - /// Used by hosts to replay session-level history into their own history - /// system (e.g., document-level undo after exiting a text editing session). - pub fn undo_states(&self) -> impl Iterator { - self.undo_stack.iter().map(|e| &e.state) - } - pub fn clear(&mut self) { self.undo_stack.clear(); self.redo_stack.clear(); diff --git a/crates/grida-text-edit/src/lib.rs b/crates/grida-text-edit/src/lib.rs index a083c18a39..e267a6b65b 100644 --- a/crates/grida-text-edit/src/lib.rs +++ b/crates/grida-text-edit/src/lib.rs @@ -15,7 +15,7 @@ mod tests; #[cfg(test)] mod session_tests; -pub use history::{EditHistory, EditKind, GenericEditHistory}; +pub use history::EditKind; pub use layout::{line_index_for_offset, CaretRect, LineMetrics, ManagedTextLayout, SelectionRect, TextLayoutEngine}; pub use simple_layout::SimpleLayoutEngine; pub use text_edit_session::{ClickTracker, KeyAction, KeyName, TextEditSession}; diff --git a/crates/grida-text-edit/src/tests.rs b/crates/grida-text-edit/src/tests.rs index 24c08206b9..558d89940c 100644 --- a/crates/grida-text-edit/src/tests.rs +++ b/crates/grida-text-edit/src/tests.rs @@ -3,7 +3,7 @@ //! All tests use `SimpleLayoutEngine` — no Skia, no winit. The layout is //! monospace / no-wrap, so every assertion is exact. -use crate::{apply_command, floor_char_boundary, ceil_char_boundary, layout::{CaretRect, TextLayoutEngine}, line_index_for_offset_utf8, snap_grapheme_boundary, word_segment_at, EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; +use crate::{apply_command, floor_char_boundary, ceil_char_boundary, layout::{CaretRect, TextLayoutEngine}, line_index_for_offset_utf8, snap_grapheme_boundary, word_segment_at, history::EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; fn layout() -> SimpleLayoutEngine { SimpleLayoutEngine::default_test() diff --git a/crates/grida-text-edit/src/text_edit_session.rs b/crates/grida-text-edit/src/text_edit_session.rs index 439ac4ff0f..559f05fdc4 100644 --- a/crates/grida-text-edit/src/text_edit_session.rs +++ b/crates/grida-text-edit/src/text_edit_session.rs @@ -722,42 +722,6 @@ impl TextEditSession { } } - /// Returns the text at each undo boundary, from oldest to current. - /// - /// The first element is the oldest snapshot (the text before the first - /// recorded edit). The last element is the current text. Each consecutive - /// pair `(texts[i], texts[i+1])` represents one undo step. - /// - /// Returns an empty `Vec` if the session has no history (no edits were - /// made, or all edits were undone back to the original state). - /// - /// Used by hosts to replay session-level undo boundaries into their own - /// history system — for example, injecting fine-grained document-level - /// undo entries when exiting a text editing session. - /// - /// # Future direction - /// - /// A more principled approach would be a drain-based event system where - /// the session emits `HistoryEvent { previous_text, current_text, kind }` - /// as undo boundaries are created, and the host polls them per-frame - /// (similar to ProseMirror's transaction dispatch or CodeMirror's change - /// listener). This would keep the host's history in real-time sync during - /// editing, rather than batch-replaying at exit. The current batch - /// approach is chosen for simplicity and minimal FFI surface. - pub fn history_texts(&self) -> Vec { - let snapshots: Vec = self - .history - .undo_states() - .map(|snap| snap.state.text.clone()) - .collect(); - if snapshots.is_empty() { - return Vec::new(); - } - let mut texts = snapshots; - texts.push(self.state.text.clone()); - texts - } - // ----------------------------------------------------------------------- // Rich text: style toggles // ----------------------------------------------------------------------- diff --git a/editor/grida-canvas-react/viewport/surface.tsx b/editor/grida-canvas-react/viewport/surface.tsx index 13bf320de0..819b251767 100644 --- a/editor/grida-canvas-react/viewport/surface.tsx +++ b/editor/grida-canvas-react/viewport/surface.tsx @@ -49,7 +49,7 @@ import { VectorMeasurementGuide } from "./ui/vector-measurement"; import { SnapGuide } from "./ui/snap"; import { Knob } from "./ui/knob"; import { cursors } from "../../components/cursor/cursor-data"; -import { SurfaceTextEditor } from "./ui/text-editor"; +import { SurfaceTextEditor } from "./ui/surface-text-editor"; import { SurfaceVectorEditor } from "./ui/surface-vector-editor"; import { SurfaceGradientEditor } from "./ui/surface-gradient-editor"; import { SurfaceImageEditor } from "./ui/surface-image-editor"; @@ -264,7 +264,11 @@ export function EditorSurface() { if (event.defaultPrevented) return; // [order matters] - otherwise, it will always try to enter the content edit mode - editor.surface.surfaceTryToggleContentEditMode(); // 1 + // Skip toggle when already in content edit mode — prevents double-click + // inside the text editor from exiting it. + if (!content_edit_mode) { + editor.surface.surfaceTryToggleContentEditMode(); // 1 + } editor.surface.surfaceDoubleClick(event); // 2 }, onDragStart: ({ event }) => { diff --git a/editor/grida-canvas-react/viewport/ui/surface-text-editor.tsx b/editor/grida-canvas-react/viewport/ui/surface-text-editor.tsx new file mode 100644 index 0000000000..8e720722b8 --- /dev/null +++ b/editor/grida-canvas-react/viewport/ui/surface-text-editor.tsx @@ -0,0 +1,627 @@ +/** + * Surface Text Editor + * + * Renders an inline text editing overlay on the canvas surface. + * + * Two modes based on the rendering backend: + * + * **WASM/Canvas backend (primary):** + * The text editing engine (grida-text-edit) runs entirely in WASM. + * This component provides a thin input relay — a hidden `