From 806d5013038d89348451508eafda19cea85f2642 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 19 Feb 2026 22:01:52 +0900 Subject: [PATCH 01/13] docs: expand AGENTS.md with multi-tenancy details and routing information - Added a new section on multi-tenancy, detailing host-based tenant routing and entry points. - Included specifics on tenant domains, host classes, routing behavior, and local development setup. - Clarified the role of `proxy.ts` in managing Supabase auth and tenant routing. --- editor/AGENTS.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/editor/AGENTS.md b/editor/AGENTS.md index bb2962094c..4226c08a4a 100644 --- a/editor/AGENTS.md +++ b/editor/AGENTS.md @@ -9,7 +9,7 @@ This package is the Next.js app that powers **`grida.co`** and tenant domains (e - **Auth is special**: `app/(auth)` is security-critical. **Do not modify** routes/flows there. - **Public API is versioned**: treat `app/(api)/(public)/v1` as **backwards-compatible** (additive changes only unless you’re intentionally breaking/v2-ing). - **Layouts are per route group**: there isn’t a single shared root layout across the whole `app/` tree — top-level route groups own their root `layout.tsx`/metadata. -- **Edge entrypoint is `proxy.ts`**: on Next.js 16 this replaces `middleware.ts` (same runtime + semantics). Don’t add a new `middleware.ts`. +- **Edge entrypoint is `proxy.ts`**: on Next.js 16 this replaces `middleware.ts` (same runtime + semantics). Don’t add a new `middleware.ts`. In this repo it’s also where maintenance mode, Supabase session refresh, and host-based tenant routing are wired together (see “Multi-tenancy” below). - **Tenant pages are tenant-aware**: follow [`app/(tenant)/README.md`]() for host-prefixed fetches (`server.HOST` / `web.HOST`) and tenant-friendly `href="/path"` patterns. - **Shared UI boundaries matter**: - `components/` should remain route-agnostic and override-friendly (see [`components/AGENTS.md`](components/AGENTS.md)) @@ -17,6 +17,23 @@ This package is the Next.js app that powers **`grida.co`** and tenant domains (e - `scaffolds/` are feature assemblies and may bind to global/editor state - **Stable public asset URLs**: put canonical assets under `public/` (e.g. `/brand/...png`) when you need a durable, crawlable, cache-friendly path. (If you care about image search quirks, see [`app/(www)/SEO.md`]().) +## Multi-tenancy (host-based tenant routing) + +Tenant sites are primarily accessed via **tenant domains** (e.g. `xyz.grida.site`, `xyz.grida.app`, or a custom domain like `xyz.com`). Internally, tenant routes live under the **tenant root**: `/~//*` (see `app/(tenant)`). + +- **Entrypoint**: `proxy.ts` + - refreshes Supabase auth cookies via `lib/supabase/proxy.ts` (`updateSession`) + - then calls `lib/tenant/middleware.ts` (`TanantMiddleware.routeProxyRequest`) to perform host-based routing +- **Host classes** (see `lib/domains/index.ts`) + - **reserved app hosts** (`grida.co`, `bridged.xyz` + subdomains): never tenant identities; direct `/~/...` access is blocked on these hosts + - **platform tenant hosts**: `*.grida.site` and `*.grida.app` (default canonical suffix is `grida.site`) + - **custom domains**: any other hostname (hosted: resolved via DB mapping) +- **What routing does** + - **rewrite** tenant requests under `/~//*` (prefix the path with `/~/`) so the `(tenant)` route group serves the request + - **canonicalize (hosted)** hostnames via **301 redirects** (one canonical hostname per tenant) +- **Resolvers (hosted)**: prefers cached internal resolution (`app/(api)/internal/resolve-host/route.ts`), then falls back to DB RPCs (`www_resolve_hostname`, `www_get_canonical_hostname`) +- **Local dev**: `tenant.localhost:` is rewritten to `/~//*` (no DB) + ## Directory map ### Editor root (selected) From 3e51451f7fb221a4fc26931b8ceae035b12001dc Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 18:38:35 +0900 Subject: [PATCH 02/13] feat: implement text editing features and layout engine - Added a new text editing module with core functionalities including text insertion, deletion, and cursor navigation. - Introduced a `SimpleLayoutEngine` for deterministic text layout in tests, supporting line metrics and cursor positioning. - Created a minimal plain-text editor example using `winit` and `Skia`. - Updated dependencies in `Cargo.toml` to include `arboard` for clipboard functionality and other necessary libraries. - Enhanced documentation for text editing features and layout options. --- Cargo.lock | 62 + crates/grida-dev/Cargo.toml | 28 +- crates/grida-dev/examples/wd_text_editor.rs | 1460 +++++++++++++++++ crates/grida-dev/src/lib.rs | 1 + crates/grida-dev/src/text_edit/layout.rs | 80 + crates/grida-dev/src/text_edit/mod.rs | 472 ++++++ .../grida-dev/src/text_edit/simple_layout.rs | 157 ++ crates/grida-dev/src/text_edit/tests.rs | 947 +++++++++++ docs/wg/feat-text-editing/index.md | 195 ++- 9 files changed, 3359 insertions(+), 43 deletions(-) create mode 100644 crates/grida-dev/examples/wd_text_editor.rs create mode 100644 crates/grida-dev/src/text_edit/layout.rs create mode 100644 crates/grida-dev/src/text_edit/mod.rs create mode 100644 crates/grida-dev/src/text_edit/simple_layout.rs create mode 100644 crates/grida-dev/src/text_edit/tests.rs diff --git a/Cargo.lock b/Cargo.lock index fac46d71d8..2ef7664e2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,26 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.1", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -585,6 +605,15 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1016,6 +1045,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.11" @@ -1495,6 +1530,7 @@ name = "grida-dev" version = "0.0.0" dependencies = [ "anyhow", + "arboard", "cg", "clap", "dify", @@ -1514,6 +1550,7 @@ dependencies = [ "skia-safe", "tokio", "toml 0.9.8", + "unicode-segmentation", "winit", ] @@ -2587,6 +2624,7 @@ dependencies = [ "bitflags 2.9.1", "objc2 0.6.1", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.1", ] @@ -2637,6 +2675,19 @@ dependencies = [ "objc2 0.6.1", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -2691,6 +2742,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" diff --git a/crates/grida-dev/Cargo.toml b/crates/grida-dev/Cargo.toml index c01c08d824..c9de7c5d17 100644 --- a/crates/grida-dev/Cargo.toml +++ b/crates/grida-dev/Cargo.toml @@ -7,11 +7,17 @@ license = "Apache-2.0" description = "Native winit playground for grida-canvas scenes." [dependencies] -cg = { path = "../grida-canvas", features = ["figma", "web", "native-clock-tick"] } +cg = { path = "../grida-canvas", features = [ + "figma", + "web", + "native-clock-tick", +] } math2 = { path = "../math2" } anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", +] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] } futures = "0.3" gl = "0.14.0" @@ -19,13 +25,25 @@ glutin = "0.32.0" glutin-winit = "0.5.0" raw-window-handle = "0.6.0" winit = "0.30.0" -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", +] } figma-api = "0.31.3" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "webp", +] } dify = "0.7" indicatif = "0.17" toml = "0.9.8" glob = "0.3.3" - +arboard = "3" +unicode-segmentation = "1" diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs new file mode 100644 index 0000000000..a3a6b4219c --- /dev/null +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -0,0 +1,1460 @@ +//! Minimal plain-text editor built directly on winit + Skia. +//! +//! Editing logic lives in `grida_dev::text_edit` (no Skia dependency). +//! This file wires it up to Skia paragraph layout (`SkiaLayoutEngine`) and +//! the winit event loop. +//! +//! Feature checklist +//! ----------------- +//! Editing +//! [x] Text insertion – IME commit (Ime::Commit) + Key::Character fallback +//! [x] Backspace – delete grapheme before cursor (or selected range) +//! [x] Delete – delete grapheme after cursor (or selected range) +//! [x] Enter – insert newline +//! [x] Tab – insert 4 spaces +//! +//! Cursor movement +//! [x] ← / → grapheme-cluster navigation +//! [x] ↑ / ↓ line-aware navigation (Skia line-metrics + position_at_point) +//! [x] Home / End line start / end +//! [x] PageUp / PageDown move by ~visible lines (manifesto viewport boundaries) +//! [x] Cmd+← / → line start / end (macOS) +//! [x] Cmd+↑ / ↓ document start / end (macOS) +//! [x] Option+← / → word jump (macOS) +//! [x] Ctrl+← / → word jump (Windows / Linux) +//! +//! Selection +//! [x] Shift+arrow extend selection in any direction +//! [x] Shift+Cmd/Opt/Ctrl extend selection with the same jumps as above +//! [x] Mouse click place cursor +//! [x] Mouse drag drag-to-select range +//! [x] Shift+click extend selection from current cursor to click position +//! [x] k=2 double-click select word (Skia get_word_boundary) +//! [x] k=3 triple-click select visual line (Skia get_line_metrics) +//! [x] k=4 quad-click select entire document +//! [x] Cmd+A select all +//! +//! Clipboard +//! [x] Cmd/Ctrl+C copy selection +//! [x] Cmd/Ctrl+X cut selection +//! [x] Cmd/Ctrl+V paste +//! +//! Rendering +//! [x] Multiline text with wrapping +//! [x] Cursor blink (500 ms, resets on any input) +//! [x] Selection highlight (Skia get_rects_for_range) +//! [x] Empty-line selection invariant (configurable: GlyphRect vs LineBox) +//! [x] Resize – paragraph relaid out on window resize +//! +//! [x] IME composition (set_ime_allowed + Preedit → underlined inline segment; +//! Key::Character suppressed during active composition) +//! +//! Not yet implemented +//! [ ] Undo / redo +//! [ ] Scroll (vertical) +//! [ ] Visual-order bidi cursor movement + +#![allow(clippy::single_match)] + +use std::ffi::CString; +use std::num::NonZeroU32; +use std::time::{Duration, Instant}; + +use arboard::Clipboard; +use gl::types::GLint; +use glutin::{ + config::{ConfigTemplateBuilder, GlConfig}, + context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}, + display::{GetGlDisplay, GlDisplay}, + prelude::{GlSurface, NotCurrentGlContext}, + surface::{Surface as GlutinSurface, SurfaceAttributesBuilder, WindowSurface}, +}; +use glutin_winit::DisplayBuilder; +#[allow(deprecated)] +use raw_window_handle::HasRawWindowHandle; +use skia_safe::{ + gpu::{self, backend_render_targets, gl::FramebufferInfo, surfaces::wrap_backend_render_target}, + textlayout::{ + FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, RectHeightStyle, + RectWidthStyle, TextDecoration, TextStyle, + }, + Color, ColorType, FontMgr, Paint, Point, Rect, Surface, +}; +use winit::{ + application::ApplicationHandler, + dpi::{LogicalPosition, LogicalSize, PhysicalSize}, + event::{ElementState, Ime, MouseButton, WindowEvent}, + event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, + keyboard::{Key, ModifiersState, NamedKey}, + window::{Window, WindowAttributes, WindowId}, +}; + +use grida_dev::text_edit::{ + apply_command, prev_grapheme_boundary, snap_grapheme_boundary, utf16_to_utf8_offset, + utf8_to_utf16_offset, EditingCommand, LineMetrics, TextEditorState, TextLayoutEngine, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const WINDOW_W: u32 = 800; +const WINDOW_H: u32 = 600; +const PADDING: f32 = 24.0; +const FONT_SIZE: f32 = 18.0; +const BLINK_INTERVAL: Duration = Duration::from_millis(500); +const CURSOR_WIDTH: f32 = 2.0; + +// --------------------------------------------------------------------------- +// Empty-line selection policy (doc §"Empty-line selection invariant") +// --------------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum EmptyLineSelectionPolicy { + None, + GlyphRect, + LineBox, +} + +/// Compute selection rectangles for the UTF-16 range `u16_lo..u16_hi`. +fn selection_rects_with_empty_line_invariant( + paragraph: &Paragraph, + text: &str, + u16_lo: usize, + u16_hi: usize, + layout_width: f32, + policy: EmptyLineSelectionPolicy, +) -> Vec { + let raw = paragraph.get_rects_for_range( + u16_lo..u16_hi, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ); + + if policy == EmptyLineSelectionPolicy::None { + return raw.iter().map(|tb| tb.rect).collect(); + } + + let metrics = paragraph.get_line_metrics(); + + struct LineBand { + top: f32, + bottom: f32, + left: f32, + right: f32, + has_content: bool, + start_u16: usize, + end_u16: usize, + } + + let mut bands: Vec = metrics + .iter() + .map(|lm| { + let top = lm.baseline as f32 - lm.ascent as f32; + let bot = lm.baseline as f32 + lm.descent as f32; + LineBand { + top, + bottom: bot, + left: f32::MAX, + right: f32::MIN, + has_content: false, + start_u16: lm.start_index, + end_u16: lm.end_index, + } + }) + .collect(); + + for tb in &raw { + let mid_y = (tb.rect.top + tb.rect.bottom) * 0.5; + for band in &mut bands { + if mid_y >= band.top - 0.5 && mid_y <= band.bottom + 0.5 { + band.left = band.left.min(tb.rect.left); + band.right = band.right.max(tb.rect.right); + band.has_content = true; + break; + } + } + } + + let text_u16_len = text.encode_utf16().count(); + let sel_first_line = skia_line_index_for_u16_offset(&metrics, u16_lo); + let sel_last_line = + skia_line_index_for_u16_offset(&metrics, u16_hi.saturating_sub(1).max(u16_lo)); + + let mut out: Vec = Vec::with_capacity(bands.len()); + for (i, band) in bands.iter().enumerate() { + if i < sel_first_line || i > sel_last_line { + continue; + } + if !band.has_content { + let w = match policy { + EmptyLineSelectionPolicy::GlyphRect => FONT_SIZE * 0.5, + EmptyLineSelectionPolicy::LineBox => layout_width, + EmptyLineSelectionPolicy::None => unreachable!(), + }; + out.push(Rect::from_ltrb(0.0, band.top, w, band.bottom)); + continue; + } + + let mut left = band.left; + let mut right = band.right; + let is_zero_width = (right - left).abs() < 0.5; + + match policy { + EmptyLineSelectionPolicy::GlyphRect => { + if is_zero_width { + right = left + FONT_SIZE * 0.5; + } + } + EmptyLineSelectionPolicy::LineBox => { + if is_zero_width { + left = 0.0; + right = layout_width; + } else { + let fully_covered = + u16_lo <= band.start_u16 && u16_hi >= band.end_u16; + let is_first = i == sel_first_line; + let is_last = i == sel_last_line; + if fully_covered || (!is_first && !is_last) { + left = 0.0; + right = layout_width; + } else { + if is_first && u16_lo <= band.start_u16 { + left = 0.0; + } + if is_last && u16_hi >= band.end_u16 { + right = layout_width; + } + if is_first && !is_last { + right = layout_width; + } + if is_last && !is_first { + left = 0.0; + } + } + } + } + EmptyLineSelectionPolicy::None => unreachable!(), + } + + out.push(Rect::from_ltrb(left, band.top, right, band.bottom)); + } + + // Trailing phantom line + if u16_hi >= text_u16_len && text.ends_with('\n') && metrics.len() >= 2 { + let phantom = &metrics[metrics.len() - 1]; + let top = phantom.baseline as f32 - phantom.ascent as f32; + let bot = phantom.baseline as f32 + phantom.descent as f32; + let already_covered = out.iter().any(|r| { + let mid = (r.top + r.bottom) * 0.5; + mid >= top - 1.0 && mid <= bot + 1.0 + }); + if !already_covered { + let w = match policy { + EmptyLineSelectionPolicy::GlyphRect => FONT_SIZE * 0.5, + EmptyLineSelectionPolicy::LineBox => layout_width, + EmptyLineSelectionPolicy::None => unreachable!(), + }; + out.push(Rect::from_ltrb(0.0, top, w, bot)); + } + } + + out +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +struct TextEditorConfig { + empty_line_policy: EmptyLineSelectionPolicy, +} + +impl Default for TextEditorConfig { + fn default() -> Self { + Self { empty_line_policy: EmptyLineSelectionPolicy::GlyphRect } + } +} + +// --------------------------------------------------------------------------- +// SkiaLayoutEngine — implements TextLayoutEngine using Skia Paragraph +// --------------------------------------------------------------------------- + +/// Skia-backed `TextLayoutEngine`. +/// +/// Rebuilds the `Paragraph` lazily when text or layout_width changes. +struct SkiaLayoutEngine { + font_collection: FontCollection, + paragraph: Option, + layout_width: f32, + layout_height: f32, + /// Text at the time of the last paragraph build. + cached_text: String, +} + +impl SkiaLayoutEngine { + fn new(layout_width: f32, layout_height: f32) -> Self { + let mut fc = FontCollection::new(); + fc.set_default_font_manager(FontMgr::new(), None); + Self { + font_collection: fc, + paragraph: None, + layout_width, + layout_height, + cached_text: String::new(), + } + } + + fn ensure_layout(&mut self, text: &str) { + if self.paragraph.is_none() || self.cached_text != text { + self.rebuild(text); + } + } + + fn rebuild(&mut self, text: &str) { + let mut style = ParagraphStyle::new(); + style.set_apply_rounding_hack(false); + let mut ts = TextStyle::new(); + ts.set_font_size(FONT_SIZE); + ts.set_color(Color::BLACK); + ts.set_font_families(&["Menlo", "Courier New", "monospace"]); + let mut builder = ParagraphBuilder::new(&style, &self.font_collection); + builder.push_style(&ts); + builder.add_text(text); + let mut para = builder.build(); + para.layout(self.layout_width); + self.paragraph = Some(para); + self.cached_text = text.to_owned(); + } + + fn set_layout_width(&mut self, w: f32) { + let new_w = (w - PADDING * 2.0).max(1.0); + if (new_w - self.layout_width).abs() > 0.5 { + self.layout_width = new_w; + self.paragraph = None; // invalidate + } + } + + fn set_layout_height(&mut self, h: f32) { + let new_h = (h - PADDING * 2.0).max(1.0); + if (new_h - self.layout_height).abs() > 0.5 { + self.layout_height = new_h; + } + } + + /// Return a reference to the paragraph, ensuring it's built for `text`. + fn para(&mut self, text: &str) -> &Paragraph { + self.ensure_layout(text); + self.paragraph.as_ref().unwrap() + } +} + +impl TextLayoutEngine for SkiaLayoutEngine { + fn line_metrics(&mut self, text: &str) -> Vec { + let skia = self.para(text).get_line_metrics(); + skia.iter() + .map(|lm| LineMetrics { + start_index: utf16_to_utf8_offset(text, lm.start_index), + end_index: utf16_to_utf8_offset(text, lm.end_index).min(text.len()), + baseline: lm.baseline as f32, + ascent: lm.ascent as f32, + descent: lm.descent as f32, + }) + .collect() + } + + fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { + self.ensure_layout(text); + let para = self.paragraph.as_ref().unwrap(); + let metrics = para.get_line_metrics(); + + // Empty-line check: if the target y falls within an empty line's band, + // return that line's start offset directly. Skia's hit-test returns + // the previous line's position for empty lines → cursor gets locked. + for lm in &metrics { + let top = lm.baseline as f32 - lm.ascent as f32; + let bot = lm.baseline as f32 + lm.descent as f32; + if y >= top - 0.5 && y <= bot + 0.5 { + if lm.end_index.saturating_sub(lm.start_index) <= 1 { + return utf16_to_utf8_offset(text, lm.start_index).min(text.len()); + } + break; + } + } + + let pwa = para.get_glyph_position_at_coordinate(Point::new(x, y)); + let raw = utf16_to_utf8_offset(text, pwa.position.max(0) as usize).min(text.len()); + // Use snap_grapheme_boundary rather than prev_grapheme_boundary: + // Skia often returns the exact start of a grapheme; prev_grapheme_boundary + // would step back one cluster in that case, locking the cursor on the + // previous line (the empty-line lock bug). + snap_grapheme_boundary(text, raw) + } + + fn caret_x_at(&mut self, text: &str, offset: usize) -> f32 { + if offset == 0 { + return 0.0; + } + if text[..offset].ends_with('\n') { + return 0.0; + } + self.ensure_layout(text); + let u16_end = utf8_to_utf16_offset(text, offset); + + // Query the rect for the ENTIRE grapheme cluster that ends at `offset`, + // not just the last UTF-16 code unit. + // + // Without this, complex-script combining marks (Devanagari virama/vowel + // signs, Thai sara) produce a rect positioned at the base consonant + // rather than the visual end of the cluster, causing the caret to jump + // leftward after stepping through the cluster — visually incorrect for + // LTR scripts. + let cluster_start = prev_grapheme_boundary(text, offset); + let u16_start = utf8_to_utf16_offset(text, cluster_start); + + let rects = self.paragraph.as_ref().unwrap().get_rects_for_range( + u16_start..u16_end, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ); + // Use the rightmost right edge across all returned rects (handles bidi + // runs where the cluster may span more than one rect). + rects.iter().map(|tb| tb.rect.right()).fold(0.0_f32, f32::max) + } + + fn word_boundary_at(&mut self, text: &str, offset: usize) -> (usize, usize) { + self.ensure_layout(text); + let u16_pos = utf8_to_utf16_offset(text, offset) as u32; + let para = self.paragraph.as_ref().unwrap(); + let range = para.get_word_boundary(u16_pos); + let start = utf16_to_utf8_offset(text, range.start as usize); + let end = utf16_to_utf8_offset(text, range.end as usize); + (start, end) + } + + fn viewport_height(&self) -> f32 { + self.layout_height + } +} + +// --------------------------------------------------------------------------- +// Utility: find line index from Skia UTF-16 offset (for selection_rects only) +// --------------------------------------------------------------------------- + +fn skia_line_index_for_u16_offset( + metrics: &[skia_safe::textlayout::LineMetrics], + u16_offset: usize, +) -> usize { + for (i, lm) in metrics.iter().enumerate().rev() { + if lm.start_index <= u16_offset { + return i; + } + } + 0 +} + +// --------------------------------------------------------------------------- +// TextEditor – thin shell: pure editing state + Skia layout + UI state +// --------------------------------------------------------------------------- + +struct TextEditor { + /// Pure editing state: text, cursor, anchor. + pub state: TextEditorState, + /// Skia-backed layout engine (shared with apply_command calls). + layout: SkiaLayoutEngine, + + // UI-only state (not part of editing logic) + cursor_visible: bool, + last_blink: Instant, + mouse_down: bool, + drag_anchor_utf8: Option, + + /// Active IME preedit string (NOT in state.text; rendered inline). + preedit: Option, + + empty_line_policy: EmptyLineSelectionPolicy, +} + +impl TextEditor { + fn new(config: TextEditorConfig) -> Self { + Self { + state: TextEditorState::with_cursor(String::new(), 0), + layout: SkiaLayoutEngine::new( + (WINDOW_W as f32) - PADDING * 2.0, + (WINDOW_H as f32) - PADDING * 2.0, + ), + cursor_visible: true, + last_blink: Instant::now(), + mouse_down: false, + drag_anchor_utf8: None, + preedit: None, + empty_line_policy: config.empty_line_policy, + } + } + + // ----------------------------------------------------------------------- + // Core: apply an editing command + // ----------------------------------------------------------------------- + + fn apply(&mut self, cmd: EditingCommand) { + self.state = apply_command(&self.state, cmd, &mut self.layout); + self.reset_blink(); + } + + // ----------------------------------------------------------------------- + // Convenience wrappers (called from event handler) + // ----------------------------------------------------------------------- + + fn insert_text(&mut self, s: &str) { + self.apply(EditingCommand::Insert(s.to_owned())); + } + + fn backspace(&mut self) { + self.apply(EditingCommand::Backspace); + } + + fn delete_forward(&mut self) { + self.apply(EditingCommand::Delete); + } + + fn move_left(&mut self, extend: bool) { + self.apply(EditingCommand::MoveLeft { extend }); + } + + fn move_right(&mut self, extend: bool) { + self.apply(EditingCommand::MoveRight { extend }); + } + + fn move_up(&mut self, extend: bool) { + self.apply(EditingCommand::MoveUp { extend }); + } + + fn move_down(&mut self, extend: bool) { + self.apply(EditingCommand::MoveDown { extend }); + } + + fn move_home(&mut self, extend: bool) { + self.apply(EditingCommand::MoveHome { extend }); + } + + fn move_end(&mut self, extend: bool) { + self.apply(EditingCommand::MoveEnd { extend }); + } + + fn move_doc_start(&mut self, extend: bool) { + self.apply(EditingCommand::MoveDocStart { extend }); + } + + fn move_doc_end(&mut self, extend: bool) { + self.apply(EditingCommand::MoveDocEnd { extend }); + } + + fn move_page_up(&mut self, extend: bool) { + self.apply(EditingCommand::MovePageUp { extend }); + } + + fn move_page_down(&mut self, extend: bool) { + self.apply(EditingCommand::MovePageDown { extend }); + } + + fn move_word_left(&mut self, extend: bool) { + self.apply(EditingCommand::MoveWordLeft { extend }); + } + + fn move_word_right(&mut self, extend: bool) { + self.apply(EditingCommand::MoveWordRight { extend }); + } + + fn select_all(&mut self) { + self.apply(EditingCommand::SelectAll); + } + + fn has_selection(&self) -> bool { + self.state.has_selection() + } + + fn selected_text(&self) -> Option<&str> { + self.state.selected_text() + } + + fn selection_range(&self) -> Option<(usize, usize)> { + self.state.selection_range() + } + + // ----------------------------------------------------------------------- + // Mouse + // ----------------------------------------------------------------------- + + fn on_mouse_down(&mut self, x: f32, y: f32) { + self.mouse_down = true; + let pos = self.layout.position_at_point(&self.state.text, x, y); + self.state.cursor = pos; + self.state.anchor = None; + self.drag_anchor_utf8 = Some(pos); + self.reset_blink(); + } + + fn on_mouse_move(&mut self, x: f32, y: f32) { + if !self.mouse_down { + return; + } + let pos = self.layout.position_at_point(&self.state.text, x, y); + if let Some(anchor) = self.drag_anchor_utf8 { + if pos != anchor { + self.state.anchor = Some(anchor); + self.state.cursor = pos; + } else { + self.state.anchor = None; + self.state.cursor = pos; + } + } else { + self.drag_anchor_utf8 = Some(pos); + self.state.cursor = pos; + } + } + + fn on_mouse_up(&mut self) { + self.mouse_down = false; + self.drag_anchor_utf8 = None; + } + + fn shift_click(&mut self, x: f32, y: f32) { + self.apply(EditingCommand::ExtendTo { x, y }); + self.reset_blink(); + } + + fn select_word_at(&mut self, x: f32, y: f32) { + self.apply(EditingCommand::SelectWordAt { x, y }); + self.mouse_down = false; + self.drag_anchor_utf8 = None; + } + + fn select_line_at(&mut self, x: f32, y: f32) { + self.apply(EditingCommand::SelectLineAt { x, y }); + self.mouse_down = false; + self.drag_anchor_utf8 = None; + } + + // ----------------------------------------------------------------------- + // Layout sizing + // ----------------------------------------------------------------------- + + fn set_layout_width(&mut self, w: f32) { + self.layout.set_layout_width(w); + } + + fn set_layout_height(&mut self, h: f32) { + self.layout.set_layout_height(h); + } + + // ----------------------------------------------------------------------- + // Blink + // ----------------------------------------------------------------------- + + fn reset_blink(&mut self) { + self.cursor_visible = true; + self.last_blink = Instant::now(); + } + + fn tick_blink(&mut self) -> bool { + if self.last_blink.elapsed() >= BLINK_INTERVAL { + self.cursor_visible = !self.cursor_visible; + self.last_blink = Instant::now(); + true + } else { + false + } + } + + fn next_blink_deadline(&self) -> Instant { + self.last_blink + BLINK_INTERVAL + } + + // ----------------------------------------------------------------------- + // IME composition + // ----------------------------------------------------------------------- + + fn update_preedit(&mut self, text: String) { + self.preedit = if text.is_empty() { None } else { Some(text) }; + self.reset_blink(); + } + + fn cancel_preedit(&mut self) { + self.preedit = None; + self.reset_blink(); + } + + // ----------------------------------------------------------------------- + // Caret geometry helpers (for cursor rendering and IME popup placement) + // ----------------------------------------------------------------------- + + fn cursor_x(&mut self) -> f32 { + self.layout.caret_x_at(&self.state.text, self.state.cursor) + } + + /// Baseline y of the cursor line, in layout-local space. + /// Handles the Skia phantom-line case for trailing newlines. + fn cursor_baseline_y(&mut self) -> f32 { + self.layout.ensure_layout(&self.state.text); + let metrics = self.layout.paragraph.as_ref().unwrap().get_line_metrics(); + if metrics.is_empty() { + return FONT_SIZE; + } + let cur_u16 = utf8_to_utf16_offset(&self.state.text, self.state.cursor); + let idx = skia_line_index_for_u16_offset(&metrics, cur_u16); + let baseline = metrics[idx].baseline as f32; + + // Skia does NOT emit line metrics for a trailing '\n'. Extrapolate. + let after_trailing_newline = self.state.cursor > 0 + && self.state.text[..self.state.cursor].ends_with('\n') + && idx == metrics.len() - 1; + if after_trailing_newline { + let line_height = if metrics.len() >= 2 { + (metrics[metrics.len() - 1].baseline + - metrics[metrics.len() - 2].baseline) as f32 + } else { + FONT_SIZE * 1.3 + }; + return baseline + line_height; + } + + baseline + } + + // ----------------------------------------------------------------------- + // Draw + // ----------------------------------------------------------------------- + + fn draw(&mut self, canvas: &skia_safe::Canvas) { + canvas.clear(Color::WHITE); + let origin = Point::new(PADDING, PADDING); + + let preedit = self.preedit.as_deref().filter(|p| !p.is_empty()).map(str::to_owned); + + if let Some(ref p) = preedit { + // ---- preedit mode ---- + let pre = &self.state.text[..self.state.cursor]; + let post = &self.state.text[self.state.cursor..]; + let display_text = format!("{}{}{}", pre, p, post); + + let preedit_end_utf8 = pre.len() + p.len(); + let preedit_start_u16 = utf8_to_utf16_offset(&display_text, pre.len()); + let preedit_end_u16 = utf8_to_utf16_offset(&display_text, preedit_end_utf8); + + let ts_normal = { + let mut ts = TextStyle::new(); + ts.set_font_size(FONT_SIZE); + ts.set_color(Color::BLACK); + ts.set_font_families(&["Menlo", "Courier New", "monospace"]); + ts + }; + let ts_preedit = { + let mut ts = TextStyle::new(); + ts.set_font_size(FONT_SIZE); + ts.set_color(Color::BLACK); + ts.set_font_families(&["Menlo", "Courier New", "monospace"]); + ts.set_decoration_type(TextDecoration::UNDERLINE); + ts + }; + let mut para_style = ParagraphStyle::new(); + para_style.set_apply_rounding_hack(false); + let mut builder = + ParagraphBuilder::new(¶_style, &self.layout.font_collection); + builder.push_style(&ts_normal); + builder.add_text(pre); + builder.push_style(&ts_preedit); + builder.add_text(p.as_str()); + builder.pop(); + builder.add_text(post); + let mut dp = builder.build(); + dp.layout(self.layout.layout_width); + + // Selection + if let Some((lo, hi)) = self.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_empty_line_invariant( + &dp, + &display_text, + u16_lo, + u16_hi, + self.layout.layout_width, + self.empty_line_policy, + ); + let mut sp = Paint::default(); + sp.set_color(Color::from_argb(80, 66, 133, 244)); + sp.set_anti_alias(true); + for r in &rects { + canvas.draw_rect( + Rect::from_ltrb( + r.left + origin.x, + r.top + origin.y, + r.right + origin.x, + r.bottom + origin.y, + ), + &sp, + ); + } + } + } + + dp.paint(canvas, origin); + + // Cursor at end of preedit + 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 + } + }; + 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, + ); + } else { + // ---- normal mode ---- + self.layout.ensure_layout(&self.state.text); + + // Selection + if let Some((lo, hi)) = self.selection_range() { + if lo < hi { + let u16_lo = utf8_to_utf16_offset(&self.state.text, lo); + let u16_hi = utf8_to_utf16_offset(&self.state.text, hi); + let rects = selection_rects_with_empty_line_invariant( + self.layout.paragraph.as_ref().unwrap(), + &self.state.text, + u16_lo, + u16_hi, + self.layout.layout_width, + self.empty_line_policy, + ); + let mut sp = Paint::default(); + sp.set_color(Color::from_argb(80, 66, 133, 244)); + sp.set_anti_alias(true); + for r in &rects { + canvas.draw_rect( + Rect::from_ltrb( + r.left + origin.x, + r.top + origin.y, + r.right + origin.x, + r.bottom + origin.y, + ), + &sp, + ); + } + } + } + + // Text + if let Some(ref para) = self.layout.paragraph { + para.paint(canvas, origin); + } + + // Cursor + if self.cursor_visible && !self.has_selection() { + let cx = self.cursor_x(); + let cy = self.cursor_baseline_y(); + let cursor_rect = Rect::from_xywh( + cx + origin.x - CURSOR_WIDTH / 2.0, + cy - FONT_SIZE + origin.y, + CURSOR_WIDTH, + FONT_SIZE * 1.2, + ); + let mut cp = Paint::default(); + cp.set_color(Color::BLACK); + cp.set_anti_alias(false); + canvas.draw_rect(cursor_rect, &cp); + } + } + } +} + +// --------------------------------------------------------------------------- +// GL + Skia surface helpers +// --------------------------------------------------------------------------- + +struct GlSkiaSurface { + gr_context: skia_safe::gpu::DirectContext, + fb_info: FramebufferInfo, + surface: Surface, + gl_surface: GlutinSurface, + gl_context: PossiblyCurrentContext, + num_samples: usize, + stencil_bits: usize, +} + +impl GlSkiaSurface { + fn recreate_skia_surface(&mut self, width: i32, height: i32) { + let backend = backend_render_targets::make_gl( + (width, height), + self.num_samples, + self.stencil_bits, + self.fb_info, + ); + self.surface = wrap_backend_render_target( + &mut self.gr_context, + &backend, + gpu::SurfaceOrigin::BottomLeft, + ColorType::RGBA8888, + None, + None, + ) + .expect("could not re-create skia surface"); + } + + fn flush_and_present(&mut self) { + self.gr_context.flush_and_submit(); + self.gl_surface.swap_buffers(&self.gl_context).expect("swap buffers"); + } +} + +// --------------------------------------------------------------------------- +// Application +// --------------------------------------------------------------------------- + +struct TextEditorApp { + config: TextEditorConfig, + inner: Option, + modifiers: ModifiersState, + clipboard: Clipboard, + last_mouse_pos: (f32, f32), + click_count: u32, + last_click_time: Option, + last_click_pos: (f32, f32), +} + +struct AppInner { + window: Window, + gl_skia: GlSkiaSurface, + editor: TextEditor, +} + +impl TextEditorApp { + fn new(config: TextEditorConfig) -> Self { + Self { + config, + inner: None, + modifiers: ModifiersState::empty(), + clipboard: Clipboard::new().expect("could not open system clipboard"), + last_mouse_pos: (0.0, 0.0), + click_count: 0, + last_click_time: None, + last_click_pos: (0.0, 0.0), + } + } +} + +impl ApplicationHandler for TextEditorApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.inner.is_some() { + return; + } + + let window_attrs = WindowAttributes::default() + .with_title("wd_text_editor – Skia text editor prototype") + .with_inner_size(winit::dpi::LogicalSize::new(WINDOW_W, WINDOW_H)); + + let template = ConfigTemplateBuilder::new().with_alpha_size(8); + let display_builder = + DisplayBuilder::new().with_window_attributes(window_attrs.into()); + + let (window, gl_config) = display_builder + .build(event_loop, template, |mut cfgs| { + let mut best = cfgs.next().expect("no GL config"); + for c in cfgs { + if c.num_samples() < best.num_samples() { + best = c; + } + } + best + }) + .expect("failed to build GL window"); + let window = window.expect("window creation failed"); + + #[allow(deprecated)] + let raw_handle = window.raw_window_handle().expect("raw window handle"); + + let ctx_attrs = ContextAttributesBuilder::new().build(Some(raw_handle)); + let fallback_attrs = ContextAttributesBuilder::new() + .with_context_api(ContextApi::Gles(None)) + .build(Some(raw_handle)); + + let not_current = unsafe { + gl_config + .display() + .create_context(&gl_config, &ctx_attrs) + .unwrap_or_else(|_| { + gl_config + .display() + .create_context(&gl_config, &fallback_attrs) + .expect("GL context creation failed") + }) + }; + + let (w, h): (u32, u32) = window.inner_size().into(); + let surf_attrs = SurfaceAttributesBuilder::::new().build( + raw_handle, + NonZeroU32::new(w).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + NonZeroU32::new(h).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + ); + let gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &surf_attrs) + .expect("GL surface creation failed") + }; + + let gl_context = not_current.make_current(&gl_surface).expect("make current"); + + gl::load_with(|s| { + let Ok(c) = CString::new(s) else { return std::ptr::null(); }; + gl_config.display().get_proc_address(c.as_c_str()) + }); + + let interface = skia_safe::gpu::gl::Interface::new_load_with(|name| { + if name == "eglGetCurrentDisplay" { + return std::ptr::null(); + } + let Ok(c) = CString::new(name) else { return std::ptr::null(); }; + gl_config.display().get_proc_address(c.as_c_str()) + }) + .expect("Skia GL interface"); + + let mut gr_context = + skia_safe::gpu::direct_contexts::make_gl(interface, None).expect("Skia DirectContext"); + + let fb_info = { + let mut fboid: GLint = 0; + unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; + FramebufferInfo { + fboid: fboid.try_into().unwrap_or_default(), + format: skia_safe::gpu::gl::Format::RGBA8.into(), + ..Default::default() + } + }; + + let num_samples = gl_config.num_samples() as usize; + let stencil_bits = gl_config.stencil_size() as usize; + + let backend = backend_render_targets::make_gl( + (w as i32, h as i32), + num_samples, + stencil_bits, + fb_info, + ); + let skia_surface = wrap_backend_render_target( + &mut gr_context, + &backend, + gpu::SurfaceOrigin::BottomLeft, + ColorType::RGBA8888, + None, + None, + ) + .expect("Skia surface"); + + let gl_skia = GlSkiaSurface { + gr_context, + fb_info, + surface: skia_surface, + gl_surface, + gl_context, + num_samples, + stencil_bits, + }; + + let mut editor = TextEditor::new(TextEditorConfig { + empty_line_policy: self.config.empty_line_policy, + }); + editor.set_layout_width(w as f32); + editor.set_layout_height(h as f32); + editor.state.text = concat!( + "Hello, World!\n", + "Type here to edit text.\n", + "\n", + "=== Controls ===\n", + "← → ↑ ↓ move cursor Shift+arrow extends selection\n", + "Cmd+← / → line start/end Cmd+↑ / ↓ document start/end\n", + "Option+← / → word jump Ctrl+← / → word jump (Win/Linux)\n", + "Home / End line start/end PageUp / PageDown move by ~visible lines\n", + "Double-click select word Mouse drag select range\n", + "Cmd+A select all Cmd+C / X / V clipboard\n", + "\n", + "=== Writing Systems / Shaping / Selection Tests ===\n", + "\n", + "[Latin + punctuation]\n", + "The quick brown fox jumps over 13 lazy dogs. (quotes) \u{201C}like this\u{201D} and \u{2018}this\u{2019}.\n", + "Hyphens: state-of-the-art \u{2014} em dash \u{2014} en dash \u{2013} minus \u{2212} ellipsis \u{2026}\n", + "\n", + "[Accents / combining marks]\n", + "precomposed: caf\u{00E9}, na\u{00EF}ve, co\u{00F6}perate\n", + "combining: cafe\u{0301} (e + U+0301) a\u{0308} (a + U+0308)\n", + "edge: Z\u{0351}\u{0327}\u{0301} (stacked combining marks)\n", + "\n", + "[Hangul]\n", + "Korean: \u{C548}\u{B155}\u{D558}\u{C138}\u{C694} (precomposed syllables)\n", + "Jamo: \u{3147}\u{314F}\u{3134}\u{3134}\u{3155}\u{3147}\u{314E}\u{314F}\u{3145}\u{3154}\u{3147}\u{3155} (decomposed jamo sequence)\n", + "Mix: ABC\u{AC00}\u{B098}\u{B2E4}123 (Latin + Hangul + digits)\n", + "\n", + "[Japanese]\n", + "\u{65E5}\u{672C}\u{8A9E}: \u{3053}\u{3093}\u{306B}\u{3061}\u{306F}\u{4E16}\u{754C} / \u{30AB}\u{30BF}\u{30AB}\u{30CA}: \u{30C6}\u{30B9}\u{30C8} / \u{3072}\u{3089}\u{304C}\u{306A}: \u{3066}\u{3059}\u{3068}\n", + "\n", + "[Chinese]\n", + "\u{4E2D}\u{6587}: \u{4F60}\u{597D}\u{FF0C}\u{4E16}\u{754C}\u{3002}\u{7E41}\u{9AD4}\u{5B57}\u{FF1A}\u{7E41}\u{9AD4}\u{4E2D}\u{6587}\u{3002}\n", + "\n", + "[Arabic (RTL) + mixing]\n", + "\u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064A}\u{0629}: \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} \u{0628}\u{0627}\u{0644}\u{0639}\u{0627}\u{0644}\u{0645}\n", + "mix RTL/LTR: ABC \u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064A}\u{0629} 123 DEF\n", + "numbers: \u{0661}\u{0662}\u{0663}\u{0664}\u{0665} vs 12345\n", + "\n", + "[Hebrew (RTL) + mixing]\n", + "\u{05E2}\u{05D1}\u{05E8}\u{05D9}\u{05EA}: \u{05E9}\u{05DC}\u{05D5}\u{05DD} \u{05E2}\u{05D5}\u{05DC}\u{05DD}\n", + "mix RTL/LTR: ABC \u{05E9}\u{05DC}\u{05D5}\u{05DD} 123 DEF\n", + "\n", + "[Devanagari (conjuncts / reordering)]\n", + "\u{0939}\u{093F}\u{0928}\u{094D}\u{0926}\u{0940}: \u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947} \u{0926}\u{0941}\u{0928}\u{093F}\u{092F}\u{093E} / conjunct-ish: \u{0915}\u{094D}\u{0937}, \u{0924}\u{094D}\u{0930}, \u{091C}\u{094D}\u{091E}\n", + "\n", + "[Thai (no spaces between words)]\n", + "\u{0E44}\u{0E17}\u{0E22}: \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}\u{0E14}\u{0E35}\u{0E42}\u{0E25}\u{0E01} (word boundaries can be tricky)\n", + "\n", + "[Emoji / ZWJ sequences / skin tones]\n", + "emoji: \u{1F600} \u{1F601} \u{1F602} \u{1F605} \u{1F607}\n", + "ZWJ family: \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466} couple: \u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F469}\n", + "professions: \u{1F9D1}\u{200D}\u{1F4BB} \u{1F469}\u{200D}\u{1F52C} \u{1F468}\u{200D}\u{1F373}\n", + "skin tones: \u{1F44D} \u{1F44D}\u{1F3FB} \u{1F44D}\u{1F3FD} \u{1F44D}\u{1F3FF}\n", + "flags: \u{1F1F0}\u{1F1F7} \u{1F1FA}\u{1F1F8} \u{1F1EF}\u{1F1F5} \u{1F1EB}\u{1F1F7}\n", + "\n", + "[Ligature hint (font-dependent)]\n", + "fi fl ffi ffl (ligatures may appear depending on font)\n", + "\n", + "[Whitespace / tabs]\n", + "spaces: A B C D\n", + "tabs: A\tB\tC\tD\n", + ) + .to_string(); + editor.state.cursor = editor.state.text.len(); + + window.set_ime_allowed(true); + window.set_ime_cursor_area( + LogicalPosition::new(PADDING as f64, PADDING as f64), + LogicalSize::new(1.0f64, FONT_SIZE as f64), + ); + + window.request_redraw(); + + self.inner = Some(AppInner { window, gl_skia, editor }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + let next_blink = self + .inner + .as_ref() + .map(|i| i.editor.next_blink_deadline()) + .unwrap_or_else(|| Instant::now() + BLINK_INTERVAL); + event_loop.set_control_flow(ControlFlow::WaitUntil(next_blink)); + + let Some(inner) = self.inner.as_mut() else { return; }; + + match event { + WindowEvent::CloseRequested => { + event_loop.exit(); + } + WindowEvent::Resized(PhysicalSize { width, height }) => { + let w = width.max(1); + let h = height.max(1); + inner.gl_skia.gl_surface.resize( + &inner.gl_skia.gl_context, + NonZeroU32::new(w).unwrap(), + NonZeroU32::new(h).unwrap(), + ); + inner.gl_skia.recreate_skia_surface(w as i32, h as i32); + inner.editor.set_layout_width(w as f32); + inner.editor.set_layout_height(h as f32); + inner.window.request_redraw(); + } + + WindowEvent::ModifiersChanged(m) => { + self.modifiers = m.state(); + } + + WindowEvent::Ime(Ime::Preedit(text, _cursor_range)) => { + inner.editor.update_preedit(text); + inner.window.request_redraw(); + } + WindowEvent::Ime(Ime::Commit(s)) => { + inner.editor.cancel_preedit(); + inner.editor.insert_text(&s); + inner.window.request_redraw(); + } + WindowEvent::Ime(Ime::Enabled) => { + inner.editor.cancel_preedit(); + } + WindowEvent::Ime(Ime::Disabled) => { + inner.editor.cancel_preedit(); + inner.window.request_redraw(); + } + + WindowEvent::KeyboardInput { event: ke, .. } + if ke.state == ElementState::Pressed => + { + let meta = self.modifiers.super_key(); + let alt = self.modifiers.alt_key(); + let ctrl = self.modifiers.control_key(); + let shift = self.modifiers.shift_key(); + let cmd = meta || ctrl; + let word = alt || ctrl; + + match &ke.logical_key { + Key::Named(NamedKey::ArrowLeft) => { + if meta { + inner.editor.move_home(shift); + } else if word { + inner.editor.move_word_left(shift); + } else { + inner.editor.move_left(shift); + } + inner.window.request_redraw(); + } + Key::Named(NamedKey::ArrowRight) => { + if meta { + inner.editor.move_end(shift); + } else if word { + inner.editor.move_word_right(shift); + } else { + inner.editor.move_right(shift); + } + inner.window.request_redraw(); + } + Key::Named(NamedKey::ArrowUp) => { + if meta { + inner.editor.move_doc_start(shift); + } else { + inner.editor.move_up(shift); + } + inner.window.request_redraw(); + } + Key::Named(NamedKey::ArrowDown) => { + if meta { + inner.editor.move_doc_end(shift); + } else { + inner.editor.move_down(shift); + } + inner.window.request_redraw(); + } + Key::Named(NamedKey::Home) => { + inner.editor.move_home(shift); + inner.window.request_redraw(); + } + Key::Named(NamedKey::End) => { + inner.editor.move_end(shift); + inner.window.request_redraw(); + } + Key::Named(NamedKey::PageUp) => { + inner.editor.move_page_up(shift); + inner.window.request_redraw(); + } + Key::Named(NamedKey::PageDown) => { + inner.editor.move_page_down(shift); + inner.window.request_redraw(); + } + + Key::Named(NamedKey::Backspace) => { + inner.editor.backspace(); + inner.window.request_redraw(); + } + Key::Named(NamedKey::Delete) => { + inner.editor.delete_forward(); + inner.window.request_redraw(); + } + Key::Named(NamedKey::Enter) => { + inner.editor.insert_text("\n"); + inner.window.request_redraw(); + } + + Key::Character(c) if cmd => { + match c.to_lowercase().as_str() { + "a" => { + inner.editor.select_all(); + inner.window.request_redraw(); + } + "c" => { + if let Some(sel) = inner.editor.selected_text() { + let _ = self.clipboard.set_text(sel.to_string()); + } + } + "x" => { + if let Some(sel) = inner.editor.selected_text() { + let _ = self.clipboard.set_text(sel.to_string()); + } + if inner.editor.has_selection() { + inner.editor.apply(EditingCommand::Delete); + inner.window.request_redraw(); + } + } + "v" => { + if let Ok(text) = self.clipboard.get_text() { + inner.editor.insert_text(&text); + inner.window.request_redraw(); + } + } + _ => {} + } + } + + Key::Character(c) + if !cmd && inner.editor.preedit.is_none() => + { + inner.editor.insert_text(c.as_str()); + inner.window.request_redraw(); + } + + Key::Named(NamedKey::Space) => { + inner.editor.insert_text(" "); + inner.window.request_redraw(); + } + Key::Named(NamedKey::Tab) => { + inner.editor.insert_text(" "); + inner.window.request_redraw(); + } + + _ => {} + } + } + + WindowEvent::CursorMoved { position, .. } => { + let x = position.x as f32; + let y = position.y as f32; + self.last_mouse_pos = (x, y); + inner.editor.on_mouse_move(x - PADDING, y - PADDING); + if inner.editor.mouse_down { + inner.window.request_redraw(); + } + } + + WindowEvent::MouseInput { + state, + button: MouseButton::Left, + .. + } => match state { + ElementState::Pressed => { + let (x, y) = self.last_mouse_pos; + let local_x = x - PADDING; + let local_y = y - PADDING; + let shift = self.modifiers.shift_key(); + + if shift { + inner.editor.shift_click(local_x, local_y); + inner.window.request_redraw(); + } else { + let now = Instant::now(); + let in_sequence = self + .last_click_time + .map(|t| { + let (px, py) = self.last_click_pos; + now.duration_since(t) < Duration::from_millis(400) + && (px - x).abs() < 5.0 + && (py - y).abs() < 5.0 + }) + .unwrap_or(false); + + self.click_count = + if in_sequence { (self.click_count + 1).min(4) } else { 1 }; + self.last_click_time = Some(now); + self.last_click_pos = (x, y); + + match self.click_count { + 1 => inner.editor.on_mouse_down(local_x, local_y), + 2 => inner.editor.select_word_at(local_x, local_y), + 3 => inner.editor.select_line_at(local_x, local_y), + _ => inner.editor.select_all(), + } + inner.window.request_redraw(); + } + } + ElementState::Released => { + inner.editor.on_mouse_up(); + } + }, + + WindowEvent::RedrawRequested => { + inner.editor.tick_blink(); + { + let canvas = inner.gl_skia.surface.canvas(); + inner.editor.draw(canvas); + } + inner.gl_skia.flush_and_present(); + + let cx = inner.editor.cursor_x() + PADDING; + let cy = inner.editor.cursor_baseline_y() - FONT_SIZE + PADDING; + inner.window.set_ime_cursor_area( + LogicalPosition::new(cx as f64, cy as f64), + LogicalSize::new(1.0f64, FONT_SIZE as f64), + ); + + let deadline = inner.editor.next_blink_deadline(); + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); + } + + _ => {} + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(inner) = self.inner.as_mut() else { return; }; + if inner.editor.tick_blink() { + inner.window.request_redraw(); + } + let deadline = inner.editor.next_blink_deadline(); + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +fn main() { + let policy = std::env::args() + .find(|a| a.starts_with("--rect-mode=")) + .map(|a| match a.strip_prefix("--rect-mode=").unwrap() { + "none" => EmptyLineSelectionPolicy::None, + "tight" => EmptyLineSelectionPolicy::GlyphRect, + "linebox" => EmptyLineSelectionPolicy::LineBox, + other => { + eprintln!("unknown --rect-mode={other}, using 'tight'"); + EmptyLineSelectionPolicy::GlyphRect + } + }) + .unwrap_or(EmptyLineSelectionPolicy::GlyphRect); + + let config = TextEditorConfig { empty_line_policy: policy }; + let el = EventLoop::new().expect("event loop"); + let mut app = TextEditorApp::new(config); + el.run_app(&mut app).expect("run_app"); +} diff --git a/crates/grida-dev/src/lib.rs b/crates/grida-dev/src/lib.rs index 3be444786a..07ce4b81b0 100644 --- a/crates/grida-dev/src/lib.rs +++ b/crates/grida-dev/src/lib.rs @@ -1 +1,2 @@ pub mod platform; +pub mod text_edit; diff --git a/crates/grida-dev/src/text_edit/layout.rs b/crates/grida-dev/src/text_edit/layout.rs new file mode 100644 index 0000000000..0f754da26f --- /dev/null +++ b/crates/grida-dev/src/text_edit/layout.rs @@ -0,0 +1,80 @@ +/// Layout-agnostic line metrics. +/// +/// All offsets are **UTF-8 byte offsets** into the text string. +/// `end_index` is exclusive and includes the trailing `\n` when one exists. +#[derive(Clone, Debug, PartialEq)] +pub struct LineMetrics { + /// UTF-8 byte offset of the first character in this visual line. + pub start_index: usize, + /// UTF-8 byte offset one past the last character (includes the `\n` terminator, if any). + pub end_index: usize, + /// Y coordinate of the text baseline, in layout-local space. + pub baseline: f32, + /// Distance above baseline to the top of the line. + pub ascent: f32, + /// Distance below baseline to the bottom of the line. + pub descent: f32, +} + +impl LineMetrics { + /// Returns `true` when the line contains no glyph content (only a newline terminator). + pub fn is_empty_line(&self) -> bool { + self.end_index.saturating_sub(self.start_index) <= 1 + } + + /// Top of this line's band (layout-local y). + pub fn top(&self) -> f32 { + self.baseline - self.ascent + } + + /// Bottom of this line's band (layout-local y). + pub fn bottom(&self) -> f32 { + self.baseline + self.descent + } +} + +/// Abstract geometry provider. +/// +/// Implementations include: +/// - `SimpleLayoutEngine` – monospace, no wrapping; used for deterministic tests. +/// - `SkiaLayoutEngine` – real shaping via Skia Paragraph (in `wd_text_editor`). +/// +/// All offsets exchanged through this trait are **UTF-8 byte offsets**. +pub trait TextLayoutEngine { + /// Compute visual line metrics for the given text. + fn line_metrics(&mut self, text: &str) -> Vec; + + /// Map a layout-local point `(x, y)` to the nearest valid cursor position + /// (UTF-8 byte offset, on a grapheme boundary). + /// + /// Implementations MUST handle empty lines correctly: a point that falls + /// within an empty line's y-band should return the start of that line. + fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize; + + /// Return the x coordinate (layout-local) of the caret at `offset`. + fn caret_x_at(&mut self, text: &str, offset: usize) -> f32; + + /// Return `(word_start, word_end)` for the word that contains `offset`. + /// Both bounds are UTF-8 byte offsets. + fn word_boundary_at(&mut self, text: &str, offset: usize) -> (usize, usize); + + /// Height of the visible viewport, used for PageUp / PageDown. + fn viewport_height(&self) -> f32; +} + +// --------------------------------------------------------------------------- +// Utility: find the line index for a UTF-8 offset +// --------------------------------------------------------------------------- + +/// Find which line index contains `utf8_offset`. +/// +/// Scans from the last line backwards: `start_index <= offset` correctly maps +/// a cursor at the start of line N to line N (not N-1). +pub fn line_index_for_offset(metrics: &[LineMetrics], utf8_offset: usize) -> usize { + for (i, lm) in metrics.iter().enumerate().rev() { + if lm.start_index <= utf8_offset { + return i; + } + } + 0 +} diff --git a/crates/grida-dev/src/text_edit/mod.rs b/crates/grida-dev/src/text_edit/mod.rs new file mode 100644 index 0000000000..8cd1d4df57 --- /dev/null +++ b/crates/grida-dev/src/text_edit/mod.rs @@ -0,0 +1,472 @@ +pub mod layout; +pub mod simple_layout; + +pub use layout::{line_index_for_offset, LineMetrics, TextLayoutEngine}; +pub use simple_layout::SimpleLayoutEngine; + +use unicode_segmentation::UnicodeSegmentation; + +// --------------------------------------------------------------------------- +// UTF-8 ↔ UTF-16 helpers (pub so SkiaLayoutEngine in the example can reuse) +// --------------------------------------------------------------------------- + +pub fn utf8_to_utf16_offset(text: &str, utf8: usize) -> usize { + text[..utf8.min(text.len())].encode_utf16().count() +} + +pub fn utf16_to_utf8_offset(text: &str, utf16: usize) -> usize { + let mut count = 0usize; + for (byte_idx, ch) in text.char_indices() { + if count >= utf16 { + return byte_idx; + } + count += ch.len_utf16(); + } + text.len() +} + +/// Normalize CRLF (`\r\n`) and lone CR (`\r`) to LF (`\n`). +pub fn normalize_newlines(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n") +} + +// --------------------------------------------------------------------------- +// Grapheme boundary helpers +// --------------------------------------------------------------------------- + +pub fn prev_grapheme_boundary(text: &str, pos: usize) -> usize { + if pos == 0 { + return 0; + } + let mut prev = 0; + for (i, g) in text.grapheme_indices(true) { + let end = i + g.len(); + if end >= pos { + return prev; + } + prev = end; + } + prev +} + +/// Snap `pos` to the **start of its grapheme cluster**. +/// +/// Unlike `prev_grapheme_boundary`, this returns `pos` unchanged when `pos` +/// is already exactly at a grapheme cluster start — it does NOT step back to +/// the previous boundary. Use this wherever the goal is "ensure the offset +/// is a valid cursor stop" rather than "find the grapheme before the cursor". +pub fn snap_grapheme_boundary(text: &str, pos: usize) -> usize { + let pos = pos.min(text.len()); + if pos == 0 { + return 0; + } + for (i, g) in text.grapheme_indices(true) { + let end = i + g.len(); + if i <= pos && pos < end { + return i; // pos is inside this cluster — snap to its start + } + if i > pos { + // overshot without a match (should not happen for well-formed UTF-8) + return i; + } + } + text.len() +} + +pub fn next_grapheme_boundary(text: &str, pos: usize) -> usize { + for (i, g) in text.grapheme_indices(true) { + if i >= pos { + return i + g.len(); + } + } + text.len() +} + +// --------------------------------------------------------------------------- +// Core state +// --------------------------------------------------------------------------- + +/// Pure editing state: text buffer, cursor, and optional selection anchor. +/// +/// All offsets are UTF-8 byte offsets on grapheme cluster boundaries. +/// When `anchor == None` or `anchor == Some(cursor)`, there is no selection +/// (caret mode). The selected range is always `[min, max)`. +#[derive(Clone, Debug, PartialEq)] +pub struct TextEditorState { + pub text: String, + /// Caret position (UTF-8 byte offset). + pub cursor: usize, + /// Selection anchor. `None` is equivalent to `Some(cursor)` (no selection). + pub anchor: Option, +} + +impl TextEditorState { + pub fn new(text: impl Into) -> Self { + let text = text.into(); + let cursor = text.len(); + Self { text, cursor, anchor: None } + } + + pub fn with_cursor(text: impl Into, cursor: usize) -> Self { + Self { text: text.into(), cursor, anchor: None } + } + + pub fn has_selection(&self) -> bool { + self.anchor.map_or(false, |a| a != self.cursor) + } + + pub fn selection_range(&self) -> Option<(usize, usize)> { + self.anchor.map(|a| { + let lo = a.min(self.cursor); + let hi = a.max(self.cursor); + (lo, hi) + }) + } + + pub fn selected_text(&self) -> Option<&str> { + self.selection_range().map(|(lo, hi)| &self.text[lo..hi]) + } +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Editing commands understood by [`apply_command`]. +/// +/// Commands that require geometry (move_up, move_down, etc.) call through the +/// provided `TextLayoutEngine`; pure commands (insert, backspace, move_left, +/// etc.) do not touch the layout engine at all. +#[derive(Clone, Debug)] +pub enum EditingCommand { + // --- pure (no layout needed) --- + Insert(String), + Backspace, + Delete, + MoveLeft { extend: bool }, + MoveRight { extend: bool }, + MoveDocStart { extend: bool }, + MoveDocEnd { extend: bool }, + SelectAll, + /// Directly set cursor (and optionally anchor). Used when the caller + /// has already computed the desired offset (e.g. from drag state). + SetCursorPos { pos: usize, anchor: Option }, + + // --- need layout --- + MoveUp { extend: bool }, + MoveDown { extend: bool }, + MoveHome { extend: bool }, + MoveEnd { extend: bool }, + MovePageUp { extend: bool }, + MovePageDown { extend: bool }, + MoveWordLeft { extend: bool }, + MoveWordRight { extend: bool }, + + // --- point-based (need layout.position_at_point) --- + /// Place caret at the text position nearest to (x, y). + MoveTo { x: f32, y: f32 }, + /// Extend the current selection focus to (x, y) keeping the anchor fixed. + ExtendTo { x: f32, y: f32 }, + /// Select the word at (x, y). + SelectWordAt { x: f32, y: f32 }, + /// Select the visual line at (x, y). + SelectLineAt { x: f32, y: f32 }, +} + +// --------------------------------------------------------------------------- +// apply_command +// --------------------------------------------------------------------------- + +/// Apply a single editing command to `state`, returning the new state. +/// +/// Layout-dependent commands call into `layout`; pure commands do not. +/// The function is intentionally pure in the non-layout path (no side effects). +pub fn apply_command( + state: &TextEditorState, + command: EditingCommand, + layout: &mut dyn TextLayoutEngine, +) -> TextEditorState { + let mut s = state.clone(); + + match command { + EditingCommand::Insert(text) => { + let pos = delete_selection_in_place(&mut s); + let normalized = normalize_newlines(&text); + s.text.insert_str(pos, &normalized); + s.cursor = pos + normalized.len(); + s.anchor = None; + } + + EditingCommand::Backspace => { + if s.has_selection() { + s.cursor = delete_selection_in_place(&mut s); + } else if s.cursor > 0 { + let prev = prev_grapheme_boundary(&s.text, s.cursor); + s.text.drain(prev..s.cursor); + s.cursor = prev; + } + s.anchor = None; + } + + EditingCommand::Delete => { + if s.has_selection() { + s.cursor = delete_selection_in_place(&mut s); + } else if s.cursor < s.text.len() { + let next = next_grapheme_boundary(&s.text, s.cursor); + s.text.drain(s.cursor..next); + } + s.anchor = None; + } + + EditingCommand::MoveLeft { extend } => { + if !extend && s.has_selection() { + if let Some((lo, _)) = s.selection_range() { + s.cursor = lo; + s.anchor = None; + return s; + } + } + set_anchor_if_extending(&mut s, extend); + s.cursor = prev_grapheme_boundary(&s.text, s.cursor); + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveRight { extend } => { + if !extend && s.has_selection() { + if let Some((_, hi)) = s.selection_range() { + s.cursor = hi; + s.anchor = None; + return s; + } + } + set_anchor_if_extending(&mut s, extend); + s.cursor = next_grapheme_boundary(&s.text, s.cursor); + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveDocStart { extend } => { + set_anchor_if_extending(&mut s, extend); + s.cursor = 0; + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveDocEnd { extend } => { + set_anchor_if_extending(&mut s, extend); + s.cursor = s.text.len(); + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::SelectAll => { + s.anchor = Some(0); + s.cursor = s.text.len(); + } + + EditingCommand::SetCursorPos { pos, anchor } => { + s.cursor = pos.min(s.text.len()); + s.anchor = anchor; + } + + EditingCommand::MoveUp { extend } => { + set_anchor_if_extending(&mut s, extend); + let x = layout.caret_x_at(&s.text, s.cursor); + let metrics = layout.line_metrics(&s.text); + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + if line_idx > 0 { + let prev = &metrics[line_idx - 1]; + let target_y = prev.baseline - prev.ascent * 0.5; + s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); + } else { + s.cursor = 0; + } + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveDown { extend } => { + set_anchor_if_extending(&mut s, extend); + let x = layout.caret_x_at(&s.text, s.cursor); + let metrics = layout.line_metrics(&s.text); + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + if line_idx + 1 < metrics.len() { + let next = &metrics[line_idx + 1]; + let target_y = next.baseline - next.ascent * 0.5; + // Delegate to position_at_point; implementations handle empty lines. + s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); + } else { + s.cursor = s.text.len(); + } + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveHome { extend } => { + set_anchor_if_extending(&mut s, extend); + let metrics = layout.line_metrics(&s.text); + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + s.cursor = metrics[line_idx].start_index; + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveEnd { extend } => { + set_anchor_if_extending(&mut s, extend); + let metrics = layout.line_metrics(&s.text); + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let lm = &metrics[line_idx]; + let mut end = lm.end_index.min(s.text.len()); + // Step back over trailing newline so caret sits before it visually. + if end > 0 && s.text[..end].ends_with('\n') { + end = prev_grapheme_boundary(&s.text, end); + } + s.cursor = end; + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MovePageUp { extend } => { + set_anchor_if_extending(&mut s, extend); + let metrics = layout.line_metrics(&s.text); + if metrics.is_empty() { + s.cursor = 0; + } else { + let line_height = + (metrics[0].ascent + metrics[0].descent).max(1.0); + let visible_lines = + (layout.viewport_height() / line_height).floor().max(1.0) as usize; + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let steps = visible_lines.min(line_idx); + if steps == 0 { + s.cursor = 0; + } else { + let target_line = line_idx - steps; + let target_y = metrics[target_line].baseline + - metrics[target_line].ascent * 0.5; + let x = layout.caret_x_at(&s.text, s.cursor); + s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); + } + } + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MovePageDown { extend } => { + set_anchor_if_extending(&mut s, extend); + let metrics = layout.line_metrics(&s.text); + if metrics.is_empty() { + s.cursor = s.text.len(); + } else { + let line_height = + (metrics[0].ascent + metrics[0].descent).max(1.0); + let visible_lines = + (layout.viewport_height() / line_height).floor().max(1.0) as usize; + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let remaining = metrics.len().saturating_sub(1).saturating_sub(line_idx); + let steps = visible_lines.min(remaining); + if steps == 0 { + s.cursor = s.text.len(); + } else { + let target_line = line_idx + steps; + let target_y = metrics[target_line].baseline + - metrics[target_line].ascent * 0.5; + let x = layout.caret_x_at(&s.text, s.cursor); + s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); + } + } + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveWordLeft { extend } => { + set_anchor_if_extending(&mut s, extend); + let (start, _) = layout.word_boundary_at(&s.text, s.cursor); + if start < s.cursor { + s.cursor = start; + } else if s.cursor > 0 { + let (start2, _) = layout.word_boundary_at(&s.text, s.cursor - 1); + s.cursor = start2; + } + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveWordRight { extend } => { + set_anchor_if_extending(&mut s, extend); + let (_, end) = layout.word_boundary_at(&s.text, s.cursor); + if end > s.cursor { + s.cursor = end; + } else if s.cursor < s.text.len() { + let (_, end2) = layout.word_boundary_at(&s.text, s.cursor + 1); + s.cursor = end2; + } + clear_anchor_if_not_extending(&mut s, extend); + } + + EditingCommand::MoveTo { x, y } => { + let pos = layout.position_at_point(&s.text, x, y); + s.cursor = pos; + s.anchor = None; + } + + EditingCommand::ExtendTo { x, y } => { + if s.anchor.is_none() { + s.anchor = Some(s.cursor); + } + s.cursor = layout.position_at_point(&s.text, x, y); + } + + EditingCommand::SelectWordAt { x, y } => { + let pos = layout.position_at_point(&s.text, x, y); + let (start, end) = layout.word_boundary_at(&s.text, pos); + s.anchor = Some(start); + s.cursor = end; + } + + EditingCommand::SelectLineAt { x, y } => { + let pos = layout.position_at_point(&s.text, x, y); + let metrics = layout.line_metrics(&s.text); + if metrics.is_empty() { + s.anchor = Some(0); + s.cursor = s.text.len(); + } else { + let idx = line_index_for_offset_utf8(&metrics, pos); + let lm = &metrics[idx]; + s.anchor = Some(lm.start_index); + s.cursor = lm.end_index.min(s.text.len()); + } + } + } + + s +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +fn delete_selection_in_place(s: &mut TextEditorState) -> usize { + if let Some((lo, hi)) = s.selection_range() { + s.text.drain(lo..hi); + s.anchor = None; + lo + } else { + s.cursor + } +} + +fn set_anchor_if_extending(s: &mut TextEditorState, extend: bool) { + if extend && s.anchor.is_none() { + s.anchor = Some(s.cursor); + } +} + +fn clear_anchor_if_not_extending(s: &mut TextEditorState, extend: bool) { + if !extend { + s.anchor = None; + } +} + +/// line_index_for_offset using UTF-8 metrics (mirrors the Skia-agnostic version). +pub fn line_index_for_offset_utf8(metrics: &[LineMetrics], utf8_offset: usize) -> usize { + for (i, lm) in metrics.iter().enumerate().rev() { + if lm.start_index <= utf8_offset { + return i; + } + } + 0 +} + +#[cfg(test)] +mod tests; diff --git a/crates/grida-dev/src/text_edit/simple_layout.rs b/crates/grida-dev/src/text_edit/simple_layout.rs new file mode 100644 index 0000000000..0ded2f44b0 --- /dev/null +++ b/crates/grida-dev/src/text_edit/simple_layout.rs @@ -0,0 +1,157 @@ +//! `SimpleLayoutEngine` — layout-agnostic engine for deterministic tests. +//! +//! Assumptions: +//! - Each `\n` in the text produces exactly one visual line (no soft wrapping). +//! - All characters have equal width (`char_width`). +//! - Line height is fixed (`line_height = ascent + descent`). +//! +//! These assumptions make geometry trivially computable from the text alone, +//! without any font/shaping dependency. The engine is intentionally simple +//! and wrong for real rendering — its only purpose is to produce deterministic, +//! inspectable results for unit tests. + +use super::{ + layout::{LineMetrics, TextLayoutEngine}, + line_index_for_offset_utf8, +}; + +use unicode_segmentation::UnicodeSegmentation; + +pub struct SimpleLayoutEngine { + /// Height of the visible viewport (for PageUp / PageDown). + pub viewport_height: f32, + /// Fixed height of each line. + pub line_height: f32, + /// Fixed width of each character (monospace assumption). + pub char_width: f32, +} + +impl SimpleLayoutEngine { + pub fn new(viewport_height: f32, line_height: f32, char_width: f32) -> Self { + Self { viewport_height, line_height, char_width } + } + + /// Compute line metrics from text by splitting at `\n`. + /// Returns UTF-8 byte offsets. Does NOT emit a phantom line after a + /// trailing `\n` (mirrors Skia behavior). + fn compute_metrics(&self, text: &str) -> Vec { + let mut metrics = Vec::new(); + let mut start = 0usize; + let mut line_num = 0usize; + + while start <= text.len() { + // Find next \n + let next_nl = text[start..].find('\n').map(|i| start + i); + + let (end_excl, has_nl) = match next_nl { + Some(nl_pos) => (nl_pos + 1, true), // end_index includes the \n + None => (text.len(), false), + }; + + let baseline = (line_num as f32 + 1.0) * self.line_height - self.line_height * 0.2; + let ascent = self.line_height * 0.8; + let descent = self.line_height * 0.2; + + metrics.push(LineMetrics { + start_index: start, + end_index: end_excl, + baseline, + ascent, + descent, + }); + + if !has_nl { + break; + } + + start = end_excl; + line_num += 1; + } + + metrics + } + + /// The inclusive end of displayable content on a line (before any trailing `\n`). + fn content_end(lm: &LineMetrics, text: &str) -> usize { + if lm.end_index > 0 + && lm.end_index <= text.len() + && text.as_bytes().get(lm.end_index - 1) == Some(&b'\n') + { + lm.end_index - 1 + } else { + lm.end_index + } + } +} + +impl TextLayoutEngine for SimpleLayoutEngine { + fn line_metrics(&mut self, text: &str) -> Vec { + self.compute_metrics(text) + } + + fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { + let metrics = self.compute_metrics(text); + if metrics.is_empty() { + return 0; + } + // Map y → line index + let line_idx = ((y / self.line_height).floor() as usize).min(metrics.len() - 1); + let lm = &metrics[line_idx]; + + if lm.is_empty_line() { + // Empty line: place cursor at start. + return lm.start_index; + } + + // Map x → column (character index within the line). + // Do NOT apply prev_grapheme_boundary here: column-based offsets are + // already on character boundaries and the function would shift them back + // one position when pos happens to equal a grapheme-end. + let content_end = Self::content_end(lm, text); + let line_char_len = content_end - lm.start_index; + let column = ((x / self.char_width).round() as usize).min(line_char_len); + (lm.start_index + column).min(text.len()) + } + + fn caret_x_at(&mut self, text: &str, offset: usize) -> f32 { + // Cursor right after \n → x = 0 on new line. + if offset > 0 && text[..offset].ends_with('\n') { + return 0.0; + } + let metrics = self.compute_metrics(text); + if metrics.is_empty() { + return 0.0; + } + let line_idx = line_index_for_offset_utf8(&metrics, offset); + let lm = &metrics[line_idx]; + let column = offset - lm.start_index; + column as f32 * self.char_width + } + + fn word_boundary_at(&mut self, text: &str, offset: usize) -> (usize, usize) { + let offset = offset.min(text.len()); + // Find the word segment that contains `offset`. + let mut start = 0usize; + for (byte_idx, segment) in text.split_word_bound_indices() { + let end = byte_idx + segment.len(); + if byte_idx <= offset && offset < end { + return (byte_idx, end); + } + start = byte_idx; + } + // Fallback: return (start_of_last_segment, text.len()). + (start, text.len()) + } + + fn viewport_height(&self) -> f32 { + self.viewport_height + } +} + +/// Helper: test-friendly constructor with typical values (18px font, 8px char). +impl SimpleLayoutEngine { + pub fn default_test() -> Self { + Self::new(600.0, 24.0, 10.0) + } +} + diff --git a/crates/grida-dev/src/text_edit/tests.rs b/crates/grida-dev/src/text_edit/tests.rs new file mode 100644 index 0000000000..1b94520e91 --- /dev/null +++ b/crates/grida-dev/src/text_edit/tests.rs @@ -0,0 +1,947 @@ +//! Deterministic editing-logic tests. +//! +//! All tests use `SimpleLayoutEngine` — no Skia, no winit. The layout is +//! monospace / no-wrap, so every assertion is exact. + +use super::{apply_command, layout::TextLayoutEngine, snap_grapheme_boundary, EditingCommand, SimpleLayoutEngine, TextEditorState}; + +fn layout() -> SimpleLayoutEngine { + SimpleLayoutEngine::default_test() +} + +/// Shorthand: apply one command and return the new state. +fn apply(state: &TextEditorState, cmd: EditingCommand) -> TextEditorState { + apply_command(state, cmd, &mut layout()) +} + +// --------------------------------------------------------------------------- +// Text insertion / deletion +// --------------------------------------------------------------------------- + +#[test] +fn insert_at_end() { + let s = TextEditorState::new("Hello"); + let s = apply(&s, EditingCommand::Insert(" World".into())); + assert_eq!(s.text, "Hello World"); + assert_eq!(s.cursor, 11); +} + +#[test] +fn insert_replaces_selection() { + let mut s = TextEditorState::new("Hello World"); + s.cursor = 6; + s.anchor = Some(11); + let s = apply(&s, EditingCommand::Insert("Rust".into())); + assert_eq!(s.text, "Hello Rust"); + assert_eq!(s.cursor, 10); + assert!(!s.has_selection()); +} + +#[test] +fn insert_normalizes_crlf() { + let s = TextEditorState::new(""); + let s = apply(&s, EditingCommand::Insert("a\r\nb".into())); + assert_eq!(s.text, "a\nb"); +} + +#[test] +fn backspace_grapheme() { + let s = TextEditorState::with_cursor("Hello", 5); + let s = apply(&s, EditingCommand::Backspace); + assert_eq!(s.text, "Hell"); + assert_eq!(s.cursor, 4); +} + +#[test] +fn backspace_emoji_cluster() { + // 👍🏽 is a 2-codepoint ZWJ sequence (8 UTF-8 bytes). + let s = TextEditorState::with_cursor("a👍🏽b", 9); // after 👍🏽 + let s = apply(&s, EditingCommand::Backspace); + assert_eq!(s.text, "ab"); + assert_eq!(s.cursor, 1); +} + +#[test] +fn backspace_deletes_selection() { + let mut s = TextEditorState::new("Hello World"); + s.cursor = 11; + s.anchor = Some(6); + let s = apply(&s, EditingCommand::Backspace); + assert_eq!(s.text, "Hello "); + assert_eq!(s.cursor, 6); +} + +#[test] +fn delete_grapheme() { + let s = TextEditorState::with_cursor("Hello", 0); + let s = apply(&s, EditingCommand::Delete); + assert_eq!(s.text, "ello"); + assert_eq!(s.cursor, 0); +} + +// --------------------------------------------------------------------------- +// Cursor: left / right +// --------------------------------------------------------------------------- + +#[test] +fn move_left_collapses_selection_to_lo() { + let mut s = TextEditorState::new("Hello"); + s.cursor = 5; + s.anchor = Some(2); + // selection [2,5); MoveLeft without extend → collapse to lo + let s = apply(&s, EditingCommand::MoveLeft { extend: false }); + assert_eq!(s.cursor, 2); + assert!(!s.has_selection()); +} + +#[test] +fn move_right_collapses_selection_to_hi() { + let mut s = TextEditorState::new("Hello"); + s.cursor = 2; + s.anchor = Some(5); + let s = apply(&s, EditingCommand::MoveRight { extend: false }); + assert_eq!(s.cursor, 5); + assert!(!s.has_selection()); +} + +#[test] +fn move_left_extends_selection() { + let s = TextEditorState::with_cursor("Hello", 3); + let s = apply(&s, EditingCommand::MoveLeft { extend: true }); + assert_eq!(s.cursor, 2); + assert_eq!(s.anchor, Some(3)); +} + +// --------------------------------------------------------------------------- +// Cursor: home / end +// --------------------------------------------------------------------------- + +#[test] +fn move_home_goes_to_line_start() { + // "Hello\nWorld", cursor at 8 (middle of "World") + let s = TextEditorState::with_cursor("Hello\nWorld", 8); + let s = apply(&s, EditingCommand::MoveHome { extend: false }); + assert_eq!(s.cursor, 6); // start of "World" +} + +#[test] +fn move_end_goes_before_newline() { + // "Hello\nWorld", cursor at 0 + let s = TextEditorState::with_cursor("Hello\nWorld", 0); + let s = apply(&s, EditingCommand::MoveEnd { extend: false }); + // end of "Hello" line is before the \n at index 5 + assert_eq!(s.cursor, 5); +} + +// --------------------------------------------------------------------------- +// Cursor: up / down (line navigation) +// --------------------------------------------------------------------------- + +#[test] +fn move_down_to_nonempty_line() { + // "Hello\nWorld", cursor at 0 (start of "Hello") + let s = TextEditorState::with_cursor("Hello\nWorld", 0); + let s = apply(&s, EditingCommand::MoveDown { extend: false }); + // x=0, target line = "World" (start=6), position_at_point(x=0, line1_y) → offset 6 + assert_eq!(s.cursor, 6); +} + +#[test] +fn move_down_over_empty_line() { + // "Hello\n\nWorld", cursor at 5 (end of "Hello", before first \n) + let s = TextEditorState::with_cursor("Hello\n\nWorld", 5); + let s = apply(&s, EditingCommand::MoveDown { extend: false }); + // line 1 is empty ("\n" at index 6); SimpleLayoutEngine returns start_index = 6 + assert_eq!(s.cursor, 6, "cursor should land on empty line (index 6)"); +} + +#[test] +fn move_down_from_empty_line() { + // "Hello\n\nWorld", cursor at 6 (on the empty line) + let s = TextEditorState::with_cursor("Hello\n\nWorld", 6); + let s = apply(&s, EditingCommand::MoveDown { extend: false }); + // line 2 is "World" (start=7); cursor should land at start + assert_eq!(s.cursor, 7, "cursor should move to start of 'World'"); +} + +#[test] +fn move_up_over_empty_line() { + // "Hello\n\nWorld", cursor at 7 (start of "World") + let s = TextEditorState::with_cursor("Hello\n\nWorld", 7); + let s = apply(&s, EditingCommand::MoveUp { extend: false }); + // target line = empty line 1 (start=6) + assert_eq!(s.cursor, 6, "cursor should land on empty line"); +} + +#[test] +fn move_up_from_first_line_goes_to_zero() { + let s = TextEditorState::with_cursor("Hello\nWorld", 2); + let s = apply(&s, EditingCommand::MoveUp { extend: false }); + assert_eq!(s.cursor, 0); +} + +#[test] +fn move_down_from_last_line_goes_to_end() { + let s = TextEditorState::with_cursor("Hello\nWorld", 8); + let s = apply(&s, EditingCommand::MoveDown { extend: false }); + assert_eq!(s.cursor, 11); // text.len() +} + +// --------------------------------------------------------------------------- +// Cursor: word navigation +// --------------------------------------------------------------------------- + +#[test] +fn move_word_right() { + let s = TextEditorState::with_cursor("hello world", 0); + let s = apply(&s, EditingCommand::MoveWordRight { extend: false }); + // UAX#29: "hello" ends at 5 + assert_eq!(s.cursor, 5); +} + +#[test] +fn move_word_left() { + let s = TextEditorState::with_cursor("hello world", 11); + let s = apply(&s, EditingCommand::MoveWordLeft { extend: false }); + // "world" starts at 6 + assert_eq!(s.cursor, 6); +} + +// --------------------------------------------------------------------------- +// Select all +// --------------------------------------------------------------------------- + +#[test] +fn select_all() { + let s = TextEditorState::with_cursor("Hello", 2); + let s = apply(&s, EditingCommand::SelectAll); + assert_eq!(s.anchor, Some(0)); + assert_eq!(s.cursor, 5); +} + +// --------------------------------------------------------------------------- +// Point-based selection +// --------------------------------------------------------------------------- + +#[test] +fn move_to_point() { + // SimpleLayoutEngine: line_height=24, char_width=10 + // "Hello\nWorld": line 0 y=0..24, line 1 y=24..48 + // (x=30, y=30) → line 1, column 3 → offset = 6 + 3 = 9 ("l" in "World") + let s = TextEditorState::with_cursor("Hello\nWorld", 0); + let s = apply(&s, EditingCommand::MoveTo { x: 30.0, y: 30.0 }); + assert_eq!(s.cursor, 9); + assert!(!s.has_selection()); +} + +#[test] +fn select_word_at() { + // "Hello World", click at x=65 (column 6 = 'W' in "World") + let s = TextEditorState::with_cursor("Hello World", 0); + let s = apply(&s, EditingCommand::SelectWordAt { x: 65.0, y: 0.0 }); + // "World" runs from 6 to 11 + assert_eq!(s.anchor, Some(6)); + assert_eq!(s.cursor, 11); +} + +#[test] +fn select_line_at() { + // "Hello\nWorld", click on line 1 + let s = TextEditorState::with_cursor("Hello\nWorld", 0); + let s = apply(&s, EditingCommand::SelectLineAt { x: 0.0, y: 30.0 }); + assert_eq!(s.anchor, Some(6)); + assert_eq!(s.cursor, 11); +} + +// --------------------------------------------------------------------------- +// Extend-to +// --------------------------------------------------------------------------- + +#[test] +fn extend_to_sets_anchor_on_first_use() { + let s = TextEditorState::with_cursor("Hello", 2); + // ExtendTo from cursor=2 to click position at x=40 (col 4 = "o") + let s = apply(&s, EditingCommand::ExtendTo { x: 40.0, y: 0.0 }); + assert_eq!(s.anchor, Some(2), "anchor should be the original cursor"); + assert_eq!(s.cursor, 4); +} + +// --------------------------------------------------------------------------- +// snap_grapheme_boundary (the function SkiaLayoutEngine must use) +// --------------------------------------------------------------------------- + +#[test] +fn snap_stays_at_valid_boundary() { + // ASCII: every byte offset that is a char start should be returned as-is. + let t = "Hello\n\nWorld"; + assert_eq!(snap_grapheme_boundary(t, 0), 0); + assert_eq!(snap_grapheme_boundary(t, 5), 5, "start of first \\n"); + assert_eq!(snap_grapheme_boundary(t, 6), 6, "start of second \\n"); + assert_eq!(snap_grapheme_boundary(t, 7), 7, "start of 'W' — this is what SkiaLayoutEngine returns"); + assert_eq!(snap_grapheme_boundary(t, 12), 12, "text.len()"); +} + +#[test] +fn snap_mid_cluster_snaps_to_start() { + // 👋 is a 4-byte grapheme cluster. Middle bytes should snap to 0. + let t = "\u{1F44B}!"; // "👋!" + assert_eq!(snap_grapheme_boundary(t, 0), 0); + assert_eq!(snap_grapheme_boundary(t, 1), 0, "mid-cluster → snap to 0"); + assert_eq!(snap_grapheme_boundary(t, 2), 0, "mid-cluster → snap to 0"); + assert_eq!(snap_grapheme_boundary(t, 3), 0, "mid-cluster → snap to 0"); + assert_eq!(snap_grapheme_boundary(t, 4), 4, "'!' starts at 4"); +} + +// --------------------------------------------------------------------------- +// Loop: move_down walks through all lines without getting stuck +// --------------------------------------------------------------------------- + +/// Walk the cursor down through every line of `text` one step at a time. +/// Returns the sequence of (line_index, cursor_offset) at each step. +fn walk_down(text: &str) -> Vec<(usize, usize)> { + let mut lay = SimpleLayoutEngine::default_test(); + let mut s = TextEditorState::with_cursor(text, 0); + let mut path: Vec<(usize, usize)> = Vec::new(); + let max_steps = text.lines().count() + 10; // generous upper bound + + for _ in 0..max_steps { + let metrics = lay.line_metrics(text); + let line = metrics.iter().position(|lm| { + lm.start_index <= s.cursor && s.cursor < lm.end_index + }).unwrap_or(metrics.len() - 1); + path.push((line, s.cursor)); + + if s.cursor >= text.len() { + break; + } + let before = s.cursor; + s = apply_command(&s, EditingCommand::MoveDown { extend: false }, &mut lay); + if s.cursor == before { + // Cursor did not advance — record the stuck position and bail. + path.push((line, s.cursor)); + break; + } + } + path +} + +#[test] +fn move_down_never_locks_simple() { + let text = "Line0\n\nLine2\n\nLine4"; + let path = walk_down(text); + + // Verify every cursor in the path is strictly increasing (or at text.len()). + let cursors: Vec = path.iter().map(|(_, c)| *c).collect(); + for w in cursors.windows(2) { + assert!( + w[1] >= w[0], + "cursor went backwards: {} → {}", + w[0], w[1] + ); + } + // Must eventually reach the last line. + let last_cursor = cursors.last().copied().unwrap_or(0); + assert!( + last_cursor >= text.len() - "Line4".len(), + "cursor never reached last line, stuck at {}", + last_cursor + ); +} + +#[test] +fn move_down_reaches_end_of_document() { + // Mirrors the wd_text_editor initial text structure: content, empty lines, more content. + let text = concat!( + "Hello, World!\n", + "Type here to edit text.\n", + "\n", + "=== Controls ===\n", + "\n", + "Latin text: The quick brown fox jumps over 13 lazy dogs.\n", + "\n", + "[Hangul]\n", + "Korean: \u{C548}\u{B155}\u{D558}\u{C138}\u{C694}\n", + "\n", + "[Emoji]\n", + "emoji: \u{1F600} \u{1F601}\n", + ); + + let line_count = text.matches('\n').count(); + let mut lay = SimpleLayoutEngine::default_test(); + let mut s = TextEditorState::with_cursor(text, 0); + + for step in 0..=(line_count + 2) { + if s.cursor >= text.len() { + break; + } + let before = s.cursor; + s = apply_command(&s, EditingCommand::MoveDown { extend: false }, &mut lay); + assert!( + s.cursor > before || s.cursor == text.len(), + "cursor locked at offset {} on step {}", + before, + step + ); + } + + assert_eq!( + s.cursor, text.len(), + "cursor should reach end of document" + ); +} + +#[test] +fn move_down_consecutive_empty_lines() { + // Three consecutive empty lines followed by content. + let text = "A\n\n\n\nB"; + // A \n \n \n \n B + // bytes: 0 1 2 3 4 5 + let path = walk_down(text); + let cursors: Vec = path.iter().map(|(_, c)| *c).collect(); + + // Every step must advance. + for w in cursors.windows(2) { + assert!(w[1] > w[0], "stuck: {} → {}", w[0], w[1]); + } + // Last cursor is either at 'B' (5) or past it (text.len()=6 after final down). + assert!(*cursors.last().unwrap() >= 5, "should reach last line"); +} + +// --------------------------------------------------------------------------- +// Loop: move_left / move_right walk through text without getting stuck +// +// Left/right movement operates in logical (Unicode) order regardless of +// visual direction. RTL scripts like Arabic and Hebrew are therefore +// tested with the same traversal invariants as LTR text: +// - move_right from 0 → text.len() must visit every grapheme boundary +// exactly once, and the cursor must strictly increase at every step. +// - move_left from text.len() → 0 is the mirror image. +// --------------------------------------------------------------------------- + +/// Collect every grapheme-cluster start offset in `text` in order. +fn grapheme_starts(text: &str) -> Vec { + use unicode_segmentation::UnicodeSegmentation; + text.grapheme_indices(true).map(|(i, _)| i).collect() +} + +/// Walk cursor right through `text` one grapheme at a time starting from +/// offset 0. Returns the list of cursor positions after each MoveRight. +fn walk_right(text: &str) -> Vec { + let mut s = TextEditorState::with_cursor(text, 0); + let mut positions = vec![0]; + let max_steps = text.chars().count() + 5; + for _ in 0..max_steps { + if s.cursor >= text.len() { + break; + } + s = apply(&s, EditingCommand::MoveRight { extend: false }); + positions.push(s.cursor); + } + positions +} + +/// Walk cursor left through `text` one grapheme at a time starting from +/// `text.len()`. Returns the list of cursor positions after each MoveLeft. +fn walk_left(text: &str) -> Vec { + let mut s = TextEditorState::new(text); // cursor = text.len() + let mut positions = vec![text.len()]; + let max_steps = text.chars().count() + 5; + for _ in 0..max_steps { + if s.cursor == 0 { + break; + } + s = apply(&s, EditingCommand::MoveLeft { extend: false }); + positions.push(s.cursor); + } + positions +} + +// --- ASCII / Latin ----------------------------------------------------------- + +#[test] +fn move_right_visits_every_ascii_grapheme() { + let text = "Hello!"; + let positions = walk_right(text); + assert_eq!(positions, vec![0, 1, 2, 3, 4, 5, 6]); +} + +#[test] +fn move_left_visits_every_ascii_grapheme() { + let text = "Hello!"; + let positions = walk_left(text); + assert_eq!(positions, vec![6, 5, 4, 3, 2, 1, 0]); +} + +// --- Emoji / multi-byte grapheme clusters ----------------------------------- + +#[test] +fn move_right_through_emoji_clusters() { + // ZWJ family: 👨‍👩‍👧‍👦 is one grapheme cluster (25 bytes). + // 😀 is a single grapheme (4 bytes). + let text = "A\u{1F600}B"; // "A😀B" — 1 + 4 + 1 = 6 bytes + let positions = walk_right(text); + assert_eq!(positions, vec![0, 1, 5, 6], + "should step over the 4-byte emoji in one move"); +} + +#[test] +fn move_right_through_skin_tone_emoji() { + // 👍🏽 = U+1F44D + U+1F3FD (thumbs up + medium skin tone modifier) = 8 bytes, 1 grapheme. + let text = "\u{1F44D}\u{1F3FD}!"; // 8 + 1 = 9 bytes + let positions = walk_right(text); + assert_eq!(positions, vec![0, 8, 9]); +} + +#[test] +fn move_left_through_emoji_clusters() { + let text = "A\u{1F600}B"; // A😀B, 6 bytes + let positions = walk_left(text); + assert_eq!(positions, vec![6, 5, 1, 0], + "should step back over the 4-byte emoji in one move"); +} + +// --- Arabic (RTL) ----------------------------------------------------------- +// +// Logical-order movement: move_right advances toward higher byte offsets +// regardless of visual direction. Arabic characters are 2 bytes each in +// UTF-8 (U+0600–U+06FF range, which encodes as 2-byte sequences). + +#[test] +fn move_right_through_arabic_never_locks() { + // From wd_text_editor default text, Arabic-only portion (logical characters): + // "مرحبا" = م(2) ر(2) ح(2) ب(2) ا(2) = 10 bytes, 5 graphemes + let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; // مرحبا + let positions = walk_right(text); + let starts = grapheme_starts(text); + + // Every grapheme boundary is visited in ascending order. + assert_eq!(&positions[..starts.len()], starts.as_slice(), + "move_right should visit all {} Arabic grapheme starts", starts.len()); + assert_eq!(*positions.last().unwrap(), text.len()); +} + +#[test] +fn move_left_through_arabic_never_locks() { + let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; // مرحبا (10 bytes) + let positions = walk_left(text); + + // Must start at text.len() and strictly decrease to 0. + assert_eq!(positions[0], text.len()); + for w in positions.windows(2) { + assert!(w[1] < w[0], "cursor did not retreat: {} → {}", w[0], w[1]); + } + assert_eq!(*positions.last().unwrap(), 0); +} + +#[test] +fn move_right_arabic_with_latin_mix() { + // "ABC العربية 123" — from wd_text_editor default text + // This exercises the bidi-mixed case at a logical-order level. + let text = "ABC \u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064A}\u{0629} 123"; + let starts = grapheme_starts(text); + let positions = walk_right(text); + + // Every grapheme boundary should be visited exactly, in order. + for (i, &expected) in starts.iter().enumerate() { + assert_eq!( + positions[i], expected, + "step {} should be at byte {} (grapheme start), got {}", + i, expected, positions[i] + ); + } + assert_eq!(*positions.last().unwrap(), text.len()); +} + +// --- Hebrew (RTL) ----------------------------------------------------------- + +#[test] +fn move_right_through_hebrew_never_locks() { + // "שלום" = שׁ(2) ל(2) ו(2) ם(2) = 8 bytes, 4 graphemes + let text = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"; // שלום + let positions = walk_right(text); + let starts = grapheme_starts(text); + + assert_eq!(&positions[..starts.len()], starts.as_slice()); + assert_eq!(*positions.last().unwrap(), text.len()); +} + +#[test] +fn move_left_through_hebrew_never_locks() { + let text = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"; // שלום (8 bytes) + let positions = walk_left(text); + + assert_eq!(positions[0], text.len()); + for w in positions.windows(2) { + assert!(w[1] < w[0], "cursor did not retreat: {} → {}", w[0], w[1]); + } + assert_eq!(*positions.last().unwrap(), 0); +} + +// --- Hangul / Korean -------------------------------------------------------- +// Precomposed Hangul syllables are 3 bytes each (U+AC00–U+D7A3). + +#[test] +fn move_right_through_korean_precomposed() { + // "안녕" = 안(3) 녕(3) = 6 bytes, 2 graphemes + let text = "\u{C548}\u{B155}"; // 안녕 + let positions = walk_right(text); + assert_eq!(positions, vec![0, 3, 6]); +} + +#[test] +fn move_left_through_korean_precomposed() { + let text = "\u{C548}\u{B155}"; // 안녕 (6 bytes) + let positions = walk_left(text); + assert_eq!(positions, vec![6, 3, 0]); +} + +#[test] +fn move_right_korean_and_latin_mix() { + // "ABC가나다123" — from wd_text_editor default text + // Hangul syllables are 3-byte grapheme clusters. + let text = "ABC\u{AC00}\u{B098}\u{B2E4}123"; + let starts = grapheme_starts(text); + let positions = walk_right(text); + + for (i, &expected) in starts.iter().enumerate() { + assert_eq!(positions[i], expected, + "step {}: expected byte {}, got {}", i, expected, positions[i]); + } + assert_eq!(*positions.last().unwrap(), text.len()); +} + +// --------------------------------------------------------------------------- +// Devanagari (conjuncts / reordering) +// +// Devanagari uses a virama (U+094D, ्) to form conjunct consonants. +// A base consonant + virama + next consonant = ONE grapheme cluster. +// Vowel signs (matras) also attach to the preceding consonant cluster. +// +// Verified with `unicode_segmentation` (UAX#29 grapheme cluster rules): +// +// "नमस्ते" (18 bytes, 3 clusters): +// [0] "न" bytes 0-2 (3 bytes, U+0928) +// [1] "म" bytes 3-5 (3 bytes, U+092E) +// [2] "स्ते" bytes 6-17 (12 bytes, U+0938 + U+094D + U+0924 + U+0947) +// ↑ virama fuses the full conjunct+vowel into one cluster +// +// "क्ष" (9 bytes, 1 cluster): +// [0] "क्ष" bytes 0-8 (9 bytes, U+0915 + U+094D + U+0937) +// ↑ the entire word is one grapheme cluster +// +// "हिन्दी" (18 bytes, 2 clusters): +// [0] "हि" bytes 0-5 (6 bytes, U+0939 + U+093F) +// [1] "न्दी" bytes 6-17 (12 bytes, U+0928 + U+094D + U+0926 + U+0940) +// +// Correct cursor behaviour: never stop at a position that is NOT a cluster +// boundary (e.g. must NOT stop at byte 3 inside "स्ते"). +// --------------------------------------------------------------------------- + +#[test] +fn move_right_through_devanagari_namaste() { + let text = "नमस्ते"; + // Clusters: "न"(0) "म"(3) "स्ते"(6) — end at 18. + let positions = walk_right(text); + assert_eq!(positions, vec![0, 3, 6, 18], + "conjunct 'स्ते' must be stepped over as ONE cluster (12 bytes)"); +} + +#[test] +fn move_left_through_devanagari_namaste() { + let text = "नमस्ते"; + let positions = walk_left(text); + assert_eq!(positions, vec![18, 6, 3, 0], + "moving left must not stop inside the conjunct cluster"); +} + +#[test] +fn move_right_through_devanagari_conjunct_ksha() { + // "क्ष" is a single grapheme cluster (9 bytes). + let text = "क्ष"; + let positions = walk_right(text); + assert_eq!(positions, vec![0, 9], + "entire conjunct word 'क्ष' is one cluster — one step from 0 to 9"); +} + +#[test] +fn move_right_through_devanagari_hindi() { + let text = "हिन्दी"; + // Clusters: "हि"(0) "न्दी"(6) — end at 18. + let positions = walk_right(text); + assert_eq!(positions, vec![0, 6, 18]); +} + +#[test] +fn move_right_devanagari_never_stops_at_virama() { + // Virama (U+094D) must NEVER be a cursor stop on its own. + // "स्त" = U+0938 + U+094D + U+0924 = 9 bytes, 1 cluster. + let text = "स्त"; + let positions = walk_right(text); + // Must go 0 → 9 directly; must NOT visit byte 3 (after 'स') or byte 6 (the virama). + assert_eq!(positions, vec![0, 9], + "virama at byte 3 and the second consonant at byte 6 must not be cursor stops"); +} + +#[test] +fn move_right_devanagari_matches_grapheme_starts() { + // Full wd_text_editor line: "नमस्ते दुनिया" + let text = "नमस्ते दुनिया"; + let starts = grapheme_starts(text); + let positions = walk_right(text); + assert_eq!(&positions[..starts.len()], starts.as_slice(), + "move_right must visit exactly the UAX#29 grapheme cluster starts"); + assert_eq!(*positions.last().unwrap(), text.len()); +} + +// --------------------------------------------------------------------------- +// Thai (combining vowel marks / no spaces between words) +// +// Thai vowel marks (sara) are combining characters that attach to the +// preceding consonant. They form one grapheme cluster with their base. +// +// Verified with `unicode_segmentation` (UAX#29): +// +// "สวัสดี" (18 bytes, 4 clusters): +// [0] "ส" bytes 0-2 (3 bytes, U+0E2A) +// [1] "วั" bytes 3-8 (6 bytes, U+0E27 + U+0E31 sara_a) +// [2] "ส" bytes 9-11 (3 bytes, U+0E2A) +// [3] "ดี" bytes 12-17 (6 bytes, U+0E14 + U+0E35 sara_ii) +// ↑ sara (vowel sign) bonds to its base consonant +// +// "โลก" (9 bytes, 3 clusters — each consonant standalone): +// [0] "โ" [1] "ล" [2] "ก" (3 bytes each) +// +// Note: Thai does not insert spaces between words; word segmentation requires +// a language-aware tokenizer. Cursor movement here operates at the grapheme +// cluster level and does NOT respect word boundaries. +// --------------------------------------------------------------------------- + +#[test] +fn move_right_through_thai_sawatdi() { + let text = "สวัสดี"; + // Clusters: ส(0) วั(3) ส(9) ดี(12) — end at 18. + let positions = walk_right(text); + assert_eq!(positions, vec![0, 3, 9, 12, 18], + "sara U+0E31 must attach to 'ว' — combined cluster is 6 bytes"); +} + +#[test] +fn move_left_through_thai_sawatdi() { + let text = "สวัสดี"; + let positions = walk_left(text); + assert_eq!(positions, vec![18, 12, 9, 3, 0]); +} + +#[test] +fn move_right_through_thai_lok() { + // "โลก" — three standalone consonants, each 3 bytes. + let text = "โลก"; + let positions = walk_right(text); + assert_eq!(positions, vec![0, 3, 6, 9]); +} + +#[test] +fn move_right_thai_never_stops_inside_sara() { + // "วั" = U+0E27 + U+0E31 = 6 bytes, 1 cluster. + // Cursor must NOT stop at byte 3 (the sara alone). + let text = "วั"; + let positions = walk_right(text); + assert_eq!(positions, vec![0, 6], + "sara at byte 3 must not be a cursor stop — it bonds with 'ว'"); +} + +#[test] +fn move_right_thai_full_word_matches_grapheme_starts() { + // Full wd_text_editor Thai line: "สวัสดีโลก" + let text = "สวัสดีโลก"; + let starts = grapheme_starts(text); + let positions = walk_right(text); + assert_eq!(&positions[..starts.len()], starts.as_slice(), + "move_right must visit exactly the UAX#29 grapheme cluster starts"); + assert_eq!(*positions.last().unwrap(), text.len()); +} + +#[test] +fn move_left_thai_full_word_matches_grapheme_starts() { + let text = "สวัสดีโลก"; + let starts = grapheme_starts(text); + let positions = walk_left(text); + // walk_left starts at text.len() and goes down; reverse of walk_right. + let mut expected: Vec = starts.clone(); + expected.push(text.len()); + expected.reverse(); + assert_eq!(positions, expected); +} + +// --------------------------------------------------------------------------- +// Per-step cursor trace: Devanagari and Thai +// +// These tests enumerate EVERY cursor position produced by repeated MoveRight +// (or MoveLeft) presses so that any change in behaviour is immediately +// visible as a diff. They also explain WHY the step sizes vary. +// +// Rule: for LTR scripts (Devanagari, Thai) every step moves the VISUAL caret +// rightward. The byte delta per step varies because grapheme clusters have +// different widths. The visual caret position is correct only when +// `caret_x_at` queries the rect for the WHOLE last grapheme cluster (not just +// the last code unit) — combining marks like virama / sara are positioned at +// the base consonant in Skia and would otherwise cause backward caret jumps. +// - simple Devanagari consonant: 3 bytes +// - Devanagari conjunct (consonant + virama + consonant + vowel): up to 12 bytes +// - simple Thai consonant: 3 bytes +// - Thai consonant + sara (vowel mark): 6 bytes +// +// For RTL scripts (Arabic, Hebrew) each MoveRight step increases the byte +// offset (logical forward), but the VISUAL caret moves leftward because the +// text renders right-to-left. This is the "mixed direction" that can look +// surprising. Visual-order bidi cursor movement is not yet implemented; +// logical-order movement is the correct baseline. +// --------------------------------------------------------------------------- + +// ── Devanagari step trace ────────────────────────────────────────────────── + +#[test] +fn devanagari_namaste_right_step_trace() { + // "नमस्ते" — 18 bytes, 3 grapheme clusters: + // + // step 0 → 1: cursor 0→3 "न" (3 bytes, simple consonant) + // step 1 → 2: cursor 3→6 "म" (3 bytes, simple consonant) + // step 2 → 3: cursor 6→18 "स्ते" (12 bytes: स + ् + त + े, ONE conjunct) + // + // The 12-byte jump is correct — the whole conjunct+vowel is one cluster. + let text = "नमस्ते"; + let positions = walk_right(text); + assert_eq!(positions[0], 0, "step 0: start"); + assert_eq!(positions[1], 3, "step 1: after 'न' (3 bytes)"); + assert_eq!(positions[2], 6, "step 2: after 'म' (3 bytes)"); + assert_eq!(positions[3], 18, "step 3: after 'स्ते' (12-byte conjunct)"); +} + +#[test] +fn devanagari_namaste_left_step_trace() { + let text = "नमस्ते"; + let positions = walk_left(text); + assert_eq!(positions[0], 18, "start at end"); + assert_eq!(positions[1], 6, "step 1: jump back 12 bytes over 'स्ते' conjunct"); + assert_eq!(positions[2], 3, "step 2: back over 'म' (3 bytes)"); + assert_eq!(positions[3], 0, "step 3: back over 'न' (3 bytes)"); +} + +#[test] +fn devanagari_virama_makes_large_jump() { + // "ब + ् + ब + ् + ब" = two viramas binding three consonants. + // Expected clusters: whole thing is ONE cluster (virama chains). + let text = "ब्ब्ब"; + let starts = grapheme_starts(text); + let positions = walk_right(text); + // Whatever the cluster count, every step must land on a grapheme start. + assert_eq!(&positions[..starts.len()], starts.as_slice()); + assert_eq!(*positions.last().unwrap(), text.len()); + // And no step may land inside the chain (any byte that is not a cluster start). + for &pos in positions.iter() { + if pos < text.len() { + assert!( + starts.contains(&pos), + "cursor landed at byte {} which is not a grapheme start in {:?}", + pos, text + ); + } + } +} + +// ── Thai step trace ──────────────────────────────────────────────────────── + +#[test] +fn thai_sawatdee_right_step_trace() { + // "สวัสดี" — 18 bytes, 4 grapheme clusters: + // + // step 0 → 1: cursor 0→3 "ส" (3 bytes, standalone consonant) + // step 1 → 2: cursor 3→9 "วั" (6 bytes: ว + ั sara-a, ONE cluster) + // step 2 → 3: cursor 9→12 "ส" (3 bytes, standalone consonant) + // step 3 → 4: cursor 12→18 "ดี" (6 bytes: ด + ี sara-ii, ONE cluster) + // + // Sara vowel marks are Extend characters in UAX#29, so they bond with + // the preceding consonant into one grapheme cluster. + let text = "สวัสดี"; + let positions = walk_right(text); + assert_eq!(positions[0], 0, "step 0: start"); + assert_eq!(positions[1], 3, "step 1: after 'ส' (3 bytes)"); + assert_eq!(positions[2], 9, "step 2: after 'วั' (6-byte cluster: ว+sara)"); + assert_eq!(positions[3], 12, "step 3: after second 'ส' (3 bytes)"); + assert_eq!(positions[4], 18, "step 4: after 'ดี' (6-byte cluster: ด+sara)"); +} + +#[test] +fn thai_sawatdee_left_step_trace() { + let text = "สวัสดี"; + let positions = walk_left(text); + assert_eq!(positions[0], 18, "start at end"); + assert_eq!(positions[1], 12, "step 1: back over 'ดี' (6-byte cluster)"); + assert_eq!(positions[2], 9, "step 2: back over second 'ส' (3 bytes)"); + assert_eq!(positions[3], 3, "step 3: back over 'วั' (6-byte cluster)"); + assert_eq!(positions[4], 0, "step 4: back over first 'ส' (3 bytes)"); +} + +#[test] +fn thai_sara_step_never_stops_mid_cluster() { + // Comprehensive: every cursor position must be a grapheme start. + let text = "สวัสดีโลก"; + let starts = grapheme_starts(text); + let positions = walk_right(text); + for &pos in positions.iter() { + if pos < text.len() { + assert!( + starts.contains(&pos), + "cursor at byte {} is not a grapheme start (mid-cluster stop in {:?})", + pos, text + ); + } + } +} + +// ── RTL visual-vs-logical note ──────────────────────────────────────────── + +#[test] +fn rtl_arabic_right_always_increases_byte_offset() { + // For RTL text, MoveRight advances the LOGICAL (byte) offset even though + // the visual caret moves LEFT on screen. This is the expected behaviour + // until visual-order bidi cursor movement is implemented. + let text = "مرحبا بالعالم"; // "Hello world" in Arabic + let positions = walk_right(text); + for w in positions.windows(2) { + assert!( + w[1] > w[0], + "MoveRight must always increase the byte offset; got {} → {}", + w[0], w[1] + ); + } +} + +#[test] +fn rtl_hebrew_left_always_decreases_byte_offset() { + // Symmetric: MoveLeft always decreases the byte offset for RTL text too. + let text = "שלום עולם"; // "Hello world" in Hebrew + let positions = walk_left(text); + for w in positions.windows(2) { + assert!( + w[1] < w[0], + "MoveLeft must always decrease the byte offset; got {} → {}", + w[0], w[1] + ); + } +} + +// --------------------------------------------------------------------------- +// PageUp / PageDown +// --------------------------------------------------------------------------- + +#[test] +fn page_down_moves_by_visible_lines() { + // viewport_height=60, line_height=24 → visible ≈ 2 lines + let mut lay = SimpleLayoutEngine::new(60.0, 24.0, 10.0); + let text = "L0\nL1\nL2\nL3\nL4"; + let s = TextEditorState::with_cursor(text, 0); // line 0 + let s2 = apply_command(&s, EditingCommand::MovePageDown { extend: false }, &mut lay); + // target_line = 0 + 2 = 2, start = index of "L2" + assert_eq!(s2.cursor, 6, "should jump to line 2"); +} diff --git a/docs/wg/feat-text-editing/index.md b/docs/wg/feat-text-editing/index.md index 4eb8de9ad1..3aa521ee60 100644 --- a/docs/wg/feat-text-editing/index.md +++ b/docs/wg/feat-text-editing/index.md @@ -31,7 +31,7 @@ This document proposes a **minimal but extensible** text editing model and geome - **Model vs view separation**: input state is pure data; geometry is queried; rendering is host-defined. - **Text positions are contracts**: cursoring and selection operate on _valid cursor stops_, not arbitrary integers. -- **Determinism (scoped)**: given the same font set, shaping engine, and layout constraints, the same state yields the same geometry. +- **Determinism (scoped)**: for fixed font set, shaping engine, and layout constraints, the mapping (state) → geometry is a function (same input ⇒ same output). - **Incremental computation**: queries should be cheap, cacheable, and invalidated predictably. - **Accessibility compatibility**: expose enough structure to bridge to platform accessibility layers. @@ -49,17 +49,17 @@ These contracts exist to prevent subtly-wrong implementations that “mostly wor ## Minimal input state model -The minimal state that a plain text input exposes can be represented as: +The minimal state that a plain text input exposes can be represented as a tuple: -- **value**: the full plain text string -- **selectionStart**: one selection endpoint (**TextPosition**) -- **selectionEnd**: the other endpoint (**TextPosition**) +- **value** ∈ Σ\*: the full plain text string +- **selection_start** ∈ P: one selection endpoint +- **selection_end** ∈ P: the other endpoint -When `selectionStart == selectionEnd`, the caret is at that position (no selection). +where P denotes the set of valid `text_position` values. When `selection_start = selection_end`, the caret is at that position (no selection). ### Indexing: UTF-16 code units (host interop) -For compatibility with common platform APIs, `selectionStart`/`selectionEnd` are often expressed in **UTF-16 code unit offsets** (not bytes, not Unicode scalar indices). This is primarily a host-interop decision. +For compatibility with common platform APIs, `selection_start` / `selection_end` are often expressed in **UTF-16 code unit offsets** (not bytes, not Unicode scalar indices). This is primarily a host-interop decision. If UTF-16 offsets are used, the system must explicitly handle these pitfalls: @@ -69,13 +69,13 @@ If UTF-16 offsets are used, the system must explicitly handle these pitfalls: ### A real position type (do not use “just numbers” conceptually) -Even if you serialize/interoperate via UTF-16 offsets, editing should be defined in terms of a **TextPosition**: +Even if you serialize/interoperate via UTF-16 offsets, editing should be defined in terms of a **text_position**: -- `TextPosition = { offset: number, affinity?: "upstream" | "downstream" }` +- `text_position = { offset: number, affinity?: "upstream" | "downstream" }` An alternative design is to make positions **opaque** (engine-owned tokens) for maximal correctness, and only convert to/from UTF-16 at the host boundary. This improves correctness but reduces direct interop with APIs that require numeric offsets. -`affinity` matters at: +`affinity` (optional) matters at: - soft wraps (visual line breaks) - bidi boundaries (visual/logical discontinuities) @@ -91,14 +91,14 @@ An alternative design is to make positions **opaque** (engine-owned tokens) for To support modern input correctly, the following are often necessary (but can be optional fields): - **composition** (IME): - - `compositionRange?: { start: TextPosition; end: TextPosition }` + - `composition_range?: { start: text_position; end: text_position }` - Define whether composition is **in-band** (part of `value`) or **overlay** (not yet committed into `value`). ## Layout options (minimum) Correctness requires layout options that affect bidi and line breaking: -- **paragraphDirection**: `"ltr" | "rtl" | "auto"` +- **paragraph_direction**: `"ltr" | "rtl" | "auto"` - `"auto"` resolves base direction from the text (per Unicode BiDi rules / UAX#9). - width/line wrap constraints (implementation-defined) - newline policy (see below) @@ -107,20 +107,20 @@ Correctness requires layout options that affect bidi and line breaking: To render the caret and selection in a custom canvas, the engine should provide: -- **Position from point**: - - `positionAtPoint(x, y) -> TextPosition` -- **Caret geometry**: - - `caretRectAtPosition(position: TextPosition) -> Rect` -- **Selection geometry**: - - `selectionRectsForRange(start: TextPosition, end: TextPosition, options?) -> RectWithDirection[]` -- **Boundaries / granularity**: - - `boundaryAt(position: TextPosition, granularity: "grapheme" | "word" | "line" | "paragraph") -> { start: TextPosition, end: TextPosition }` - - `nextPosition(position: TextPosition, granularity) -> TextPosition` - - `prevPosition(position: TextPosition, granularity) -> TextPosition` +- **position from point**: + - `position_at_point(x, y) → text_position` +- **caret geometry**: + - `caret_rect_at_position(position: text_position) → rect` +- **selection geometry**: + - `selection_rects_for_range(start: text_position, end: text_position, options?) → rect_with_direction[]` +- **boundaries / granularity**: + - `boundary_at(position: text_position, granularity: "grapheme" | "word" | "line" | "paragraph") → { start: text_position, end: text_position }` + - `next_position(position: text_position, granularity) → text_position` + - `prev_position(position: text_position, granularity) → text_position` - **Line metrics** (optional early, essential later): - baselines, line boxes, ascent/descent, wrapped line breaks -### RectWithDirection (bidi awareness) +### rect_with_direction (bidi awareness) Selection is not always a single rectangle. For bidi text and wrapped lines, selection geometry is naturally a list of rectangles. Including the **direction per rect** enables correct visual treatment and future features (handles, highlights, navigation). @@ -128,9 +128,32 @@ Selection is not always a single rectangle. For bidi text and wrapped lines, sel Selection rectangles depend on policy. Even a minimal API should include: -- **rectMode**: `"tight" | "lineBox"` (glyph-tight bounds vs line-height boxes) -- **includeTrailingNewline**: boolean -- **endOfLineAffinityPolicy**: `"upstream" | "downstream" | "preserve"` +- **rect_mode**: `"none" | "tight" | "linebox"` + - `"none"`: pass raw engine output through unchanged; empty lines produce zero-width (invisible) rects. Keeps the host fully engine-agnostic and is the correct choice when the host intends to apply its own synthesis or test the raw output. + - `"tight"`: expand zero-width rects for empty lines to a minimum visible width; non-empty lines keep glyph-tight bounds. + - `"linebox"`: expand every selected line's rect to the full layout width; both empty and non-empty lines become uniform full-row blocks. +- **include_trailing_newline**: boolean +- **end_of_line_affinity_policy**: `"upstream" | "downstream" | "preserve"` + +**Empty-line selection invariant**: + +If a logical line is fully or partially covered by the selection range, the engine MUST return at least one visible selection rectangle for that line, even when the line contains no glyphs (e.g. an empty line or a line consisting only of a line terminator). The exact rectangle shape follows the active selection painting policy (e.g. glyph‑tight vs line‑box), but the absence of glyphs MUST NOT result in a visually missing highlight. + +**Implementation note (shaping engines):** + +A typical paragraph engine's `get_rects_for_range` (e.g. Skia, ICU Layout, or equivalent) satisfies the invariant at the data level but violates it visually: it returns a rect for every line in the range, but lines with no glyph content (empty lines, lines consisting only of a line terminator) receive a **zero-width rect** (`left == right`), which renders as invisible. Additionally, a trailing newline at the very end of the text is a special case: its rect is placed at the y-coordinates of the preceding content line rather than the phantom empty line that follows it; and when the selection range covers the full text, that phantom line may receive no rect at all. + +When `rect_mode` is `"none"` the raw output is forwarded unchanged and no post-processing is applied. For `"tight"` and `"linebox"`, two post-processing steps are required: + +1. **Expand zero-width rects.** For any rect where `left ≈ right`, expand the right edge to a minimum visible width according to `rect_mode`: + - `"tight"` (glyph-tight): expand by a small fixed amount (e.g. approximately half the font size), anchored at `left`. + - `"linebox"`: expand all rects — including non-empty lines — so that `right` equals the layout width. This produces a full-row highlight for every selected line. + + The expansion strategy should distinguish two sub-cases for zero-width rects: + - `left ≈ 0`: the line is entirely empty; apply the full `rect_mode` expansion. + - `left > 0`: the rect represents a line-terminator character at the end of a content line; always apply a small fixed bump regardless of `rect_mode`, to avoid painting the entire row background when only the terminator is selected. + +2. **Inject a rect for the trailing phantom line.** When the selection extends to the last code unit of a text that ends with a line terminator, check whether the paragraph's line metrics contain a final entry (the phantom empty line) whose y-band is not represented by any existing rect. If so, inject a synthetic rect for that band using the `rect_mode` expansion width. Future extensions often needed: @@ -151,21 +174,40 @@ Two acceptable contracts: ## Interaction model (host responsibilities) -The host (DOM, native, custom shell) captures input events and updates `TextInputState`. The engine provides geometry to make those updates accurate. +The host (DOM, native, custom shell) captures input events and updates `text_input_state`. The engine provides geometry to make those updates accurate. ### Pointer-driven selection (gesture) A minimal gesture model: - **PointerDown**: - - `anchor = positionAtPoint(x, y)` + - `anchor = position_at_point(x, y)` - If modifier for “extend selection” is active, keep existing anchor and update focus. - **PointerMove (drag)**: - - `focus = positionAtPoint(x, y)` + - `focus = position_at_point(x, y)` - `selection = (anchor, focus)` - **PointerUp**: - Commit selection state. +### Multi-click selection (sequential clicks) + +Text editors commonly treat rapid sequential clicks as an escalating selection gesture. The host is responsible for counting clicks and deciding whether two clicks are part of the same sequence (a platform-defined time/space threshold). + +Let $p \in P$ be the text position under the pointer at click time (via `position_at_point`). For granularity $g \in \{\text{grapheme}, \text{word}, \text{line}, \text{paragraph}\}$, let $R_g(p) = [a, b)$ be the closed-open logical range returned by `boundary_at(p, g)`. + +Let click count $k \in \{1, 2, 3, 4\}$ within the same sequence: + +- **k = 1 (single click)**: place the caret at `p` (a collapsed selection). +- **k = 2 (double click)**: select the **word** containing `p`, i.e. the range `R_word(p)`. +- **k = 3 (triple click)**: select the **line** containing `p`, i.e. the range `R_line(p)`. +- **k = 4 (quadruple click)**: select the entire editable value (document range). + +Notes: + +- These selections are **logical ranges**; visual highlighting is produced via the selection-rectangle queries and may be split across multiple rectangles (wraps, bidi). +- “Line” refers to the host’s chosen line granularity. If the engine exposes a `line` boundary tied to layout, triple-click should map to the **visual line in the current layout** (including soft wraps). If the host wants “hard line” (paragraph line breaks only), it must use `paragraph` boundaries or an explicit policy. +- The definition above is intentionally deterministic: given the same `position_at_point` mapping and the same boundary rules, the same click sequence produces the same logical selection. + ### Keyboard navigation (minimal baseline) At minimum, support: @@ -180,16 +222,16 @@ The “what is a word” rule should come from engine word-boundary queries or a To avoid every host re-implementing subtly different editing behavior, define a minimal, shared operation surface: -- `applyEdit(state, command) -> newState` +- `apply_edit(state, command) → new_state` Where `command` includes at least: -- `insertText(text)` -- `replaceRange(start: TextPosition, end: TextPosition, text)` +- `insert_text(text)` +- `replace_range(start: text_position, end: text_position, text)` - `backspace(granularity?: "grapheme" | "word")` - `delete(granularity?: "grapheme" | "word")` -- `setSelection(start: TextPosition, end: TextPosition)` -- `setComposition(range, text?)` / `commitComposition()` / `cancelComposition()` +- `set_selection(start: text_position, end: text_position)` +- `set_composition(range, text?)` / `commit_composition()` / `cancel_composition()` The core guarantee: these operations **never create invalid positions** (never split surrogates or clusters) and always return a valid state. @@ -213,7 +255,7 @@ Cursor movement and deletion should respect **grapheme clusters** (e.g. emoji se CJK text stresses boundary and navigation rules: -- **Word boundaries are not whitespace-delimited**: “word” selection/navigation typically relies on language-aware segmentation (and may be user-configurable). A naive whitespace-based `wordBoundaryAt` will behave incorrectly. +- **Word boundaries are not whitespace-delimited**: “word” selection/navigation typically relies on language-aware segmentation (and may be user-configurable). A naive whitespace-based `word_boundary_at` will behave incorrectly. - **IME-first workflows**: composition ranges and candidate selection are core, not edge cases. Composition must interact correctly with selection, deletion, and undo grouping. - **Line breaking and punctuation**: wrapping behavior and caret movement are influenced by language-specific line-breaking rules and full-width punctuation. If line granularity is provided, it must reflect the layout’s actual break opportunities. @@ -257,16 +299,56 @@ This affects Home/End, line-boundary selection, and end-of-line affinity behavio Two viable strategies exist: - **Host-rendered overlays**: host queries geometry and renders caret/selection as overlay primitives. - - Pros: simplest composition with existing UI layers, easy cursor blink timing. - Cons: requires consistent coordinate transforms and synchronization. -- **Engine-rendered overlays**: host passes `TextInputState` into the engine; the engine renders caret/selection. +- **Engine-rendered overlays**: host passes `text_input_state` into the engine; the engine renders caret/selection. - Pros: single source of truth for transforms and pixel-perfect alignment with text rendering. - Cons: needs an animation clock (cursor blink) and careful invalidation/redraw policy. Either is valid; choosing should be guided by performance goals, platform constraints, and correctness needs. +## Text diagnostics and spellcheck indication (UX layer) + +This manifesto does **not** define how spelling or grammar analysis is performed, since dictionaries, language models, and platform services vary widely across environments (native OS, browser, WASM, custom dictionaries, etc.). + +Instead, the engine defines the **visual indication contract** for text diagnostics such as spelling errors, grammar warnings, or similar annotations. + +### Diagnostic ranges (logical) + +Diagnostics are expressed as logical text ranges over the same **text_position** model used for selection. Each diagnostic has: + +- a logical `[start, end)` range +- a diagnostic **kind** (e.g. spelling, grammar, suggestion, informational) +- optional metadata owned by the host (suggestions, language, severity, etc.) + +The engine does **not** interpret diagnostic meaning; it only guarantees correct geometry and rendering alignment. + +### Geometry for diagnostic underlines (visual) + +For any diagnostic range, the engine MUST provide geometry consistent with shaped layout runs, wrapping, and bidi resolution—identical correctness requirements as selection rectangles. + +The most common UX form is a **wavy underline** (e.g. red squiggle for spelling). The exact drawing style is host‑defined, but the engine MUST ensure: + +- Underline geometry follows **glyph shaping and line breaks**, not codepoint boxes. +- Geometry splits naturally across **wrapped lines and bidi runs**. +- Empty visual lines inside the diagnostic range still produce a visible underline segment consistent with line‑box policy. + +### Interaction with selection and composition + +- Diagnostics MUST NOT visually interfere with caret or selection rendering priority. +- By default, diagnostics **should not appear inside an active IME composition range**, unless explicitly requested by the host. +- Diagnostic geometry must remain stable under incremental edits using the same invalidation rules as selection geometry. + +### Rendering responsibility + +As with caret and selection, two strategies are valid: + +- **Host‑rendered diagnostics** using engine‑provided geometry. +- **Engine‑rendered diagnostics** as part of the text overlay pipeline. + +The contract of this manifesto is limited to **correct geometry and layering**, not linguistic correctness. + ## Undo/redo boundaries (even if host-owned) Even if history is not implemented in the engine, specify: @@ -274,7 +356,7 @@ Even if history is not implemented in the engine, specify: - What constitutes an **atomic edit** - How IME composition edits are **grouped** (composition typically needs special grouping) -One approach: have `applyEdit` optionally emit an **edit grouping key** or “transaction id” so hosts can build consistent undo stacks. +One approach: have `apply_edit` optionally emit an **edit grouping key** or “transaction id” so hosts can build consistent undo stacks. ## Phased roadmap @@ -315,3 +397,40 @@ One approach: have `applyEdit` optionally emit an **edit grouping key** or “tr - full-width punctuation and brackets (caret/selection around punctuation) - mixed Arabic/Hebrew with numbers and punctuation (classic bidi cases) - ligatures and font fallback across scripts + +## References + +This manifesto aligns with established text-editing models and platform behaviors. The following references are useful for deeper study and cross‑validation of concepts such as editing intents, keybinding actions, shaping, and IME handling. + +### Web platform + +- W3C Input Events Level 2 — standardized editing intent vocabulary (`beforeinput`, `inputType`): + https://www.w3.org/TR/input-events-2/ +- HTML Editing APIs and selection behavior (WHATWG HTML): + https://html.spec.whatwg.org/ + +### Native platforms + +- Apple `NSStandardKeyBindingResponding` — canonical macOS text‑editing command/action set: + https://developer.apple.com/documentation/appkit/nsstandardkeybindingresponding +- Cocoa Text System key bindings and customization: + https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/TextDefaultsBindings/TextDefaultsBindings.html + +### Framework references + +- Flutter text editing shortcuts and intents: + https://api.flutter.dev/flutter/widgets/DefaultTextEditingShortcuts-class.html +- Flutter `Shortcuts` / `Actions` system: + https://api.flutter.dev/flutter/widgets/Shortcuts-class.html + +### Text shaping and layout engines + +- HarfBuzz text shaping engine: + https://harfbuzz.github.io/ +- Unicode Text Segmentation (UAX #29 — grapheme/word boundaries): + https://www.unicode.org/reports/tr29/ +- Unicode Bidirectional Algorithm (UAX #9): + https://www.unicode.org/reports/tr9/ + +These documents are not normative for this manifesto but serve as widely accepted +reference points across web, native, and cross‑platform text systems. From 007c052d61a7d848c69238773e746762fdee31ba Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 20:17:17 +0900 Subject: [PATCH 03/13] mv --- crates/grida-dev/{src => examples}/text_edit/layout.rs | 0 crates/grida-dev/{src => examples}/text_edit/mod.rs | 0 .../grida-dev/{src => examples}/text_edit/simple_layout.rs | 1 - crates/grida-dev/{src => examples}/text_edit/tests.rs | 0 crates/grida-dev/examples/wd_text_editor.rs | 7 +++++-- crates/grida-dev/src/lib.rs | 1 - crates/grida-dev/tests/text_edit_example.rs | 5 +++++ 7 files changed, 10 insertions(+), 4 deletions(-) rename crates/grida-dev/{src => examples}/text_edit/layout.rs (100%) rename crates/grida-dev/{src => examples}/text_edit/mod.rs (100%) rename crates/grida-dev/{src => examples}/text_edit/simple_layout.rs (99%) rename crates/grida-dev/{src => examples}/text_edit/tests.rs (100%) create mode 100644 crates/grida-dev/tests/text_edit_example.rs diff --git a/crates/grida-dev/src/text_edit/layout.rs b/crates/grida-dev/examples/text_edit/layout.rs similarity index 100% rename from crates/grida-dev/src/text_edit/layout.rs rename to crates/grida-dev/examples/text_edit/layout.rs diff --git a/crates/grida-dev/src/text_edit/mod.rs b/crates/grida-dev/examples/text_edit/mod.rs similarity index 100% rename from crates/grida-dev/src/text_edit/mod.rs rename to crates/grida-dev/examples/text_edit/mod.rs diff --git a/crates/grida-dev/src/text_edit/simple_layout.rs b/crates/grida-dev/examples/text_edit/simple_layout.rs similarity index 99% rename from crates/grida-dev/src/text_edit/simple_layout.rs rename to crates/grida-dev/examples/text_edit/simple_layout.rs index 0ded2f44b0..57faabf226 100644 --- a/crates/grida-dev/src/text_edit/simple_layout.rs +++ b/crates/grida-dev/examples/text_edit/simple_layout.rs @@ -154,4 +154,3 @@ impl SimpleLayoutEngine { Self::new(600.0, 24.0, 10.0) } } - diff --git a/crates/grida-dev/src/text_edit/tests.rs b/crates/grida-dev/examples/text_edit/tests.rs similarity index 100% rename from crates/grida-dev/src/text_edit/tests.rs rename to crates/grida-dev/examples/text_edit/tests.rs diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs index a3a6b4219c..9d40fbd323 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -1,8 +1,11 @@ //! Minimal plain-text editor built directly on winit + Skia. //! -//! Editing logic lives in `grida_dev::text_edit` (no Skia dependency). +//! Editing logic lives in the local `text_edit` example module (no Skia dependency). //! This file wires it up to Skia paragraph layout (`SkiaLayoutEngine`) and //! the winit event loop. + +#[path = "text_edit/mod.rs"] +mod text_edit; //! //! Feature checklist //! ----------------- @@ -89,7 +92,7 @@ use winit::{ window::{Window, WindowAttributes, WindowId}, }; -use grida_dev::text_edit::{ +use crate::text_edit::{ apply_command, prev_grapheme_boundary, snap_grapheme_boundary, utf16_to_utf8_offset, utf8_to_utf16_offset, EditingCommand, LineMetrics, TextEditorState, TextLayoutEngine, }; diff --git a/crates/grida-dev/src/lib.rs b/crates/grida-dev/src/lib.rs index 07ce4b81b0..3be444786a 100644 --- a/crates/grida-dev/src/lib.rs +++ b/crates/grida-dev/src/lib.rs @@ -1,2 +1 @@ pub mod platform; -pub mod text_edit; diff --git a/crates/grida-dev/tests/text_edit_example.rs b/crates/grida-dev/tests/text_edit_example.rs new file mode 100644 index 0000000000..bcc97f3399 --- /dev/null +++ b/crates/grida-dev/tests/text_edit_example.rs @@ -0,0 +1,5 @@ +//! Include the `text_edit` example module so its unit tests run with `cargo test`. +//! The module lives under `examples/text_edit/` and is used by the `wd_text_editor` example. + +#[path = "../examples/text_edit/mod.rs"] +mod text_edit; From 36a7b8f59d9ba2df41ba2979d1ee0c1f1dbd7044 Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 20:45:30 +0900 Subject: [PATCH 04/13] feat: implement undo/redo functionality and enhance text editing commands - Introduced `EditHistory` to manage undo and redo operations with merge capabilities for consecutive edits. - Added new editing commands: `BackspaceWord`, `DeleteWord`, `BackspaceLine`, and `DeleteLine` for improved text manipulation. - Updated `apply_command` function to handle new commands and integrate with the history management. - Enhanced tests to cover undo/redo scenarios and command classification for better reliability. --- .../grida-dev/examples/text_edit/history.rs | 169 +++++++ crates/grida-dev/examples/text_edit/mod.rs | 153 ++++++ crates/grida-dev/examples/text_edit/tests.rs | 462 +++++++++++++++++- crates/grida-dev/examples/wd_text_editor.rs | 197 +++++--- 4 files changed, 924 insertions(+), 57 deletions(-) create mode 100644 crates/grida-dev/examples/text_edit/history.rs diff --git a/crates/grida-dev/examples/text_edit/history.rs b/crates/grida-dev/examples/text_edit/history.rs new file mode 100644 index 0000000000..59e8851fd9 --- /dev/null +++ b/crates/grida-dev/examples/text_edit/history.rs @@ -0,0 +1,169 @@ +use std::time::{Duration, Instant}; + +use super::TextEditorState; + +const DEFAULT_MAX_ENTRIES: usize = 200; +const DEFAULT_MERGE_TIMEOUT: Duration = Duration::from_secs(2); + +// --------------------------------------------------------------------------- +// EditKind – classifies text-mutating commands for merge decisions +// --------------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum EditKind { + Typing, + Backspace, + Delete, + Paste, + ImeCommit, + Newline, +} + +impl EditKind { + pub fn is_mergeable(self) -> bool { + matches!(self, Self::Typing | Self::Backspace | Self::Delete) + } +} + +// --------------------------------------------------------------------------- +// HistoryEntry +// --------------------------------------------------------------------------- + +struct HistoryEntry { + state: TextEditorState, + kind: EditKind, + timestamp: Instant, +} + +// --------------------------------------------------------------------------- +// EditHistory +// --------------------------------------------------------------------------- + +pub struct EditHistory { + undo_stack: Vec, + redo_stack: Vec, + max_entries: usize, + merge_timeout: Duration, +} + +impl EditHistory { + pub fn new() -> Self { + Self { + undo_stack: Vec::new(), + redo_stack: Vec::new(), + max_entries: DEFAULT_MAX_ENTRIES, + merge_timeout: DEFAULT_MERGE_TIMEOUT, + } + } + + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + pub fn clear(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } + + /// Record the state **before** an edit. + /// + /// If the top of the undo stack has the same mergeable kind and the + /// elapsed time since that entry is within `merge_timeout`, the push is + /// skipped (we keep the older snapshot so that undo jumps back to the + /// state before the entire merged run). + /// + /// Any pending redo stack is cleared on push. + pub fn push(&mut self, state_before: &TextEditorState, kind: EditKind) { + if kind.is_mergeable() { + if let Some(top) = self.undo_stack.last_mut() { + if top.kind == kind && top.timestamp.elapsed() < self.merge_timeout { + top.timestamp = Instant::now(); + self.redo_stack.clear(); + return; + } + } + } + + self.undo_stack.push(HistoryEntry { + state: state_before.clone(), + kind, + timestamp: Instant::now(), + }); + + if self.undo_stack.len() > self.max_entries { + self.undo_stack.remove(0); + } + + self.redo_stack.clear(); + } + + /// Undo: saves `current` onto the redo stack and returns the previous state. + pub fn undo(&mut self, current: &TextEditorState) -> Option { + let entry = self.undo_stack.pop()?; + self.redo_stack.push(HistoryEntry { + state: current.clone(), + kind: entry.kind, + timestamp: Instant::now(), + }); + Some(entry.state) + } + + /// Redo: saves `current` onto the undo stack and returns the next state. + pub fn redo(&mut self, current: &TextEditorState) -> Option { + let entry = self.redo_stack.pop()?; + self.undo_stack.push(HistoryEntry { + state: current.clone(), + kind: entry.kind, + timestamp: Instant::now(), + }); + Some(entry.state) + } +} + +impl Default for EditHistory { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Test-only helpers +// --------------------------------------------------------------------------- + +#[cfg(test)] +impl EditHistory { + /// Create a history with a custom merge timeout (useful for testing). + pub fn with_merge_timeout(timeout: Duration) -> Self { + Self { + merge_timeout: timeout, + ..Self::new() + } + } + + /// Force-expire the timestamp on the top undo entry so the next push + /// of the same kind will NOT merge. + pub fn expire_top(&mut self) { + if let Some(top) = self.undo_stack.last_mut() { + top.timestamp -= self.merge_timeout + Duration::from_millis(1); + } + } + + pub fn undo_len(&self) -> usize { + self.undo_stack.len() + } + + pub fn redo_len(&self) -> usize { + self.redo_stack.len() + } + + pub fn with_max_entries(max: usize) -> Self { + Self { + max_entries: max, + ..Self::new() + } + } +} diff --git a/crates/grida-dev/examples/text_edit/mod.rs b/crates/grida-dev/examples/text_edit/mod.rs index 8cd1d4df57..ef1be8f0cc 100644 --- a/crates/grida-dev/examples/text_edit/mod.rs +++ b/crates/grida-dev/examples/text_edit/mod.rs @@ -1,6 +1,8 @@ +pub mod history; pub mod layout; pub mod simple_layout; +pub use history::{EditHistory, EditKind}; pub use layout::{line_index_for_offset, LineMetrics, TextLayoutEngine}; pub use simple_layout::SimpleLayoutEngine; @@ -82,6 +84,69 @@ pub fn next_grapheme_boundary(text: &str, pos: usize) -> usize { text.len() } +// --------------------------------------------------------------------------- +// Word segment boundary (UAX #29) +// --------------------------------------------------------------------------- + +/// Find the UAX #29 word segment containing `offset`. +/// +/// Returns `(start, end)` — the byte range of the segment. This is the +/// standalone equivalent of `TextLayoutEngine::word_boundary_at` so that +/// pure editing commands (`BackspaceWord`, `DeleteWord`) can resolve word +/// boundaries without requiring a layout engine. +pub fn word_segment_at(text: &str, offset: usize) -> (usize, usize) { + let offset = offset.min(text.len()); + let mut last_start = 0usize; + for (byte_idx, segment) in text.split_word_bound_indices() { + let end = byte_idx + segment.len(); + if byte_idx <= offset && offset < end { + return (byte_idx, end); + } + last_start = byte_idx; + } + (last_start, text.len()) +} + +/// Find the start of the previous word segment from `pos`. +/// +/// If `pos` is at the start of a segment, returns the start of the +/// preceding segment. Used by `BackspaceWord` to determine delete range. +fn prev_word_segment_start(text: &str, pos: usize) -> usize { + if pos == 0 { + return 0; + } + let (seg_start, _) = word_segment_at(text, pos); + if seg_start < pos { + return seg_start; + } + // pos is exactly at a segment boundary — step back into the previous one + if pos > 0 { + let (prev_start, _) = word_segment_at(text, pos - 1); + return prev_start; + } + 0 +} + +/// Find the end of the current word segment from `pos`. +/// +/// If `pos` is at the end of a segment, returns the end of the next +/// segment. Used by `DeleteWord` to determine delete range. +fn next_word_segment_end(text: &str, pos: usize) -> usize { + if pos >= text.len() { + return text.len(); + } + let (_, seg_end) = word_segment_at(text, pos); + if seg_end > pos { + return seg_end; + } + // pos is exactly at a segment boundary — step into the next one + if pos < text.len() { + let (_, next_end) = word_segment_at(text, pos + 1); + return next_end; + } + text.len() +} + // --------------------------------------------------------------------------- // Core state // --------------------------------------------------------------------------- @@ -142,7 +207,9 @@ pub enum EditingCommand { // --- pure (no layout needed) --- Insert(String), Backspace, + BackspaceWord, Delete, + DeleteWord, MoveLeft { extend: bool }, MoveRight { extend: bool }, MoveDocStart { extend: bool }, @@ -153,6 +220,8 @@ pub enum EditingCommand { SetCursorPos { pos: usize, anchor: Option }, // --- need layout --- + BackspaceLine, + DeleteLine, MoveUp { extend: bool }, MoveDown { extend: bool }, MoveHome { extend: bool }, @@ -173,6 +242,36 @@ pub enum EditingCommand { SelectLineAt { x: f32, y: f32 }, } +impl EditingCommand { + /// Classify this command for undo/redo grouping. + /// + /// Returns `Some(kind)` for text-mutating commands (insert, delete) and + /// `None` for cursor-only commands (movement, selection). + pub fn edit_kind(&self) -> Option { + match self { + Self::Insert(s) => { + let clusters: Vec<&str> = s.graphemes(true).collect(); + if clusters.len() == 1 { + if clusters[0] == "\n" { + Some(EditKind::Newline) + } else { + Some(EditKind::Typing) + } + } else { + Some(EditKind::Paste) + } + } + Self::Backspace | Self::BackspaceWord | Self::BackspaceLine => { + Some(EditKind::Backspace) + } + Self::Delete | Self::DeleteWord | Self::DeleteLine => { + Some(EditKind::Delete) + } + _ => None, + } + } +} + // --------------------------------------------------------------------------- // apply_command // --------------------------------------------------------------------------- @@ -208,6 +307,17 @@ pub fn apply_command( s.anchor = None; } + EditingCommand::BackspaceWord => { + if s.has_selection() { + s.cursor = delete_selection_in_place(&mut s); + } else if s.cursor > 0 { + let target = prev_word_segment_start(&s.text, s.cursor); + s.text.drain(target..s.cursor); + s.cursor = target; + } + s.anchor = None; + } + EditingCommand::Delete => { if s.has_selection() { s.cursor = delete_selection_in_place(&mut s); @@ -218,6 +328,16 @@ pub fn apply_command( s.anchor = None; } + EditingCommand::DeleteWord => { + if s.has_selection() { + s.cursor = delete_selection_in_place(&mut s); + } else if s.cursor < s.text.len() { + let target = next_word_segment_end(&s.text, s.cursor); + s.text.drain(s.cursor..target); + } + s.anchor = None; + } + EditingCommand::MoveLeft { extend } => { if !extend && s.has_selection() { if let Some((lo, _)) = s.selection_range() { @@ -266,6 +386,39 @@ pub fn apply_command( s.anchor = anchor; } + EditingCommand::BackspaceLine => { + if s.has_selection() { + s.cursor = delete_selection_in_place(&mut s); + } else { + let metrics = layout.line_metrics(&s.text); + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let line_start = metrics[line_idx].start_index; + if line_start < s.cursor { + s.text.drain(line_start..s.cursor); + s.cursor = line_start; + } + } + s.anchor = None; + } + + EditingCommand::DeleteLine => { + if s.has_selection() { + s.cursor = delete_selection_in_place(&mut s); + } else { + let metrics = layout.line_metrics(&s.text); + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let lm = &metrics[line_idx]; + let mut line_end = lm.end_index.min(s.text.len()); + if line_end > 0 && s.text[..line_end].ends_with('\n') { + line_end = prev_grapheme_boundary(&s.text, line_end); + } + if s.cursor < line_end { + s.text.drain(s.cursor..line_end); + } + } + s.anchor = None; + } + EditingCommand::MoveUp { extend } => { set_anchor_if_extending(&mut s, extend); let x = layout.caret_x_at(&s.text, s.cursor); diff --git a/crates/grida-dev/examples/text_edit/tests.rs b/crates/grida-dev/examples/text_edit/tests.rs index 1b94520e91..553468a301 100644 --- a/crates/grida-dev/examples/text_edit/tests.rs +++ b/crates/grida-dev/examples/text_edit/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 super::{apply_command, layout::TextLayoutEngine, snap_grapheme_boundary, EditingCommand, SimpleLayoutEngine, TextEditorState}; +use super::{apply_command, layout::TextLayoutEngine, snap_grapheme_boundary, word_segment_at, EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; fn layout() -> SimpleLayoutEngine { SimpleLayoutEngine::default_test() @@ -945,3 +945,463 @@ fn page_down_moves_by_visible_lines() { // target_line = 0 + 2 = 2, start = index of "L2" assert_eq!(s2.cursor, 6, "should jump to line 2"); } + +// --------------------------------------------------------------------------- +// Undo / redo +// --------------------------------------------------------------------------- + +#[test] +fn undo_restores_previous_state() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("Hello", 5); + h.push(&s0, EditKind::Typing); + let s1 = apply(&s0, EditingCommand::Insert(" W".into())); + + let restored = h.undo(&s1).expect("should undo"); + assert_eq!(restored, s0); +} + +#[test] +fn redo_after_undo() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("Hello", 5); + h.push(&s0, EditKind::Typing); + let s1 = apply(&s0, EditingCommand::Insert("!".into())); + + let restored = h.undo(&s1).expect("undo"); + assert_eq!(restored, s0); + + let redone = h.redo(&restored).expect("redo"); + assert_eq!(redone, s1); +} + +#[test] +fn redo_cleared_by_new_edit() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("Hello", 5); + h.push(&s0, EditKind::Typing); + let s1 = apply(&s0, EditingCommand::Insert("!".into())); + + h.undo(&s1).expect("undo"); + + h.push(&s0, EditKind::Typing); + assert!(!h.can_redo(), "redo stack should be cleared after new edit"); +} + +#[test] +fn consecutive_typing_merges_into_one_undo_step() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("", 0); + + h.push(&s0, EditKind::Typing); + let s1 = apply(&s0, EditingCommand::Insert("H".into())); + h.push(&s1, EditKind::Typing); + let s2 = apply(&s1, EditingCommand::Insert("i".into())); + h.push(&s2, EditKind::Typing); + let s3 = apply(&s2, EditingCommand::Insert("!".into())); + + assert_eq!(h.undo_len(), 1, "consecutive typing should merge"); + + let restored = h.undo(&s3).expect("undo"); + assert_eq!(restored, s0, "should jump back to state before entire typing run"); +} + +#[test] +fn different_kinds_do_not_merge() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("abc", 3); + + h.push(&s0, EditKind::Typing); + let s1 = apply(&s0, EditingCommand::Insert("d".into())); + + h.push(&s1, EditKind::Backspace); + let s2 = apply(&s1, EditingCommand::Backspace); + + assert_eq!(h.undo_len(), 2, "Typing then Backspace should be 2 entries"); + + let after_first_undo = h.undo(&s2).expect("undo backspace"); + assert_eq!(after_first_undo, s1); + + let after_second_undo = h.undo(&after_first_undo).expect("undo typing"); + assert_eq!(after_second_undo, s0); +} + +#[test] +fn timeout_breaks_merge() { + use std::time::Duration; + + let mut h = EditHistory::with_merge_timeout(Duration::from_secs(2)); + let s0 = TextEditorState::with_cursor("", 0); + + h.push(&s0, EditKind::Typing); + let s1 = apply(&s0, EditingCommand::Insert("a".into())); + + h.expire_top(); + + h.push(&s1, EditKind::Typing); + let s2 = apply(&s1, EditingCommand::Insert("b".into())); + + assert_eq!(h.undo_len(), 2, "expired timeout should break merge"); + + let restored = h.undo(&s2).expect("undo second group"); + assert_eq!(restored, s1); +} + +#[test] +fn ime_commit_never_merges() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("", 0); + + h.push(&s0, EditKind::ImeCommit); + let s1 = apply(&s0, EditingCommand::Insert("가".into())); + + h.push(&s1, EditKind::ImeCommit); + let s2 = apply(&s1, EditingCommand::Insert("나".into())); + + assert_eq!(h.undo_len(), 2, "IME commits should never merge"); +} + +#[test] +fn newline_never_merges() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("a", 1); + + h.push(&s0, EditKind::Newline); + let s1 = apply(&s0, EditingCommand::Insert("\n".into())); + + h.push(&s1, EditKind::Newline); + let s2 = apply(&s1, EditingCommand::Insert("\n".into())); + + assert_eq!(h.undo_len(), 2, "newlines should never merge"); + let _ = s2; +} + +#[test] +fn paste_never_merges() { + let mut h = EditHistory::new(); + let s0 = TextEditorState::with_cursor("", 0); + + h.push(&s0, EditKind::Paste); + let s1 = apply(&s0, EditingCommand::Insert("hello world".into())); + + h.push(&s1, EditKind::Paste); + let _s2 = apply(&s1, EditingCommand::Insert("foo bar".into())); + + assert_eq!(h.undo_len(), 2, "pastes should never merge"); +} + +#[test] +fn undo_on_empty_stack_returns_none() { + let mut h = EditHistory::new(); + let s = TextEditorState::with_cursor("x", 1); + assert!(h.undo(&s).is_none()); +} + +#[test] +fn redo_on_empty_stack_returns_none() { + let mut h = EditHistory::new(); + let s = TextEditorState::with_cursor("x", 1); + assert!(h.redo(&s).is_none()); +} + +#[test] +fn max_entries_evicts_oldest() { + let mut h = EditHistory::with_max_entries(3); + + let s0 = TextEditorState::with_cursor("", 0); + h.push(&s0, EditKind::Newline); + let s1 = apply(&s0, EditingCommand::Insert("\n".into())); + + h.push(&s1, EditKind::Newline); + let s2 = apply(&s1, EditingCommand::Insert("\n".into())); + + h.push(&s2, EditKind::Newline); + let s3 = apply(&s2, EditingCommand::Insert("\n".into())); + + assert_eq!(h.undo_len(), 3); + + h.push(&s3, EditKind::Newline); + let s4 = apply(&s3, EditingCommand::Insert("\n".into())); + + assert_eq!(h.undo_len(), 3, "oldest entry should be evicted"); + + let r = h.undo(&s4).expect("undo"); + assert_eq!(r, s3, "most recent entry is s3"); +} + +#[test] +fn edit_kind_classification() { + assert_eq!(EditingCommand::Insert("a".into()).edit_kind(), Some(EditKind::Typing)); + assert_eq!(EditingCommand::Insert("\n".into()).edit_kind(), Some(EditKind::Newline)); + assert_eq!(EditingCommand::Insert("hello world".into()).edit_kind(), Some(EditKind::Paste)); + assert_eq!(EditingCommand::Backspace.edit_kind(), Some(EditKind::Backspace)); + assert_eq!(EditingCommand::BackspaceWord.edit_kind(), Some(EditKind::Backspace)); + assert_eq!(EditingCommand::Delete.edit_kind(), Some(EditKind::Delete)); + assert_eq!(EditingCommand::DeleteWord.edit_kind(), Some(EditKind::Delete)); + assert_eq!(EditingCommand::MoveLeft { extend: false }.edit_kind(), None); + assert_eq!(EditingCommand::SelectAll.edit_kind(), None); +} + +// --------------------------------------------------------------------------- +// Word segment boundary (UAX #29) +// --------------------------------------------------------------------------- + +#[test] +fn word_segment_at_returns_correct_segments() { + // 0123456789... + let text = "(abc) d efg h?"; + // UAX#29 segments: ( | abc | ) | _ | d | _ | efg | _ | h | ? + assert_eq!(word_segment_at(text, 0), (0, 1)); // ( + assert_eq!(word_segment_at(text, 1), (1, 4)); // abc + assert_eq!(word_segment_at(text, 3), (1, 4)); // still inside abc + assert_eq!(word_segment_at(text, 4), (4, 5)); // ) + assert_eq!(word_segment_at(text, 5), (5, 6)); // space + assert_eq!(word_segment_at(text, 6), (6, 7)); // d + assert_eq!(word_segment_at(text, 8), (8, 11)); // efg + assert_eq!(word_segment_at(text, 12), (12, 13)); // h + assert_eq!(word_segment_at(text, 13), (13, 14)); // ? +} + +// --------------------------------------------------------------------------- +// Word deletion: BackspaceWord +// --------------------------------------------------------------------------- + +#[test] +fn backspace_word_deletes_word() { + // "hello world" cursor at 11 (end) → delete "world" + let s = TextEditorState::with_cursor("hello world", 11); + let s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "hello "); + assert_eq!(s.cursor, 6); +} + +#[test] +fn backspace_word_deletes_space() { + // "hello world" cursor at 6 (after space, before 'w') → delete space + let s = TextEditorState::with_cursor("hello world", 6); + let s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "helloworld"); + assert_eq!(s.cursor, 5); +} + +#[test] +fn backspace_word_deletes_punctuation() { + let s = TextEditorState::with_cursor("(abc)", 5); + let s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "(abc"); + assert_eq!(s.cursor, 4); +} + +#[test] +fn backspace_word_at_start_is_noop() { + let s = TextEditorState::with_cursor("hello", 0); + let s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "hello"); + assert_eq!(s.cursor, 0); +} + +#[test] +fn backspace_word_deletes_selection_first() { + let mut s = TextEditorState::with_cursor("hello world", 5); + s.anchor = Some(11); + let s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "hello"); + assert_eq!(s.cursor, 5); +} + +#[test] +fn backspace_word_repeated() { + // "(abc) d" cursor at end (7) + // Step 1: delete "d" → "(abc) " + // Step 2: delete " " → "(abc)" + // Step 3: delete ")" → "(abc" + // Step 4: delete "abc" → "(" + // Step 5: delete "(" → "" + let mut s = TextEditorState::with_cursor("(abc) d", 7); + s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "(abc) ", "step 1: delete 'd'"); + + s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "(abc)", "step 2: delete space"); + + s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "(abc", "step 3: delete ')'"); + + s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "(", "step 4: delete 'abc'"); + + s = apply(&s, EditingCommand::BackspaceWord); + assert_eq!(s.text, "", "step 5: delete '('"); +} + +// --------------------------------------------------------------------------- +// Word deletion: DeleteWord +// --------------------------------------------------------------------------- + +#[test] +fn delete_word_deletes_word() { + // "hello world" cursor at 0 → delete "hello" + let s = TextEditorState::with_cursor("hello world", 0); + let s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, " world"); + assert_eq!(s.cursor, 0); +} + +#[test] +fn delete_word_deletes_space() { + // "hello world" cursor at 5 (on space) → delete " " + let s = TextEditorState::with_cursor("hello world", 5); + let s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, "helloworld"); + assert_eq!(s.cursor, 5); +} + +#[test] +fn delete_word_deletes_punctuation() { + let s = TextEditorState::with_cursor("(abc)", 0); + let s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, "abc)"); + assert_eq!(s.cursor, 0); +} + +#[test] +fn delete_word_at_end_is_noop() { + let s = TextEditorState::with_cursor("hello", 5); + let s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, "hello"); + assert_eq!(s.cursor, 5); +} + +#[test] +fn delete_word_repeated() { + // "(abc) d" cursor at 0 + // Step 1: delete "(" → "abc) d" + // Step 2: delete "abc" → ") d" + // Step 3: delete ")" → " d" + // Step 4: delete " " → "d" + // Step 5: delete "d" → "" + let mut s = TextEditorState::with_cursor("(abc) d", 0); + s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, "abc) d", "step 1: delete '('"); + + s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, ") d", "step 2: delete 'abc'"); + + s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, " d", "step 3: delete ')'"); + + s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, "d", "step 4: delete space"); + + s = apply(&s, EditingCommand::DeleteWord); + assert_eq!(s.text, "", "step 5: delete 'd'"); +} + +// --------------------------------------------------------------------------- +// Word navigation: full walk through user's example +// --------------------------------------------------------------------------- + +#[test] +fn move_word_right_walks_all_segments() { + // "(abc) d efg h?" — cursor should stop at every UAX#29 segment boundary. + let text = "(abc) d efg h?"; + let mut s = TextEditorState::with_cursor(text, 0); + let mut stops = vec![0usize]; + for _ in 0..20 { + if s.cursor >= text.len() { + break; + } + s = apply(&s, EditingCommand::MoveWordRight { extend: false }); + stops.push(s.cursor); + } + // Expected: ( | abc | ) | _ | d | _ | efg | _ | h | ? + assert_eq!(stops, vec![0, 1, 4, 5, 6, 7, 8, 11, 12, 13, 14]); +} + +#[test] +fn move_word_left_walks_all_segments() { + let text = "(abc) d efg h?"; + let mut s = TextEditorState::new(text); // cursor at end (14) + let mut stops = vec![14usize]; + for _ in 0..20 { + if s.cursor == 0 { + break; + } + s = apply(&s, EditingCommand::MoveWordLeft { extend: false }); + stops.push(s.cursor); + } + assert_eq!(stops, vec![14, 13, 12, 11, 8, 7, 6, 5, 4, 1, 0]); +} + +// --------------------------------------------------------------------------- +// Line deletion: BackspaceLine / DeleteLine +// --------------------------------------------------------------------------- + +#[test] +fn backspace_line_deletes_to_line_start() { + // "Hello\nWorld" cursor at 8 (middle of "World") → delete "Wo" + let s = TextEditorState::with_cursor("Hello\nWorld", 8); + let s = apply(&s, EditingCommand::BackspaceLine); + assert_eq!(s.text, "Hello\nrld"); + assert_eq!(s.cursor, 6); +} + +#[test] +fn backspace_line_at_line_start_is_noop() { + let s = TextEditorState::with_cursor("Hello\nWorld", 6); + let s = apply(&s, EditingCommand::BackspaceLine); + assert_eq!(s.text, "Hello\nWorld"); + assert_eq!(s.cursor, 6); +} + +#[test] +fn backspace_line_on_first_line() { + let s = TextEditorState::with_cursor("Hello World", 5); + let s = apply(&s, EditingCommand::BackspaceLine); + assert_eq!(s.text, " World"); + assert_eq!(s.cursor, 0); +} + +#[test] +fn backspace_line_with_selection_deletes_selection() { + let mut s = TextEditorState::with_cursor("Hello\nWorld", 8); + s.anchor = Some(6); + let s = apply(&s, EditingCommand::BackspaceLine); + assert_eq!(s.text, "Hello\nrld"); + assert_eq!(s.cursor, 6); +} + +#[test] +fn delete_line_deletes_to_line_end() { + // "Hello\nWorld" cursor at 8 → delete "rld" + let s = TextEditorState::with_cursor("Hello\nWorld", 8); + let s = apply(&s, EditingCommand::DeleteLine); + assert_eq!(s.text, "Hello\nWo"); + assert_eq!(s.cursor, 8); +} + +#[test] +fn delete_line_at_line_end_is_noop() { + // cursor at 5 (end of "Hello", before \n) — line content ends at 5 + let s = TextEditorState::with_cursor("Hello\nWorld", 5); + let s = apply(&s, EditingCommand::DeleteLine); + assert_eq!(s.text, "Hello\nWorld"); + assert_eq!(s.cursor, 5); +} + +#[test] +fn delete_line_on_last_line() { + let s = TextEditorState::with_cursor("Hello\nWorld", 8); + let s = apply(&s, EditingCommand::DeleteLine); + assert_eq!(s.text, "Hello\nWo"); + assert_eq!(s.cursor, 8); +} + +#[test] +fn delete_line_from_start_of_line() { + // cursor at 0 → delete entire "Hello" (but not the \n) + let s = TextEditorState::with_cursor("Hello\nWorld", 0); + let s = apply(&s, EditingCommand::DeleteLine); + assert_eq!(s.text, "\nWorld"); + assert_eq!(s.cursor, 0); +} diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs index 9d40fbd323..09327a7017 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -4,60 +4,68 @@ //! This file wires it up to Skia paragraph layout (`SkiaLayoutEngine`) and //! the winit event loop. +#![allow(clippy::single_match)] + #[path = "text_edit/mod.rs"] mod text_edit; -//! -//! Feature checklist -//! ----------------- -//! Editing -//! [x] Text insertion – IME commit (Ime::Commit) + Key::Character fallback -//! [x] Backspace – delete grapheme before cursor (or selected range) -//! [x] Delete – delete grapheme after cursor (or selected range) -//! [x] Enter – insert newline -//! [x] Tab – insert 4 spaces -//! -//! Cursor movement -//! [x] ← / → grapheme-cluster navigation -//! [x] ↑ / ↓ line-aware navigation (Skia line-metrics + position_at_point) -//! [x] Home / End line start / end -//! [x] PageUp / PageDown move by ~visible lines (manifesto viewport boundaries) -//! [x] Cmd+← / → line start / end (macOS) -//! [x] Cmd+↑ / ↓ document start / end (macOS) -//! [x] Option+← / → word jump (macOS) -//! [x] Ctrl+← / → word jump (Windows / Linux) -//! -//! Selection -//! [x] Shift+arrow extend selection in any direction -//! [x] Shift+Cmd/Opt/Ctrl extend selection with the same jumps as above -//! [x] Mouse click place cursor -//! [x] Mouse drag drag-to-select range -//! [x] Shift+click extend selection from current cursor to click position -//! [x] k=2 double-click select word (Skia get_word_boundary) -//! [x] k=3 triple-click select visual line (Skia get_line_metrics) -//! [x] k=4 quad-click select entire document -//! [x] Cmd+A select all -//! -//! Clipboard -//! [x] Cmd/Ctrl+C copy selection -//! [x] Cmd/Ctrl+X cut selection -//! [x] Cmd/Ctrl+V paste -//! -//! Rendering -//! [x] Multiline text with wrapping -//! [x] Cursor blink (500 ms, resets on any input) -//! [x] Selection highlight (Skia get_rects_for_range) -//! [x] Empty-line selection invariant (configurable: GlyphRect vs LineBox) -//! [x] Resize – paragraph relaid out on window resize -//! -//! [x] IME composition (set_ime_allowed + Preedit → underlined inline segment; -//! Key::Character suppressed during active composition) -//! -//! Not yet implemented -//! [ ] Undo / redo -//! [ ] Scroll (vertical) -//! [ ] Visual-order bidi cursor movement - -#![allow(clippy::single_match)] +// +// Feature checklist +// ----------------- +// Editing +// [x] Text insertion – IME commit (Ime::Commit) + Key::Character fallback +// [x] Backspace – delete grapheme before cursor (or selected range) +// [x] Delete – delete grapheme after cursor (or selected range) +// [x] Option/Ctrl+Backspace – delete word segment backward (UAX #29) +// [x] Option/Ctrl+Delete – delete word segment forward (UAX #29) +// [x] Cmd+Backspace – delete to line start +// [x] Cmd+Delete – delete to line end +// [x] Enter – insert newline +// [x] Tab – insert 4 spaces +// +// Cursor movement +// [x] ← / → grapheme-cluster navigation +// [x] ↑ / ↓ line-aware navigation (Skia line-metrics + position_at_point) +// [x] Home / End line start / end +// [x] PageUp / PageDown move by ~visible lines (manifesto viewport boundaries) +// [x] Cmd+← / → line start / end (macOS) +// [x] Cmd+↑ / ↓ document start / end (macOS) +// [x] Option+← / → word jump (macOS) +// [x] Ctrl+← / → word jump (Windows / Linux) +// +// Selection +// [x] Shift+arrow extend selection in any direction +// [x] Shift+Cmd/Opt/Ctrl extend selection with the same jumps as above +// [x] Mouse click place cursor +// [x] Mouse drag drag-to-select range +// [x] Shift+click extend selection from current cursor to click position +// [x] k=2 double-click select word (Skia get_word_boundary) +// [x] k=3 triple-click select visual line (Skia get_line_metrics) +// [x] k=4 quad-click select entire document +// [x] Cmd+A select all +// +// Clipboard +// [x] Cmd/Ctrl+C copy selection +// [x] Cmd/Ctrl+X cut selection +// [x] Cmd/Ctrl+V paste +// +// Rendering +// [x] Multiline text with wrapping +// [x] Cursor blink (500 ms, resets on any input) +// [x] Selection highlight (Skia get_rects_for_range) +// [x] Empty-line selection invariant (configurable: GlyphRect vs LineBox) +// [x] Resize – paragraph relaid out on window resize +// +// [x] IME composition (set_ime_allowed + Preedit → underlined inline segment; +// Key::Character suppressed during active composition) +// +// History +// [x] Undo / redo (Cmd/Ctrl+Z / Cmd/Ctrl+Shift+Z) +// Snapshot-based history with merge: consecutive typing, backspace, or +// delete are grouped; paste, newline, and IME commit are discrete steps. +// +// Not yet implemented +// [ ] Scroll (vertical) +// [ ] Visual-order bidi cursor movement use std::ffi::CString; use std::num::NonZeroU32; @@ -94,7 +102,8 @@ use winit::{ use crate::text_edit::{ apply_command, prev_grapheme_boundary, snap_grapheme_boundary, utf16_to_utf8_offset, - utf8_to_utf16_offset, EditingCommand, LineMetrics, TextEditorState, TextLayoutEngine, + utf8_to_utf16_offset, EditHistory, EditKind, EditingCommand, LineMetrics, TextEditorState, + TextLayoutEngine, }; // --------------------------------------------------------------------------- @@ -476,6 +485,9 @@ struct TextEditor { preedit: Option, empty_line_policy: EmptyLineSelectionPolicy, + + /// Undo / redo history (snapshot-based). + history: EditHistory, } impl TextEditor { @@ -492,6 +504,7 @@ impl TextEditor { drag_anchor_utf8: None, preedit: None, empty_line_policy: config.empty_line_policy, + history: EditHistory::new(), } } @@ -500,10 +513,39 @@ impl TextEditor { // ----------------------------------------------------------------------- fn apply(&mut self, cmd: EditingCommand) { + if let Some(kind) = cmd.edit_kind() { + self.history.push(&self.state, kind); + } self.state = apply_command(&self.state, cmd, &mut self.layout); self.reset_blink(); } + fn apply_with_kind(&mut self, cmd: EditingCommand, kind: EditKind) { + self.history.push(&self.state, kind); + self.state = apply_command(&self.state, cmd, &mut self.layout); + self.reset_blink(); + } + + fn undo(&mut self) -> bool { + if let Some(prev) = self.history.undo(&self.state) { + self.state = prev; + self.reset_blink(); + true + } else { + false + } + } + + fn redo(&mut self) -> bool { + if let Some(next) = self.history.redo(&self.state) { + self.state = next; + self.reset_blink(); + true + } else { + false + } + } + // ----------------------------------------------------------------------- // Convenience wrappers (called from event handler) // ----------------------------------------------------------------------- @@ -516,10 +558,26 @@ impl TextEditor { self.apply(EditingCommand::Backspace); } + fn backspace_word(&mut self) { + self.apply(EditingCommand::BackspaceWord); + } + + fn backspace_line(&mut self) { + self.apply(EditingCommand::BackspaceLine); + } + fn delete_forward(&mut self) { self.apply(EditingCommand::Delete); } + fn delete_word_forward(&mut self) { + self.apply(EditingCommand::DeleteWord); + } + + fn delete_line_forward(&mut self) { + self.apply(EditingCommand::DeleteLine); + } + fn move_left(&mut self, extend: bool) { self.apply(EditingCommand::MoveLeft { extend }); } @@ -1103,6 +1161,9 @@ impl ApplicationHandler for TextEditorApp { "Home / End line start/end PageUp / PageDown move by ~visible lines\n", "Double-click select word Mouse drag select range\n", "Cmd+A select all Cmd+C / X / V clipboard\n", + "Cmd+Z undo Cmd+Shift+Z redo\n", + "Option+Backspace delete word backward Option+Delete delete word forward\n", + "Cmd+Backspace delete to line start Cmd+Delete delete to line end\n", "\n", "=== Writing Systems / Shaping / Selection Tests ===\n", "\n", @@ -1212,7 +1273,10 @@ impl ApplicationHandler for TextEditorApp { } WindowEvent::Ime(Ime::Commit(s)) => { inner.editor.cancel_preedit(); - inner.editor.insert_text(&s); + inner.editor.apply_with_kind( + EditingCommand::Insert(s), + EditKind::ImeCommit, + ); inner.window.request_redraw(); } WindowEvent::Ime(Ime::Enabled) => { @@ -1288,11 +1352,23 @@ impl ApplicationHandler for TextEditorApp { } Key::Named(NamedKey::Backspace) => { - inner.editor.backspace(); + if meta { + inner.editor.backspace_line(); + } else if word { + inner.editor.backspace_word(); + } else { + inner.editor.backspace(); + } inner.window.request_redraw(); } Key::Named(NamedKey::Delete) => { - inner.editor.delete_forward(); + if meta { + inner.editor.delete_line_forward(); + } else if word { + inner.editor.delete_word_forward(); + } else { + inner.editor.delete_forward(); + } inner.window.request_redraw(); } Key::Named(NamedKey::Enter) => { @@ -1306,6 +1382,15 @@ impl ApplicationHandler for TextEditorApp { inner.editor.select_all(); inner.window.request_redraw(); } + "z" => { + if shift { + if inner.editor.redo() { + inner.window.request_redraw(); + } + } else if inner.editor.undo() { + inner.window.request_redraw(); + } + } "c" => { if let Some(sel) = inner.editor.selected_text() { let _ = self.clipboard.set_text(sel.to_string()); From 50a4fcf2eccc10434993ac8da7a39da1b027264a Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 20:51:41 +0900 Subject: [PATCH 05/13] fix: improve cursor baseline calculation and add IME handling - Updated the `cursor_baseline_y` function to simplify the calculation of the cursor's baseline, removing unnecessary extrapolation for trailing newlines. - Introduced `ime_suppress_next_key` flag in `TextEditorApp` to prevent double input when an IME commit occurs, ensuring smoother text input handling. --- crates/grida-dev/examples/wd_text_editor.rs | 23 +++++---------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs index 09327a7017..5be8cd61ec 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -754,7 +754,10 @@ impl TextEditor { } /// Baseline y of the cursor line, in layout-local space. - /// Handles the Skia phantom-line case for trailing newlines. + /// + /// Skia's `get_line_metrics()` emits an entry for the phantom empty line + /// that follows a trailing `\n`, so `skia_line_index_for_u16_offset` + /// already maps the cursor to that line. No extrapolation is needed. fn cursor_baseline_y(&mut self) -> f32 { self.layout.ensure_layout(&self.state.text); let metrics = self.layout.paragraph.as_ref().unwrap().get_line_metrics(); @@ -763,23 +766,7 @@ impl TextEditor { } let cur_u16 = utf8_to_utf16_offset(&self.state.text, self.state.cursor); let idx = skia_line_index_for_u16_offset(&metrics, cur_u16); - let baseline = metrics[idx].baseline as f32; - - // Skia does NOT emit line metrics for a trailing '\n'. Extrapolate. - let after_trailing_newline = self.state.cursor > 0 - && self.state.text[..self.state.cursor].ends_with('\n') - && idx == metrics.len() - 1; - if after_trailing_newline { - let line_height = if metrics.len() >= 2 { - (metrics[metrics.len() - 1].baseline - - metrics[metrics.len() - 2].baseline) as f32 - } else { - FONT_SIZE * 1.3 - }; - return baseline + line_height; - } - - baseline + metrics[idx].baseline as f32 } // ----------------------------------------------------------------------- From 47e41ceb47d0434160195529f8f2ae3790be7e1e Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 20:59:03 +0900 Subject: [PATCH 06/13] fix: refine IME handling and text insertion logic - Updated the `update_preedit` method to always set `preedit` to `Some(text)`, ensuring consistent preedit state. - Modified key event handling to allow text insertion for Enter, Space, and Tab keys only when `preedit` is None, improving user experience. - Added logic to clear empty preedit states after IME events, preventing input issues during text editing. --- crates/grida-dev/examples/wd_text_editor.rs | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs index 5be8cd61ec..7fc3d0b561 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -736,7 +736,7 @@ impl TextEditor { // ----------------------------------------------------------------------- fn update_preedit(&mut self, text: String) { - self.preedit = if text.is_empty() { None } else { Some(text) }; + self.preedit = Some(text); self.reset_blink(); } @@ -1259,7 +1259,6 @@ impl ApplicationHandler for TextEditorApp { inner.window.request_redraw(); } WindowEvent::Ime(Ime::Commit(s)) => { - inner.editor.cancel_preedit(); inner.editor.apply_with_kind( EditingCommand::Insert(s), EditKind::ImeCommit, @@ -1358,7 +1357,9 @@ impl ApplicationHandler for TextEditorApp { } inner.window.request_redraw(); } - Key::Named(NamedKey::Enter) => { + Key::Named(NamedKey::Enter) + if inner.editor.preedit.is_none() => + { inner.editor.insert_text("\n"); inner.window.request_redraw(); } @@ -1409,17 +1410,29 @@ impl ApplicationHandler for TextEditorApp { inner.window.request_redraw(); } - Key::Named(NamedKey::Space) => { + Key::Named(NamedKey::Space) + if inner.editor.preedit.is_none() => + { inner.editor.insert_text(" "); inner.window.request_redraw(); } - Key::Named(NamedKey::Tab) => { + Key::Named(NamedKey::Tab) + if inner.editor.preedit.is_none() => + { inner.editor.insert_text(" "); inner.window.request_redraw(); } _ => {} } + + // Drain the empty-preedit sentinel left by Ime::Preedit(""). + // It blocked the text-insertion arms above for exactly this + // one KeyboardInput event; now reset so the next key works + // normally. + if inner.editor.preedit.as_deref() == Some("") { + inner.editor.preedit = None; + } } WindowEvent::CursorMoved { position, .. } => { From 4df9567a8051816ac96597433134e9f28872521b Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 21:04:22 +0900 Subject: [PATCH 07/13] fix: enhance key event handling for text editor - Updated key event handling to utilize `PhysicalKey` and `KeyCode` for improved accuracy in detecting key presses. - Simplified logic for handling text insertion and command execution based on physical key codes, enhancing user experience during text editing. - Ensured consistent behavior for key commands such as select all, copy, cut, and paste. --- crates/grida-dev/examples/wd_text_editor.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs index 7fc3d0b561..b9a83d55ec 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -96,7 +96,7 @@ use winit::{ dpi::{LogicalPosition, LogicalSize, PhysicalSize}, event::{ElementState, Ime, MouseButton, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, - keyboard::{Key, ModifiersState, NamedKey}, + keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey}, window::{Window, WindowAttributes, WindowId}, }; @@ -1357,20 +1357,18 @@ impl ApplicationHandler for TextEditorApp { } inner.window.request_redraw(); } - Key::Named(NamedKey::Enter) - if inner.editor.preedit.is_none() => - { + Key::Named(NamedKey::Enter) => { inner.editor.insert_text("\n"); inner.window.request_redraw(); } - Key::Character(c) if cmd => { - match c.to_lowercase().as_str() { - "a" => { + _ if cmd => { + match ke.physical_key { + PhysicalKey::Code(KeyCode::KeyA) => { inner.editor.select_all(); inner.window.request_redraw(); } - "z" => { + PhysicalKey::Code(KeyCode::KeyZ) => { if shift { if inner.editor.redo() { inner.window.request_redraw(); @@ -1379,12 +1377,12 @@ impl ApplicationHandler for TextEditorApp { inner.window.request_redraw(); } } - "c" => { + PhysicalKey::Code(KeyCode::KeyC) => { if let Some(sel) = inner.editor.selected_text() { let _ = self.clipboard.set_text(sel.to_string()); } } - "x" => { + PhysicalKey::Code(KeyCode::KeyX) => { if let Some(sel) = inner.editor.selected_text() { let _ = self.clipboard.set_text(sel.to_string()); } @@ -1393,7 +1391,7 @@ impl ApplicationHandler for TextEditorApp { inner.window.request_redraw(); } } - "v" => { + PhysicalKey::Code(KeyCode::KeyV) => { if let Ok(text) = self.clipboard.get_text() { inner.editor.insert_text(&text); inner.window.request_redraw(); From 39257ad8dd87637ecc7e2dafac9bd4dd08145345 Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 21:19:30 +0900 Subject: [PATCH 08/13] refactor: add char-boundary safety helpers and improve cursor movement logic - Introduced `floor_char_boundary` and `ceil_char_boundary` functions to ensure safe adjustments of raw byte offsets in multi-byte text. - Updated cursor movement commands to skip whitespace when navigating between words, enhancing text editing experience. - Enhanced tests to validate new boundary functions and cursor movement behavior, ensuring robustness in text manipulation. --- crates/grida-dev/examples/text_edit/mod.rs | 101 +++++- crates/grida-dev/examples/text_edit/tests.rs | 309 ++++++++++++++++++- 2 files changed, 388 insertions(+), 22 deletions(-) diff --git a/crates/grida-dev/examples/text_edit/mod.rs b/crates/grida-dev/examples/text_edit/mod.rs index ef1be8f0cc..5c63d1e09b 100644 --- a/crates/grida-dev/examples/text_edit/mod.rs +++ b/crates/grida-dev/examples/text_edit/mod.rs @@ -13,7 +13,8 @@ use unicode_segmentation::UnicodeSegmentation; // --------------------------------------------------------------------------- pub fn utf8_to_utf16_offset(text: &str, utf8: usize) -> usize { - text[..utf8.min(text.len())].encode_utf16().count() + let safe = floor_char_boundary(text, utf8); + text[..safe].encode_utf16().count() } pub fn utf16_to_utf8_offset(text: &str, utf16: usize) -> usize { @@ -84,6 +85,35 @@ pub fn next_grapheme_boundary(text: &str, pos: usize) -> usize { text.len() } +// --------------------------------------------------------------------------- +// Char-boundary safety helpers +// +// These are the ONLY functions that should be used to adjust a raw byte +// offset when there is any possibility it does not fall on a char boundary +// (e.g. after `pos + 1` / `pos - 1` arithmetic on multi-byte text). +// They mirror the nightly `str::floor_char_boundary` / `str::ceil_char_boundary`. +// --------------------------------------------------------------------------- + +/// Snap `pos` backward to the nearest char boundary at or before `pos`. +pub fn floor_char_boundary(text: &str, pos: usize) -> usize { + let pos = pos.min(text.len()); + let mut p = pos; + while p > 0 && !text.is_char_boundary(p) { + p -= 1; + } + p +} + +/// Snap `pos` forward to the nearest char boundary at or after `pos`. +pub fn ceil_char_boundary(text: &str, pos: usize) -> usize { + let pos = pos.min(text.len()); + let mut p = pos; + while p < text.len() && !text.is_char_boundary(p) { + p += 1; + } + p +} + // --------------------------------------------------------------------------- // Word segment boundary (UAX #29) // --------------------------------------------------------------------------- @@ -121,7 +151,8 @@ fn prev_word_segment_start(text: &str, pos: usize) -> usize { } // pos is exactly at a segment boundary — step back into the previous one if pos > 0 { - let (prev_start, _) = word_segment_at(text, pos - 1); + let safe = floor_char_boundary(text, pos.saturating_sub(1)); + let (prev_start, _) = word_segment_at(text, safe); return prev_start; } 0 @@ -141,7 +172,8 @@ fn next_word_segment_end(text: &str, pos: usize) -> usize { } // pos is exactly at a segment boundary — step into the next one if pos < text.len() { - let (_, next_end) = word_segment_at(text, pos + 1); + let safe = ceil_char_boundary(text, pos + 1); + let (_, next_end) = word_segment_at(text, safe); return next_end; } text.len() @@ -525,25 +557,51 @@ pub fn apply_command( EditingCommand::MoveWordLeft { extend } => { set_anchor_if_extending(&mut s, extend); - let (start, _) = layout.word_boundary_at(&s.text, s.cursor); - if start < s.cursor { - s.cursor = start; - } else if s.cursor > 0 { - let (start2, _) = layout.word_boundary_at(&s.text, s.cursor - 1); - s.cursor = start2; + let mut pos = s.cursor; + loop { + let old = pos; + let (seg_start, _) = layout.word_boundary_at(&s.text, pos); + pos = if seg_start < old { + seg_start + } else if old > 0 { + let prev = prev_grapheme_boundary(&s.text, old); + layout.word_boundary_at(&s.text, prev).0 + } else { + break; + }; + if pos == old { + break; + } + if !s.text[pos..old].chars().all(|c| c.is_whitespace()) { + break; + } } + s.cursor = pos; clear_anchor_if_not_extending(&mut s, extend); } EditingCommand::MoveWordRight { extend } => { set_anchor_if_extending(&mut s, extend); - let (_, end) = layout.word_boundary_at(&s.text, s.cursor); - if end > s.cursor { - s.cursor = end; - } else if s.cursor < s.text.len() { - let (_, end2) = layout.word_boundary_at(&s.text, s.cursor + 1); - s.cursor = end2; + let mut pos = s.cursor; + loop { + let old = pos; + let (_, seg_end) = layout.word_boundary_at(&s.text, pos); + pos = if seg_end > old { + seg_end + } else if old < s.text.len() { + let next = next_grapheme_boundary(&s.text, old); + layout.word_boundary_at(&s.text, next).1 + } else { + break; + }; + if pos == old { + break; + } + if !s.text[old..pos].chars().all(|c| c.is_whitespace()) { + break; + } } + s.cursor = pos.min(s.text.len()); clear_anchor_if_not_extending(&mut s, extend); } @@ -582,6 +640,19 @@ pub fn apply_command( } } + debug_assert!( + s.cursor <= s.text.len() && s.text.is_char_boundary(s.cursor), + "apply_command produced invalid cursor {} for text len {}", + s.cursor, + s.text.len(), + ); + debug_assert!( + s.anchor.map_or(true, |a| a <= s.text.len() && s.text.is_char_boundary(a)), + "apply_command produced invalid anchor {:?} for text len {}", + s.anchor, + s.text.len(), + ); + s } diff --git a/crates/grida-dev/examples/text_edit/tests.rs b/crates/grida-dev/examples/text_edit/tests.rs index 553468a301..fe7905970d 100644 --- a/crates/grida-dev/examples/text_edit/tests.rs +++ b/crates/grida-dev/examples/text_edit/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 super::{apply_command, layout::TextLayoutEngine, snap_grapheme_boundary, word_segment_at, EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; +use super::{apply_command, floor_char_boundary, ceil_char_boundary, layout::TextLayoutEngine, snap_grapheme_boundary, word_segment_at, EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; fn layout() -> SimpleLayoutEngine { SimpleLayoutEngine::default_test() @@ -1302,8 +1302,19 @@ fn delete_word_repeated() { // --------------------------------------------------------------------------- #[test] -fn move_word_right_walks_all_segments() { - // "(abc) d efg h?" — cursor should stop at every UAX#29 segment boundary. +fn move_word_right_skips_whitespace() { + // "(abc) d efg h?" — whitespace is skipped, cursor lands at word ends. + // + // Segments: ( | abc | ) | _ | d | _ | efg | _ | h | ? + // + // Option+Right from each stop: + // 0 → 1 skip "(" (punctuation, non-ws → stop) + // 1 → 4 skip "abc" (word, non-ws → stop) + // 4 → 5 skip ")" (punctuation, non-ws → stop) + // 5 → 7 skip " " (ws → continue) then "d" (non-ws → stop) + // 7 → 11 skip " " (ws → continue) then "efg" (non-ws → stop) + // 11 → 13 skip " " (ws → continue) then "h" (non-ws → stop) + // 13 → 14 skip "?" (punctuation, non-ws → stop) let text = "(abc) d efg h?"; let mut s = TextEditorState::with_cursor(text, 0); let mut stops = vec![0usize]; @@ -1314,12 +1325,20 @@ fn move_word_right_walks_all_segments() { s = apply(&s, EditingCommand::MoveWordRight { extend: false }); stops.push(s.cursor); } - // Expected: ( | abc | ) | _ | d | _ | efg | _ | h | ? - assert_eq!(stops, vec![0, 1, 4, 5, 6, 7, 8, 11, 12, 13, 14]); + assert_eq!(stops, vec![0, 1, 4, 5, 7, 11, 13, 14]); } #[test] -fn move_word_left_walks_all_segments() { +fn move_word_left_skips_whitespace() { + // Symmetric: Option+Left skips whitespace, lands at word starts. + // + // 14 → 13 back over "?" → stop + // 13 → 12 back over "h" → stop + // 12 → 8 back over " " (ws → continue) then "efg" start → stop + // 8 → 6 back over " " (ws → continue) then "d" start → stop + // 6 → 4 back over " " (ws → continue) then ")" start → stop + // 4 → 1 back over "abc" → stop + // 1 → 0 back over "(" → stop let text = "(abc) d efg h?"; let mut s = TextEditorState::new(text); // cursor at end (14) let mut stops = vec![14usize]; @@ -1330,7 +1349,27 @@ fn move_word_left_walks_all_segments() { s = apply(&s, EditingCommand::MoveWordLeft { extend: false }); stops.push(s.cursor); } - assert_eq!(stops, vec![14, 13, 12, 11, 8, 7, 6, 5, 4, 1, 0]); + assert_eq!(stops, vec![14, 13, 12, 8, 6, 4, 1, 0]); +} + +#[test] +fn move_word_right_simple() { + // "A B" — cursor should skip space, land at end of B. + let s = TextEditorState::with_cursor("A B", 0); + let s = apply(&s, EditingCommand::MoveWordRight { extend: false }); + assert_eq!(s.cursor, 1, "end of A"); + let s = apply(&s, EditingCommand::MoveWordRight { extend: false }); + assert_eq!(s.cursor, 3, "end of B — space skipped"); +} + +#[test] +fn move_word_left_simple() { + // "A B" — cursor should skip space, land at start of A. + let s = TextEditorState::new("A B"); // cursor at 3 + let s = apply(&s, EditingCommand::MoveWordLeft { extend: false }); + assert_eq!(s.cursor, 2, "start of B"); + let s = apply(&s, EditingCommand::MoveWordLeft { extend: false }); + assert_eq!(s.cursor, 0, "start of A — space skipped"); } // --------------------------------------------------------------------------- @@ -1405,3 +1444,259 @@ fn delete_line_from_start_of_line() { assert_eq!(s.text, "\nWorld"); assert_eq!(s.cursor, 0); } + +// =========================================================================== +// Char-boundary safety: floor / ceil helpers +// =========================================================================== + +#[test] +fn floor_char_boundary_on_ascii() { + let t = "hello"; + for i in 0..=t.len() { + assert_eq!(floor_char_boundary(t, i), i); + } +} + +#[test] +fn floor_char_boundary_mid_multibyte() { + let t = "A\u{2026}B"; // A + … (3 bytes) + B = 5 bytes + assert_eq!(floor_char_boundary(t, 0), 0); // A + assert_eq!(floor_char_boundary(t, 1), 1); // start of … + assert_eq!(floor_char_boundary(t, 2), 1); // mid … → snap back to 1 + assert_eq!(floor_char_boundary(t, 3), 1); // mid … → snap back to 1 + assert_eq!(floor_char_boundary(t, 4), 4); // B + assert_eq!(floor_char_boundary(t, 5), 5); // end +} + +#[test] +fn ceil_char_boundary_mid_multibyte() { + let t = "A\u{2026}B"; // A(1) + …(3) + B(1) = 5 bytes + assert_eq!(ceil_char_boundary(t, 0), 0); + assert_eq!(ceil_char_boundary(t, 1), 1); + assert_eq!(ceil_char_boundary(t, 2), 4); // mid … → snap forward to B + assert_eq!(ceil_char_boundary(t, 3), 4); + assert_eq!(ceil_char_boundary(t, 4), 4); + assert_eq!(ceil_char_boundary(t, 5), 5); +} + +#[test] +fn floor_ceil_on_emoji() { + let t = "\u{1F600}!"; // 😀(4 bytes) + !(1) = 5 bytes + assert_eq!(floor_char_boundary(t, 2), 0); // mid emoji → 0 + assert_eq!(ceil_char_boundary(t, 2), 4); // mid emoji → 4 +} + +// =========================================================================== +// Offset safety: exhaustive command sweep on multi-byte text +// +// For each test string, place the cursor at every grapheme boundary and +// apply every movement / deletion command. The debug_assert inside +// apply_command will catch any invalid cursor or anchor produced. +// =========================================================================== + +fn grapheme_boundaries(text: &str) -> Vec { + use unicode_segmentation::UnicodeSegmentation; + let mut v: Vec = text.grapheme_indices(true).map(|(i, _)| i).collect(); + v.push(text.len()); + v +} + +fn assert_valid_state(s: &TextEditorState) { + assert!( + s.cursor <= s.text.len() && s.text.is_char_boundary(s.cursor), + "invalid cursor {} in text of len {} ({:?})", + s.cursor, s.text.len(), &s.text[..s.text.len().min(40)] + ); + if let Some(a) = s.anchor { + assert!( + a <= s.text.len() && s.text.is_char_boundary(a), + "invalid anchor {} in text of len {}", a, s.text.len() + ); + } +} + +/// Movement commands that do not mutate text. +fn movement_commands() -> Vec { + vec![ + EditingCommand::MoveLeft { extend: false }, + EditingCommand::MoveLeft { extend: true }, + EditingCommand::MoveRight { extend: false }, + EditingCommand::MoveRight { extend: true }, + EditingCommand::MoveUp { extend: false }, + EditingCommand::MoveDown { extend: false }, + EditingCommand::MoveHome { extend: false }, + EditingCommand::MoveEnd { extend: false }, + EditingCommand::MoveDocStart { extend: false }, + EditingCommand::MoveDocEnd { extend: false }, + EditingCommand::MoveWordLeft { extend: false }, + EditingCommand::MoveWordLeft { extend: true }, + EditingCommand::MoveWordRight { extend: false }, + EditingCommand::MoveWordRight { extend: true }, + EditingCommand::MovePageUp { extend: false }, + EditingCommand::MovePageDown { extend: false }, + EditingCommand::SelectAll, + ] +} + +/// Deletion commands that mutate text. +fn deletion_commands() -> Vec { + vec![ + EditingCommand::Backspace, + EditingCommand::BackspaceWord, + EditingCommand::BackspaceLine, + EditingCommand::Delete, + EditingCommand::DeleteWord, + EditingCommand::DeleteLine, + ] +} + +const SAFETY_TEXTS: &[&str] = &[ + "hello world", + "caf\u{00E9} na\u{00EF}ve", + "cafe\u{0301} (combining)", + "\u{D55C}\u{AD6D}\u{C5B4} \u{D14C}\u{C2A4}\u{D2B8}", + "A\u{2026}B (test) \u{65E5}\u{672C}\u{8A9E}!", + "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466} family \u{1F600}", + "\u{1F44D}\u{1F3FD} thumbs", + "a\nb\n\nc\n", + "(abc) d efg h?", + "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947} \u{0926}\u{0941}\u{0928}\u{093F}\u{092F}\u{093E}", + "\u{0E2A}\u{0E27}\u{0E31}\u{0E2A}\u{0E14}\u{0E35}\u{0E42}\u{0E25}\u{0E01}", + "state\u{2014}of\u{2014}the\u{2013}art \u{2026} end", + "", + "x", +]; + +#[test] +fn all_movement_commands_produce_valid_offsets() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + for cmd in movement_commands() { + let s = TextEditorState::with_cursor(text, pos); + let result = apply_command(&s, cmd.clone(), &mut lay); + assert_valid_state(&result); + } + } + } +} + +#[test] +fn all_movement_commands_with_selection_produce_valid_offsets() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + if boundaries.len() < 2 { + continue; + } + let anchor = boundaries[0]; + let cursor = boundaries[boundaries.len() / 2]; + for cmd in movement_commands() { + let mut s = TextEditorState::with_cursor(text, cursor); + s.anchor = Some(anchor); + let result = apply_command(&s, cmd.clone(), &mut lay); + assert_valid_state(&result); + } + } +} + +#[test] +fn all_deletion_commands_produce_valid_offsets() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + for cmd in deletion_commands() { + let s = TextEditorState::with_cursor(text, pos); + let result = apply_command(&s, cmd.clone(), &mut lay); + assert_valid_state(&result); + } + } + } +} + +#[test] +fn all_deletion_commands_with_selection_produce_valid_offsets() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + if boundaries.len() < 2 { + continue; + } + for i in 0..boundaries.len() { + for j in (i + 1)..boundaries.len() { + let anchor = boundaries[i]; + let cursor = boundaries[j]; + for cmd in deletion_commands() { + let mut s = TextEditorState::with_cursor(text, cursor); + s.anchor = Some(anchor); + let result = apply_command(&s, cmd.clone(), &mut lay); + assert_valid_state(&result); + } + } + } + } +} + +#[test] +fn insert_at_every_position_produces_valid_offsets() { + let mut lay = layout(); + let inserts = &["x", "\n", "hello world", "\u{1F600}", "\u{D55C}"]; + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + for &ins in inserts { + let s = TextEditorState::with_cursor(text, pos); + let result = apply_command( + &s, + EditingCommand::Insert(ins.to_string()), + &mut lay, + ); + assert_valid_state(&result); + } + } + } +} + +#[test] +fn repeated_word_movement_never_panics() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let mut s = TextEditorState::with_cursor(text, 0); + for _ in 0..100 { + if s.cursor >= text.len() { break; } + s = apply_command(&s, EditingCommand::MoveWordRight { extend: false }, &mut lay); + assert_valid_state(&s); + } + + s = TextEditorState::new(text); + for _ in 0..100 { + if s.cursor == 0 { break; } + s = apply_command(&s, EditingCommand::MoveWordLeft { extend: false }, &mut lay); + assert_valid_state(&s); + } + } +} + +#[test] +fn repeated_word_deletion_never_panics() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let mut s = TextEditorState::new(text); + for _ in 0..100 { + if s.text.is_empty() { break; } + s = apply_command(&s, EditingCommand::BackspaceWord, &mut lay); + assert_valid_state(&s); + } + assert!(s.text.is_empty(), "BackspaceWord loop did not empty: {:?}", s.text); + + let mut s = TextEditorState::with_cursor(text, 0); + for _ in 0..100 { + if s.text.is_empty() { break; } + s = apply_command(&s, EditingCommand::DeleteWord, &mut lay); + assert_valid_state(&s); + } + assert!(s.text.is_empty(), "DeleteWord loop did not empty: {:?}", s.text); + } +} From 09ccaa91b1ab1ba982309d728e5b68ed44435e9f Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 21 Feb 2026 21:19:38 +0900 Subject: [PATCH 09/13] fix: enhance text editing logic for cursor movement and line metrics - Added checks for empty line metrics before accessing line indices to prevent potential panics during text editing operations. - Improved the logic for draining text based on cursor position, ensuring more robust handling of text deletions in the editor. --- crates/grida-dev/examples/text_edit/mod.rs | 30 ++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/grida-dev/examples/text_edit/mod.rs b/crates/grida-dev/examples/text_edit/mod.rs index 5c63d1e09b..c81e131a26 100644 --- a/crates/grida-dev/examples/text_edit/mod.rs +++ b/crates/grida-dev/examples/text_edit/mod.rs @@ -423,11 +423,13 @@ pub fn apply_command( s.cursor = delete_selection_in_place(&mut s); } else { let metrics = layout.line_metrics(&s.text); - let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); - let line_start = metrics[line_idx].start_index; - if line_start < s.cursor { - s.text.drain(line_start..s.cursor); - s.cursor = line_start; + if !metrics.is_empty() { + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let line_start = metrics[line_idx].start_index; + if line_start < s.cursor { + s.text.drain(line_start..s.cursor); + s.cursor = line_start; + } } } s.anchor = None; @@ -438,14 +440,16 @@ pub fn apply_command( s.cursor = delete_selection_in_place(&mut s); } else { let metrics = layout.line_metrics(&s.text); - let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); - let lm = &metrics[line_idx]; - let mut line_end = lm.end_index.min(s.text.len()); - if line_end > 0 && s.text[..line_end].ends_with('\n') { - line_end = prev_grapheme_boundary(&s.text, line_end); - } - if s.cursor < line_end { - s.text.drain(s.cursor..line_end); + if !metrics.is_empty() { + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let lm = &metrics[line_idx]; + let mut line_end = lm.end_index.min(s.text.len()); + if line_end > 0 && s.text[..line_end].ends_with('\n') { + line_end = prev_grapheme_boundary(&s.text, line_end); + } + if s.cursor < line_end { + s.text.drain(s.cursor..line_end); + } } } s.anchor = None; From 27d7b10804ac63f86ab1240149964553c9d7b7a7 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 22 Feb 2026 00:30:55 +0900 Subject: [PATCH 10/13] refactor: reorganize text layout engine and enhance caret handling - Moved `SkiaLayoutEngine` implementation to a dedicated module for better structure and maintainability. - Introduced `CaretRect` struct to encapsulate caret geometry, improving clarity in caret positioning logic. - Updated `TextLayoutEngine` trait to include `caret_rect_at` method, enhancing caret management across layout engines. - Refined cursor movement logic to ensure consistent behavior when navigating through text and lines. --- crates/grida-dev/examples/text_edit/layout.rs | 44 ++- crates/grida-dev/examples/text_edit/mod.rs | 133 ++++--- .../examples/text_edit/simple_layout.rs | 53 +-- .../examples/text_edit/skia_layout.rs | 212 +++++++++++ crates/grida-dev/examples/text_edit/tests.rs | 341 +++++++++++++++++- crates/grida-dev/examples/wd_text_editor.rs | 222 ++---------- 6 files changed, 720 insertions(+), 285 deletions(-) create mode 100644 crates/grida-dev/examples/text_edit/skia_layout.rs diff --git a/crates/grida-dev/examples/text_edit/layout.rs b/crates/grida-dev/examples/text_edit/layout.rs index 0f754da26f..6b2c7e2fef 100644 --- a/crates/grida-dev/examples/text_edit/layout.rs +++ b/crates/grida-dev/examples/text_edit/layout.rs @@ -33,6 +33,20 @@ impl LineMetrics { } } +/// Caret geometry returned by [`TextLayoutEngine::caret_rect_at`]. +/// +/// All coordinates are in **layout-local space** (origin at top-left of the +/// text layout box). +#[derive(Clone, Debug, PartialEq)] +pub struct CaretRect { + /// X coordinate of the caret (left edge). + pub x: f32, + /// Y coordinate of the top of the caret. + pub y: f32, + /// Height of the caret (covers one line). + pub height: f32, +} + /// Abstract geometry provider. /// /// Implementations include: @@ -51,8 +65,19 @@ pub trait TextLayoutEngine { /// within an empty line's y-band should return the start of that line. fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize; + /// Return the full caret rectangle for the cursor at `offset`. + /// + /// The line that owns the cursor is determined by the forward-scan rule: + /// the first line where `offset < end_index`, falling back to the last + /// line when `offset >= all end indices` (e.g. cursor at text end). + fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect; + /// Return the x coordinate (layout-local) of the caret at `offset`. - fn caret_x_at(&mut self, text: &str, offset: usize) -> f32; + /// + /// Default implementation delegates to `caret_rect_at`. + fn caret_x_at(&mut self, text: &str, offset: usize) -> f32 { + self.caret_rect_at(text, offset).x + } /// Return `(word_start, word_end)` for the word that contains `offset`. /// Both bounds are UTF-8 byte offsets. @@ -66,15 +91,14 @@ pub trait TextLayoutEngine { // Utility: find the line index for a UTF-8 offset // --------------------------------------------------------------------------- -/// Find which line index contains `utf8_offset`. +/// Find which line contains `utf8_offset`. /// -/// Scans from the last line backwards: `start_index <= offset` correctly maps -/// a cursor at the start of line N to line N (not N-1). +/// Forward-scan: first line where `offset < end_index`. +/// Falls back to the last line when offset is past all end indices. +/// This is the same rule used by `caret_rect_at`. pub fn line_index_for_offset(metrics: &[LineMetrics], utf8_offset: usize) -> usize { - for (i, lm) in metrics.iter().enumerate().rev() { - if lm.start_index <= utf8_offset { - return i; - } - } - 0 + metrics + .iter() + .position(|lm| utf8_offset < lm.end_index) + .unwrap_or(metrics.len().saturating_sub(1)) } diff --git a/crates/grida-dev/examples/text_edit/mod.rs b/crates/grida-dev/examples/text_edit/mod.rs index c81e131a26..ec1ccf96bc 100644 --- a/crates/grida-dev/examples/text_edit/mod.rs +++ b/crates/grida-dev/examples/text_edit/mod.rs @@ -1,9 +1,10 @@ pub mod history; pub mod layout; pub mod simple_layout; +pub mod skia_layout; pub use history::{EditHistory, EditKind}; -pub use layout::{line_index_for_offset, LineMetrics, TextLayoutEngine}; +pub use layout::{line_index_for_offset, CaretRect, LineMetrics, TextLayoutEngine}; pub use simple_layout::SimpleLayoutEngine; use unicode_segmentation::UnicodeSegmentation; @@ -372,28 +373,26 @@ pub fn apply_command( EditingCommand::MoveLeft { extend } => { if !extend && s.has_selection() { - if let Some((lo, _)) = s.selection_range() { - s.cursor = lo; - s.anchor = None; - return s; - } + let lo = s.selection_range().map_or(s.cursor, |(lo, _)| lo); + s.cursor = lo; + s.anchor = None; + } else { + set_anchor_if_extending(&mut s, extend); + s.cursor = prev_grapheme_boundary(&s.text, s.cursor); + clear_anchor_if_not_extending(&mut s, extend); } - set_anchor_if_extending(&mut s, extend); - s.cursor = prev_grapheme_boundary(&s.text, s.cursor); - clear_anchor_if_not_extending(&mut s, extend); } EditingCommand::MoveRight { extend } => { if !extend && s.has_selection() { - if let Some((_, hi)) = s.selection_range() { - s.cursor = hi; - s.anchor = None; - return s; - } + let hi = s.selection_range().map_or(s.cursor, |(_, hi)| hi); + s.cursor = hi; + s.anchor = None; + } else { + set_anchor_if_extending(&mut s, extend); + s.cursor = next_grapheme_boundary(&s.text, s.cursor); + clear_anchor_if_not_extending(&mut s, extend); } - set_anchor_if_extending(&mut s, extend); - s.cursor = next_grapheme_boundary(&s.text, s.cursor); - clear_anchor_if_not_extending(&mut s, extend); } EditingCommand::MoveDocStart { extend } => { @@ -414,8 +413,8 @@ pub fn apply_command( } EditingCommand::SetCursorPos { pos, anchor } => { - s.cursor = pos.min(s.text.len()); - s.anchor = anchor; + s.cursor = snap_grapheme_boundary(&s.text, pos); + s.anchor = anchor.map(|a| snap_grapheme_boundary(&s.text, a)); } EditingCommand::BackspaceLine => { @@ -429,7 +428,15 @@ pub fn apply_command( if line_start < s.cursor { s.text.drain(line_start..s.cursor); s.cursor = line_start; + } else if s.cursor > 0 { + let prev = prev_grapheme_boundary(&s.text, s.cursor); + s.text.drain(prev..s.cursor); + s.cursor = prev; } + } else if s.cursor > 0 { + let prev = prev_grapheme_boundary(&s.text, s.cursor); + s.text.drain(prev..s.cursor); + s.cursor = prev; } } s.anchor = None; @@ -457,31 +464,46 @@ pub fn apply_command( EditingCommand::MoveUp { extend } => { set_anchor_if_extending(&mut s, extend); - let x = layout.caret_x_at(&s.text, s.cursor); let metrics = layout.line_metrics(&s.text); - let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); - if line_idx > 0 { - let prev = &metrics[line_idx - 1]; - let target_y = prev.baseline - prev.ascent * 0.5; - s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); - } else { + if metrics.is_empty() { s.cursor = 0; + } else { + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + if line_idx > 0 { + let target = &metrics[line_idx - 1]; + if target.is_empty_line() { + s.cursor = target.start_index; + } else { + let x = layout.caret_x_at(&s.text, s.cursor); + let target_y = target.baseline - target.ascent * 0.5; + s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); + } + } else { + s.cursor = 0; + } } clear_anchor_if_not_extending(&mut s, extend); } EditingCommand::MoveDown { extend } => { set_anchor_if_extending(&mut s, extend); - let x = layout.caret_x_at(&s.text, s.cursor); let metrics = layout.line_metrics(&s.text); - let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); - if line_idx + 1 < metrics.len() { - let next = &metrics[line_idx + 1]; - let target_y = next.baseline - next.ascent * 0.5; - // Delegate to position_at_point; implementations handle empty lines. - s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); - } else { + if metrics.is_empty() { s.cursor = s.text.len(); + } else { + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + if line_idx + 1 < metrics.len() { + let target = &metrics[line_idx + 1]; + if target.is_empty_line() { + s.cursor = target.start_index; + } else { + let x = layout.caret_x_at(&s.text, s.cursor); + let target_y = target.baseline - target.ascent * 0.5; + s.cursor = layout.position_at_point(&s.text, x, target_y.max(0.0)); + } + } else { + s.cursor = s.text.len(); + } } clear_anchor_if_not_extending(&mut s, extend); } @@ -489,22 +511,29 @@ pub fn apply_command( EditingCommand::MoveHome { extend } => { set_anchor_if_extending(&mut s, extend); let metrics = layout.line_metrics(&s.text); - let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); - s.cursor = metrics[line_idx].start_index; + if !metrics.is_empty() { + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + s.cursor = metrics[line_idx].start_index; + } else { + s.cursor = 0; + } clear_anchor_if_not_extending(&mut s, extend); } EditingCommand::MoveEnd { extend } => { set_anchor_if_extending(&mut s, extend); let metrics = layout.line_metrics(&s.text); - let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); - let lm = &metrics[line_idx]; - let mut end = lm.end_index.min(s.text.len()); - // Step back over trailing newline so caret sits before it visually. - if end > 0 && s.text[..end].ends_with('\n') { - end = prev_grapheme_boundary(&s.text, end); + if !metrics.is_empty() { + let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); + let lm = &metrics[line_idx]; + let mut end = lm.end_index.min(s.text.len()); + if end > lm.start_index && s.text[..end].ends_with('\n') { + end = prev_grapheme_boundary(&s.text, end).max(lm.start_index); + } + s.cursor = end; + } else { + s.cursor = s.text.len(); } - s.cursor = end; clear_anchor_if_not_extending(&mut s, extend); } @@ -686,14 +715,18 @@ fn clear_anchor_if_not_extending(s: &mut TextEditorState, extend: bool) { } } -/// line_index_for_offset using UTF-8 metrics (mirrors the Skia-agnostic version). +/// Find which line contains `utf8_offset`. +/// +/// Uses the forward-scan rule: the first line where `offset < end_index`. +/// Falls back to the last line when `offset >= all end indices` (cursor at +/// text end or on a trailing phantom line). This is the same rule used by +/// `caret_rect_at`, ensuring editing commands and caret rendering always +/// agree on which line the cursor belongs to. pub fn line_index_for_offset_utf8(metrics: &[LineMetrics], utf8_offset: usize) -> usize { - for (i, lm) in metrics.iter().enumerate().rev() { - if lm.start_index <= utf8_offset { - return i; - } - } - 0 + metrics + .iter() + .position(|lm| utf8_offset < lm.end_index) + .unwrap_or(metrics.len().saturating_sub(1)) } #[cfg(test)] diff --git a/crates/grida-dev/examples/text_edit/simple_layout.rs b/crates/grida-dev/examples/text_edit/simple_layout.rs index 57faabf226..7e6642a335 100644 --- a/crates/grida-dev/examples/text_edit/simple_layout.rs +++ b/crates/grida-dev/examples/text_edit/simple_layout.rs @@ -10,10 +10,7 @@ //! and wrong for real rendering — its only purpose is to produce deterministic, //! inspectable results for unit tests. -use super::{ - layout::{LineMetrics, TextLayoutEngine}, - line_index_for_offset_utf8, -}; +use super::layout::{CaretRect, LineMetrics, TextLayoutEngine}; use unicode_segmentation::UnicodeSegmentation; @@ -103,29 +100,43 @@ impl TextLayoutEngine for SimpleLayoutEngine { return lm.start_index; } - // Map x → column (character index within the line). - // Do NOT apply prev_grapheme_boundary here: column-based offsets are - // already on character boundaries and the function would shift them back - // one position when pos happens to equal a grapheme-end. let content_end = Self::content_end(lm, text); - let line_char_len = content_end - lm.start_index; - let column = ((x / self.char_width).round() as usize).min(line_char_len); - (lm.start_index + column).min(text.len()) + let line_content = &text[lm.start_index..content_end]; + let graphemes: Vec<(usize, &str)> = line_content.grapheme_indices(true).collect(); + let column = ((x / self.char_width).round() as usize).min(graphemes.len()); + if column >= graphemes.len() { + content_end.min(text.len()) + } else { + (lm.start_index + graphemes[column].0).min(text.len()) + } } - fn caret_x_at(&mut self, text: &str, offset: usize) -> f32 { - // Cursor right after \n → x = 0 on new line. - if offset > 0 && text[..offset].ends_with('\n') { - return 0.0; - } + fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect { let metrics = self.compute_metrics(text); if metrics.is_empty() { - return 0.0; + return CaretRect { x: 0.0, y: 0.0, height: self.line_height }; + } + + // Forward-scan: first line where offset < end_index. + let idx = metrics + .iter() + .position(|lm| offset < lm.end_index) + .unwrap_or(metrics.len() - 1); + let lm = &metrics[idx]; + + let x = if offset <= lm.start_index { + 0.0 + } else { + let before_cursor = &text[lm.start_index..offset]; + let grapheme_count = before_cursor.graphemes(true).count(); + grapheme_count as f32 * self.char_width + }; + + CaretRect { + x, + y: lm.baseline - lm.ascent, + height: lm.ascent + lm.descent, } - let line_idx = line_index_for_offset_utf8(&metrics, offset); - let lm = &metrics[line_idx]; - let column = offset - lm.start_index; - column as f32 * self.char_width } fn word_boundary_at(&mut self, text: &str, offset: usize) -> (usize, usize) { diff --git a/crates/grida-dev/examples/text_edit/skia_layout.rs b/crates/grida-dev/examples/text_edit/skia_layout.rs new file mode 100644 index 0000000000..d307cfca40 --- /dev/null +++ b/crates/grida-dev/examples/text_edit/skia_layout.rs @@ -0,0 +1,212 @@ +use skia_safe::{ + textlayout::{ + FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, + RectHeightStyle, RectWidthStyle, TextStyle, + }, + Color, FontMgr, Point, +}; + +use super::{ + layout::{CaretRect, LineMetrics, TextLayoutEngine}, + prev_grapheme_boundary, snap_grapheme_boundary, + utf16_to_utf8_offset, utf8_to_utf16_offset, +}; + +const DEFAULT_FONT_SIZE: f32 = 18.0; + +/// Skia-backed `TextLayoutEngine`. +/// +/// Rebuilds the `Paragraph` lazily when text or layout_width changes. +/// No GPU or window required — pure CPU text layout. +pub struct SkiaLayoutEngine { + pub font_collection: FontCollection, + pub paragraph: Option, + pub layout_width: f32, + pub layout_height: f32, + pub font_size: f32, + cached_text: String, +} + +impl SkiaLayoutEngine { + pub fn new(layout_width: f32, layout_height: f32) -> Self { + let mut fc = FontCollection::new(); + fc.set_default_font_manager(FontMgr::new(), None); + Self { + font_collection: fc, + paragraph: None, + layout_width, + layout_height, + font_size: DEFAULT_FONT_SIZE, + cached_text: String::new(), + } + } + + pub fn with_font_size(mut self, size: f32) -> Self { + self.font_size = size; + self + } + + pub fn ensure_layout(&mut self, text: &str) { + if self.paragraph.is_none() || self.cached_text != text { + self.rebuild(text); + } + } + + fn rebuild(&mut self, text: &str) { + let mut style = ParagraphStyle::new(); + style.set_apply_rounding_hack(false); + let mut ts = TextStyle::new(); + ts.set_font_size(self.font_size); + ts.set_color(Color::BLACK); + ts.set_font_families(&["Menlo", "Courier New", "monospace"]); + let mut builder = ParagraphBuilder::new(&style, &self.font_collection); + builder.push_style(&ts); + builder.add_text(text); + let mut para = builder.build(); + para.layout(self.layout_width); + self.paragraph = Some(para); + self.cached_text = text.to_owned(); + } + + pub fn set_layout_width(&mut self, w: f32) { + let new_w = w.max(1.0); + if (new_w - self.layout_width).abs() > 0.5 { + self.layout_width = new_w; + self.paragraph = None; + } + } + + pub fn set_layout_height(&mut self, h: f32) { + let new_h = h.max(1.0); + if (new_h - self.layout_height).abs() > 0.5 { + self.layout_height = new_h; + } + } + + fn para(&mut self, text: &str) -> &Paragraph { + self.ensure_layout(text); + self.paragraph.as_ref().unwrap() + } +} + +impl TextLayoutEngine for SkiaLayoutEngine { + fn line_metrics(&mut self, text: &str) -> Vec { + let skia = self.para(text).get_line_metrics(); + let mut result = Vec::with_capacity(skia.len()); + let mut prev_end: usize = 0; + + for lm in &skia { + let start = utf16_to_utf8_offset(text, lm.start_index); + let end = utf16_to_utf8_offset(text, lm.end_including_newline).min(text.len()); + + // Skia's phantom/trailing lines can have overlapping or + // degenerate ranges. Clamp start to never go before the + // previous line's end, and skip lines that would be empty + // and overlapping. + let start = start.max(prev_end); + let end = end.max(start); + + result.push(LineMetrics { + start_index: start, + end_index: end, + baseline: lm.baseline as f32, + ascent: lm.ascent as f32, + descent: lm.descent as f32, + }); + prev_end = end; + } + + // If the text ends with \n, ensure there's a phantom line at text.len() + // so the cursor can be placed on the new empty line. + if !text.is_empty() && text.ends_with('\n') { + let last_end = result.last().map_or(0, |lm| lm.end_index); + if last_end < text.len() || result.last().map_or(true, |lm| lm.start_index < lm.end_index) { + if last_end <= text.len() { + let last = result.last().unwrap(); + let line_height = last.ascent + last.descent; + result.push(LineMetrics { + start_index: text.len(), + end_index: text.len(), + baseline: last.baseline + line_height, + ascent: last.ascent, + descent: last.descent, + }); + } + } + } + + result + } + + fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { + self.ensure_layout(text); + let para = self.paragraph.as_ref().unwrap(); + let metrics = para.get_line_metrics(); + + for lm in &metrics { + let top = lm.baseline as f32 - lm.ascent as f32; + let bot = lm.baseline as f32 + lm.descent as f32; + if y >= top - 0.5 && y <= bot + 0.5 { + if lm.end_index.saturating_sub(lm.start_index) <= 1 { + return utf16_to_utf8_offset(text, lm.start_index).min(text.len()); + } + break; + } + } + + let pwa = para.get_glyph_position_at_coordinate(Point::new(x, y)); + let raw = utf16_to_utf8_offset(text, pwa.position.max(0) as usize).min(text.len()); + snap_grapheme_boundary(text, raw) + } + + fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect { + // Use our own line_metrics() (which includes \n in end_index and + // fixes phantom-line ranges) so the line lookup is identical to + // what apply_command uses. + let metrics = self.line_metrics(text); + + if metrics.is_empty() { + return CaretRect { x: 0.0, y: 0.0, height: self.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 { + 0.0 + } else { + self.ensure_layout(text); + let u16_end = utf8_to_utf16_offset(text, offset); + let cluster_start = prev_grapheme_boundary(text, offset); + let u16_start = utf8_to_utf16_offset(text, cluster_start); + let rects = self.paragraph.as_ref().unwrap().get_rects_for_range( + u16_start..u16_end, + RectHeightStyle::Max, + 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_layout(text); + let u16_pos = utf8_to_utf16_offset(text, offset) as u32; + let para = self.paragraph.as_ref().unwrap(); + let range = para.get_word_boundary(u16_pos); + let start = utf16_to_utf8_offset(text, range.start as usize); + let end = utf16_to_utf8_offset(text, range.end as usize); + (start, end) + } + + fn viewport_height(&self) -> f32 { + self.layout_height + } +} diff --git a/crates/grida-dev/examples/text_edit/tests.rs b/crates/grida-dev/examples/text_edit/tests.rs index fe7905970d..6e5283fdf7 100644 --- a/crates/grida-dev/examples/text_edit/tests.rs +++ b/crates/grida-dev/examples/text_edit/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 super::{apply_command, floor_char_boundary, ceil_char_boundary, layout::TextLayoutEngine, snap_grapheme_boundary, word_segment_at, EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; +use super::{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}; fn layout() -> SimpleLayoutEngine { SimpleLayoutEngine::default_test() @@ -1386,11 +1386,13 @@ fn backspace_line_deletes_to_line_start() { } #[test] -fn backspace_line_at_line_start_is_noop() { +fn backspace_line_at_line_start_deletes_newline() { + // Cursor at start of "World" (pos 6) — nothing to delete on this line, + // so fall back to grapheme backspace: delete the \n and merge lines. let s = TextEditorState::with_cursor("Hello\nWorld", 6); let s = apply(&s, EditingCommand::BackspaceLine); - assert_eq!(s.text, "Hello\nWorld"); - assert_eq!(s.cursor, 6); + assert_eq!(s.text, "HelloWorld"); + assert_eq!(s.cursor, 5); } #[test] @@ -1700,3 +1702,334 @@ fn repeated_word_deletion_never_panics() { assert!(s.text.is_empty(), "DeleteWord loop did not empty: {:?}", s.text); } } + +// =========================================================================== +// Caret rect invariants: geometry is valid for every cursor position +// =========================================================================== + +fn assert_valid_caret_rect(cr: &CaretRect, text: &str, offset: usize) { + assert!( + cr.x >= 0.0, + "caret_rect_at({}).x = {} is negative for {:?}", + offset, cr.x, &text[..text.len().min(40)] + ); + assert!( + cr.y >= 0.0, + "caret_rect_at({}).y = {} is negative", offset, cr.y + ); + assert!( + cr.height > 0.0, + "caret_rect_at({}).height = {} is non-positive", offset, cr.height + ); + assert!( + cr.x.is_finite() && cr.y.is_finite() && cr.height.is_finite(), + "caret_rect_at({}) contains non-finite value: {:?}", offset, cr + ); +} + +#[test] +fn caret_rect_valid_at_every_position() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + let cr = lay.caret_rect_at(text, pos); + assert_valid_caret_rect(&cr, text, pos); + } + } +} + +#[test] +fn caret_rect_y_monotonic_with_lines() { + let mut lay = layout(); + let texts = &[ + "hello\nworld\n\nfoo", + "A\nB\nC\nD\nE", + "\n\n\n", + "single line", + ]; + for &text in texts { + let boundaries = grapheme_boundaries(text); + let mut prev_y: Option = None; + for &pos in &boundaries { + let cr = lay.caret_rect_at(text, pos); + if let Some(py) = prev_y { + assert!( + cr.y >= py, + "caret y went backwards at offset {}: {} < {} in {:?}", + pos, cr.y, py, text + ); + } + prev_y = Some(cr.y); + } + } +} + +#[test] +fn caret_rect_x_zero_at_line_start() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let metrics = lay.line_metrics(text); + for lm in &metrics { + let cr = lay.caret_rect_at(text, lm.start_index); + assert_eq!( + cr.x, 0.0, + "caret at line start (offset {}) must have x=0, got {} in {:?}", + lm.start_index, cr.x, &text[..text.len().min(40)] + ); + } + } +} + +#[test] +fn caret_rect_valid_after_every_command() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + for cmd in movement_commands() { + let s = TextEditorState::with_cursor(text, pos); + let result = apply_command(&s, cmd, &mut lay); + let cr = lay.caret_rect_at(&result.text, result.cursor); + assert_valid_caret_rect(&cr, &result.text, result.cursor); + } + } + } +} + +// =========================================================================== +// Navigation consistency: editing commands and caret geometry must agree +// on which line the cursor is on. +// =========================================================================== + +/// For every cursor position, verify that `line_index_for_offset_utf8` +/// and `caret_rect_at` agree on the line. +#[test] +fn line_index_and_caret_rect_agree() { + let mut lay = layout(); + for &text in SAFETY_TEXTS { + let metrics = lay.line_metrics(text); + if metrics.is_empty() { continue; } + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + let idx = line_index_for_offset_utf8(&metrics, pos); + let cr = lay.caret_rect_at(text, pos); + let lm = &metrics[idx]; + assert!( + (cr.y - (lm.baseline - lm.ascent)).abs() < 0.01, + "line_index says line {} (y={}) but caret_rect_at({}) gives y={} in {:?}", + idx, lm.baseline - lm.ascent, pos, cr.y, &text[..text.len().min(40)] + ); + } + } +} + +#[test] +fn move_end_stays_on_same_line() { + let mut lay = layout(); + let text = "Hello, World!\nType here\n\n=== Controls ===\n"; + let metrics = lay.line_metrics(text); + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + let before_line = line_index_for_offset_utf8(&metrics, pos); + let s = TextEditorState::with_cursor(text, pos); + let result = apply_command(&s, EditingCommand::MoveEnd { extend: false }, &mut lay); + let after_line = line_index_for_offset_utf8(&metrics, result.cursor); + assert_eq!( + before_line, after_line, + "MoveEnd moved from line {} to line {} (cursor {}→{}) in {:?}", + before_line, after_line, pos, result.cursor, text + ); + } +} + +#[test] +fn move_home_stays_on_same_line() { + let mut lay = layout(); + let text = "Hello, World!\nType here\n\n=== Controls ===\n"; + let metrics = lay.line_metrics(text); + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + let before_line = line_index_for_offset_utf8(&metrics, pos); + let s = TextEditorState::with_cursor(text, pos); + let result = apply_command(&s, EditingCommand::MoveHome { extend: false }, &mut lay); + let after_line = line_index_for_offset_utf8(&metrics, result.cursor); + assert_eq!( + before_line, after_line, + "MoveHome moved from line {} to line {} (cursor {}→{}) in {:?}", + before_line, after_line, pos, result.cursor, text + ); + } +} + +// =========================================================================== +// Loop navigation tests: from EVERY position, walk in each direction. +// Assert the cursor always makes progress and never gets stuck. +// =========================================================================== + +const NAV_TEXTS: &[&str] = &[ + "Hello, World!\nType here to edit text.\n\n=== Controls ===\n", + "A\nB\n\nC\n\nD", + "Hello\n\n===", + "(abc) d efg h?\n\nmore text\n", + "\n\n\n", + "single line no newline", + "trailing newline\n", + "\u{D55C}\u{AD6D}\u{C5B4}\n\u{65E5}\u{672C}\u{8A9E}\n\nend", + "", + "x", +]; + +fn assert_walk_right(text: &str, start: usize, lay: &mut dyn TextLayoutEngine) { + let mut s = TextEditorState::with_cursor(text, start); + let max = text.len() + 10; + for step in 0..max { + if s.cursor >= text.len() { return; } + let before = s.cursor; + s = apply_command(&s, EditingCommand::MoveRight { extend: false }, lay); + assert!(s.cursor > before, + "MoveRight stuck at offset {} (step {}) in {:?}", + before, step, &text[..text.len().min(60)]); + } + panic!("MoveRight did not reach end from offset {} in {:?}", start, &text[..text.len().min(60)]); +} + +fn assert_walk_left(text: &str, start: usize, lay: &mut dyn TextLayoutEngine) { + let mut s = TextEditorState::with_cursor(text, start); + let max = text.len() + 10; + for step in 0..max { + if s.cursor == 0 { return; } + let before = s.cursor; + s = apply_command(&s, EditingCommand::MoveLeft { extend: false }, lay); + assert!(s.cursor < before, + "MoveLeft stuck at offset {} (step {}) in {:?}", + before, step, &text[..text.len().min(60)]); + } + panic!("MoveLeft did not reach 0 from offset {} in {:?}", start, &text[..text.len().min(60)]); +} + +fn assert_walk_down(text: &str, start: usize, lay: &mut dyn TextLayoutEngine) { + let mut s = TextEditorState::with_cursor(text, start); + let max = text.len() + 10; + for step in 0..max { + if s.cursor >= text.len() { return; } + let before = s.cursor; + s = apply_command(&s, EditingCommand::MoveDown { extend: false }, lay); + assert!(s.cursor > before || s.cursor == text.len(), + "MoveDown stuck at offset {} (step {}) in {:?}", + before, step, &text[..text.len().min(60)]); + } + panic!("MoveDown did not reach end from offset {} in {:?}", start, &text[..text.len().min(60)]); +} + +fn assert_walk_up(text: &str, start: usize, lay: &mut dyn TextLayoutEngine) { + let mut s = TextEditorState::with_cursor(text, start); + let max = text.len() + 10; + for step in 0..max { + if s.cursor == 0 { return; } + let before = s.cursor; + s = apply_command(&s, EditingCommand::MoveUp { extend: false }, lay); + assert!(s.cursor < before || s.cursor == 0, + "MoveUp stuck at offset {} (step {}) in {:?}", + before, step, &text[..text.len().min(60)]); + } + panic!("MoveUp did not reach 0 from offset {} in {:?}", start, &text[..text.len().min(60)]); +} + +fn run_nav_tests(lay: &mut dyn TextLayoutEngine) { + for &text in NAV_TEXTS { + let boundaries = grapheme_boundaries(text); + for &pos in &boundaries { + assert_walk_right(text, pos, lay); + assert_walk_left(text, pos, lay); + assert_walk_down(text, pos, lay); + assert_walk_up(text, pos, lay); + } + } +} + +// --- SimpleLayoutEngine --- + +#[test] +fn nav_never_locks_simple() { + run_nav_tests(&mut layout()); +} + +// --- SkiaLayoutEngine (real Skia paragraph layout, no GPU needed) --- + +use super::skia_layout::SkiaLayoutEngine; + +fn skia_layout() -> SkiaLayoutEngine { + SkiaLayoutEngine::new(752.0, 576.0) +} + +#[test] +fn nav_never_locks_skia() { + run_nav_tests(&mut skia_layout()); +} + +// =========================================================================== +// Hit-testing: position_at_point returns valid, correct offsets +// =========================================================================== + +fn run_hit_test_invariants(lay: &mut dyn TextLayoutEngine, label: &str) { + for &text in NAV_TEXTS { + if text.is_empty() { continue; } + let metrics = lay.line_metrics(text); + if metrics.is_empty() { continue; } + + // For each non-phantom line, clicking at x=0 must return start_index. + for (line_idx, lm) in metrics.iter().enumerate() { + if lm.start_index == lm.end_index { continue; } + let mid_y = lm.baseline - lm.ascent * 0.5; + + let pos = lay.position_at_point(text, 0.0, mid_y); + assert!( + text.is_char_boundary(pos) && pos <= text.len(), + "[{}] position_at_point(0, {}) returned invalid offset {} for {:?}", + label, mid_y, pos, &text[..text.len().min(40)] + ); + assert_eq!( + pos, lm.start_index, + "[{}] click at x=0 on line {} should give start_index {}, got {} in {:?}", + label, line_idx, lm.start_index, pos, &text[..text.len().min(40)] + ); + } + + // Sweep: for every non-phantom line, sample x positions and verify + // offsets are valid char boundaries and monotonically non-decreasing. + for (line_idx, lm) in metrics.iter().enumerate() { + if lm.start_index == lm.end_index { continue; } + let mid_y = lm.baseline - lm.ascent * 0.5; + let mut prev_pos: Option = None; + + for x_step in 0..=20 { + let x = x_step as f32 * 40.0; + let pos = lay.position_at_point(text, x, mid_y); + assert!( + text.is_char_boundary(pos) && pos <= text.len(), + "[{}] position_at_point({}, {}) invalid offset {} on line {}", + label, x, mid_y, pos, line_idx + ); + if let Some(pp) = prev_pos { + assert!( + pos >= pp, + "[{}] position_at_point not monotonic: x={} gave {}, x={} gave {} on line {} in {:?}", + label, (x_step - 1) as f32 * 40.0, pp, x, pos, line_idx, &text[..text.len().min(40)] + ); + } + prev_pos = Some(pos); + } + } + } +} + +#[test] +fn hit_test_invariants_simple() { + run_hit_test_invariants(&mut layout(), "simple"); +} + +#[test] +fn hit_test_invariants_skia() { + run_hit_test_invariants(&mut skia_layout(), "skia"); +} diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-dev/examples/wd_text_editor.rs index b9a83d55ec..b7870da26c 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-dev/examples/wd_text_editor.rs @@ -86,10 +86,10 @@ use raw_window_handle::HasRawWindowHandle; use skia_safe::{ gpu::{self, backend_render_targets, gl::FramebufferInfo, surfaces::wrap_backend_render_target}, textlayout::{ - FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, RectHeightStyle, + Paragraph, ParagraphBuilder, ParagraphStyle, RectHeightStyle, RectWidthStyle, TextDecoration, TextStyle, }, - Color, ColorType, FontMgr, Paint, Point, Rect, Surface, + Color, ColorType, Paint, Point, Rect, Surface, }; use winit::{ application::ApplicationHandler, @@ -101,10 +101,11 @@ use winit::{ }; use crate::text_edit::{ - apply_command, prev_grapheme_boundary, snap_grapheme_boundary, utf16_to_utf8_offset, - utf8_to_utf16_offset, EditHistory, EditKind, EditingCommand, LineMetrics, TextEditorState, - TextLayoutEngine, + apply_command, utf16_to_utf8_offset, utf8_to_utf16_offset, + EditHistory, EditKind, EditingCommand, LineMetrics, TextEditorState, TextLayoutEngine, }; +use crate::text_edit::layout::CaretRect; +use crate::text_edit::skia_layout::SkiaLayoutEngine; // --------------------------------------------------------------------------- // Constants @@ -288,166 +289,7 @@ impl Default for TextEditorConfig { } } -// --------------------------------------------------------------------------- -// SkiaLayoutEngine — implements TextLayoutEngine using Skia Paragraph -// --------------------------------------------------------------------------- - -/// Skia-backed `TextLayoutEngine`. -/// -/// Rebuilds the `Paragraph` lazily when text or layout_width changes. -struct SkiaLayoutEngine { - font_collection: FontCollection, - paragraph: Option, - layout_width: f32, - layout_height: f32, - /// Text at the time of the last paragraph build. - cached_text: String, -} - -impl SkiaLayoutEngine { - fn new(layout_width: f32, layout_height: f32) -> Self { - let mut fc = FontCollection::new(); - fc.set_default_font_manager(FontMgr::new(), None); - Self { - font_collection: fc, - paragraph: None, - layout_width, - layout_height, - cached_text: String::new(), - } - } - - fn ensure_layout(&mut self, text: &str) { - if self.paragraph.is_none() || self.cached_text != text { - self.rebuild(text); - } - } - - fn rebuild(&mut self, text: &str) { - let mut style = ParagraphStyle::new(); - style.set_apply_rounding_hack(false); - let mut ts = TextStyle::new(); - ts.set_font_size(FONT_SIZE); - ts.set_color(Color::BLACK); - ts.set_font_families(&["Menlo", "Courier New", "monospace"]); - let mut builder = ParagraphBuilder::new(&style, &self.font_collection); - builder.push_style(&ts); - builder.add_text(text); - let mut para = builder.build(); - para.layout(self.layout_width); - self.paragraph = Some(para); - self.cached_text = text.to_owned(); - } - - fn set_layout_width(&mut self, w: f32) { - let new_w = (w - PADDING * 2.0).max(1.0); - if (new_w - self.layout_width).abs() > 0.5 { - self.layout_width = new_w; - self.paragraph = None; // invalidate - } - } - - fn set_layout_height(&mut self, h: f32) { - let new_h = (h - PADDING * 2.0).max(1.0); - if (new_h - self.layout_height).abs() > 0.5 { - self.layout_height = new_h; - } - } - - /// Return a reference to the paragraph, ensuring it's built for `text`. - fn para(&mut self, text: &str) -> &Paragraph { - self.ensure_layout(text); - self.paragraph.as_ref().unwrap() - } -} - -impl TextLayoutEngine for SkiaLayoutEngine { - fn line_metrics(&mut self, text: &str) -> Vec { - let skia = self.para(text).get_line_metrics(); - skia.iter() - .map(|lm| LineMetrics { - start_index: utf16_to_utf8_offset(text, lm.start_index), - end_index: utf16_to_utf8_offset(text, lm.end_index).min(text.len()), - baseline: lm.baseline as f32, - ascent: lm.ascent as f32, - descent: lm.descent as f32, - }) - .collect() - } - - fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { - self.ensure_layout(text); - let para = self.paragraph.as_ref().unwrap(); - let metrics = para.get_line_metrics(); - - // Empty-line check: if the target y falls within an empty line's band, - // return that line's start offset directly. Skia's hit-test returns - // the previous line's position for empty lines → cursor gets locked. - for lm in &metrics { - let top = lm.baseline as f32 - lm.ascent as f32; - let bot = lm.baseline as f32 + lm.descent as f32; - if y >= top - 0.5 && y <= bot + 0.5 { - if lm.end_index.saturating_sub(lm.start_index) <= 1 { - return utf16_to_utf8_offset(text, lm.start_index).min(text.len()); - } - break; - } - } - - let pwa = para.get_glyph_position_at_coordinate(Point::new(x, y)); - let raw = utf16_to_utf8_offset(text, pwa.position.max(0) as usize).min(text.len()); - // Use snap_grapheme_boundary rather than prev_grapheme_boundary: - // Skia often returns the exact start of a grapheme; prev_grapheme_boundary - // would step back one cluster in that case, locking the cursor on the - // previous line (the empty-line lock bug). - snap_grapheme_boundary(text, raw) - } - - fn caret_x_at(&mut self, text: &str, offset: usize) -> f32 { - if offset == 0 { - return 0.0; - } - if text[..offset].ends_with('\n') { - return 0.0; - } - self.ensure_layout(text); - let u16_end = utf8_to_utf16_offset(text, offset); - - // Query the rect for the ENTIRE grapheme cluster that ends at `offset`, - // not just the last UTF-16 code unit. - // - // Without this, complex-script combining marks (Devanagari virama/vowel - // signs, Thai sara) produce a rect positioned at the base consonant - // rather than the visual end of the cluster, causing the caret to jump - // leftward after stepping through the cluster — visually incorrect for - // LTR scripts. - let cluster_start = prev_grapheme_boundary(text, offset); - let u16_start = utf8_to_utf16_offset(text, cluster_start); - - let rects = self.paragraph.as_ref().unwrap().get_rects_for_range( - u16_start..u16_end, - RectHeightStyle::Max, - RectWidthStyle::Tight, - ); - // Use the rightmost right edge across all returned rects (handles bidi - // runs where the cluster may span more than one rect). - rects.iter().map(|tb| tb.rect.right()).fold(0.0_f32, f32::max) - } - - fn word_boundary_at(&mut self, text: &str, offset: usize) -> (usize, usize) { - self.ensure_layout(text); - let u16_pos = utf8_to_utf16_offset(text, offset) as u32; - let para = self.paragraph.as_ref().unwrap(); - let range = para.get_word_boundary(u16_pos); - let start = utf16_to_utf8_offset(text, range.start as usize); - let end = utf16_to_utf8_offset(text, range.end as usize); - (start, end) - } - - fn viewport_height(&self) -> f32 { - self.layout_height - } -} +// SkiaLayoutEngine lives in text_edit::skia_layout (shared with tests). // --------------------------------------------------------------------------- // Utility: find line index from Skia UTF-16 offset (for selection_rects only) @@ -701,11 +543,11 @@ impl TextEditor { // ----------------------------------------------------------------------- fn set_layout_width(&mut self, w: f32) { - self.layout.set_layout_width(w); + self.layout.set_layout_width((w - PADDING * 2.0).max(1.0)); } fn set_layout_height(&mut self, h: f32) { - self.layout.set_layout_height(h); + self.layout.set_layout_height((h - PADDING * 2.0).max(1.0)); } // ----------------------------------------------------------------------- @@ -745,30 +587,6 @@ impl TextEditor { self.reset_blink(); } - // ----------------------------------------------------------------------- - // Caret geometry helpers (for cursor rendering and IME popup placement) - // ----------------------------------------------------------------------- - - fn cursor_x(&mut self) -> f32 { - self.layout.caret_x_at(&self.state.text, self.state.cursor) - } - - /// Baseline y of the cursor line, in layout-local space. - /// - /// Skia's `get_line_metrics()` emits an entry for the phantom empty line - /// that follows a trailing `\n`, so `skia_line_index_for_u16_offset` - /// already maps the cursor to that line. No extrapolation is needed. - fn cursor_baseline_y(&mut self) -> f32 { - self.layout.ensure_layout(&self.state.text); - let metrics = self.layout.paragraph.as_ref().unwrap().get_line_metrics(); - if metrics.is_empty() { - return FONT_SIZE; - } - let cur_u16 = utf8_to_utf16_offset(&self.state.text, self.state.cursor); - let idx = skia_line_index_for_u16_offset(&metrics, cur_u16); - metrics[idx].baseline as f32 - } - // ----------------------------------------------------------------------- // Draw // ----------------------------------------------------------------------- @@ -922,13 +740,12 @@ impl TextEditor { // Cursor if self.cursor_visible && !self.has_selection() { - let cx = self.cursor_x(); - let cy = self.cursor_baseline_y(); + let cr = self.layout.caret_rect_at(&self.state.text, self.state.cursor); let cursor_rect = Rect::from_xywh( - cx + origin.x - CURSOR_WIDTH / 2.0, - cy - FONT_SIZE + origin.y, + cr.x + origin.x - CURSOR_WIDTH / 2.0, + cr.y + origin.y, CURSOR_WIDTH, - FONT_SIZE * 1.2, + cr.height, ); let mut cp = Paint::default(); cp.set_color(Color::BLACK); @@ -1496,11 +1313,16 @@ impl ApplicationHandler for TextEditorApp { } inner.gl_skia.flush_and_present(); - let cx = inner.editor.cursor_x() + PADDING; - let cy = inner.editor.cursor_baseline_y() - FONT_SIZE + PADDING; + let cr = inner.editor.layout.caret_rect_at( + &inner.editor.state.text, + inner.editor.state.cursor, + ); inner.window.set_ime_cursor_area( - LogicalPosition::new(cx as f64, cy as f64), - LogicalSize::new(1.0f64, FONT_SIZE as f64), + LogicalPosition::new( + (cr.x + PADDING) as f64, + (cr.y + PADDING) as f64, + ), + LogicalSize::new(1.0f64, cr.height as f64), ); let deadline = inner.editor.next_blink_deadline(); From fce0bc74d89d67f9bc1d8011395333cda8c69ae2 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 23 Feb 2026 22:22:31 +0900 Subject: [PATCH 11/13] feat: introduce grida-text-edit module for enhanced text editing capabilities - Added a new `grida-text-edit` crate to provide a platform-agnostic text editing engine. - Implemented core functionalities including text insertion, deletion, and cursor navigation. - Integrated `SkiaLayoutEngine` for advanced text layout and rendering. - Established a minimal plain-text editor example using `winit` and Skia for demonstration. - Updated `Cargo.toml` to include necessary dependencies for the new module. --- Cargo.lock | 15 + Cargo.toml | 1 + crates/grida-dev/Cargo.toml | 1 - .../examples/text_edit/skia_layout.rs | 212 ---------- crates/grida-dev/tests/text_edit_example.rs | 5 - crates/grida-text-edit/Cargo.toml | 35 ++ .../examples/wd_text_editor.rs | 15 +- .../src}/history.rs | 0 .../src}/layout.rs | 21 + .../mod.rs => grida-text-edit/src/lib.rs} | 10 +- .../src}/simple_layout.rs | 63 ++- crates/grida-text-edit/src/skia_layout.rs | 388 ++++++++++++++++++ .../src}/tests.rs | 10 +- 13 files changed, 541 insertions(+), 235 deletions(-) delete mode 100644 crates/grida-dev/examples/text_edit/skia_layout.rs delete mode 100644 crates/grida-dev/tests/text_edit_example.rs create mode 100644 crates/grida-text-edit/Cargo.toml rename crates/{grida-dev => grida-text-edit}/examples/wd_text_editor.rs (99%) rename crates/{grida-dev/examples/text_edit => grida-text-edit/src}/history.rs (100%) rename crates/{grida-dev/examples/text_edit => grida-text-edit/src}/layout.rs (84%) rename crates/{grida-dev/examples/text_edit/mod.rs => grida-text-edit/src/lib.rs} (99%) rename crates/{grida-dev/examples/text_edit => grida-text-edit/src}/simple_layout.rs (73%) create mode 100644 crates/grida-text-edit/src/skia_layout.rs rename crates/{grida-dev/examples/text_edit => grida-text-edit/src}/tests.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index 2ef7664e2a..2254c9e300 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1550,6 +1550,21 @@ dependencies = [ "skia-safe", "tokio", "toml 0.9.8", + "winit", +] + +[[package]] +name = "grida-text-edit" +version = "0.0.0" +dependencies = [ + "arboard", + "gl", + "glutin", + "glutin-winit", + "raw-window-handle", + "serde", + "serde_json", + "skia-safe", "unicode-segmentation", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index ceed734c2b..ae24f4f4cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/grida-canvas-wasm", "crates/grida-canvas-fonts", "crates/grida-dev", + "crates/grida-text-edit", "crates/csscascade", "crates/math2", ] diff --git a/crates/grida-dev/Cargo.toml b/crates/grida-dev/Cargo.toml index c9de7c5d17..9a3be0bde0 100644 --- a/crates/grida-dev/Cargo.toml +++ b/crates/grida-dev/Cargo.toml @@ -46,4 +46,3 @@ indicatif = "0.17" toml = "0.9.8" glob = "0.3.3" arboard = "3" -unicode-segmentation = "1" diff --git a/crates/grida-dev/examples/text_edit/skia_layout.rs b/crates/grida-dev/examples/text_edit/skia_layout.rs deleted file mode 100644 index d307cfca40..0000000000 --- a/crates/grida-dev/examples/text_edit/skia_layout.rs +++ /dev/null @@ -1,212 +0,0 @@ -use skia_safe::{ - textlayout::{ - FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, - RectHeightStyle, RectWidthStyle, TextStyle, - }, - Color, FontMgr, Point, -}; - -use super::{ - layout::{CaretRect, LineMetrics, TextLayoutEngine}, - prev_grapheme_boundary, snap_grapheme_boundary, - utf16_to_utf8_offset, utf8_to_utf16_offset, -}; - -const DEFAULT_FONT_SIZE: f32 = 18.0; - -/// Skia-backed `TextLayoutEngine`. -/// -/// Rebuilds the `Paragraph` lazily when text or layout_width changes. -/// No GPU or window required — pure CPU text layout. -pub struct SkiaLayoutEngine { - pub font_collection: FontCollection, - pub paragraph: Option, - pub layout_width: f32, - pub layout_height: f32, - pub font_size: f32, - cached_text: String, -} - -impl SkiaLayoutEngine { - pub fn new(layout_width: f32, layout_height: f32) -> Self { - let mut fc = FontCollection::new(); - fc.set_default_font_manager(FontMgr::new(), None); - Self { - font_collection: fc, - paragraph: None, - layout_width, - layout_height, - font_size: DEFAULT_FONT_SIZE, - cached_text: String::new(), - } - } - - pub fn with_font_size(mut self, size: f32) -> Self { - self.font_size = size; - self - } - - pub fn ensure_layout(&mut self, text: &str) { - if self.paragraph.is_none() || self.cached_text != text { - self.rebuild(text); - } - } - - fn rebuild(&mut self, text: &str) { - let mut style = ParagraphStyle::new(); - style.set_apply_rounding_hack(false); - let mut ts = TextStyle::new(); - ts.set_font_size(self.font_size); - ts.set_color(Color::BLACK); - ts.set_font_families(&["Menlo", "Courier New", "monospace"]); - let mut builder = ParagraphBuilder::new(&style, &self.font_collection); - builder.push_style(&ts); - builder.add_text(text); - let mut para = builder.build(); - para.layout(self.layout_width); - self.paragraph = Some(para); - self.cached_text = text.to_owned(); - } - - pub fn set_layout_width(&mut self, w: f32) { - let new_w = w.max(1.0); - if (new_w - self.layout_width).abs() > 0.5 { - self.layout_width = new_w; - self.paragraph = None; - } - } - - pub fn set_layout_height(&mut self, h: f32) { - let new_h = h.max(1.0); - if (new_h - self.layout_height).abs() > 0.5 { - self.layout_height = new_h; - } - } - - fn para(&mut self, text: &str) -> &Paragraph { - self.ensure_layout(text); - self.paragraph.as_ref().unwrap() - } -} - -impl TextLayoutEngine for SkiaLayoutEngine { - fn line_metrics(&mut self, text: &str) -> Vec { - let skia = self.para(text).get_line_metrics(); - let mut result = Vec::with_capacity(skia.len()); - let mut prev_end: usize = 0; - - for lm in &skia { - let start = utf16_to_utf8_offset(text, lm.start_index); - let end = utf16_to_utf8_offset(text, lm.end_including_newline).min(text.len()); - - // Skia's phantom/trailing lines can have overlapping or - // degenerate ranges. Clamp start to never go before the - // previous line's end, and skip lines that would be empty - // and overlapping. - let start = start.max(prev_end); - let end = end.max(start); - - result.push(LineMetrics { - start_index: start, - end_index: end, - baseline: lm.baseline as f32, - ascent: lm.ascent as f32, - descent: lm.descent as f32, - }); - prev_end = end; - } - - // If the text ends with \n, ensure there's a phantom line at text.len() - // so the cursor can be placed on the new empty line. - if !text.is_empty() && text.ends_with('\n') { - let last_end = result.last().map_or(0, |lm| lm.end_index); - if last_end < text.len() || result.last().map_or(true, |lm| lm.start_index < lm.end_index) { - if last_end <= text.len() { - let last = result.last().unwrap(); - let line_height = last.ascent + last.descent; - result.push(LineMetrics { - start_index: text.len(), - end_index: text.len(), - baseline: last.baseline + line_height, - ascent: last.ascent, - descent: last.descent, - }); - } - } - } - - result - } - - fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { - self.ensure_layout(text); - let para = self.paragraph.as_ref().unwrap(); - let metrics = para.get_line_metrics(); - - for lm in &metrics { - let top = lm.baseline as f32 - lm.ascent as f32; - let bot = lm.baseline as f32 + lm.descent as f32; - if y >= top - 0.5 && y <= bot + 0.5 { - if lm.end_index.saturating_sub(lm.start_index) <= 1 { - return utf16_to_utf8_offset(text, lm.start_index).min(text.len()); - } - break; - } - } - - let pwa = para.get_glyph_position_at_coordinate(Point::new(x, y)); - let raw = utf16_to_utf8_offset(text, pwa.position.max(0) as usize).min(text.len()); - snap_grapheme_boundary(text, raw) - } - - fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect { - // Use our own line_metrics() (which includes \n in end_index and - // fixes phantom-line ranges) so the line lookup is identical to - // what apply_command uses. - let metrics = self.line_metrics(text); - - if metrics.is_empty() { - return CaretRect { x: 0.0, y: 0.0, height: self.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 { - 0.0 - } else { - self.ensure_layout(text); - let u16_end = utf8_to_utf16_offset(text, offset); - let cluster_start = prev_grapheme_boundary(text, offset); - let u16_start = utf8_to_utf16_offset(text, cluster_start); - let rects = self.paragraph.as_ref().unwrap().get_rects_for_range( - u16_start..u16_end, - RectHeightStyle::Max, - 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_layout(text); - let u16_pos = utf8_to_utf16_offset(text, offset) as u32; - let para = self.paragraph.as_ref().unwrap(); - let range = para.get_word_boundary(u16_pos); - let start = utf16_to_utf8_offset(text, range.start as usize); - let end = utf16_to_utf8_offset(text, range.end as usize); - (start, end) - } - - fn viewport_height(&self) -> f32 { - self.layout_height - } -} diff --git a/crates/grida-dev/tests/text_edit_example.rs b/crates/grida-dev/tests/text_edit_example.rs deleted file mode 100644 index bcc97f3399..0000000000 --- a/crates/grida-dev/tests/text_edit_example.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Include the `text_edit` example module so its unit tests run with `cargo test`. -//! The module lives under `examples/text_edit/` and is used by the `wd_text_editor` example. - -#[path = "../examples/text_edit/mod.rs"] -mod text_edit; diff --git a/crates/grida-text-edit/Cargo.toml b/crates/grida-text-edit/Cargo.toml new file mode 100644 index 0000000000..5b807b507d --- /dev/null +++ b/crates/grida-text-edit/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "grida-text-edit" +version = "0.0.0" +edition = "2021" +publish = false +license = "Apache-2.0" +description = "Platform-agnostic text editing engine." + +[features] +default = ["skia"] +skia = ["dep:skia-safe"] + +[dependencies] +unicode-segmentation = "1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dependencies.skia-safe] +version = "0.91.0" +features = ["textlayout"] +optional = true + +[dev-dependencies] +arboard = "3" +gl = "0.14.0" +glutin = "0.32.0" +glutin-winit = "0.5.0" +raw-window-handle = "0.6.0" +winit = "0.30.0" +skia-safe = { version = "0.91.0", features = ["gpu", "gl", "textlayout"] } + +[[example]] +name = "wd_text_editor" +path = "examples/wd_text_editor.rs" +required-features = ["skia"] diff --git a/crates/grida-dev/examples/wd_text_editor.rs b/crates/grida-text-edit/examples/wd_text_editor.rs similarity index 99% rename from crates/grida-dev/examples/wd_text_editor.rs rename to crates/grida-text-edit/examples/wd_text_editor.rs index b7870da26c..027c134b2f 100644 --- a/crates/grida-dev/examples/wd_text_editor.rs +++ b/crates/grida-text-edit/examples/wd_text_editor.rs @@ -1,13 +1,9 @@ //! Minimal plain-text editor built directly on winit + Skia. //! -//! Editing logic lives in the local `text_edit` example module (no Skia dependency). -//! This file wires it up to Skia paragraph layout (`SkiaLayoutEngine`) and -//! the winit event loop. +//! Uses the grida-text-edit crate for editing logic and Skia paragraph layout. #![allow(clippy::single_match)] -#[path = "text_edit/mod.rs"] -mod text_edit; // // Feature checklist // ----------------- @@ -100,12 +96,11 @@ use winit::{ window::{Window, WindowAttributes, WindowId}, }; -use crate::text_edit::{ - apply_command, utf16_to_utf8_offset, utf8_to_utf16_offset, - EditHistory, EditKind, EditingCommand, LineMetrics, TextEditorState, TextLayoutEngine, +use grida_text_edit::{ + apply_command, utf8_to_utf16_offset, + EditHistory, EditKind, EditingCommand, + SkiaLayoutEngine, TextEditorState, TextLayoutEngine, }; -use crate::text_edit::layout::CaretRect; -use crate::text_edit::skia_layout::SkiaLayoutEngine; // --------------------------------------------------------------------------- // Constants diff --git a/crates/grida-dev/examples/text_edit/history.rs b/crates/grida-text-edit/src/history.rs similarity index 100% rename from crates/grida-dev/examples/text_edit/history.rs rename to crates/grida-text-edit/src/history.rs diff --git a/crates/grida-dev/examples/text_edit/layout.rs b/crates/grida-text-edit/src/layout.rs similarity index 84% rename from crates/grida-dev/examples/text_edit/layout.rs rename to crates/grida-text-edit/src/layout.rs index 6b2c7e2fef..e43a85a04a 100644 --- a/crates/grida-dev/examples/text_edit/layout.rs +++ b/crates/grida-text-edit/src/layout.rs @@ -47,6 +47,18 @@ pub struct CaretRect { pub height: f32, } +/// A single rectangle in the selection highlight geometry. +/// +/// All coordinates are in **layout-local space**. +/// A selection may span multiple rects when it crosses line breaks. +#[derive(Clone, Debug, PartialEq)] +pub struct SelectionRect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + /// Abstract geometry provider. /// /// Implementations include: @@ -72,6 +84,15 @@ pub trait TextLayoutEngine { /// line when `offset >= all end indices` (e.g. cursor at text end). fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect; + /// Return selection highlight rectangles for the range `[start, end)`. + /// + /// A selection spanning multiple lines produces multiple rects (one per + /// visual line or line fragment). Empty-line invariant: every selected + /// line produces at least one visible rect even if it has no glyphs. + fn selection_rects_for_range( + &mut self, text: &str, start: usize, end: usize + ) -> Vec; + /// Return the x coordinate (layout-local) of the caret at `offset`. /// /// Default implementation delegates to `caret_rect_at`. diff --git a/crates/grida-dev/examples/text_edit/mod.rs b/crates/grida-text-edit/src/lib.rs similarity index 99% rename from crates/grida-dev/examples/text_edit/mod.rs rename to crates/grida-text-edit/src/lib.rs index ec1ccf96bc..b602d4070e 100644 --- a/crates/grida-dev/examples/text_edit/mod.rs +++ b/crates/grida-text-edit/src/lib.rs @@ -1,11 +1,17 @@ pub mod history; pub mod layout; pub mod simple_layout; +#[cfg(feature = "skia")] pub mod skia_layout; +#[cfg(test)] +mod tests; + pub use history::{EditHistory, EditKind}; -pub use layout::{line_index_for_offset, CaretRect, LineMetrics, TextLayoutEngine}; +pub use layout::{line_index_for_offset, CaretRect, LineMetrics, SelectionRect, TextLayoutEngine}; pub use simple_layout::SimpleLayoutEngine; +#[cfg(feature = "skia")] +pub use skia_layout::SkiaLayoutEngine; use unicode_segmentation::UnicodeSegmentation; @@ -729,5 +735,3 @@ pub fn line_index_for_offset_utf8(metrics: &[LineMetrics], utf8_offset: usize) - .unwrap_or(metrics.len().saturating_sub(1)) } -#[cfg(test)] -mod tests; diff --git a/crates/grida-dev/examples/text_edit/simple_layout.rs b/crates/grida-text-edit/src/simple_layout.rs similarity index 73% rename from crates/grida-dev/examples/text_edit/simple_layout.rs rename to crates/grida-text-edit/src/simple_layout.rs index 7e6642a335..13854e27bd 100644 --- a/crates/grida-dev/examples/text_edit/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 super::layout::{CaretRect, LineMetrics, TextLayoutEngine}; +use crate::layout::{CaretRect, LineMetrics, SelectionRect, TextLayoutEngine}; use unicode_segmentation::UnicodeSegmentation; @@ -154,6 +154,67 @@ impl TextLayoutEngine for SimpleLayoutEngine { (start, text.len()) } + fn selection_rects_for_range( + &mut self, text: &str, start: usize, end: usize + ) -> Vec { + if start >= end { + return Vec::new(); + } + let metrics = self.compute_metrics(text); + let mut rects = Vec::new(); + + for lm in &metrics { + // Does this line overlap [start, end)? + let line_lo = lm.start_index; + let line_hi = lm.end_index; + let overlap_lo = start.max(line_lo); + let overlap_hi = end.min(line_hi); + if overlap_lo >= overlap_hi { + continue; + } + + let line_y = lm.baseline - lm.ascent; + let line_h = lm.ascent + lm.descent; + + if lm.is_empty_line() { + // Empty line: produce a small visible rect at x=0. + rects.push(SelectionRect { + x: 0.0, + y: line_y, + width: self.char_width * 0.5, + height: line_h, + }); + continue; + } + + let content_end = Self::content_end(lm, text); + let line_content = &text[lm.start_index..content_end]; + + let x_lo = if overlap_lo <= lm.start_index { + 0.0 + } else { + let before = &text[lm.start_index..overlap_lo]; + before.chars().count() as f32 * self.char_width + }; + + let x_hi = if overlap_hi >= content_end { + line_content.chars().count() as f32 * self.char_width + } else { + let before = &text[lm.start_index..overlap_hi]; + before.chars().count() as f32 * self.char_width + }; + + rects.push(SelectionRect { + x: x_lo, + y: line_y, + width: (x_hi - x_lo).max(self.char_width * 0.5), + height: line_h, + }); + } + + rects + } + fn viewport_height(&self) -> f32 { self.viewport_height } diff --git a/crates/grida-text-edit/src/skia_layout.rs b/crates/grida-text-edit/src/skia_layout.rs new file mode 100644 index 0000000000..1195b5daec --- /dev/null +++ b/crates/grida-text-edit/src/skia_layout.rs @@ -0,0 +1,388 @@ +use skia_safe::{ + self as skia_safe, + textlayout::{ + FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, + RectHeightStyle, RectWidthStyle, TextStyle, TypefaceFontProvider, + }, + Color, FontMgr, Point, +}; + +use crate::{ + layout::{CaretRect, LineMetrics, SelectionRect, TextLayoutEngine}, + line_index_for_offset_utf8, + prev_grapheme_boundary, snap_grapheme_boundary, + utf16_to_utf8_offset, utf8_to_utf16_offset, +}; + +const DEFAULT_FONT_SIZE: f32 = 18.0; + +/// Horizontal text alignment. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum TextAlign { + #[default] + Left, + Center, + Right, + Justify, +} + +impl TextAlign { + fn to_skia(&self) -> skia_safe::textlayout::TextAlign { + match self { + Self::Left => skia_safe::textlayout::TextAlign::Left, + Self::Center => skia_safe::textlayout::TextAlign::Center, + Self::Right => skia_safe::textlayout::TextAlign::Right, + Self::Justify => skia_safe::textlayout::TextAlign::Justify, + } + } +} + +/// Configuration for the Skia text layout paragraph. +/// +/// Host-agnostic: the host (WASM app, winit, etc.) supplies font, size, align, +/// and optional text color so the editor can match the "real" text appearance. +#[derive(Clone, Debug)] +pub struct TextConfig { + /// Font family names in priority order. Use **explicit** names (e.g. `"Geist"`, `"Inter"`). + /// On WASM there are no system fonts; generic names like `"monospace"` or `"sans-serif"` are + /// not valid. The host must pass names that have been registered with the layout engine + /// (e.g. via `add_font_bytes`) so the first available family in this list is used. + pub font_families: Vec, + /// Font size in layout-local points. + pub font_size: f32, + /// Horizontal paragraph alignment. + pub text_align: TextAlign, + /// Line height multiplier (1.0 = normal). `None` uses Skia's default. + pub line_height: Option, + /// Additional letter spacing in points. `None` uses Skia's default. + pub letter_spacing: Option, + /// Text fill color. `None` means use black (default); host sets this to + /// match the node's fill so overlay text matches the real text. + pub text_color: Option, + /// When true, use italic slant; otherwise upright. Must match the node so the overlay + /// doesn't show the wrong variant (e.g. Inter italic when the node is upright). + pub font_style_italic: bool, + /// Font weight (1–1000). Typical: 400 = normal, 700 = bold. Host passes node's weight. + pub font_weight: u32, +} + +impl Default for TextConfig { + fn default() -> Self { + Self { + font_families: vec![ + "Menlo".into(), + "Courier New".into(), + "monospace".into(), + ], + font_size: DEFAULT_FONT_SIZE, + text_align: TextAlign::Left, + line_height: None, + letter_spacing: None, + text_color: None, + font_style_italic: false, + font_weight: 400, + } + } +} + +/// Skia-backed `TextLayoutEngine`. +/// +/// Rebuilds the `Paragraph` lazily when text or layout_width changes. +/// No GPU or window required — pure CPU text layout. +pub struct SkiaLayoutEngine { + pub font_collection: FontCollection, + pub paragraph: Option, + pub layout_width: f32, + pub layout_height: f32, + /// Convenience accessor — mirrors `config.font_size`. + pub font_size: f32, + pub config: TextConfig, + cached_text: String, +} + +impl SkiaLayoutEngine { + pub fn new(layout_width: f32, layout_height: f32) -> Self { + Self::new_with_config(layout_width, layout_height, TextConfig::default()) + } + + pub fn new_with_config(layout_width: f32, layout_height: f32, config: TextConfig) -> Self { + let mut fc = FontCollection::new(); + fc.set_default_font_manager(FontMgr::new(), None); + let font_size = config.font_size; + Self { + font_collection: fc, + paragraph: None, + layout_width, + layout_height, + font_size, + config, + cached_text: String::new(), + } + } + + /// Convenience builder for changing font size without a full config. + pub fn with_font_size(mut self, size: f32) -> Self { + self.config.font_size = size; + self.font_size = size; + self.paragraph = None; + self + } + + pub fn ensure_layout(&mut self, text: &str) { + if self.paragraph.is_none() || self.cached_text != text { + self.rebuild(text); + } + } + + fn rebuild(&mut self, text: &str) { + let mut para_style = ParagraphStyle::new(); + para_style.set_apply_rounding_hack(false); + para_style.set_text_align(self.config.text_align.to_skia()); + + let mut ts = TextStyle::new(); + ts.set_font_size(self.config.font_size); + ts.set_color(self.config.text_color.unwrap_or(Color::BLACK)); + let families: Vec<&str> = self.config.font_families.iter().map(|s| s.as_str()).collect(); + ts.set_font_families(&families); + let slant = if self.config.font_style_italic { + skia_safe::font_style::Slant::Italic + } else { + skia_safe::font_style::Slant::Upright + }; + let weight = skia_safe::font_style::Weight::from(self.config.font_weight as i32); + let font_style = skia_safe::FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant); + ts.set_font_style(font_style); + if let Some(ls) = self.config.letter_spacing { + ts.set_letter_spacing(ls); + } + if let Some(lh) = self.config.line_height { + let mut strut = skia_safe::textlayout::StrutStyle::new(); + strut.set_strut_enabled(true); + strut.set_force_strut_height(true); + strut.set_height(lh); + para_style.set_strut_style(strut); + } + + let mut builder = ParagraphBuilder::new(¶_style, &self.font_collection); + builder.push_style(&ts); + builder.add_text(text); + let mut para = builder.build(); + para.layout(self.layout_width); + self.paragraph = Some(para); + self.cached_text = text.to_owned(); + } + + pub fn set_layout_width(&mut self, w: f32) { + let new_w = w.max(1.0); + if (new_w - self.layout_width).abs() > 0.5 { + self.layout_width = new_w; + self.paragraph = None; + } + } + + pub fn set_layout_height(&mut self, h: f32) { + let new_h = h.max(1.0); + if (new_h - self.layout_height).abs() > 0.5 { + self.layout_height = new_h; + } + } + + /// Register a font from raw TTF/OTF bytes. + /// + /// In environments where system fonts are unavailable (e.g. WASM/browser) + /// this is the only way to give Skia a font to shape with. + pub fn add_font_bytes(&mut self, family: &str, bytes: &[u8]) { + let loader = FontMgr::new(); + if let Some(tf) = loader.new_from_data(bytes, None) { + let mut provider = TypefaceFontProvider::new(); + provider.register_typeface(tf, Some(family)); + self.font_collection.set_asset_font_manager(Some(provider.into())); + } + self.paragraph = None; + } + + /// Invalidate the cached paragraph so the next layout call rebuilds. + /// Call this after modifying `font_collection` externally. + pub fn invalidate(&mut self) { + self.paragraph = None; + } + + fn para(&mut self, text: &str) -> &Paragraph { + self.ensure_layout(text); + self.paragraph.as_ref().unwrap() + } + + /// Paint the laid-out paragraph at (0, 0). Used by the host to draw the + /// current session text (and optional preedit) so typed content appears + /// immediately without waiting for document commit. + pub fn paint_paragraph(&mut self, canvas: &skia_safe::Canvas, text: &str) { + let para = self.para(text); + para.paint(canvas, Point::new(0.0, 0.0)); + } +} + +impl TextLayoutEngine for SkiaLayoutEngine { + fn line_metrics(&mut self, text: &str) -> Vec { + let skia = self.para(text).get_line_metrics(); + let mut result = Vec::with_capacity(skia.len()); + let mut prev_end: usize = 0; + + for lm in &skia { + let start = utf16_to_utf8_offset(text, lm.start_index); + let end = utf16_to_utf8_offset(text, lm.end_including_newline).min(text.len()); + + let start = start.max(prev_end); + let end = end.max(start); + + result.push(LineMetrics { + start_index: start, + end_index: end, + baseline: lm.baseline as f32, + ascent: lm.ascent as f32, + descent: lm.descent as f32, + }); + prev_end = end; + } + + if !text.is_empty() && text.ends_with('\n') { + let last_end = result.last().map_or(0, |lm| lm.end_index); + if last_end < text.len() || result.last().map_or(true, |lm| lm.start_index < lm.end_index) { + if last_end <= text.len() { + let last = result.last().unwrap(); + let line_height = last.ascent + last.descent; + result.push(LineMetrics { + start_index: text.len(), + end_index: text.len(), + baseline: last.baseline + line_height, + ascent: last.ascent, + descent: last.descent, + }); + } + } + } + + result + } + + fn position_at_point(&mut self, text: &str, x: f32, y: f32) -> usize { + self.ensure_layout(text); + let para = self.paragraph.as_ref().unwrap(); + let metrics = para.get_line_metrics(); + + for lm in &metrics { + let top = lm.baseline as f32 - lm.ascent as f32; + let bot = lm.baseline as f32 + lm.descent as f32; + if y >= top - 0.5 && y <= bot + 0.5 { + if lm.end_index.saturating_sub(lm.start_index) <= 1 { + return utf16_to_utf8_offset(text, lm.start_index).min(text.len()); + } + break; + } + } + + let pwa = para.get_glyph_position_at_coordinate(Point::new(x, y)); + let raw = utf16_to_utf8_offset(text, pwa.position.max(0) as usize).min(text.len()); + snap_grapheme_boundary(text, raw) + } + + fn caret_rect_at(&mut self, text: &str, offset: usize) -> CaretRect { + let metrics = self.line_metrics(text); + + if metrics.is_empty() { + return CaretRect { x: 0.0, y: 0.0, height: self.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 { + 0.0 + } else { + self.ensure_layout(text); + let u16_end = utf8_to_utf16_offset(text, offset); + let cluster_start = prev_grapheme_boundary(text, offset); + let u16_start = utf8_to_utf16_offset(text, cluster_start); + let rects = self.paragraph.as_ref().unwrap().get_rects_for_range( + u16_start..u16_end, + RectHeightStyle::Max, + 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_layout(text); + let u16_pos = utf8_to_utf16_offset(text, offset) as u32; + let para = self.paragraph.as_ref().unwrap(); + let range = para.get_word_boundary(u16_pos); + let start = utf16_to_utf8_offset(text, range.start as usize); + let end = utf16_to_utf8_offset(text, range.end as usize); + (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(text); + if metrics.is_empty() { + return Vec::new(); + } + self.ensure_layout(text); + let para = self.paragraph.as_ref().unwrap(); + + 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, + skia_safe::textlayout::RectHeightStyle::Max, + skia_safe::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: every selected line must have a visible rect. + let first_line = line_index_for_offset_utf8(&metrics, start); + let last_line = line_index_for_offset_utf8(&metrics, end.saturating_sub(1).max(start)); + + for idx in first_line..=last_line { + let lm = &metrics[idx]; + if !lm.is_empty_line() { 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: 0.0, + y: lm.baseline - lm.ascent, + width: self.font_size * 0.5, + height: lm.ascent + lm.descent, + }); + } + } + + rects + } + + fn viewport_height(&self) -> f32 { + self.layout_height + } +} diff --git a/crates/grida-dev/examples/text_edit/tests.rs b/crates/grida-text-edit/src/tests.rs similarity index 99% rename from crates/grida-dev/examples/text_edit/tests.rs rename to crates/grida-text-edit/src/tests.rs index 6e5283fdf7..c38c0f7a4c 100644 --- a/crates/grida-dev/examples/text_edit/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 super::{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, EditHistory, EditKind, EditingCommand, SimpleLayoutEngine, TextEditorState}; fn layout() -> SimpleLayoutEngine { SimpleLayoutEngine::default_test() @@ -1056,7 +1056,7 @@ fn ime_commit_never_merges() { let s1 = apply(&s0, EditingCommand::Insert("가".into())); h.push(&s1, EditKind::ImeCommit); - let s2 = apply(&s1, EditingCommand::Insert("나".into())); + let _s2 = apply(&s1, EditingCommand::Insert("나".into())); assert_eq!(h.undo_len(), 2, "IME commits should never merge"); } @@ -1957,12 +1957,15 @@ fn nav_never_locks_simple() { // --- SkiaLayoutEngine (real Skia paragraph layout, no GPU needed) --- -use super::skia_layout::SkiaLayoutEngine; +#[cfg(feature = "skia")] +use crate::skia_layout::SkiaLayoutEngine; +#[cfg(feature = "skia")] fn skia_layout() -> SkiaLayoutEngine { SkiaLayoutEngine::new(752.0, 576.0) } +#[cfg(feature = "skia")] #[test] fn nav_never_locks_skia() { run_nav_tests(&mut skia_layout()); @@ -2029,6 +2032,7 @@ fn hit_test_invariants_simple() { run_hit_test_invariants(&mut layout(), "simple"); } +#[cfg(feature = "skia")] #[test] fn hit_test_invariants_skia() { run_hit_test_invariants(&mut skia_layout(), "skia"); From c17ce66d5c27c862b0b535b06b30d7d3d1cb7aa2 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 25 Feb 2026 18:17:47 +0900 Subject: [PATCH 12/13] fix: correct spelling of TenantMiddleware in documentation and code - Updated references from `TanantMiddleware` to `TenantMiddleware` in `AGENTS.md`, `proxy.ts`, and `middleware.ts` for consistency and accuracy. - Ensured proper naming conventions are followed across the codebase to prevent potential confusion. --- editor/AGENTS.md | 2 +- editor/lib/tenant/middleware.ts | 2 +- editor/proxy.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/editor/AGENTS.md b/editor/AGENTS.md index 4226c08a4a..2776cc0879 100644 --- a/editor/AGENTS.md +++ b/editor/AGENTS.md @@ -23,7 +23,7 @@ Tenant sites are primarily accessed via **tenant domains** (e.g. `xyz.grida.site - **Entrypoint**: `proxy.ts` - refreshes Supabase auth cookies via `lib/supabase/proxy.ts` (`updateSession`) - - then calls `lib/tenant/middleware.ts` (`TanantMiddleware.routeProxyRequest`) to perform host-based routing + - then calls `lib/tenant/middleware.ts` (`TenantMiddleware.routeProxyRequest`) to perform host-based routing - **Host classes** (see `lib/domains/index.ts`) - **reserved app hosts** (`grida.co`, `bridged.xyz` + subdomains): never tenant identities; direct `/~/...` access is blocked on these hosts - **platform tenant hosts**: `*.grida.site` and `*.grida.app` (default canonical suffix is `grida.site`) diff --git a/editor/lib/tenant/middleware.ts b/editor/lib/tenant/middleware.ts index e7f906db95..8157a64576 100644 --- a/editor/lib/tenant/middleware.ts +++ b/editor/lib/tenant/middleware.ts @@ -9,7 +9,7 @@ import { platformSiteTenantFromHostname, } from "@/lib/domains"; -export namespace TanantMiddleware { +export namespace TenantMiddleware { const IS_DEV = process.env.NODE_ENV === "development"; export const analyze = function ( diff --git a/editor/proxy.ts b/editor/proxy.ts index 4134dcf3df..23f7b5bdae 100644 --- a/editor/proxy.ts +++ b/editor/proxy.ts @@ -12,7 +12,7 @@ import { NextResponse } from "next/server"; import { get } from "@vercel/edge-config"; import type { NextRequest } from "next/server"; -import { TanantMiddleware } from "./lib/tenant/middleware"; +import { TenantMiddleware } from "./lib/tenant/middleware"; import { updateSession } from "./lib/supabase/proxy"; import { Env } from "./env"; @@ -75,7 +75,7 @@ export async function proxy(req: NextRequest) { } // ------------------------------------------------------------ - const routed = await TanantMiddleware.routeProxyRequest(req, res); + const routed = await TenantMiddleware.routeProxyRequest(req, res); if (routed) return routed; return res; From 7d50306715f01721ff37aa10d498e98ce65c566a Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 25 Feb 2026 21:56:40 +0900 Subject: [PATCH 13/13] fix: update is_empty_line method to accept text parameter for accurate line checks - Modified the `is_empty_line` method in `LineMetrics` to take a `text` parameter, ensuring it accurately checks for empty lines based on the provided text. - Updated all relevant calls to `is_empty_line` across the codebase to pass the necessary text context, enhancing the robustness of line metrics handling during text editing operations. --- crates/grida-text-edit/src/layout.rs | 10 +++++++--- crates/grida-text-edit/src/lib.rs | 8 +++++--- crates/grida-text-edit/src/simple_layout.rs | 4 ++-- crates/grida-text-edit/src/skia_layout.rs | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/grida-text-edit/src/layout.rs b/crates/grida-text-edit/src/layout.rs index e43a85a04a..f68559623b 100644 --- a/crates/grida-text-edit/src/layout.rs +++ b/crates/grida-text-edit/src/layout.rs @@ -17,9 +17,13 @@ pub struct LineMetrics { } impl LineMetrics { - /// Returns `true` when the line contains no glyph content (only a newline terminator). - pub fn is_empty_line(&self) -> bool { - self.end_index.saturating_sub(self.start_index) <= 1 + /// Returns `true` when the line contains no glyph content (only a newline terminator, + /// or a zero-length phantom line after a trailing newline). + pub fn is_empty_line(&self, text: &str) -> bool { + if self.start_index >= self.end_index { + return true; + } + text.get(self.start_index..self.end_index) == Some("\n") } /// Top of this line's band (layout-local y). diff --git a/crates/grida-text-edit/src/lib.rs b/crates/grida-text-edit/src/lib.rs index b602d4070e..1548a62ef6 100644 --- a/crates/grida-text-edit/src/lib.rs +++ b/crates/grida-text-edit/src/lib.rs @@ -212,7 +212,9 @@ impl TextEditorState { } pub fn with_cursor(text: impl Into, cursor: usize) -> Self { - Self { text: text.into(), cursor, anchor: None } + let text = text.into(); + let cursor = snap_grapheme_boundary(&text, cursor); + Self { text, cursor, anchor: None } } pub fn has_selection(&self) -> bool { @@ -477,7 +479,7 @@ pub fn apply_command( let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); if line_idx > 0 { let target = &metrics[line_idx - 1]; - if target.is_empty_line() { + if target.is_empty_line(&s.text) { s.cursor = target.start_index; } else { let x = layout.caret_x_at(&s.text, s.cursor); @@ -500,7 +502,7 @@ pub fn apply_command( let line_idx = line_index_for_offset_utf8(&metrics, s.cursor); if line_idx + 1 < metrics.len() { let target = &metrics[line_idx + 1]; - if target.is_empty_line() { + if target.is_empty_line(&s.text) { s.cursor = target.start_index; } else { let x = layout.caret_x_at(&s.text, s.cursor); diff --git a/crates/grida-text-edit/src/simple_layout.rs b/crates/grida-text-edit/src/simple_layout.rs index 13854e27bd..04687761c5 100644 --- a/crates/grida-text-edit/src/simple_layout.rs +++ b/crates/grida-text-edit/src/simple_layout.rs @@ -95,7 +95,7 @@ impl TextLayoutEngine for SimpleLayoutEngine { let line_idx = ((y / self.line_height).floor() as usize).min(metrics.len() - 1); let lm = &metrics[line_idx]; - if lm.is_empty_line() { + if lm.is_empty_line(text) { // Empty line: place cursor at start. return lm.start_index; } @@ -176,7 +176,7 @@ impl TextLayoutEngine for SimpleLayoutEngine { let line_y = lm.baseline - lm.ascent; let line_h = lm.ascent + lm.descent; - if lm.is_empty_line() { + if lm.is_empty_line(text) { // Empty line: produce a small visible rect at x=0. rects.push(SelectionRect { x: 0.0, diff --git a/crates/grida-text-edit/src/skia_layout.rs b/crates/grida-text-edit/src/skia_layout.rs index 1195b5daec..2d2d4a5a4c 100644 --- a/crates/grida-text-edit/src/skia_layout.rs +++ b/crates/grida-text-edit/src/skia_layout.rs @@ -364,7 +364,7 @@ impl TextLayoutEngine for SkiaLayoutEngine { for idx in first_line..=last_line { let lm = &metrics[idx]; - if !lm.is_empty_line() { continue; } + 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