Skip to main content

rosace_widgets/tree/
text_edit.rs

1//! The text-editing core (D112/Phase 28 Step 1, restructured under D116).
2//!
3//! Five layered seams, per D116, all living in this one module except the
4//! platform keymap (which needs `rosace_platform::Key` — a lower layer
5//! this crate doesn't depend on — so the Key→Command translation lives in
6//! `rosace/src/engine.rs`, which already sees both):
7//!
8//! 1. **Document** — a plain `String`, app-owned (`EditableDecl::value`),
9//!    mutated ONLY through [`Transaction`]s below.
10//! 2. **Edit core** — [`Transaction`] (invertible), [`Selection`] (a list
11//!    of ranges — multi-cursor is a data-model citizen now, a UI feature
12//!    later), undo/redo (a per-field stack of inverse transactions with
13//!    typing coalesced into one unit), grapheme/word boundaries via
14//!    `unicode-segmentation`.
15//! 3. **Layout seam** — `TextLayoutSnapshot` is Step 3's job; not here yet.
16//! 4. **Behavior** — [`Command`], the abstract vocabulary a keymap
17//!    translates key events into (`rosace/src/engine.rs` owns the actual
18//!    keymap and clipboard I/O; [`apply_command`] here executes the
19//!    non-clipboard commands).
20//! 5. **Render** — [`EditController`] is the app-facing programmatic
21//!    handle (D101 `FocusNode`/`ScrollController` precedent); styling
22//!    (`SpanSource`/`CursorStyle`) is Step 5's job.
23//!
24//! Positions are CHAR indices (`str::chars()` count), not byte indices —
25//! simple, stable String-slicing math. Grapheme-cluster correctness
26//! (combining marks, ZWJ emoji, flag pairs treated as one editable unit)
27//! comes from snapping every cursor/selection boundary produced by the ops
28//! below to real grapheme boundaries — never from redefining the
29//! coordinate space itself.
30
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, Mutex};
33
34use rosace_core::types::Rect;
35use rosace_render::{Color, FontWeight};
36use unicode_segmentation::UnicodeSegmentation;
37
38// ─────────────────────────────────────────────────────────────────────────
39// Selection
40// ─────────────────────────────────────────────────────────────────────────
41
42/// Which side of a wrap/grapheme boundary a caret visually prefers.
43/// Unused by single-line `TextInput`; carried from day one (D116) so
44/// Step 4's wrapped up/down movement and v1.0's BiDi caret don't need a
45/// `Selection` rewrite to add it later.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
47pub enum Affinity {
48    #[default]
49    Upstream,
50    Downstream,
51}
52
53/// One selection range: `anchor` is where the selection started,
54/// `head` is the live end (where the caret glyph renders). `anchor ==
55/// head` is a plain collapsed caret — the overwhelmingly common case for
56/// `TextInput`/`TextArea` today; multiple `SelectionRange`s (multi-cursor)
57/// is a real, supported shape nothing here forbids, reserved for a future
58/// code-editor-class widget (D116 — not built by this phase).
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct SelectionRange {
61    pub anchor: usize,
62    pub head: usize,
63    pub affinity: Affinity,
64}
65
66impl SelectionRange {
67    pub fn collapsed_at(pos: usize) -> Self {
68        Self { anchor: pos, head: pos, affinity: Affinity::default() }
69    }
70    pub fn collapsed(&self) -> bool {
71        self.anchor == self.head
72    }
73    /// `(start, end)` with `start <= end` — the shape every string-slicing
74    /// call site wants, regardless of which direction the user selected in.
75    pub fn normalized(&self) -> (usize, usize) {
76        (self.anchor.min(self.head), self.anchor.max(self.head))
77    }
78}
79
80/// A list of [`SelectionRange`]s — NEVER empty (enforced by keeping the
81/// backing `Vec` private behind constructors that always seed one range).
82/// `TextInput`/`TextArea` only ever populate the primary (last) range;
83/// the list shape exists so a future multi-cursor widget is additive, not
84/// a rewrite (D116).
85#[derive(Clone, Debug, PartialEq)]
86pub struct Selection {
87    ranges: Vec<SelectionRange>,
88}
89
90impl Selection {
91    pub fn single(pos: usize) -> Self {
92        Self { ranges: vec![SelectionRange::collapsed_at(pos)] }
93    }
94    pub fn range(anchor: usize, head: usize) -> Self {
95        Self { ranges: vec![SelectionRange { anchor, head, affinity: Affinity::default() }] }
96    }
97    /// The primary (most-recently-active) range — for single-cursor
98    /// widgets, the only one that exists.
99    pub fn primary(&self) -> &SelectionRange {
100        self.ranges.last().expect("Selection is never empty")
101    }
102    pub fn primary_range(&self) -> (usize, usize) {
103        self.primary().normalized()
104    }
105    pub fn ranges(&self) -> &[SelectionRange] {
106        &self.ranges
107    }
108}
109
110impl Default for Selection {
111    fn default() -> Self {
112        Selection::single(0)
113    }
114}
115
116// ─────────────────────────────────────────────────────────────────────────
117// Transaction
118// ─────────────────────────────────────────────────────────────────────────
119
120/// One atomic edit: the chars in `range` (char indices, `[start, end)`,
121/// against the string this edit is applied to) are replaced by
122/// `replacement`.
123#[derive(Clone, Debug, PartialEq)]
124pub struct Edit {
125    pub range: (usize, usize),
126    pub replacement: String,
127}
128
129/// A set of [`Edit`]s applied atomically. Multiple edits must target
130/// DISJOINT, non-overlapping ranges of the string being applied to (the
131/// "type the same char at every cursor" shape a future multi-cursor
132/// widget needs) — [`Transaction::apply`] applies them highest-range-first
133/// internally so earlier (lower) ranges' indices never shift under later
134/// ones, and produces a real inverse: applying the inverse to the result
135/// exactly reconstructs the input. This IS the undo mechanism (D116) —
136/// nothing else computes or stores a diff.
137#[derive(Clone, Debug, PartialEq, Default)]
138pub struct Transaction {
139    pub edits: Vec<Edit>,
140}
141
142impl Transaction {
143    pub fn single(range: (usize, usize), replacement: impl Into<String>) -> Self {
144        Transaction { edits: vec![Edit { range, replacement: replacement.into() }] }
145    }
146
147    /// Apply to `value`, returning the new value and this transaction's
148    /// inverse (an undo entry — see the struct doc).
149    pub fn apply(&self, value: &str) -> (String, Transaction) {
150        let mut edits = self.edits.clone();
151        edits.sort_by_key(|e| std::cmp::Reverse(e.range.0)); // highest start first
152
153        let mut result = value.to_string();
154        let mut inverse_edits = Vec::with_capacity(edits.len());
155        for e in &edits {
156            let bs = char_byte_offset(&result, e.range.0);
157            let be = char_byte_offset(&result, e.range.1);
158            let removed = result[bs..be].to_string();
159            let mut next = String::with_capacity(result.len() - (be - bs) + e.replacement.len());
160            next.push_str(&result[..bs]);
161            next.push_str(&e.replacement);
162            next.push_str(&result[be..]);
163            let new_end = e.range.0 + char_count(&e.replacement);
164            inverse_edits.push(Edit { range: (e.range.0, new_end), replacement: removed });
165            result = next;
166        }
167        (result, Transaction { edits: inverse_edits })
168    }
169}
170
171/// The union char range (in the NEW value's coordinate space, after
172/// `edits` has been applied) touched by a set of edits — the
173/// `TextEditState::last_edit_range` this transaction produces (D116 Step
174/// 5). A conservative min-start/max-end union across multiple edits
175/// (multi-cursor's future shape) is still far smaller than "the whole
176/// document" for any realistic edit.
177fn edits_affected_range(edits: &[Edit]) -> Option<(usize, usize)> {
178    edits.iter().map(|e| (e.range.0, e.range.0 + char_count(&e.replacement)))
179        .fold(None, |acc: Option<(usize, usize)>, r| Some(match acc {
180            None => r,
181            Some(a) => (a.0.min(r.0), a.1.max(r.1)),
182        }))
183}
184
185// ─────────────────────────────────────────────────────────────────────────
186// Char/grapheme/word boundary helpers
187// ─────────────────────────────────────────────────────────────────────────
188
189/// Number of chars in `s` — the coordinate space every position here uses.
190pub fn char_count(s: &str) -> usize {
191    s.chars().count()
192}
193
194/// Byte offset of char index `idx` in `s` (clamped to `s.len()` past the
195/// end) — the bridge from char-indexed positions to `&str` slicing.
196pub fn char_byte_offset(s: &str, idx: usize) -> usize {
197    s.char_indices().nth(idx).map(|(b, _)| b).unwrap_or(s.len())
198}
199
200/// Char-index positions of every grapheme-cluster boundary in `s`,
201/// including 0 and `char_count(s)`. A combining-mark sequence or a ZWJ
202/// emoji sequence (family emoji, flag pairs) collapses to ONE boundary
203/// step, not one per codepoint — this is what makes movement/deletion
204/// grapheme-correct (D116) without changing the char-index coordinate
205/// space everything else here uses.
206///
207/// O(n) per call, recomputed fresh each time — fine for the field-length
208/// text this phase's widgets hold. Step 4 (large `TextArea` documents)
209/// should revisit with a cached/incremental structure rather than calling
210/// this per keystroke on a multi-thousand-line value.
211pub fn grapheme_boundaries(s: &str) -> Vec<usize> {
212    let mut bounds = Vec::with_capacity(s.len() + 1);
213    bounds.push(0usize);
214    let mut char_idx = 0usize;
215    for g in s.graphemes(true) {
216        char_idx += g.chars().count();
217        bounds.push(char_idx);
218    }
219    bounds
220}
221
222/// The nearest grapheme boundary strictly before `pos` (0 if none).
223pub fn prev_grapheme_boundary(s: &str, pos: usize) -> usize {
224    grapheme_boundaries(s).into_iter().rev().find(|&b| b < pos).unwrap_or(0)
225}
226
227/// The nearest grapheme boundary strictly after `pos` (the string's end
228/// if none).
229pub fn next_grapheme_boundary(s: &str, pos: usize) -> usize {
230    let bounds = grapheme_boundaries(s);
231    bounds.iter().copied().find(|&b| b > pos).unwrap_or_else(|| *bounds.last().unwrap())
232}
233
234/// Char-index boundaries between word/non-word runs (`split_word_bounds`),
235/// including 0 and `char_count(s)`.
236fn word_bound_boundaries(s: &str) -> Vec<(usize, bool)> {
237    // (char_idx_start, is_word) for each run, plus a trailing sentinel.
238    let mut out = Vec::new();
239    let mut char_idx = 0usize;
240    for w in s.split_word_bounds() {
241        let is_word = w.chars().next().map(|c| c.is_alphanumeric()).unwrap_or(false);
242        out.push((char_idx, is_word));
243        char_idx += char_count(w);
244    }
245    out.push((char_idx, false)); // sentinel end
246    out
247}
248
249/// Word-left: skip any whitespace/punctuation immediately to the left,
250/// then land at the start of the previous word run (or 0). Standard
251/// Alt/Ctrl+Left convention.
252pub fn prev_word_boundary(s: &str, pos: usize) -> usize {
253    let runs = word_bound_boundaries(s);
254    // Find the run containing (or immediately before) `pos`.
255    let mut idx = runs.len().saturating_sub(1);
256    for i in (0..runs.len() - 1).rev() {
257        if runs[i].0 < pos {
258            idx = i;
259            break;
260        }
261    }
262    // Walk left, skipping non-word runs, to the start of the previous
263    // word run strictly before `pos`.
264    let mut i = idx;
265    loop {
266        let (start, is_word) = runs[i];
267        if start < pos && is_word {
268            return start;
269        }
270        if i == 0 {
271            return 0;
272        }
273        i -= 1;
274    }
275}
276
277/// Word-right: if the cursor sits inside (or at the start of) a word run,
278/// land at THAT run's end; otherwise skip forward past whitespace/
279/// punctuation to the next word run's end (or the string's end). Standard
280/// Alt/Ctrl+Right convention.
281pub fn next_word_boundary(s: &str, pos: usize) -> usize {
282    let runs = word_bound_boundaries(s); // ascending starts, sentinel (n, false) last
283    let n = char_count(s);
284
285    // The run containing `pos`: the last run whose start <= pos.
286    let mut i = 0;
287    while i + 1 < runs.len() && runs[i + 1].0 <= pos {
288        i += 1;
289    }
290
291    if runs[i].1 {
292        let end = runs.get(i + 1).map(|&(st, _)| st).unwrap_or(n);
293        if end > pos {
294            return end;
295        }
296    }
297    // In whitespace (or already at a word run's end) — advance to the
298    // next word run's end.
299    let mut j = i + 1;
300    while j < runs.len() {
301        if runs[j].1 {
302            return runs.get(j + 1).map(|&(st, _)| st).unwrap_or(n);
303        }
304        j += 1;
305    }
306    n
307}
308
309/// The `[start, end)` word run containing `pos` — double-click-to-select
310/// (D116 Step 3). A click inside whitespace/punctuation selects that
311/// whitespace run itself (matches most editors: double-clicking a gap
312/// selects the gap, not the nearest word).
313pub fn word_range_at(s: &str, pos: usize) -> (usize, usize) {
314    let runs = word_bound_boundaries(s);
315    let n = char_count(s);
316    let mut i = 0;
317    while i + 1 < runs.len() && runs[i + 1].0 <= pos {
318        i += 1;
319    }
320    let start = runs[i].0;
321    let end = runs.get(i + 1).map(|&(st, _)| st).unwrap_or(n);
322    (start, end)
323}
324
325// ─────────────────────────────────────────────────────────────────────────
326// TextLayoutSnapshot (D116 layer 3) — the keystone seam. Built during
327// paint (where FontCache IS available) as plain, Send+Sync-free data;
328// engine dispatch queries it with ZERO font access, dissolving the
329// `!Sync` wall Step 1's EditableDecl doc comment names. One structure
330// answers every pointer-positioning question: click-to-glyph, drag
331// selection, double/triple-click, and (Step 6) the IME candidate-window
332// rect.
333// ─────────────────────────────────────────────────────────────────────────
334
335/// One line's geometry within a [`TextLayoutSnapshot`]. `TextInput`
336/// (single-line) always has exactly one; `TextArea` (Step 4) will have
337/// one per wrapped visual line — the shape already supports that, so
338/// Step 4 needs no snapshot redesign.
339#[derive(Clone, Debug, Default, PartialEq)]
340pub struct LineLayout {
341    /// Char range `[start, end)` this line's text spans (end exclusive
342    /// of any line-break character).
343    pub char_range: (usize, usize),
344    /// Top y and height, WORLD-SPACE (matching `EditableDecl::rect`) —
345    /// so engine dispatch can query directly against raw click
346    /// coordinates with no translation.
347    pub y: f32,
348    pub height: f32,
349    /// Absolute char index of every grapheme boundary in this line,
350    /// ascending, paired 1:1 with `boundary_x`.
351    pub boundary_chars: Vec<usize>,
352    /// World-space x of each boundary in `boundary_chars`.
353    pub boundary_x: Vec<f32>,
354}
355
356impl LineLayout {
357    /// World-space x of `target` within this line — clamped to the
358    /// line's own `char_range` first, so an index from a multi-line
359    /// selection or a `Span` that extends past this line's end/start
360    /// still resolves to a sane on-screen position (this line's own
361    /// right/left edge) instead of panicking or guessing.
362    pub fn x_at(&self, target: usize) -> f32 {
363        let clamped = target.clamp(self.char_range.0, self.char_range.1);
364        if let Some(i) = self.boundary_chars.iter().position(|&c| c == clamped) {
365            self.boundary_x[i]
366        } else if clamped <= self.char_range.0 {
367            self.boundary_x.first().copied().unwrap_or(0.0)
368        } else {
369            self.boundary_x.last().copied().unwrap_or(0.0)
370        }
371    }
372}
373
374/// Plain-data paint-time geometry for an editable field (D116 layer 3).
375/// Declared fresh each paint on [`EditableDecl`], like everything else
376/// there — never mutated by dispatch, only read.
377#[derive(Clone, Debug, Default, PartialEq)]
378pub struct TextLayoutSnapshot {
379    pub lines: Vec<LineLayout>,
380}
381
382impl TextLayoutSnapshot {
383    fn line_for_y(&self, y: f32) -> Option<&LineLayout> {
384        if self.lines.is_empty() {
385            return None;
386        }
387        for line in &self.lines {
388            if y < line.y + line.height {
389                return Some(line);
390            }
391        }
392        self.lines.last()
393    }
394
395    /// The grapheme boundary nearest `(x, y)` — the click-to-caret
396    /// primitive. Picks the line whose y-band contains (or is nearest)
397    /// `y`, then within that line the boundary straddling `x`, snapped
398    /// to whichever side of the nearest glyph's midpoint `x` falls on
399    /// (the universal "click past halfway selects the position after
400    /// it" convention). Returns 0 if the snapshot has no lines at all
401    /// (shouldn't happen for a real editable widget's own paint).
402    pub fn position_at(&self, x: f32, y: f32) -> usize {
403        let Some(line) = self.line_for_y(y) else { return 0; };
404        if line.boundary_x.is_empty() {
405            return line.char_range.0;
406        }
407        let mut idx = 0usize;
408        for (i, &bx) in line.boundary_x.iter().enumerate() {
409            if bx <= x {
410                idx = i;
411            } else {
412                break;
413            }
414        }
415        if idx + 1 < line.boundary_x.len() {
416            let mid = (line.boundary_x[idx] + line.boundary_x[idx + 1]) / 2.0;
417            if x > mid {
418                idx += 1;
419            }
420        }
421        line.boundary_chars[idx]
422    }
423
424    /// World-space x of `char_idx`, if it's a known boundary in some
425    /// line — caret rendering and scroll-into-view both want this
426    /// (computed once here rather than re-measuring text at paint time
427    /// AND again at dispatch time from two different code paths).
428    pub fn x_of(&self, char_idx: usize) -> Option<f32> {
429        for line in &self.lines {
430            if let Some(i) = line.boundary_chars.iter().position(|&c| c == char_idx) {
431                return Some(line.boundary_x[i]);
432            }
433        }
434        None
435    }
436
437    /// The `[start, end)` range of the line containing `char_idx` —
438    /// triple-click-to-select-line. For `TextInput` (one line spanning
439    /// the whole value) this is equivalent to select-all; `TextArea`
440    /// (Step 4) gets real per-visual-line selection for free.
441    pub fn line_range_at(&self, char_idx: usize) -> (usize, usize) {
442        for line in &self.lines {
443            if char_idx >= line.char_range.0 && char_idx <= line.char_range.1 {
444                return line.char_range;
445            }
446        }
447        self.lines.last().map(|l| l.char_range).unwrap_or((0, 0))
448    }
449}
450
451// ─────────────────────────────────────────────────────────────────────────
452// Span + style_runs (D116 layer 5) — THE markdown/syntax-highlighting
453// seam. The core never learns what markdown, JSON, or a language grammar
454// is; the app supplies a `SpanSource` closure (`TextInput::spans`/
455// `TextArea::spans`) that inspects the current value and returns colored/
456// weighted ranges. `style_runs` is the shared primitive both widgets use
457// to turn an arbitrary (possibly overlapping, possibly gappy) span list
458// into a contiguous, non-overlapping paint plan for one line.
459// ─────────────────────────────────────────────────────────────────────────
460
461/// One styled range of the document — a token from the app's own
462/// tokenizer (a markdown bold run, a JSON string, a syntax-highlighted
463/// keyword). `None` fields fall back to the widget's own default text
464/// color/weight, so a `SpanSource` only needs to describe what it wants
465/// to OVERRIDE, not restate the whole style for every char.
466#[derive(Clone, Debug)]
467pub struct Span {
468    pub range: (usize, usize),
469    pub color: Option<Color>,
470    pub weight: Option<FontWeight>,
471}
472
473impl PartialEq for Span {
474    fn eq(&self, other: &Self) -> bool {
475        self.range == other.range
476            && self.color.map(color_bits) == other.color.map(color_bits)
477            && self.weight == other.weight
478    }
479}
480
481/// `Color` has no `PartialEq` of its own (not every `rosace-render`
482/// consumer needs one) — this is the local, comparable projection `Span`/
483/// `CursorStyle` equality needs.
484fn color_bits(c: Color) -> (u8, u8, u8, u8) { (c.r, c.g, c.b, c.a) }
485
486impl Span {
487    pub fn new(range: (usize, usize)) -> Self {
488        Self { range, color: None, weight: None }
489    }
490    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
491    pub fn weight(mut self, w: FontWeight) -> Self { self.weight = Some(w); self }
492}
493
494/// The app-supplied tokenizer hook (`TextInput::spans`/`TextArea::spans`).
495/// Called with the CURRENT value and, when available, the char range that
496/// changed since the last call (`None` on the very first call, or when
497/// the whole document should be considered changed — e.g. a controller-
498/// driven `replace_range` spanning most of the text) — an incremental
499/// tokenizer uses this to only re-scan the affected region instead of the
500/// whole document every keystroke.
501pub type SpanFn = dyn Fn(&str, Option<(usize, usize)>) -> Vec<Span> + Send + Sync;
502
503/// Split `[ls, le)` into contiguous, non-overlapping style runs from
504/// `spans` — gaps between/around spans become `(range, None, None)` runs
505/// (the widget's own default color/weight). When multiple spans cover the
506/// same sub-range, the LAST one in `spans` wins (simple override
507/// semantics — good enough for "syntax highlighting layered over a base
508/// style", the common case; spans are not expected to be a stacking z-order).
509pub fn style_runs(spans: &[Span], ls: usize, le: usize) -> Vec<(usize, usize, Option<Color>, Option<FontWeight>)> {
510    if ls >= le {
511        return Vec::new();
512    }
513    let mut points: Vec<usize> = vec![ls, le];
514    for s in spans {
515        if s.range.0 > ls && s.range.0 < le { points.push(s.range.0); }
516        if s.range.1 > ls && s.range.1 < le { points.push(s.range.1); }
517    }
518    points.sort_unstable();
519    points.dedup();
520    points.windows(2).map(|w| {
521        let (a, b) = (w[0], w[1]);
522        let cover = spans.iter().rev().find(|s| s.range.0 <= a && s.range.1 >= b);
523        (a, b, cover.and_then(|s| s.color), cover.and_then(|s| s.weight))
524    }).collect()
525}
526
527// ─────────────────────────────────────────────────────────────────────────
528// CursorStyle (D116 layer 5) — we already paint the caret ourselves
529// (never the OS's), so it's fully themable: width, color, corner radius,
530// blink rate, and shape, including a `Custom` app-supplied painter (an
531// icon, a shader fill, anything `PaintCtx` can record). Theme-level
532// default via `ThemeData::ext`/`with_ext` (D105 Phase 23's type-keyed
533// extension map — no edit to `ThemeData` itself needed); per-field
534// override via `.cursor_style()` wins when set.
535// ─────────────────────────────────────────────────────────────────────────
536
537/// An app-supplied caret painter: `Fn(&mut PaintCtx, caret_rect)` —
538/// see [`CursorShape::Custom`].
539pub type CursorPainter = Arc<dyn Fn(&mut super::PaintCtx, Rect) + Send + Sync>;
540
541/// How the caret renders. `Custom`'s painter receives the caret's
542/// world-space rect (position + the field's own line height) and paints
543/// whatever it wants — the default shapes below are themselves just
544/// convenience presets a `Custom` painter could fully replicate.
545#[derive(Clone)]
546pub enum CursorShape {
547    /// A thin vertical bar at the caret position — the universal default.
548    Bar,
549    /// A filled block spanning to the next glyph boundary (or a fallback
550    /// width at end-of-line) — the classic terminal/overwrite-mode caret.
551    Block,
552    /// A thin bar at the BOTTOM of the line instead of a vertical stroke.
553    Underline,
554    /// App-supplied painter: `Fn(&mut PaintCtx, caret_rect)`.
555    Custom(CursorPainter),
556}
557
558impl std::fmt::Debug for CursorShape {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        match self {
561            CursorShape::Bar => write!(f, "Bar"),
562            CursorShape::Block => write!(f, "Block"),
563            CursorShape::Underline => write!(f, "Underline"),
564            CursorShape::Custom(_) => write!(f, "Custom(..)"),
565        }
566    }
567}
568
569impl PartialEq for CursorShape {
570    fn eq(&self, other: &Self) -> bool {
571        matches!(
572            (self, other),
573            (CursorShape::Bar, CursorShape::Bar)
574                | (CursorShape::Block, CursorShape::Block)
575                | (CursorShape::Underline, CursorShape::Underline)
576        ) // Custom painters are never equal, even to themselves — not a meaningful comparison.
577    }
578}
579
580#[derive(Clone, Debug)]
581pub struct CursorStyle {
582    pub width: f32,
583    pub color: Color,
584    pub corner_radius: f32,
585    /// Seconds per half-cycle (on/off) of the blink — matches the
586    /// pre-Step-5 hardcoded `0.53` by default.
587    pub blink_rate: f32,
588    pub shape: CursorShape,
589}
590
591impl PartialEq for CursorStyle {
592    fn eq(&self, other: &Self) -> bool {
593        self.width == other.width
594            && color_bits(self.color) == color_bits(other.color)
595            && self.corner_radius == other.corner_radius
596            && self.blink_rate == other.blink_rate
597            && self.shape == other.shape
598    }
599}
600
601impl Default for CursorStyle {
602    fn default() -> Self {
603        Self {
604            width: 1.5,
605            color: Color::rgb(180, 160, 255),
606            corner_radius: 0.0,
607            blink_rate: 0.53,
608            shape: CursorShape::Bar,
609        }
610    }
611}
612
613// ─────────────────────────────────────────────────────────────────────────
614// InputFilter (D116 Step 8) — applied at the ONE funnel every edit
615// source reaches (`engine.rs`'s `commit_text_edit`), not per transaction-
616// builder function, so typed chars/paste/IME-commit/controller ops all
617// get filtered identically without duplicating the check everywhere.
618// Deliberately separate from `rosace_forms::Validator`: a filter REJECTS
619// characters as you type ("this field can never contain a comma"); a
620// validator judges a COMPLETE value ("this field must be a valid
621// email") — conflating them would make simple things (max length) need
622// a whole `FormField` to express.
623// ─────────────────────────────────────────────────────────────────────────
624
625/// One input filter — see the module-section doc above for why this is
626/// a separate concept from `rosace_forms::Validator`.
627#[derive(Clone)]
628pub enum InputFilter {
629    /// Reject any edit that would push the value's CHAR count past `n`
630    /// (truncates from the end).
631    MaxLength(usize),
632    /// Keep only characters for which the predicate returns `true`.
633    CharClass(Arc<dyn Fn(char) -> bool + Send + Sync>),
634}
635
636impl InputFilter {
637    pub fn max_length(n: usize) -> Self { InputFilter::MaxLength(n) }
638    pub fn char_class(f: impl Fn(char) -> bool + Send + Sync + 'static) -> Self {
639        InputFilter::CharClass(Arc::new(f))
640    }
641    /// Convenience: digits only (a numeric field).
642    pub fn digits() -> Self { Self::char_class(|c| c.is_ascii_digit()) }
643    /// Convenience: letters and digits only.
644    pub fn alphanumeric() -> Self { Self::char_class(|c| c.is_alphanumeric()) }
645}
646
647/// Apply `filters` to `value` in order. Pure function — used by
648/// `engine.rs`'s `commit_text_edit`, the single funnel every edit source
649/// (typed chars, paste, IME commit, controller ops) reaches.
650pub fn apply_filters(value: &str, filters: &[InputFilter]) -> String {
651    let mut v = value.to_string();
652    for f in filters {
653        v = match f {
654            InputFilter::CharClass(pred) => v.chars().filter(|&c| pred(c)).collect(),
655            InputFilter::MaxLength(n) => v.chars().take(*n).collect(),
656        };
657    }
658    v
659}
660
661// ─────────────────────────────────────────────────────────────────────────
662// EditableDecl (D112) — declared onto the render-tree node each paint
663// ─────────────────────────────────────────────────────────────────────────
664
665/// What an editable widget declares onto its render-tree node each paint
666/// (D112) — cleared and re-declared every repaint, like `hits`/`scrolls`.
667/// The engine's key/click dispatch finds this via the render tree, NOT a
668/// captured closure: computing a click->glyph position needs `FontCache`
669/// (`!Sync`, cannot cross into a `Send + Sync` closure), and mutating the
670/// caret needs `Rc<RefCell<RenderTree>>` (`!Send`, same problem) — both
671/// are reachable from engine.rs, neither is reachable from a plain
672/// `Arc<dyn Fn + Send + Sync>` hit callback.
673pub struct EditableDecl {
674    pub value: String,
675    /// World-space rect this paint, for click-to-focus hit testing.
676    pub rect: Rect,
677    pub multiline: bool,
678    pub obscure: bool,
679    pub on_change: Arc<dyn Fn(String) + Send + Sync>,
680    /// Optional programmatic handle (D116) — `None` for the common case
681    /// (an app that only uses `.value()`/`.on_change()`).
682    pub controller: Option<EditController>,
683    /// Paint-time glyph geometry (D116 layer 3) — click-to-glyph, drag
684    /// selection, and double/triple-click all query this with zero
685    /// `FontCache` access.
686    pub layout: TextLayoutSnapshot,
687    /// Input filters (D116 Step 8) — see the module section above.
688    pub filters: Vec<InputFilter>,
689}
690
691// ─────────────────────────────────────────────────────────────────────────
692// TextEditState — persistent per-node chrome (D091)
693// ─────────────────────────────────────────────────────────────────────────
694
695const COALESCE_WINDOW_SECS: f32 = 0.5;
696
697#[derive(Clone, Debug, PartialEq)]
698struct UndoEntry {
699    /// Applying this to the value AT UNDO TIME reconstructs the value
700    /// from before the edit this entry records.
701    inverse: Transaction,
702    /// The selection to restore after undoing.
703    selection_before: Selection,
704}
705
706#[derive(Clone, Copy, Debug, PartialEq)]
707struct CoalesceInfo {
708    at: f32,
709    /// The char index right after the group's most recent insertion —
710    /// the next insert must start exactly here (no intervening move) to
711    /// extend the group instead of starting a new undo entry.
712    cursor_after: usize,
713}
714
715/// Persistent per-node editing state (D091): selection + undo/redo
716/// history, NOT the text value itself. The value stays app-owned,
717/// reported via `EditableDecl::on_change` — the same controlled-component
718/// convention every other stateful widget here uses (`Slider`, `Switch`,
719/// `Checkbox`). Survives a rebuild with the same value untouched — a
720/// widget rebuilding keeps its caret position and undo history.
721#[derive(Clone, Debug, PartialEq)]
722pub struct TextEditState {
723    pub selection: Selection,
724    /// `anim_clock()` timestamp of the last edit/move — the caret blink
725    /// resets to solid-on from this point, so typing/navigating always
726    /// reads as responsive instead of possibly mid-blink-invisible.
727    pub last_edit_at: f32,
728    undo_stack: Vec<UndoEntry>,
729    redo_stack: Vec<UndoEntry>,
730    /// Set after a coalescible (plain, no-selection) insertion; consumed
731    /// by the next insertion to decide whether to extend the top undo
732    /// entry instead of pushing a new one. Any non-coalescing op
733    /// (deletion, movement, selection change) clears it.
734    coalesce: Option<CoalesceInfo>,
735    /// Horizontal scroll-into-view offset (D116 Step 3) — how far the
736    /// content is shifted left so the caret stays visible when the
737    /// value overflows the field's width. Persistent so it doesn't
738    /// reset to 0 every repaint; recomputed/clamped by the widget each
739    /// paint against the current `TextLayoutSnapshot`.
740    pub scroll_x: f32,
741    /// Goal-column memory for vertical caret movement across wrapped
742    /// lines (D116 Step 4, `TextArea`) — the on-screen x the caret is
743    /// "trying" to stay at while Up/Down walks through shorter lines,
744    /// same convention every real editor uses. Set on the first vertical
745    /// move from the caret's actual x, reused unchanged by consecutive
746    /// vertical moves, cleared by any horizontal move/edit/click (see
747    /// `moved`/`apply_and_record`) so a fresh vertical move recomputes it.
748    pub goal_x: Option<f32>,
749    /// The char range (in the NEW value's coordinate space) touched by
750    /// the most recent CONTENT edit — `None` after a pure movement/
751    /// selection change, or when nothing has been edited yet (D116 Step
752    /// 5). This is what makes `SpanSource` incremental: the widget passes
753    /// it straight through as the tokenizer's `changed_range` argument
754    /// instead of always re-scanning the whole document.
755    pub last_edit_range: Option<(usize, usize)>,
756    /// Char range currently occupied by an UNCOMMITTED IME preedit
757    /// composition (D116 Step 6) — `TextInput`/`TextArea` render an
758    /// underline decoration under it (the universal CJK-composition
759    /// convention). Set/replaced by `ime_set_preedit`, cleared by
760    /// `ime_commit` and by any other movement/edit (composing text is not
761    /// something a click or Cmd+Z should have to know about).
762    pub ime_range: Option<(usize, usize)>,
763    /// The text that was at `ime_range`'s position BEFORE this composition
764    /// session started — captured once (the first `ime_set_preedit` call
765    /// after `ime_range` was `None`), carried unchanged through later
766    /// preedit updates, consumed by `ime_commit` so undoing the WHOLE
767    /// composition is one hop back to this, not to the last intermediate
768    /// preedit snapshot. Not exposed (`ime_range` is the public rendering
769    /// hook; this is bookkeeping).
770    ime_origin: Option<String>,
771    /// The caret position vertical scroll-into-view has already chased
772    /// (`TextArea`) — VIEW state like `scroll_x`, written by the WIDGET
773    /// during paint (via `PaintCtx::set_scrolled_cursor`), unlike every
774    /// document/selection field above, which only the engine mutates.
775    /// The widget chases the caret ONLY when `cursor()` differs from
776    /// this, then records the new position. Without the gate the chase
777    /// ran every focused frame and FOUGHT wheel input: a caret on a
778    /// bottom line snapped every scroll-up straight back (live bug,
779    /// 2026-07-12 — "no scrolling when the cursor is at the bottom").
780    pub scrolled_cursor: Option<usize>,
781}
782
783impl Default for TextEditState {
784    fn default() -> Self {
785        Self {
786            selection: Selection::default(),
787            last_edit_at: 0.0,
788            undo_stack: Vec::new(),
789            redo_stack: Vec::new(),
790            coalesce: None,
791            scroll_x: 0.0,
792            goal_x: None,
793            last_edit_range: None,
794            ime_range: None,
795            ime_origin: None,
796            scrolled_cursor: None,
797        }
798    }
799}
800
801impl TextEditState {
802    /// The primary caret's live position — sugar over `.selection`, kept
803    /// for the common single-cursor read (rendering the caret glyph).
804    pub fn cursor(&self) -> usize {
805        self.selection.primary().head
806    }
807    /// Normalized `(start, end)` char range, or `None` when the primary
808    /// selection is collapsed (no active selection).
809    pub fn selection_range(&self) -> Option<(usize, usize)> {
810        let r = self.selection.primary();
811        if r.collapsed() { None } else { Some(r.normalized()) }
812    }
813    pub fn can_undo(&self) -> bool {
814        !self.undo_stack.is_empty()
815    }
816    pub fn can_redo(&self) -> bool {
817        !self.redo_stack.is_empty()
818    }
819    /// Set the selection directly, preserving undo/redo history — the
820    /// primitive behind `EditController::set_selection` (a pure
821    /// selection change is not an edit, so it must not touch the undo
822    /// stack, but its private fields aren't reachable via struct-update
823    /// syntax from outside this module).
824    pub fn with_selection(&self, selection: Selection, now: f32) -> TextEditState {
825        moved(self, selection, now)
826    }
827}
828
829/// Apply `txn` to `value` and fold the result into a NEW [`TextEditState`]
830/// (functional style, matching every op below): records the inverse onto
831/// the undo stack — coalescing into the existing top entry when
832/// `coalesce_key` (the edit's start position) matches the pending group's
833/// end and the coalesce window hasn't elapsed — clears the redo stack
834/// (any real edit invalidates future redo), and sets `new_selection`.
835fn apply_and_record(
836    value: &str,
837    state: &TextEditState,
838    txn: Transaction,
839    new_selection: Selection,
840    now: f32,
841    coalesce_key: Option<usize>,
842) -> (String, TextEditState) {
843    apply_and_record_with_inverse(value, state, txn, None, new_selection, now, coalesce_key)
844}
845
846/// Same as [`apply_and_record`], but lets the caller override the
847/// recorded undo inverse instead of using `txn`'s own auto-computed one
848/// (D116 Step 6 — `ime_commit` needs undo to restore the PRE-composition
849/// text, not the last intermediate preedit snapshot `txn.apply`'s normal
850/// inverse would compute against the already-preedit-mutated live value).
851fn apply_and_record_with_inverse(
852    value: &str,
853    state: &TextEditState,
854    txn: Transaction,
855    inverse_override: Option<Transaction>,
856    new_selection: Selection,
857    now: f32,
858    coalesce_key: Option<usize>,
859) -> (String, TextEditState) {
860    let (new_value, auto_inverse) = txn.apply(value);
861    let inverse = inverse_override.unwrap_or(auto_inverse);
862
863    let can_coalesce = matches!(
864        (coalesce_key, &state.coalesce),
865        (Some(start), Some(info)) if start == info.cursor_after && (now - info.at) < COALESCE_WINDOW_SECS
866    );
867
868    let mut undo_stack = state.undo_stack.clone();
869    if can_coalesce {
870        if let (Some(top), Some(new_edit)) =
871            (undo_stack.last_mut().and_then(|e| e.inverse.edits.first_mut()), inverse.edits.first())
872        {
873            // Widen the existing group's "delete this range" inverse to
874            // also cover the newly inserted text.
875            top.range.1 = new_edit.range.1;
876        }
877    } else {
878        undo_stack.push(UndoEntry { inverse, selection_before: state.selection.clone() });
879    }
880
881    let coalesce = coalesce_key.map(|_| CoalesceInfo { at: now, cursor_after: new_selection.primary().head });
882    let last_edit_range = edits_affected_range(&txn.edits);
883
884    let ns = TextEditState {
885        selection: new_selection,
886        last_edit_at: now,
887        undo_stack,
888        redo_stack: Vec::new(),
889        coalesce,
890        scroll_x: state.scroll_x,
891        goal_x: None,
892        last_edit_range,
893        ime_range: None,
894        ime_origin: None,
895        scrolled_cursor: state.scrolled_cursor,
896    };
897    (new_value, ns)
898}
899
900// ─────────────────────────────────────────────────────────────────────────
901// IME preedit / commit (D116 Step 6) — the "provisional transaction"
902// model: preedit text is inserted into the value AS IF typed (so it
903// paints, wraps, and click-hit-tests exactly like real content — no
904// separate rendering path), but does NOT touch the undo stack. Only
905// `ime_commit` records a real (single, whole-composition) undo entry —
906// composing "にほん" one romaji keystroke at a time must not produce ten
907// undo steps.
908// ─────────────────────────────────────────────────────────────────────────
909
910/// Replace the current provisional (uncommitted) range with `text` — a
911/// new preedit update from the platform's IME. `cursor_in_text` is the
912/// CHAR offset within `text` to place the caret (from the platform's
913/// preedit cursor position; `None` places it at the end, matching most
914/// IMEs' default). Empty `text` clears the composition (the user deleted
915/// through their entire preedit buffer).
916pub fn ime_set_preedit(
917    value: &str, state: &TextEditState, text: &str, cursor_in_text: Option<usize>, now: f32,
918) -> (String, TextEditState) {
919    let (start, end) = state.ime_range.unwrap_or_else(|| state.selection.primary_range());
920    // Capture the pre-composition text ONCE, the first update of a fresh
921    // session (`state.ime_range` was `None`) — carried unchanged through
922    // later updates so `ime_commit` can undo the whole thing in one hop.
923    let origin = state.ime_origin.clone().unwrap_or_else(|| {
924        let sb = char_byte_offset(value, start);
925        let eb = char_byte_offset(value, end);
926        value[sb..eb].to_string()
927    });
928    let txn = Transaction::single((start, end), text);
929    let (new_value, _auto_inverse) = txn.apply(value);
930    let len = char_count(text);
931    let new_range = if len == 0 { None } else { Some((start, start + len)) };
932    let new_origin = if len == 0 { None } else { Some(origin) };
933    let cursor = start + cursor_in_text.unwrap_or(len).min(len);
934    let ns = TextEditState {
935        selection: Selection::single(cursor),
936        last_edit_at: now,
937        coalesce: None,
938        goal_x: None,
939        last_edit_range: Some((start, start + len)),
940        ime_range: new_range,
941        ime_origin: new_origin,
942        ..state.clone()
943    };
944    (new_value, ns)
945}
946
947/// Finalize the composition: replace the provisional range with `text` as
948/// a REAL, undoable edit and clear `ime_range`/`ime_origin`. The recorded
949/// undo inverse restores `ime_origin` (the PRE-composition text) directly
950/// — one Cmd+Z removes the whole committed word, not just the last
951/// preedit snapshot (`apply_and_record_with_inverse`'s whole reason for
952/// existing). If there's no active composition (a commit with no
953/// preceding preedit — some IMEs do this for single-candidate
954/// confirmations), replaces the current selection instead, same as a
955/// normal insert.
956pub fn ime_commit(value: &str, state: &TextEditState, text: &str, now: f32) -> (String, TextEditState) {
957    let (start, end) = state.ime_range.unwrap_or_else(|| state.selection.primary_range());
958    let origin = state.ime_origin.clone().unwrap_or_else(|| {
959        let sb = char_byte_offset(value, start);
960        let eb = char_byte_offset(value, end);
961        value[sb..eb].to_string()
962    });
963    let txn = Transaction::single((start, end), text);
964    let committed_len = char_count(text);
965    let real_inverse = Transaction::single((start, start + committed_len), origin);
966    let cursor = start + committed_len;
967    let (new_value, ns) = apply_and_record_with_inverse(
968        value, state, txn, Some(real_inverse), Selection::single(cursor), now, None,
969    );
970    (new_value, TextEditState { ime_range: None, ime_origin: None, ..ns })
971}
972
973// ─────────────────────────────────────────────────────────────────────────
974// Content-mutating ops — transaction builders (D116: "Step 1's pure ops
975// become transaction builders")
976// ─────────────────────────────────────────────────────────────────────────
977
978/// Insert `text` at the cursor, replacing any active selection first.
979/// Coalesces with immediately-preceding plain insertions into one undo
980/// unit (D116) — only when there was no active selection at insert time;
981/// replacing a selection always starts a fresh undo entry.
982pub fn insert_str(value: &str, state: &TextEditState, text: &str, now: f32) -> (String, TextEditState) {
983    let (start, end) = state.selection.primary_range();
984    let txn = Transaction::single((start, end), text);
985    let new_cursor = start + char_count(text);
986    let coalesce_key = if start == end { Some(start) } else { None };
987    apply_and_record(value, state, txn, Selection::single(new_cursor), now, coalesce_key)
988}
989
990/// Insert one character — sugar over [`insert_str`] for the common case
991/// (`InputEvent::Text` delivers one `char` at a time).
992pub fn insert_char(value: &str, state: &TextEditState, ch: char, now: f32) -> (String, TextEditState) {
993    let mut buf = [0u8; 4];
994    insert_str(value, state, ch.encode_utf8(&mut buf), now)
995}
996
997/// Replace an EXPLICIT `[start, end)` range — the primitive behind
998/// `EditController::replace_range`, independent of wherever the caret
999/// currently is. Never coalesces (a programmatic edit is its own event).
1000pub fn replace_range(value: &str, state: &TextEditState, start: usize, end: usize, text: &str, now: f32) -> (String, TextEditState) {
1001    let n = char_count(value);
1002    let (s, e) = (start.min(n), end.min(n));
1003    let (s, e) = (s.min(e), s.max(e));
1004    let txn = Transaction::single((s, e), text);
1005    let new_cursor = s + char_count(text);
1006    apply_and_record(value, state, txn, Selection::single(new_cursor), now, None)
1007}
1008
1009/// Backspace: delete the selection if one is active, else the grapheme
1010/// cluster before the cursor (a combining-mark sequence or ZWJ emoji
1011/// disappears in ONE press, not one per codepoint). No-op at position 0
1012/// with no selection (still bumps `last_edit_at`, resetting the blink,
1013/// matching a real editor's "the caret flashes even on a no-op key").
1014pub fn backspace(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
1015    let (start, end) = state.selection.primary_range();
1016    if start != end {
1017        let txn = Transaction::single((start, end), "");
1018        return apply_and_record(value, state, txn, Selection::single(start), now, None);
1019    }
1020    if start == 0 {
1021        return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
1022    }
1023    let prev = prev_grapheme_boundary(value, start);
1024    let txn = Transaction::single((prev, start), "");
1025    apply_and_record(value, state, txn, Selection::single(prev), now, None)
1026}
1027
1028/// Forward delete: symmetric with [`backspace`], grapheme-cluster aware.
1029pub fn delete_forward(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
1030    let (start, end) = state.selection.primary_range();
1031    if start != end {
1032        let txn = Transaction::single((start, end), "");
1033        return apply_and_record(value, state, txn, Selection::single(start), now, None);
1034    }
1035    let n = char_count(value);
1036    if start >= n {
1037        return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
1038    }
1039    let next = next_grapheme_boundary(value, start);
1040    let txn = Transaction::single((start, next), "");
1041    apply_and_record(value, state, txn, Selection::single(start), now, None)
1042}
1043
1044/// Delete the word before the cursor (Alt/Ctrl+Backspace) — deletes an
1045/// active selection instead, if one exists, same convention as
1046/// [`backspace`].
1047pub fn delete_word_back(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
1048    let (start, end) = state.selection.primary_range();
1049    if start != end {
1050        let txn = Transaction::single((start, end), "");
1051        return apply_and_record(value, state, txn, Selection::single(start), now, None);
1052    }
1053    let prev = prev_word_boundary(value, start);
1054    if prev == start {
1055        return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
1056    }
1057    let txn = Transaction::single((prev, start), "");
1058    apply_and_record(value, state, txn, Selection::single(prev), now, None)
1059}
1060
1061/// Delete the word after the cursor (Alt/Ctrl+Delete).
1062pub fn delete_word_forward(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
1063    let (start, end) = state.selection.primary_range();
1064    if start != end {
1065        let txn = Transaction::single((start, end), "");
1066        return apply_and_record(value, state, txn, Selection::single(start), now, None);
1067    }
1068    let next = next_word_boundary(value, start);
1069    if next == start {
1070        return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
1071    }
1072    let txn = Transaction::single((start, next), "");
1073    apply_and_record(value, state, txn, Selection::single(start), now, None)
1074}
1075
1076// ─────────────────────────────────────────────────────────────────────────
1077// Movement / selection ops — never touch the undo stack (not content
1078// edits), always clear a pending coalesce group (an intentional move
1079// must not let a later insertion silently merge with an unrelated one).
1080// ─────────────────────────────────────────────────────────────────────────
1081
1082fn moved(state: &TextEditState, selection: Selection, now: f32) -> TextEditState {
1083    TextEditState { selection, last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() }
1084}
1085
1086/// Move left one grapheme cluster. Without `extend`, an active selection
1087/// first COLLAPSES to its start (matches every desktop text field's
1088/// convention — Left doesn't step from wherever the caret glyph renders).
1089pub fn move_left(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
1090    let sel = state.selection.primary();
1091    if !extend && !sel.collapsed() {
1092        return moved(state, Selection::single(sel.normalized().0), now);
1093    }
1094    let prev = prev_grapheme_boundary(value, sel.head);
1095    let new_sel = if extend { Selection::range(sel.anchor, prev) } else { Selection::single(prev) };
1096    moved(state, new_sel, now)
1097}
1098
1099/// Move right one grapheme cluster (collapses an active selection to its
1100/// end first, symmetric with [`move_left`]).
1101pub fn move_right(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
1102    let sel = state.selection.primary();
1103    if !extend && !sel.collapsed() {
1104        return moved(state, Selection::single(sel.normalized().1), now);
1105    }
1106    let next = next_grapheme_boundary(value, sel.head);
1107    let new_sel = if extend { Selection::range(sel.anchor, next) } else { Selection::single(next) };
1108    moved(state, new_sel, now)
1109}
1110
1111/// Move to the start of the previous word (Alt/Ctrl+Left).
1112pub fn move_word_left(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
1113    let sel = state.selection.primary();
1114    let prev = prev_word_boundary(value, sel.head);
1115    let new_sel = if extend { Selection::range(sel.anchor, prev) } else { Selection::single(prev) };
1116    moved(state, new_sel, now)
1117}
1118
1119/// Move to the end of the next word (Alt/Ctrl+Right).
1120pub fn move_word_right(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
1121    let sel = state.selection.primary();
1122    let next = next_word_boundary(value, sel.head);
1123    let new_sel = if extend { Selection::range(sel.anchor, next) } else { Selection::single(next) };
1124    moved(state, new_sel, now)
1125}
1126
1127pub fn move_home(state: &TextEditState, extend: bool, now: f32) -> TextEditState {
1128    let sel = state.selection.primary();
1129    let new_sel = if extend { Selection::range(sel.anchor, 0) } else { Selection::single(0) };
1130    moved(state, new_sel, now)
1131}
1132
1133pub fn move_end(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
1134    let n = char_count(value);
1135    let sel = state.selection.primary();
1136    let new_sel = if extend { Selection::range(sel.anchor, n) } else { Selection::single(n) };
1137    moved(state, new_sel, now)
1138}
1139
1140pub fn select_all(value: &str, state: &TextEditState, now: f32) -> TextEditState {
1141    moved(state, Selection::range(0, char_count(value)), now)
1142}
1143
1144/// The currently selected substring, or `None` when there is no
1145/// selection.
1146pub fn selected_text(value: &str, state: &TextEditState) -> Option<String> {
1147    state.selection_range().map(|(s, e)| {
1148        let bs = char_byte_offset(value, s);
1149        let be = char_byte_offset(value, e);
1150        value[bs..be].to_string()
1151    })
1152}
1153
1154// ─────────────────────────────────────────────────────────────────────────
1155// Undo / redo
1156// ─────────────────────────────────────────────────────────────────────────
1157
1158/// Undo the most recent edit. `None` when the undo stack is empty — a
1159/// real no-op the caller should skip committing/repainting for.
1160pub fn undo(value: &str, state: &TextEditState, now: f32) -> Option<(String, TextEditState)> {
1161    let mut undo_stack = state.undo_stack.clone();
1162    let entry = undo_stack.pop()?;
1163    let last_edit_range = edits_affected_range(&entry.inverse.edits);
1164    let (new_value, redo_inverse) = entry.inverse.apply(value);
1165    let mut redo_stack = state.redo_stack.clone();
1166    redo_stack.push(UndoEntry { inverse: redo_inverse, selection_before: state.selection.clone() });
1167    Some((
1168        new_value,
1169        TextEditState {
1170            selection: entry.selection_before, last_edit_at: now, undo_stack, redo_stack,
1171            coalesce: None, scroll_x: state.scroll_x, goal_x: None, last_edit_range, ime_range: None, ime_origin: None,
1172            scrolled_cursor: state.scrolled_cursor,
1173        },
1174    ))
1175}
1176
1177/// Redo the most recently undone edit. `None` when the redo stack is
1178/// empty (or was cleared by an intervening real edit — standard).
1179pub fn redo(value: &str, state: &TextEditState, now: f32) -> Option<(String, TextEditState)> {
1180    let mut redo_stack = state.redo_stack.clone();
1181    let entry = redo_stack.pop()?;
1182    let last_edit_range = edits_affected_range(&entry.inverse.edits);
1183    let (new_value, undo_inverse) = entry.inverse.apply(value);
1184    let mut undo_stack = state.undo_stack.clone();
1185    undo_stack.push(UndoEntry { inverse: undo_inverse, selection_before: state.selection.clone() });
1186    Some((
1187        new_value,
1188        TextEditState {
1189            selection: entry.selection_before, last_edit_at: now, undo_stack, redo_stack,
1190            coalesce: None, scroll_x: state.scroll_x, goal_x: None, last_edit_range, ime_range: None, ime_origin: None,
1191            scrolled_cursor: state.scrolled_cursor,
1192        },
1193    ))
1194}
1195
1196// ─────────────────────────────────────────────────────────────────────────
1197// Command — the abstract vocabulary a keymap (rosace/src/engine.rs, which
1198// alone sees rosace_platform::Key) translates key events into (D116 layer
1199// 4). Character insertion isn't a Command — Text events feed insert_char
1200// directly (see engine.rs's dispatch comment on why, carried from Step 1).
1201// ─────────────────────────────────────────────────────────────────────────
1202
1203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1204pub enum Command {
1205    MoveLeft, MoveRight, MoveWordLeft, MoveWordRight, MoveHome, MoveEnd,
1206    ExtendLeft, ExtendRight, ExtendWordLeft, ExtendWordRight, ExtendHome, ExtendEnd,
1207    Backspace, DeleteForward, DeleteWordBack, DeleteWordForward,
1208    SelectAll, Copy, Cut, Paste, Undo, Redo,
1209}
1210
1211/// Execute a non-clipboard [`Command`]. `Copy`/`Cut`/`Paste` return `None`
1212/// — clipboard I/O is the caller's job (`rosace/src/engine.rs`, which
1213/// already owns `rosace-clipboard`; this crate doesn't depend on it).
1214/// `Undo`/`Redo` also return `None` on a genuinely empty stack; every
1215/// other command always returns `Some` (even a saturated move bumps
1216/// `last_edit_at` for the blink reset, matching Step 1).
1217pub fn apply_command(value: &str, state: &TextEditState, cmd: Command, now: f32) -> Option<(String, TextEditState)> {
1218    use Command::*;
1219    Some(match cmd {
1220        MoveLeft => (value.to_string(), move_left(value, state, false, now)),
1221        ExtendLeft => (value.to_string(), move_left(value, state, true, now)),
1222        MoveRight => (value.to_string(), move_right(value, state, false, now)),
1223        ExtendRight => (value.to_string(), move_right(value, state, true, now)),
1224        MoveWordLeft => (value.to_string(), move_word_left(value, state, false, now)),
1225        ExtendWordLeft => (value.to_string(), move_word_left(value, state, true, now)),
1226        MoveWordRight => (value.to_string(), move_word_right(value, state, false, now)),
1227        ExtendWordRight => (value.to_string(), move_word_right(value, state, true, now)),
1228        MoveHome => (value.to_string(), move_home(state, false, now)),
1229        ExtendHome => (value.to_string(), move_home(state, true, now)),
1230        MoveEnd => (value.to_string(), move_end(value, state, false, now)),
1231        ExtendEnd => (value.to_string(), move_end(value, state, true, now)),
1232        Backspace => backspace(value, state, now),
1233        DeleteForward => delete_forward(value, state, now),
1234        DeleteWordBack => delete_word_back(value, state, now),
1235        DeleteWordForward => delete_word_forward(value, state, now),
1236        SelectAll => (value.to_string(), select_all(value, state, now)),
1237        Undo => return undo(value, state, now),
1238        Redo => return redo(value, state, now),
1239        Copy | Cut | Paste => return None,
1240    })
1241}
1242
1243// ─────────────────────────────────────────────────────────────────────────
1244// EditController — the app-facing programmatic handle (D116 layer 5 /
1245// D101 FocusNode precedent). Unlike `scroll_controller()`'s
1246// auto-created-per-node shape, an app driving a markdown toolbar's Bold
1247// button needs a handle reachable from OUTSIDE the widget tree entirely
1248// (a button's `on_press` has no access to the field's render-tree node) —
1249// so this is APP-CONSTRUCTED and PASSED IN via `.controller(EditController)`,
1250// mirroring `FocusNode`. Calls enqueue an op onto a `Send + Sync` channel
1251// (the render tree itself is `Rc<RefCell<_>>`, unreachable from arbitrary
1252// closures — the exact constraint `EditableDecl` already documents) and
1253// wake the frame loop; the engine drains the queue against the matching
1254// node (found via `id()`, mirroring `FocusManager::focus_owner`) once per
1255// frame.
1256// ─────────────────────────────────────────────────────────────────────────
1257
1258static CONTROLLER_ID: AtomicU64 = AtomicU64::new(1);
1259
1260/// One pending operation enqueued by an [`EditController`] call — drained
1261/// and applied by the engine each frame.
1262#[derive(Clone, Debug)]
1263pub enum ControllerOp {
1264    ReplaceRange(usize, usize, String),
1265    InsertAtCursor(String),
1266    SetSelection(Selection),
1267    SelectAll,
1268    Undo,
1269    Redo,
1270}
1271
1272struct ControllerInner {
1273    id: u64,
1274    ops: Mutex<Vec<ControllerOp>>,
1275    snapshot: Mutex<(String, Selection)>,
1276}
1277
1278#[derive(Clone)]
1279pub struct EditController(Arc<ControllerInner>);
1280
1281impl EditController {
1282    pub fn new() -> Self {
1283        Self(Arc::new(ControllerInner {
1284            id: CONTROLLER_ID.fetch_add(1, Ordering::Relaxed),
1285            ops: Mutex::new(Vec::new()),
1286            snapshot: Mutex::new((String::new(), Selection::default())),
1287        }))
1288    }
1289
1290    /// Unique id — how the engine finds the render-tree node that
1291    /// declared this controller (same shape as `FocusNode::id`).
1292    pub fn id(&self) -> u64 {
1293        self.0.id
1294    }
1295
1296    fn enqueue(&self, op: ControllerOp) {
1297        self.0.ops.lock().unwrap_or_else(|e| e.into_inner()).push(op);
1298        rosace_state::request_frame();
1299    }
1300
1301    /// Replace an explicit `[start, end)` char range — independent of
1302    /// wherever the caret currently is (the markdown-toolbar-Bold-button
1303    /// primitive: `replace_range(sel.0, sel.1, format!("**{}**", text))`).
1304    pub fn replace_range(&self, start: usize, end: usize, text: impl Into<String>) {
1305        self.enqueue(ControllerOp::ReplaceRange(start, end, text.into()));
1306    }
1307    pub fn insert_at_cursor(&self, text: impl Into<String>) {
1308        self.enqueue(ControllerOp::InsertAtCursor(text.into()));
1309    }
1310    pub fn set_selection(&self, sel: Selection) {
1311        self.enqueue(ControllerOp::SetSelection(sel));
1312    }
1313    pub fn select_all(&self) {
1314        self.enqueue(ControllerOp::SelectAll);
1315    }
1316    pub fn undo(&self) {
1317        self.enqueue(ControllerOp::Undo);
1318    }
1319    pub fn redo(&self) {
1320        self.enqueue(ControllerOp::Redo);
1321    }
1322
1323    /// The field's value as of the last time the engine applied a drained
1324    /// op (a read-only snapshot — not live within the same frame an op
1325    /// was JUST enqueued; the engine hasn't run yet).
1326    pub fn value(&self) -> String {
1327        self.0.snapshot.lock().unwrap_or_else(|e| e.into_inner()).0.clone()
1328    }
1329    pub fn selection(&self) -> Selection {
1330        self.0.snapshot.lock().unwrap_or_else(|e| e.into_inner()).1.clone()
1331    }
1332
1333    /// Engine-internal: drain pending ops for this frame. Not part of the
1334    /// app-facing API (hidden from docs, but must be `pub` for the
1335    /// `rosace` crate to call it).
1336    #[doc(hidden)]
1337    pub fn take_ops(&self) -> Vec<ControllerOp> {
1338        std::mem::take(&mut *self.0.ops.lock().unwrap_or_else(|e| e.into_inner()))
1339    }
1340    /// Engine-internal: publish the post-drain snapshot.
1341    #[doc(hidden)]
1342    pub fn update_snapshot(&self, value: String, selection: Selection) {
1343        *self.0.snapshot.lock().unwrap_or_else(|e| e.into_inner()) = (value, selection);
1344    }
1345}
1346
1347impl Default for EditController {
1348    fn default() -> Self {
1349        Self::new()
1350    }
1351}
1352
1353impl std::fmt::Debug for EditController {
1354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1355        write!(f, "EditController(id={})", self.0.id)
1356    }
1357}
1358
1359impl PartialEq for EditController {
1360    fn eq(&self, other: &Self) -> bool {
1361        self.0.id == other.0.id
1362    }
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368
1369    fn st(cursor: usize) -> TextEditState {
1370        TextEditState { selection: Selection::single(cursor), ..Default::default() }
1371    }
1372    fn st_sel(anchor: usize, head: usize) -> TextEditState {
1373        TextEditState { selection: Selection::range(anchor, head), ..Default::default() }
1374    }
1375
1376    // ── Step 1 behavior, re-verified against the new internals ─────────
1377
1378    #[test]
1379    fn insert_char_at_end() {
1380        let s = st(5);
1381        let (v, ns) = insert_char("hello", &s, '!', 1.0);
1382        assert_eq!(v, "hello!");
1383        assert_eq!(ns.cursor(), 6);
1384        assert_eq!(ns.last_edit_at, 1.0);
1385        assert!(ns.selection_range().is_none());
1386    }
1387
1388    #[test]
1389    fn insert_char_in_middle() {
1390        let (v, ns) = insert_char("helo", &st(3), 'l', 0.0);
1391        assert_eq!(v, "hello");
1392        assert_eq!(ns.cursor(), 4);
1393    }
1394
1395    #[test]
1396    fn insert_str_replaces_selection() {
1397        let s = st_sel(6, 11); // "hello world", select "world"
1398        let (v, ns) = insert_str("hello world", &s, "there", 2.0);
1399        assert_eq!(v, "hello there");
1400        assert_eq!(ns.cursor(), 11);
1401        assert!(ns.selection_range().is_none());
1402    }
1403
1404    #[test]
1405    fn insert_handles_multibyte_utf8_without_panicking() {
1406        let (v, ns) = insert_char("café", &st(4), '!', 0.0);
1407        assert_eq!(v, "café!");
1408        assert_eq!(ns.cursor(), 5);
1409    }
1410
1411    #[test]
1412    fn backspace_removes_char_before_cursor() {
1413        let (v, ns) = backspace("hello", &st(5), 1.0);
1414        assert_eq!(v, "hell");
1415        assert_eq!(ns.cursor(), 4);
1416    }
1417
1418    #[test]
1419    fn backspace_at_start_is_noop() {
1420        let (v, ns) = backspace("hello", &st(0), 1.0);
1421        assert_eq!(v, "hello");
1422        assert_eq!(ns.cursor(), 0);
1423    }
1424
1425    #[test]
1426    fn backspace_deletes_selection_instead_of_one_char() {
1427        let s = st_sel(1, 5);
1428        let (v, ns) = backspace("hello", &s, 1.0);
1429        assert_eq!(v, "h");
1430        assert_eq!(ns.cursor(), 1);
1431        assert!(ns.selection_range().is_none());
1432    }
1433
1434    #[test]
1435    fn delete_forward_removes_char_after_cursor() {
1436        let (v, ns) = delete_forward("hello", &st(0), 1.0);
1437        assert_eq!(v, "ello");
1438        assert_eq!(ns.cursor(), 0);
1439    }
1440
1441    #[test]
1442    fn delete_forward_at_end_is_noop() {
1443        let (v, _ns) = delete_forward("hello", &st(5), 1.0);
1444        assert_eq!(v, "hello");
1445    }
1446
1447    #[test]
1448    fn move_left_decrements_and_clears_selection() {
1449        let ns = move_left("hello", &st(3), false, 1.0);
1450        assert_eq!(ns.cursor(), 2);
1451        assert!(ns.selection_range().is_none());
1452    }
1453
1454    #[test]
1455    fn move_left_saturates_at_zero() {
1456        let ns = move_left("hello", &st(0), false, 1.0);
1457        assert_eq!(ns.cursor(), 0);
1458    }
1459
1460    #[test]
1461    fn move_left_without_extend_collapses_selection_to_start() {
1462        let s = st_sel(1, 5);
1463        let ns = move_left("hello", &s, false, 1.0);
1464        assert_eq!(ns.cursor(), 1, "must jump to selection start, not head-1");
1465        assert!(ns.selection_range().is_none());
1466    }
1467
1468    #[test]
1469    fn move_right_without_extend_collapses_selection_to_end() {
1470        let s = st_sel(5, 1);
1471        let ns = move_right("hello", &s, false, 1.0);
1472        assert_eq!(ns.cursor(), 5);
1473        assert!(ns.selection_range().is_none());
1474    }
1475
1476    #[test]
1477    fn move_right_extends_selection_from_fresh_anchor() {
1478        let ns = move_right("hello", &st(2), true, 1.0);
1479        assert_eq!(ns.cursor(), 3);
1480        assert_eq!(ns.selection.primary().anchor, 2, "anchor seeds at the pre-move cursor");
1481    }
1482
1483    #[test]
1484    fn move_right_saturates_at_length() {
1485        let ns = move_right("hi", &st(2), false, 1.0);
1486        assert_eq!(ns.cursor(), 2);
1487    }
1488
1489    #[test]
1490    fn shift_arrow_sequence_grows_then_shrinks_selection() {
1491        let s0 = st(2);
1492        let s1 = move_right("hello world", &s0, true, 0.0);
1493        let s2 = move_right("hello world", &s1, true, 0.0);
1494        assert_eq!(s2.selection_range(), Some((2, 4)));
1495        let s3 = move_left("hello world", &s2, true, 0.0);
1496        assert_eq!(s3.selection_range(), Some((2, 3)));
1497    }
1498
1499    #[test]
1500    fn move_home_and_end() {
1501        let h = move_home(&st(3), false, 1.0);
1502        assert_eq!(h.cursor(), 0);
1503        let e = move_end("hello", &st(0), false, 1.0);
1504        assert_eq!(e.cursor(), 5);
1505    }
1506
1507    #[test]
1508    fn select_all_selects_full_range() {
1509        let s = select_all("hello", &st(0), 1.0);
1510        assert_eq!(s.cursor(), 5);
1511        assert_eq!(s.selection_range(), Some((0, 5)));
1512    }
1513
1514    #[test]
1515    fn selected_text_extracts_the_right_substring() {
1516        let s = st_sel(6, 11);
1517        assert_eq!(selected_text("hello world", &s).as_deref(), Some("world"));
1518    }
1519
1520    #[test]
1521    fn selected_text_none_when_anchor_equals_cursor() {
1522        let s = st(3);
1523        assert_eq!(selected_text("hello", &s), None);
1524        assert_eq!(s.selection_range(), None);
1525    }
1526
1527    #[test]
1528    fn selection_range_normalizes_backward_selection() {
1529        let s = st_sel(5, 2); // Shift+Left from 5 to 2
1530        assert_eq!(s.selection_range(), Some((2, 5)));
1531    }
1532
1533    // ── Step 2: transactions / undo / redo / coalescing ─────────────────
1534
1535    #[test]
1536    fn transaction_apply_and_invert_round_trips() {
1537        let txn = Transaction::single((2, 2), "XY");
1538        let (v1, inv) = txn.apply("hello");
1539        assert_eq!(v1, "heXYllo");
1540        let (v2, _) = inv.apply(&v1);
1541        assert_eq!(v2, "hello", "applying the inverse must reconstruct the original exactly");
1542    }
1543
1544    #[test]
1545    fn undo_reverts_an_insertion_and_restores_prior_selection() {
1546        let s0 = st(0);
1547        let (v1, s1) = insert_str("", &s0, "hi", 1.0);
1548        assert_eq!(v1, "hi");
1549        assert!(s1.can_undo());
1550
1551        let (v2, s2) = undo(&v1, &s1, 2.0).expect("undo must produce a result");
1552        assert_eq!(v2, "");
1553        assert_eq!(s2.cursor(), 0, "must restore the pre-edit selection");
1554        assert!(!s2.can_undo());
1555        assert!(s2.can_redo());
1556    }
1557
1558    #[test]
1559    fn redo_reapplies_an_undone_edit() {
1560        let s0 = st(0);
1561        let (v1, s1) = insert_str("", &s0, "hi", 1.0);
1562        let (v2, s2) = undo(&v1, &s1, 2.0).unwrap();
1563        let (v3, s3) = redo(&v2, &s2, 3.0).expect("redo must produce a result");
1564        assert_eq!(v3, "hi");
1565        assert_eq!(s3.cursor(), 2);
1566        assert!(s3.can_undo());
1567        assert!(!s3.can_redo());
1568    }
1569
1570    #[test]
1571    fn undo_on_empty_stack_is_none() {
1572        let s0 = st(0);
1573        assert!(undo("hello", &s0, 1.0).is_none());
1574    }
1575
1576    #[test]
1577    fn a_real_edit_after_undo_clears_the_redo_stack() {
1578        let s0 = st(0);
1579        let (v1, s1) = insert_str("", &s0, "a", 1.0);
1580        let (v2, s2) = undo(&v1, &s1, 2.0).unwrap();
1581        assert!(s2.can_redo());
1582        let (_, s3) = insert_str(&v2, &s2, "b", 3.0);
1583        assert!(!s3.can_redo(), "a fresh edit must invalidate the old redo branch");
1584    }
1585
1586    #[test]
1587    fn consecutive_typing_coalesces_into_one_undo_unit() {
1588        let s0 = st(0);
1589        let (v1, s1) = insert_char("", &s0, 'a', 1.0);
1590        let (v2, s2) = insert_char(&v1, &s1, 'b', 1.1);
1591        let (v3, s3) = insert_char(&v2, &s2, 'c', 1.2);
1592        assert_eq!(v3, "abc");
1593
1594        let (v4, s4) = undo(&v3, &s3, 2.0).expect("one undo");
1595        assert_eq!(v4, "", "one undo must remove the WHOLE typed group");
1596        assert_eq!(s4.cursor(), 0, "must restore the selection from BEFORE the whole group");
1597        assert!(!s4.can_undo(), "the group must have been a single undo entry");
1598    }
1599
1600    #[test]
1601    fn typing_separated_by_a_pause_does_not_coalesce() {
1602        let s0 = st(0);
1603        let (v1, s1) = insert_char("", &s0, 'a', 0.0);
1604        // Past the coalesce window.
1605        let (v2, s2) = insert_char(&v1, &s1, 'b', 0.0 + COALESCE_WINDOW_SECS + 0.01);
1606        assert_eq!(v2, "ab");
1607        let (v3, s3) = undo(&v2, &s2, 1.0).unwrap();
1608        assert_eq!(v3, "a", "only the second, un-coalesced char should undo");
1609        assert!(s3.can_undo(), "the first char's group must still be on the stack");
1610    }
1611
1612    #[test]
1613    fn typing_after_a_cursor_move_does_not_coalesce_with_earlier_typing() {
1614        let s0 = st(0);
1615        let (v1, s1) = insert_char("", &s0, 'a', 1.0);
1616        let s1_moved = move_left("a", &s1, false, 1.05); // move breaks the group
1617        let (v2, s2) = insert_char(&v1, &s1_moved, 'b', 1.06);
1618        assert_eq!(v2, "ba");
1619        let (v3, s3) = undo(&v2, &s2, 2.0).unwrap();
1620        assert_eq!(v3, "a", "only the second char's group should undo");
1621        assert!(s3.can_undo());
1622    }
1623
1624    #[test]
1625    fn replacing_a_selection_does_not_coalesce_with_prior_typing() {
1626        let s0 = st(0);
1627        let (v1, s1) = insert_str("", &s0, "hello", 1.0);
1628        let s1_sel = TextEditState { selection: Selection::range(1, 3), ..s1.clone() };
1629        let (v2, s2) = insert_str(&v1, &s1_sel, "X", 1.1);
1630        assert_eq!(v2, "hXlo");
1631        let (v3, _) = undo(&v2, &s2, 2.0).unwrap();
1632        assert_eq!(v3, "hello", "undoing the selection-replace must not also undo the typed word");
1633    }
1634
1635    // ── Step 2: grapheme-cluster correctness ─────────────────────────────
1636
1637    #[test]
1638    fn backspace_deletes_a_whole_zwj_family_emoji_in_one_press() {
1639        // Man+Woman+Girl+Boy joined by ZWJ — ONE grapheme, several chars.
1640        let family = "👨\u{200D}👩\u{200D}👧\u{200D}👦";
1641        let n = char_count(family);
1642        let s = st(n);
1643        let (v, ns) = backspace(family, &s, 1.0);
1644        assert_eq!(v, "", "the whole cluster must vanish in one Backspace, not one char at a time");
1645        assert_eq!(ns.cursor(), 0);
1646    }
1647
1648    #[test]
1649    fn move_left_steps_over_a_combining_accent_as_one_unit() {
1650        // 'e' + COMBINING ACUTE ACCENT (U+0301) — two chars, one grapheme.
1651        let s = "e\u{0301}x"; // "é" (decomposed) + "x"
1652        assert_eq!(char_count(s), 3);
1653        let state = st(3); // cursor after "x"
1654        let after_one_left = move_left(s, &state, false, 1.0);
1655        assert_eq!(after_one_left.cursor(), 2, "must land after the é-cluster, before x");
1656        let after_two_left = move_left(s, &after_one_left, false, 1.0);
1657        assert_eq!(after_two_left.cursor(), 0, "the combining accent must not be a stop of its own");
1658    }
1659
1660    #[test]
1661    fn delete_forward_removes_a_flag_emoji_as_one_grapheme() {
1662        // Regional indicator pair — two chars (surrogate-pair-free BMP+1
1663        // codepoints), one grapheme (a flag).
1664        let flag = "🇮🇳x";
1665        let s = st(0);
1666        let (v, ns) = delete_forward(flag, &s, 1.0);
1667        assert_eq!(v, "x", "the flag must vanish as one unit, not one regional indicator at a time");
1668        assert_eq!(ns.cursor(), 0);
1669    }
1670
1671    #[test]
1672    fn plain_ascii_grapheme_boundaries_match_char_boundaries() {
1673        // Sanity: for plain text every grapheme boundary is a char
1674        // boundary — the Step 1 behavior above is a special case of this.
1675        assert_eq!(grapheme_boundaries("abc"), vec![0, 1, 2, 3]);
1676    }
1677
1678    // ── Step 2: word-wise movement/deletion ──────────────────────────────
1679
1680    #[test]
1681    fn move_word_right_lands_at_the_end_of_the_next_word() {
1682        let s = st(0);
1683        let ns = move_word_right("hello world", &s, false, 1.0);
1684        assert_eq!(ns.cursor(), 5);
1685        let ns2 = move_word_right("hello world", &ns, false, 1.0);
1686        assert_eq!(ns2.cursor(), 11);
1687    }
1688
1689    #[test]
1690    fn move_word_left_lands_at_the_start_of_the_previous_word() {
1691        let s = st(11); // end of "hello world"
1692        let ns = move_word_left("hello world", &s, false, 1.0);
1693        assert_eq!(ns.cursor(), 6);
1694        let ns2 = move_word_left("hello world", &ns, false, 1.0);
1695        assert_eq!(ns2.cursor(), 0);
1696    }
1697
1698    #[test]
1699    fn delete_word_back_removes_the_preceding_word() {
1700        let s = st(11); // "hello world", cursor at end
1701        let (v, ns) = delete_word_back("hello world", &s, 1.0);
1702        assert_eq!(v, "hello ");
1703        assert_eq!(ns.cursor(), 6);
1704    }
1705
1706    #[test]
1707    fn delete_word_forward_removes_the_following_word() {
1708        let s = st(0);
1709        let (v, ns) = delete_word_forward("hello world", &s, 1.0);
1710        assert_eq!(v, " world");
1711        assert_eq!(ns.cursor(), 0);
1712    }
1713
1714    #[test]
1715    fn extend_word_right_selects_through_a_word() {
1716        let s = st(0);
1717        let ns = move_word_right("hello world", &s, true, 1.0);
1718        assert_eq!(ns.selection_range(), Some((0, 5)));
1719    }
1720
1721    // ── Step 2: Command dispatch ──────────────────────────────────────
1722
1723    #[test]
1724    fn apply_command_backspace_matches_the_direct_call() {
1725        let s = st(5);
1726        let (v1, s1) = apply_command("hello", &s, Command::Backspace, 1.0).unwrap();
1727        let (v2, s2) = backspace("hello", &s, 1.0);
1728        assert_eq!(v1, v2);
1729        assert_eq!(s1.cursor(), s2.cursor());
1730    }
1731
1732    #[test]
1733    fn apply_command_clipboard_commands_return_none() {
1734        let s = st(0);
1735        assert!(apply_command("hello", &s, Command::Copy, 1.0).is_none());
1736        assert!(apply_command("hello", &s, Command::Cut, 1.0).is_none());
1737        assert!(apply_command("hello", &s, Command::Paste, 1.0).is_none());
1738    }
1739
1740    #[test]
1741    fn apply_command_undo_on_empty_history_returns_none() {
1742        let s = st(0);
1743        assert!(apply_command("hello", &s, Command::Undo, 1.0).is_none());
1744    }
1745
1746    // ── Step 2: EditController (the toolbar Bold-button scenario) ───────
1747
1748    #[test]
1749    fn edit_controller_replace_range_wraps_a_selection_like_a_toolbar_button() {
1750        let value = "hello world";
1751        let state = st_sel(6, 11); // "world" selected
1752
1753        let controller = EditController::new();
1754        assert!(controller.take_ops().is_empty());
1755
1756        // The exact toolbar-Bold-button shape D116 promises: read the
1757        // selection, wrap it, replace_range.
1758        let (start, end) = state.selection_range().unwrap();
1759        controller.replace_range(start, end, format!("**{}**", &value[start..end]));
1760
1761        let ops = controller.take_ops();
1762        assert_eq!(ops.len(), 1);
1763        let ControllerOp::ReplaceRange(s, e, text) = &ops[0] else { panic!("expected ReplaceRange") };
1764        assert_eq!((*s, *e, text.as_str()), (6, 11, "**world**"));
1765
1766        let (new_value, new_state) = replace_range(value, &state, *s, *e, text, 1.0);
1767        assert_eq!(new_value, "hello **world**");
1768        assert_eq!(new_state.cursor(), 15);
1769
1770        controller.update_snapshot(new_value.clone(), new_state.selection.clone());
1771        assert_eq!(controller.value(), "hello **world**");
1772    }
1773
1774    #[test]
1775    fn edit_controller_has_a_stable_id_distinct_from_other_controllers() {
1776        let a = EditController::new();
1777        let b = EditController::new();
1778        assert_ne!(a.id(), b.id());
1779        assert_eq!(a.clone().id(), a.id(), "cloning must share identity, not create a new controller");
1780    }
1781
1782    #[test]
1783    fn edit_controller_undo_redo_ops_enqueue_correctly() {
1784        let c = EditController::new();
1785        c.undo();
1786        c.redo();
1787        c.select_all();
1788        let ops = c.take_ops();
1789        assert_eq!(ops.len(), 3);
1790        assert!(matches!(ops[0], ControllerOp::Undo));
1791        assert!(matches!(ops[1], ControllerOp::Redo));
1792        assert!(matches!(ops[2], ControllerOp::SelectAll));
1793    }
1794
1795    // ── D116 Step 5: style_runs, CursorStyle, last_edit_range ────────────
1796
1797    /// The comparable projection of one `style_runs` output run (`Color`
1798    /// has no `PartialEq` of its own; `color_bits` is text_edit.rs's
1799    /// existing local workaround, reused here for the same reason `Span`/
1800    /// `CursorStyle` need it).
1801    type RunBits = (usize, usize, Option<(u8, u8, u8, u8)>, Option<FontWeight>);
1802
1803    fn runs_bits(runs: &[(usize, usize, Option<Color>, Option<FontWeight>)]) -> Vec<RunBits> {
1804        runs.iter().map(|&(a, b, c, w)| (a, b, c.map(color_bits), w)).collect()
1805    }
1806
1807    #[test]
1808    fn style_runs_with_no_spans_is_one_default_run_covering_the_whole_line() {
1809        let runs = style_runs(&[], 0, 10);
1810        assert_eq!(runs_bits(&runs), vec![(0, 10, None, None)]);
1811    }
1812
1813    #[test]
1814    fn style_runs_splits_around_a_span_leaving_default_runs_in_the_gaps() {
1815        // "hello **world**" -> pretend span covers chars [8, 13) ("world").
1816        let spans = vec![Span::new((8, 13)).color(Color::rgb(255, 0, 0))];
1817        let runs = style_runs(&spans, 0, 15);
1818        assert_eq!(runs_bits(&runs), vec![
1819            (0, 8, None, None),
1820            (8, 13, Some((255, 0, 0, 255)), None),
1821            (13, 15, None, None),
1822        ]);
1823    }
1824
1825    #[test]
1826    fn style_runs_clips_a_span_that_extends_past_the_requested_range() {
1827        // Line only covers [0, 5) but the span runs [3, 20) — the run
1828        // must be clipped to the line's own bounds, not read past it.
1829        let spans = vec![Span::new((3, 20)).weight(FontWeight::Bold)];
1830        let runs = style_runs(&spans, 0, 5);
1831        assert_eq!(runs_bits(&runs), vec![(0, 3, None, None), (3, 5, None, Some(FontWeight::Bold))]);
1832    }
1833
1834    #[test]
1835    fn style_runs_last_matching_span_wins_on_overlap() {
1836        let spans = vec![
1837            Span::new((0, 10)).color(Color::rgb(1, 1, 1)),
1838            Span::new((0, 10)).color(Color::rgb(2, 2, 2)),
1839        ];
1840        let runs = style_runs(&spans, 0, 10);
1841        assert_eq!(runs_bits(&runs), vec![(0, 10, Some((2, 2, 2, 255)), None)]);
1842    }
1843
1844    #[test]
1845    fn style_runs_on_an_empty_range_returns_nothing() {
1846        assert!(style_runs(&[], 5, 5).is_empty());
1847    }
1848
1849    #[test]
1850    fn cursor_style_default_matches_the_pre_step5_hardcoded_caret() {
1851        let s = CursorStyle::default();
1852        assert_eq!(s.width, 1.5);
1853        assert_eq!(s.blink_rate, 0.53);
1854        assert_eq!(s.shape, CursorShape::Bar);
1855    }
1856
1857    #[test]
1858    fn typing_sets_last_edit_range_to_just_the_inserted_text_not_the_whole_document() {
1859        let value = "hello world, this is a long sentence";
1860        let state = st(value.chars().count());
1861        let (_, ns) = insert_char(value, &state, '!', 1.0);
1862        assert_eq!(
1863            ns.last_edit_range,
1864            Some((value.chars().count(), value.chars().count() + 1)),
1865            "an append must report only the newly inserted char's range, not (0, whole_len)"
1866        );
1867    }
1868
1869    #[test]
1870    fn moving_the_cursor_clears_last_edit_range() {
1871        let value = "hello";
1872        let state = st(0);
1873        let after_type = insert_char(value, &state, 'X', 1.0).1;
1874        assert!(after_type.last_edit_range.is_some());
1875        let after_move = move_right(value, &after_type, false, 1.0);
1876        assert_eq!(after_move.last_edit_range, None, "a pure cursor move is not a content edit");
1877    }
1878
1879    // ── D116 Step 6: IME preedit / commit ─────────────────────────────────
1880
1881    #[test]
1882    fn ime_preedit_inserts_provisional_text_at_the_cursor() {
1883        let (v, ns) = ime_set_preedit("hello ", &st(6), "に", None, 1.0);
1884        assert_eq!(v, "hello に");
1885        assert_eq!(ns.ime_range, Some((6, 7)));
1886        assert_eq!(ns.cursor(), 7, "cursor defaults to the end of the preedit text");
1887    }
1888
1889    #[test]
1890    fn ime_preedit_does_not_touch_the_undo_stack() {
1891        let s = st(0);
1892        assert!(!s.can_undo());
1893        let (_, ns) = ime_set_preedit("", &s, "に", None, 1.0);
1894        assert!(!ns.can_undo(), "a preedit update must not create an undo entry");
1895    }
1896
1897    #[test]
1898    fn a_second_preedit_update_replaces_the_first_not_appends() {
1899        // Real IME behavior: each keystroke while composing REPLACES the
1900        // whole provisional buffer with the new romaji->kana candidate,
1901        // it doesn't insert alongside the old one.
1902        let (v1, ns1) = ime_set_preedit("", &st(0), "に", None, 1.0);
1903        assert_eq!(v1, "に");
1904        let (v2, ns2) = ime_set_preedit(&v1, &ns1, "にほ", None, 1.0);
1905        assert_eq!(v2, "にほ");
1906        assert_eq!(ns2.ime_range, Some((0, 2)));
1907    }
1908
1909    #[test]
1910    fn ime_preedit_respects_the_platforms_cursor_position_within_the_text() {
1911        let (_, ns) = ime_set_preedit("", &st(0), "にほん", Some(1), 1.0);
1912        assert_eq!(ns.cursor(), 1, "cursor must land where the IME says, not always at the end");
1913    }
1914
1915    #[test]
1916    fn empty_preedit_clears_the_provisional_text_and_range() {
1917        let (v1, ns1) = ime_set_preedit("", &st(0), "に", None, 1.0);
1918        let (v2, ns2) = ime_set_preedit(&v1, &ns1, "", None, 1.0);
1919        assert_eq!(v2, "");
1920        assert_eq!(ns2.ime_range, None);
1921    }
1922
1923    #[test]
1924    fn ime_commit_finalizes_as_one_real_undoable_edit_and_clears_ime_range() {
1925        let (v1, ns1) = ime_set_preedit("", &st(0), "に", None, 1.0);
1926        let (v2, ns2) = ime_set_preedit(&v1, &ns1, "にほ", None, 1.0);
1927        let (v3, ns3) = ime_commit(&v2, &ns2, "日本", 1.0);
1928        assert_eq!(v3, "日本");
1929        assert_eq!(ns3.ime_range, None);
1930        assert_eq!(ns3.cursor(), char_count("日本"));
1931        assert!(ns3.can_undo(), "commit must produce a real, undoable edit");
1932
1933        // The WHOLE composition undoes in ONE step, not one per keystroke.
1934        let (v4, ns4) = undo(&v3, &ns3, 2.0).expect("commit must be undoable");
1935        assert_eq!(v4, "");
1936        assert!(!ns4.can_undo(), "undoing the commit must remove the ONLY undo entry the whole composition produced");
1937    }
1938
1939    #[test]
1940    fn ime_commit_with_no_prior_preedit_replaces_the_selection_like_a_normal_insert() {
1941        let (v, ns) = ime_commit("hello", &st_sel(1, 3), "X", 1.0);
1942        assert_eq!(v, "hXlo");
1943        assert_eq!(ns.cursor(), 2);
1944    }
1945
1946    // ── D116 Step 8: input filters ────────────────────────────────────────
1947
1948    #[test]
1949    fn apply_filters_with_no_filters_is_a_no_op() {
1950        assert_eq!(apply_filters("hello", &[]), "hello");
1951    }
1952
1953    #[test]
1954    fn max_length_truncates_from_the_end() {
1955        let f = [InputFilter::max_length(3)];
1956        assert_eq!(apply_filters("hello", &f), "hel");
1957    }
1958
1959    #[test]
1960    fn max_length_leaves_a_shorter_value_untouched() {
1961        let f = [InputFilter::max_length(10)];
1962        assert_eq!(apply_filters("hi", &f), "hi");
1963    }
1964
1965    #[test]
1966    fn digits_strips_non_digit_characters() {
1967        let f = [InputFilter::digits()];
1968        assert_eq!(apply_filters("a1b2c3", &f), "123");
1969    }
1970
1971    #[test]
1972    fn alphanumeric_strips_punctuation_and_spaces() {
1973        let f = [InputFilter::alphanumeric()];
1974        assert_eq!(apply_filters("ab! 12-cd", &f), "ab12cd");
1975    }
1976
1977    #[test]
1978    fn custom_char_class_filter() {
1979        let f = [InputFilter::char_class(|c| c == 'x' || c == 'y')];
1980        assert_eq!(apply_filters("xayzbx", &f), "xyx");
1981    }
1982
1983    #[test]
1984    fn filters_apply_in_order() {
1985        // Strip to digits first, THEN clamp to 2 chars.
1986        let f = [InputFilter::digits(), InputFilter::max_length(2)];
1987        assert_eq!(apply_filters("a1b2c3", &f), "12");
1988    }
1989}