Skip to main content

teksilo_widgets/rich_text/
state.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Shared mutable state for a single `RichTextEditor` instance.
5//!
6//! The widget's build-time effects and event handlers all need mutable
7//! access to the editor's inner state (engine, cursor, scroll signals,
8//! pending document events, image cache). `Rc<RefCell<State>>` is the
9//! simplest sound way to share that across closures — `&mut self` on
10//! `Widget::build()` lives too briefly for effect callbacks to borrow it
11//! directly.
12
13use std::cell::RefCell;
14use std::collections::VecDeque;
15use std::rc::Rc;
16use std::sync::{Arc, Mutex};
17
18use teksilo_core::Signal;
19use teksilo_core::kinetic::KineticScroller;
20use teksilo_text::text_document::{
21    DocumentEvent, DocumentFragment, HighlightMask, Subscription, TextCursor, TextDocument,
22};
23use teksilo_text::{CursorAffinity, RichTextEngine, WrapMode};
24use teksilo_tokens::OverscrollStyle;
25
26use super::caret_highlight::CaretHighlightSession;
27
28/// One annotation (a comment thread) covering `[start, end)` of the document, in
29/// document-absolute **character** offsets — the space cursors and `FindMatch`
30/// speak.
31///
32/// The framework stays ignorant of what an annotation *is*: the host supplies
33/// already-resolved spans and the text to announce, and this only turns them into
34/// AccessKit nodes. That keeps a comment feature's anchoring rules — which are
35/// application policy — out of the widget.
36#[derive(Clone, Debug, PartialEq, Eq, Default)]
37pub struct TextAnnotationSpan {
38    pub start: usize,
39    pub end: usize,
40    /// Durable identity of the annotation, so its synthetic `NodeId` is stable
41    /// across rebuilds and a screen reader's cursor is not thrown out of the
42    /// thread by an unrelated edit elsewhere.
43    pub group_id: u64,
44    /// What a screen reader should read: author, body, reply count, state.
45    pub summary: String,
46}
47use super::image_cache::ImageCache;
48use super::policy::{CaretPolicy, PolicyBundle};
49use crate::common::editor_runtime::{CaretBlink, Debounce};
50
51pub(crate) type SharedState = Rc<RefCell<EditorState>>;
52
53pub(crate) struct EditorState {
54    pub document: TextDocument,
55    pub engine: RichTextEngine,
56    pub cursor: TextCursor,
57
58    pub policy: PolicyBundle,
59
60    // Reactive bridge — cloned into children (scroll bars, selection badges, etc.)
61    pub document_version: Signal<u64>,
62    /// Bumps **only** on format-only document events
63    /// ([`DocumentEvent::FormatChanged`]). Distinct from
64    /// `document_version`, which bumps on both content and format
65    /// changes — toolbars that want to react to just format changes
66    /// (e.g. refresh Bold / Italic button state) observe this signal.
67    pub format_version: Signal<u64>,
68    /// Bumps once per [`DocumentEvent::LongOperationFinished`]. Starts
69    /// at 0; observers see a strictly increasing count as async
70    /// `set_html` / `set_markdown` imports complete.
71    pub document_loaded_count: Signal<u64>,
72    /// Optional user callback fired once per drain batch that contained a
73    /// genuine **content edit** ([`DocumentEvent::ContentsChanged`]) and was
74    /// not a programmatic load/reset. Set via
75    /// [`RichTextEditor::on_change`](super::RichTextEditor::on_change); runs on
76    /// the UI thread, so it may touch `Signal`s (e.g. flip a dirty flag).
77    pub on_change: Option<Rc<dyn Fn()>>,
78    /// Optional user callback fired **at each insertion**, with where the text
79    /// came from and how many characters it was. Set via
80    /// [`RichTextEditor::on_text_inserted`](super::RichTextEditor::on_text_inserted).
81    ///
82    /// Deliberately not folded into [`Self::on_change`]: that one fires once per
83    /// drain batch and says only *that* something changed, which is the right
84    /// shape for a dirty flag and the wrong one for counting. A batch can carry
85    /// a typed run and a paste, and after the fact nothing can separate them.
86    pub on_text_inserted: Option<Rc<dyn Fn(super::EditSource, usize)>>,
87    // NOTE: `report_inserted` below is the only correct way to fire it. Calling
88    // the callback directly from an insertion site would fire it for an empty
89    // string, which is not text arriving.
90    pub has_selection: Signal<bool>,
91    pub caret_visible: Signal<bool>,
92    pub cursor_position: Signal<usize>,
93    pub cursor_anchor: Signal<usize>,
94    /// Reactive undo availability — bound by toolbars, updated by the
95    /// frame loop when `DocumentEvent::UndoRedoChanged` arrives via
96    /// the per-widget event queue.
97    pub can_undo: Signal<bool>,
98    pub can_redo: Signal<bool>,
99
100    // Scroll state — NOT inside a ScrollArea (§27.10.5).
101    pub scroll_x: Signal<f32>,
102    pub scroll_y: Signal<f32>,
103    pub max_scroll_x: Signal<f32>,
104    pub max_scroll_y: Signal<f32>,
105    pub viewport_ratio_x: Signal<f32>,
106    pub viewport_ratio_y: Signal<f32>,
107    /// This surface's pan physics: the range a finger's pan is clamped to and
108    /// the offset it is currently holding. It lives beside the offsets it
109    /// moves, so [`sync_viewport`](Self::sync_viewport) — the one place the
110    /// viewport extent is known — can publish into it, and so it survives the
111    /// widget's rebuild along with the rest of the state.
112    pub scroller: Rc<RefCell<KineticScroller>>,
113
114    // Viewport (the body's bounds at the last layout pass). Written only by
115    // [`EditorState::sync_viewport`], which the body calls from BOTH
116    // `place_children` (authoritative — layout runs first) and `paint`
117    // (idempotent fallback). `viewport_origin` is the **body's** top-left in
118    // window coordinates — the engine lays text out from there.
119    pub viewport_width: f32,
120    pub viewport_height: f32,
121    pub viewport_origin: teksilo_canvas::Point,
122
123    // The **wrapper** node's top-left in window coordinates, recorded by
124    // the wrapper's `place_children`. Pointer positions now arrive
125    // wrapper-node-local (the framework converts once at dispatch), so to
126    // reach the body/engine space the handler reconstructs the window
127    // point (`position + node_origin`) and subtracts the body origin
128    // (`viewport_origin`): `local = position + node_origin -
129    // viewport_origin`. The body is inset within the wrapper, so the two
130    // origins differ.
131    pub node_origin: teksilo_canvas::Point,
132
133    /// The live input tokens — the density ladder the widened image-resize grip
134    /// and the kind-derived text-drag threshold read.
135    ///
136    /// Snapshotted in `build()` rather than read per event, because
137    /// `EventContext` exposes no theme. `set_input_density` marks the tree at
138    /// `BindingLevel::Rebuild`, so a density change re-runs `build()` and
139    /// refreshes this — which is what makes a snapshot correct rather than
140    /// merely convenient.
141    pub input_tokens: teksilo_tokens::InputTokens,
142
143    /// The body's viewport got **smaller** on the last `sync_viewport`, and the
144    /// caret has not been re-revealed for it yet.
145    ///
146    /// Set by [`sync_viewport`](Self::sync_viewport) — the single writer of the
147    /// viewport, and so the only place that can see the two sizes at once — and
148    /// consumed by the body's paint *after* the relayout the shrink forces,
149    /// because a caret cannot be revealed against a layout that has not run.
150    /// `sync_viewport`'s `bool` return cannot carry this on its own: the body
151    /// calls it from `place_children` first, so by paint time the change has
152    /// already been absorbed and the return is `false`.
153    ///
154    /// A **shrink** only. Growing reveals more text and never pushes the caret
155    /// out, and re-revealing on every resize would drag a reader's scroll
156    /// position back to the caret every time a window edge moved.
157    pub pending_caret_reveal: bool,
158
159    // Layout strategy state.
160    pub needs_full_layout: bool,
161    pub last_relayout_block_id: Option<usize>,
162    pub content_dirty: bool,
163    /// True when the most recent layout pass (in `frame_loop::tick`)
164    /// ran `layout_full`. Consumed (cleared to false) by
165    /// `RichTextEditorBody::paint` to pick `RenderChoice::Full`.
166    /// Needed because tick clears `needs_full_layout` after running
167    /// the full layout, so paint can't infer it from that flag alone.
168    pub pending_full_render: bool,
169    /// Set by a `DocumentEvent::HighlightPaintChanged` (paint-only highlight
170    /// change). `frame_loop::tick` consumes it: it recolors the cached layout
171    /// via `engine.apply_paint_highlights` and forces a re-render, WITHOUT a
172    /// reshape/reflow. Distinct from `needs_full_layout` — a paint-only change
173    /// never changes glyph metrics.
174    pub pending_recolor: bool,
175
176    /// The document extent a pending recolor covers, from
177    /// [`DocumentEvent::HighlightPaintChanged`]. `None` means "unknown — the whole document",
178    /// which is what the document-wide operations report (installing or retiring a highlighter,
179    /// a full rehighlight) and what several accumulated changes collapse to.
180    ///
181    /// When it *is* known and fits inside one block, `frame_loop::tick` recolors that block
182    /// alone instead of re-snapshotting the document — the difference between O(block) and
183    /// O(document) on every keystroke that moves a caret band, a find match or a spell squiggle.
184    pub pending_recolor_range: Option<(usize, usize)>,
185
186    /// This view's ambient caret band (the sentence or paragraph being written in), when the
187    /// host asked for one. `None` — the default — costs nothing: no session is registered on
188    /// the document at all.
189    pub caret_highlight: Option<CaretHighlightSession>,
190    /// Whether the band was last told to draw — focus *and* no selection, as `frame_loop`
191    /// computes it. Tracked here so a change is noticed without the focus or selection paths
192    /// having to know the band exists.
193    pub caret_highlight_active: bool,
194
195    // Wrap mode as configured by the builder.
196    pub wrap_mode: WrapMode,
197
198    /// Whether this view applies the document's syntax/search/spell
199    /// highlights. When `false` the view pulls a *clean* snapshot
200    /// (no highlights at all) and ignores `HighlightPaintChanged`, so a
201    /// read-only preview can mirror the same shared `TextDocument` while
202    /// staying bare of authoring-time highlighting. The single source of
203    /// truth — `frame_loop`/`drain_events` read it and pass it to the
204    /// engine's per-block relayout. Default `true`; `read_only` defaults
205    /// it to `false` (override either way via `RichTextEditor::show_highlights`).
206    pub show_highlights: bool,
207    /// Annotation bodies (comment threads) covering ranges of this document, for
208    /// the accessibility tree only.
209    ///
210    /// Deliberately separate from the highlight sessions that *paint* them: paint
211    /// says "something is here", while this says what it is and lets a screen
212    /// reader navigate into it. A sighted user gets the underline; an AT user gets
213    /// `aria-details` to a `Role::Comment` node. Neither is derivable from the
214    /// other — a highlight carries no text, and this carries no colour.
215    pub annotation_spans: Vec<TextAnnotationSpan>,
216
217    /// Window the render to the accumulated ancestor clip instead of this
218    /// widget's own bounds. `false` by default: a normal self-scrolling editor
219    /// culls correctly from its own `scroll_y`. An editor laid out at full
220    /// document height inside an outer `ScrollArea` ("dubious mode") sets this
221    /// `true` so paint-time culling follows the visible clip band rather than
222    /// the whole-document viewport. Read in `paint()`; drives
223    /// `engine.set_render_window`. See
224    /// [`RichTextEditor::window_to_clip`](crate::rich_text::RichTextEditor::window_to_clip).
225    pub window_to_clip: bool,
226    /// Whether this editor guesses its height from its text before anything has
227    /// laid it out. See
228    /// [`RichTextEditor::estimate_height_before_layout`](crate::rich_text::RichTextEditor::estimate_height_before_layout).
229    pub estimate_height_before_layout: bool,
230    /// The widest width this body has ever been asked to measure at.
231    ///
232    /// Only read by the height guess, and only until a real layout exists.
233    ///
234    /// A measurement pass may carry any width, and one of them carries a width that
235    /// is not a measure at all. `linear_layout::negotiate` opens with an **intrinsic
236    /// probe** — every child asked at `width: None`, meaning "how big do you want to
237    /// be". Skribisto's writing column resolves that `None` to `0.0`, floors it at
238    /// its own `MIN_COLUMN_WIDTH` of 100, and its editor's 12 px content padding
239    /// takes 24 off: **76**, every time, for a column whose text wraps at 447. A
240    /// guess made against it claimed six times the true height.
241    ///
242    /// By the time the number arrives here it is an ordinary `Some(76.0)` and
243    /// nothing distinguishes it from a genuinely narrow placement — the intent is
244    /// destroyed two layers up. The widest is then the honest predictor: 76 is the
245    /// floor of that whole chain, so any real measurement beats it, and a narrow
246    /// proposal is a minimum-size question while the layout that follows uses the
247    /// generous one.
248    ///
249    /// ⚠ It only grows. An editor that is measured wide, never laid out, and then
250    /// **permanently** narrowed — the writer opens the Inspector on a Full Book —
251    /// keeps guessing against the stale width until it is scrolled to. Bounded in
252    /// practice because a stream's rows are short-lived, and the failure is one
253    /// under-estimate rather than the sixfold over-estimate it replaces.
254    pub widest_measured_width: f32,
255
256    /// Which highlight sessions THIS view renders (`show_highlights` is the master switch
257    /// above it: `false` suppresses everything regardless of this mask). Default is
258    /// [`HighlightMask::all`] — every session on the document. A per-editor find banner sets
259    /// this to a narrower set so two panes over one shared document can highlight different
260    /// queries. See [`Self::effective_mask`].
261    pub highlight_mask: HighlightMask,
262
263    /// `true` once the app explicitly set a text color via
264    /// Last text color applied to the typesetter. Tracked so a theme
265    /// swap (light ↔ dark) can force a full re-render — without it
266    /// paint() would happily call `engine.with_render_cursor_only`,
267    /// which reuses the cached glyph quads with their old colors baked
268    /// in, leaving the visible text unchanged until the next typing /
269    /// scroll event triggered a Full or Block render.
270    pub last_text_color: Option<[f32; 4]>,
271
272    /// Whether this editor follows the global accessibility text scale
273    /// (`ctx.text_scale`). `true` by default; set `false` via
274    /// [`RichTextEditor::follow_text_scale`](crate::rich_text::RichTextEditor::follow_text_scale)
275    /// for documents whose font sizes are
276    /// content (e.g. a WYSIWYG editor) that should not inflate with the UI
277    /// accessibility setting.
278    pub follow_text_scale: bool,
279    /// Per-editor logical font-size multiplier (`1.0` = 100 %), composed with
280    /// the a11y text scale at paint:
281    /// `engine.font_scale = (follow ? ctx.text_scale : 1.0) × font_size_scale`.
282    /// Sharp "text size" — real shaping at a larger ppem. Default `1.0`.
283    pub font_size_scale: f32,
284    /// Last `font_scale` pushed to the engine. Tracked so the paint pass only
285    /// re-sets it (and forces a relayout) when the effective scale changes.
286    pub last_font_scale: f32,
287
288    /// Last caret colour applied to the typesetter. The paint pass syncs
289    /// the engine's cursor colour with the active theme's `editor_caret`
290    /// role each frame so light / dark theme swaps reach the blinking
291    /// caret (the engine defaults it to opaque black). Tracked so a theme
292    /// swap forces a render this frame instead of waiting for the next
293    /// blink toggle to repaint the caret in the new colour.
294    pub last_cursor_color: Option<[f32; 4]>,
295
296    /// Last selection-highlight colour applied to the engine. Tracked (like
297    /// the caret) so an app-set `selection_color` change forces a render this
298    /// frame. `None` until the app sets a colour.
299    pub last_selection_color: Option<[f32; 4]>,
300
301    /// App-set colour overrides (`impl Into<ColorProp>` — Color / theme role /
302    /// Signal), resolved against the active theme on each paint. `None` tracks
303    /// the theme's editor roles. Set by the `RichTextEditor` builders, read by
304    /// `RichTextEditorBody::paint`; `background_prop` is consumed by
305    /// `RichTextEditor::build` (threaded into the style's `make_body`).
306    pub text_color_prop: Option<teksilo_core::color_prop::ColorProp>,
307    pub caret_color_prop: Option<teksilo_core::color_prop::ColorProp>,
308    pub selection_color_prop: Option<teksilo_core::color_prop::ColorProp>,
309    pub background_prop: Option<teksilo_core::color_prop::ColorProp>,
310
311    /// Last code-block background colour applied to the engine. A
312    /// change forces a full `layout_full` (not just a render) because
313    /// the converted `BlockLayoutParams.background_color` is baked in
314    /// at layout time, not at render time.
315    pub last_code_block_bg: Option<[f32; 4]>,
316    /// Last code-block foreground colour applied to the engine. Same
317    /// rationale as `last_code_block_bg` — fragment foregrounds are
318    /// baked into the layout's shaped runs.
319    pub last_code_block_fg: Option<[f32; 4]>,
320    /// Last link foreground pushed to the engine, so a theme swap can be
321    /// told from a no-op repaint. Same reason as `last_code_block_fg`: the
322    /// colour is baked in at layout time.
323    pub last_link_fg: Option<[f32; 4]>,
324
325    // Focus — mirrored from `on_focus` so paint can gate the caret.
326    pub has_focus: bool,
327
328    /// A drag is hovering this editor, and the caret is showing where it would
329    /// land.
330    ///
331    /// The ordinary caret is gated on focus, which a drag never gives the
332    /// editor it is hovering — the focus stays wherever the drag began, often
333    /// in a different editor entirely. So the one caret the writer actually
334    /// needs to see, the one promising where the text will land, is exactly the
335    /// one the focus gate hides. This overrides it for as long as the drag is
336    /// overhead, and it does not blink: a drop target that flashes on and off
337    /// reads as uncertainty about whether it will accept.
338    pub drop_caret: bool,
339
340    /// When `true` (**the default**), moving the caret reveals it inside any
341    /// *enclosing* scroll area (via `EventContext::ensure_visible`) — the
342    /// standard editor "caret stays on screen while you type / navigate"
343    /// behaviour. It fires only on a caret *move*, never on a plain wheel /
344    /// scrollbar scroll, so the reader can still scroll freely away from the
345    /// caret and the view stays put until the caret next moves.
346    ///
347    /// This matters most for a document editor that **grows** to its content
348    /// with its own scroll suppressed (a flowing page inside an outer
349    /// `ScrollArea`): there the editor's *internal* caret-visibility is a no-op
350    /// (it shows all its content), so the enclosing page-follow is the only
351    /// thing that keeps the caret visible. Set
352    /// [`RichTextEditor::follow_caret_in_page(false)`](crate::rich_text::RichTextEditor::follow_caret_in_page)
353    /// for the rare case where the surrounding page must never move on a caret
354    /// change. See `chase_caret_into_view`.
355    pub follow_caret_in_page: bool,
356
357    /// Whether the host window is currently active (`focused AND not
358    /// occluded`). Mirrored from `BuildContext::window_active_signal` by an
359    /// effect in `RichTextEditor::build` (the frame-loop `tick` has no context,
360    /// so it can't observe the signal itself). Gates the caret alongside
361    /// `has_focus`: the caret is hidden whenever the window is inactive, the
362    /// universal desktop convention. Starts `true` to match the tree's initial
363    /// window-active value.
364    pub window_active: bool,
365
366    /// Reactive mirror of `has_focus`, kept in lockstep by the
367    /// `on_focus` handler. Exposed so the composing
368    /// `RichTextEditor` shell can pass it into
369    /// `RichTextEditorStyle::make_body` and drive a focus-aware
370    /// border without polling.
371    pub focus_signal: Signal<bool>,
372
373    /// The wrapper widget's own id, stashed on every build so a held
374    /// [`EditorHandle`](super::EditorHandle) can move keyboard focus back to the
375    /// editor (e.g. a find banner returning focus to the prose on Escape). The
376    /// wrapper is the `.focusable(true)` node, so `request_focus` on it lands
377    /// exactly where a click would.
378    pub self_id: Option<teksilo_core::widget_id::WidgetId>,
379
380    /// The node's activation signal, stashed on every build so code with no
381    /// context of its own can tell an on-screen editor from one parked dormant
382    /// in a tab that is not selected.
383    ///
384    /// Dormancy is **not** visible in the engine: `has_full_layout` is set once
385    /// and never cleared, so an editor that laid out and was then parked still
386    /// answers "I have a layout" and would happily locate an offset nobody can
387    /// see. `reveal_range` reads this to keep its promise that `false` means
388    /// nothing was requested — a caller holding several editors over one
389    /// document takes the first `true` as the answer, and a dormant one that
390    /// lies costs the reader the scroll. `None` only before the first build,
391    /// where there is no layout to reveal in anyway.
392    pub activation: Option<Signal<bool>>,
393
394    /// Sticky preferred X for vertical navigation. Set
395    /// the first time Up/Down/PageUp/PageDown is pressed, preserved
396    /// across further vertical presses so the cursor keeps trying to
397    /// land on the same visual column even when crossing short
398    /// lines. Cleared on any horizontal or edit action.
399    pub preferred_x: Option<f32>,
400
401    /// Which side of a soft-wrap boundary the caret renders at. Only
402    /// has an effect when `cursor.position()` happens to be a wrap
403    /// boundary (the same character offset appears at the end of one
404    /// display line and the start of the next). Default is
405    /// `Downstream`, which matches the pre-affinity behavior:
406    /// end-of-previous-line placement. Mouse clicks set it from
407    /// `HitTestResult::affinity`; vertical navigation
408    /// (Up/Down/PageUp/PageDown/Home/End) re-derives it via the
409    /// typesetter's hit-test after the move; edits, Left/Right, and
410    /// programmatic cursor mutations reset to `Downstream`.
411    ///
412    /// Stored on `EditorState` rather than on `TextCursor` because
413    /// affinity is a display concern that requires the layout engine
414    /// to interpret — see `docs/architecture.md` / the design rationale
415    /// in the commit message that introduced this field.
416    pub cursor_affinity: CursorAffinity,
417
418    /// Caret blink phase. Wall-clock driven, so the visible rhythm stays
419    /// locked to real seconds no matter how the frame scheduler behaves.
420    /// Shared with the other text surfaces — see
421    /// [`common::editor_runtime::CaretBlink`](crate::common::editor_runtime::CaretBlink).
422    pub blink: CaretBlink,
423
424    /// Shared handle into `WidgetTree::frame_tick_requested`. Stashed
425    /// here so the frame-tick effect can chain-request another tick
426    /// (blink, drag auto-scroll) without needing mutable access to
427    /// the tree.
428    pub frame_request: Option<Rc<std::cell::Cell<bool>>>,
429
430    /// Shared handle into `WidgetTree::pending_wake_at`. Used by the
431    /// caret blink path to schedule a one-shot 500 ms wake-up instead
432    /// of keeping the frame loop pumping at the OS's max rate.
433    pub frame_wake_at: Option<Rc<std::cell::Cell<Option<std::time::Instant>>>>,
434
435    // Shared-document event routing: each editor subscribes via
436    // `on_change` and buffers events in its own queue. The
437    // `_event_subscription` field is kept alive by the state so
438    // dropping the state unregisters the callback.
439    pub event_queue: Arc<Mutex<VecDeque<DocumentEvent>>>,
440    pub _event_subscription: Subscription,
441
442    // Resource caches.
443    pub image_cache: ImageCache,
444
445    // --- M8b editor preset state (unused by read-only preset) ----------
446    /// Accumulates typed characters within a single frame, flushed as
447    /// one `cursor.insert_text(batch)` at the start of the next
448    /// `frame_loop::tick`. Batching matches the godot reference, and collapses
449    /// a burst of keystrokes into a single `ContentsChanged` event so
450    /// incremental relayout and debounced `text_changed` emission stay
451    /// O(burst) instead of O(keystrokes).
452    pub pending_chars: String,
453    /// How many of [`Self::pending_chars`] were typed, and how many were the
454    /// settled result of an IME composition.
455    ///
456    /// Two counters rather than one label on the batch, because both routes push
457    /// into the same string and the frame loop flushes it as a unit. A single
458    /// label would have to pick one for a mixed batch — and while mixing is
459    /// vanishingly unlikely (an active IME swallows the raw keys), a count that
460    /// is exact costs two `usize`s and never has to be reasoned about again.
461    ///
462    /// Reset with the batch. See [`Self::report_inserted_chars`].
463    pub pending_typed_chars: usize,
464    pub pending_ime_chars: usize,
465
466    /// Active IME preedit text — the unfinalised string the input
467    /// method renders while the user is composing (CJK, Korean,
468    /// dead-key accents on Linux). `Some(text)` means there is a
469    /// tentative insert at `ime_preedit_range`; empty text + `Some`
470    /// means the composition was cancelled but the old range still
471    /// needs clearing. `None` means no active composition.
472    pub ime_preedit: Option<String>,
473    /// Character range (scalar-indexed, matching
474    /// `TextCursor::position`) of the tentative preedit insert. The
475    /// composition handler removes this range before inserting the
476    /// next preedit string so the document always reflects the
477    /// current IME state.
478    pub ime_preedit_range: Option<std::ops::Range<usize>>,
479
480    /// Document position (scalar-indexed caret offset) of the most recent
481    /// [`chase_caret_into_view`](super::keyboard::chase_caret_into_view). The
482    /// page-follow chase reveals the caret only when it actually *moves*: a
483    /// repeat call at the same position (IME preedit churn on Linux, a no-op
484    /// nav key, a redundant click) is skipped so it can't yank the page back
485    /// after the user has deliberately scrolled the caret off-screen.
486    pub last_chase_pos: Option<usize>,
487
488    /// Window-space `y` of the caret at the most recent **pinned** chase, used
489    /// to extend the `last_chase_pos` dedup while typewriter scrolling is on.
490    ///
491    /// Position alone is not enough for a pin: a reflow — a soft-wrap change, a
492    /// typography or zoom change, a window resize — moves the caret's *rect*
493    /// while its document offset stands still, and a pin that skipped those
494    /// would silently drift off its line and stay there.
495    pub last_chase_y: Option<f32>,
496
497    /// Typewriter scrolling: where to pin the caret line in the enclosing scroll
498    /// area, as a fraction of the viewport height (`0.5` = centred). `None`
499    /// (default) leaves the plain minimal-reveal follow in charge. Set from
500    /// [`RichTextEditor::typewriter`](crate::rich_text::RichTextEditor::typewriter).
501    pub typewriter: Option<f32>,
502
503    /// Whether the caret was last placed by the **pointer**. While set, the
504    /// typewriter pin stands down and the click position becomes the new
505    /// resting place; the next keystroke clears it and pinning resumes.
506    ///
507    /// Every well-regarded typewriter implementation converges on this rule
508    /// (Ulysses' "Variable", the CodeMirror plugins' `movedByMouse`, Sublime's
509    /// trigger list, VS Code's `cursorSurroundingLinesStyle`), and the editors
510    /// that omit it — Typora, Zettlr — carry open bugs about the view fighting
511    /// the mouse and about drag-selection becoming unusable.
512    pub mouse_anchored: bool,
513
514    /// Last IME candidate-window rectangle reported to the platform via
515    /// [`report_ime_cursor_area`](super::keyboard::report_ime_cursor_area).
516    /// Reporting is deduped against this: re-sending an unchanged area is not
517    /// only wasted work but, on some winit IME backends (ibus/fcitx), echoes
518    /// back a fresh empty `Ime::Preedit` — a self-sustaining feedback loop.
519    pub last_ime_area: Option<teksilo_canvas::Rect>,
520
521    /// Coalescing window for `text_changed` / `format_changed` /
522    /// `undo_redo_changed`. Starts already-expired so the first frame
523    /// publishes `can_undo`/`can_redo` without a 150 ms wait. Shared with the
524    /// other text surfaces — see
525    /// [`common::editor_runtime::Debounce`](crate::common::editor_runtime::Debounce).
526    pub debounce: Debounce,
527
528    /// Set whenever the document mutated this frame (insert, delete,
529    /// format). Drained once the debounce timer crosses 150 ms —
530    /// observers read the `document_version` signal `drain_events`
531    /// already bumped, since the typed `on_text_changed` emission is
532    /// still Phase B (see `frame_loop::tick`). Distinct from
533    /// `pending_format_changed` so a pure-format edit doesn't pretend
534    /// text changed. Distinct from
535    /// `pending_format_changed` so a pure-format edit doesn't pretend
536    /// text changed.
537    pub pending_text_changed: bool,
538    pub pending_format_changed: bool,
539
540    /// Latest `(can_undo, can_redo)` pair from a `DocumentEvent::UndoRedoChanged`
541    /// arriving during `drain_events`. Debounced alongside text/format
542    /// changes so rapid typing doesn't hammer toolbar observers.
543    pub pending_undo_redo: Option<(bool, bool)>,
544
545    /// Active drag-select session state. `Idle` when no primary button is
546    /// held; `Selecting` while the user is extending a selection with the
547    /// pointer, with a cached auto-scroll velocity for when the pointer
548    /// approaches the viewport edges.
549    pub drag_state: DragState,
550
551    /// In-process rich clipboard fragment captured by the last Ctrl+C /
552    /// Ctrl+X. On paste the HTML payload is inspected for
553    /// `rich_clipboard_marker`; on a match the fragment is reinserted
554    /// to preserve formatting, otherwise the clipboard's own payload is
555    /// parsed. Plain-text equality alone is not sufficient because two
556    /// different apps can publish identical plain text with different
557    /// formatting; the embedded marker disambiguates.
558    pub rich_clipboard_fragment: Option<DocumentFragment>,
559    /// Plain-text form of the same copy, and the fallback identity check on a
560    /// clipboard backend that could not carry the marker.
561    ///
562    /// Set **only** when the copy found no HTML payload on the clipboard
563    /// afterwards, which is how a backend inheriting the default `set_html`
564    /// body announces itself. On a backend that does carry HTML this stays
565    /// `None`, because there the marker is the identity and text equality is
566    /// ambiguous: another application can publish the same words with different
567    /// formatting, and matching on them would paste this editor's stale
568    /// formatting onto somebody else's text.
569    pub rich_clipboard_plain: Option<String>,
570    /// Opaque token embedded as an HTML comment in the clipboard HTML
571    /// payload of the most recent copy/cut. Regenerated on every copy,
572    /// so stale markers from a previous session or a cleared state
573    /// never match.
574    pub rich_clipboard_marker: Option<String>,
575
576    /// Ctrl+A escalation ladder position. See `keyboard.rs`: when the
577    /// caret is inside a table cell the ladder climbs through 4 levels
578    /// (paragraph → cell → table → document); outside a table it is a
579    /// single-shot `SelectionType::Document` and stays at 0. Reset to 0
580    /// by any non-SelectAll key action (matching the godot reference).
581    pub select_all_level: u8,
582
583    /// Cached flow snapshot used by the accessibility pass. The
584    /// `Widget::accessibility` walk hands each block to the shared
585    /// text-run emitter, which emits `Role::TextRun` children
586    /// directly under the editor's node (a `Role::Heading` block
587    /// keeps its heading node in between); the snapshot itself
588    /// doesn't change between rebuilds triggered by focus / resize,
589    /// so caching it avoids re-walking the document tree.
590    /// Invalidated from `drain_events` when a `ContentsChanged` or
591    /// `FormatChanged` event arrives.
592    pub accessibility_flow_snapshot: RefCell<Option<teksilo_text::text_document::FlowSnapshot>>,
593
594    /// Per-synthetic-NodeId lookup table populated during the
595    /// accessibility walk. Maps each emitted `Role::TextRun` NodeId
596    /// to its text-document element_id, absolute-document
597    /// character start, and run text. Used by the
598    /// `on_access_action_request` handler to convert AccessKit
599    /// `SetTextSelection` requests (which reference TextRun NodeIds
600    /// and in-run character indices) back into document-absolute
601    /// cursor positions.
602    pub synthetic_to_element:
603        RefCell<std::collections::HashMap<teksilo_core::accesskit::NodeId, SyntheticElementRef>>,
604
605    /// Callback invoked on a Primary-click whose hit lands on a
606    /// `HitRegion::Link`. Installed via
607    /// [`RichTextEditor::on_link_activated`](super::RichTextEditor::on_link_activated).
608    /// `Rc` rather than `Box` so the mouse handler can clone it out
609    /// of the state borrow to invoke — running the callback itself
610    /// with `state.borrow()` held would deadlock if the handler calls
611    /// back into the widget's API.
612    pub on_link_activated:
613        Option<std::rc::Rc<dyn Fn(&str, &mut teksilo_core::widget::EventContext)>>,
614    /// Asked for an image's bytes when the document has no resource under that
615    /// name — see [`super::RichTextEditor::on_image_missing`].
616    pub image_resolver: Option<super::image_cache::ImageResolver>,
617    /// Where the selected image was last painted, so a press can tell whether it
618    /// landed on one of its handles. A `RefCell` because the paint pass writes
619    /// it while other fields of this struct are mutably borrowed beside it, and
620    /// the value is not `Copy` (it names the picture).
621    pub selected_image: RefCell<Option<SelectedImageRect>>,
622    /// The rect a resize drag is currently proposing, drawn as an outline. The
623    /// document is left alone until the pointer is released: relaying out the
624    /// whole block on every pointer move would make a drag on a long scene
625    /// stutter, for a preview an outline shows just as well.
626    pub resize_preview: std::cell::Cell<Option<[f32; 4]>>,
627    /// Called with the paths of files dropped on the editor — see
628    /// [`super::RichTextEditor::on_files_dropped`].
629    pub on_files_dropped:
630        Option<std::rc::Rc<dyn Fn(&[std::path::PathBuf], &mut teksilo_core::widget::EventContext)>>,
631    /// Called when a resize drag ends — see
632    /// [`super::RichTextEditor::on_image_resized`].
633    pub on_image_resized:
634        Option<std::rc::Rc<dyn Fn(&super::ImageResize, &mut teksilo_core::widget::EventContext)>>,
635    /// Callback invoked on a Primary-click whose hit lands on a
636    /// `HitRegion::Image`. Same Rc / borrow-release convention as
637    /// [`on_link_activated`](Self::on_link_activated).
638    pub on_image_activated: Option<
639        std::rc::Rc<dyn Fn(&super::ImageActivation, &mut teksilo_core::widget::EventContext)>,
640    >,
641
642    /// `(table_id, row, column, rows, columns)` remembered from the
643    /// Ctrl+A ladder's level-1 call. After `select(BlockUnderCursor)`
644    /// the cursor's position lands on the boundary between the
645    /// selected block and the next, which for a single-block cell's
646    /// last block means `current_table_cell()` would return `None`
647    /// on the following Ctrl+A press — skipping the cell / table
648    /// levels and jumping straight to document. Caching the full
649    /// cell reference at level 1 keeps the ladder stable across
650    /// mid-sequence boundary movement. Cleared whenever
651    /// `select_all_level` resets.
652    pub select_all_anchor_cell: Option<SelectAllAnchorCell>,
653}
654
655/// Cached snapshot of the table cell the caret sat inside at Ctrl+A
656/// level 1. Used by levels 2 and 3 to dodge the boundary-ambiguity
657/// issue where `TextCursor::current_table_cell()` returns `None`
658/// when the cursor is at a block edge.
659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
660pub struct SelectAllAnchorCell {
661    pub table_id: usize,
662    pub row: usize,
663    pub column: usize,
664    pub table_rows: usize,
665    pub table_columns: usize,
666}
667
668/// Per-synthetic-NodeId element reference populated during the
669/// rich text editor's accessibility walk. Lets the
670/// `on_access_action_request` handler convert an AccessKit
671/// `TextSelection` (TextRun NodeId + character index within run)
672/// back into a document-absolute cursor position.
673#[derive(Debug, Clone)]
674pub struct SyntheticElementRef {
675    /// Stable element id in text-document.
676    pub element_id: u64,
677    /// Absolute character position of the run's first character
678    /// within the full document.
679    pub absolute_start: usize,
680    /// The run's text, cached so the handler can convert a char
681    /// index to a byte offset without re-querying the document.
682    pub text: String,
683}
684
685/// Drag-select session lifecycle. Plain `cursor.set_position(hit,
686/// KeepAnchor)` handles both text and rectangular cell selection — the
687/// cell case falls out automatically from `TextCursor::selection_kind()`
688/// at [../text-document/crates/public_api/src/cursor.rs:1200].
689// No longer `Copy`: `ResizingImage` names the picture it is resizing, and the
690// release has to report that name. Every reader clones or matches by reference.
691#[derive(Debug, Clone, PartialEq)]
692pub enum DragState {
693    Idle,
694    Selecting {
695        /// Per-second scroll velocity requested by the near-edge
696        /// auto-scroll ramp. Applied by the frame loop on every tick.
697        auto_scroll_v_per_s: f32,
698    },
699    /// A press landed inside the existing selection.
700    ///
701    /// Whether that press is a click (which collapses the selection onto it)
702    /// or the beginning of a drag of the selected text cannot be known until
703    /// the pointer either moves past the threshold or is released — so the
704    /// selection is left standing until it says which. Collapsing eagerly on
705    /// press is what makes a selection impossible to pick up: the text is gone
706    /// from the selection before the drag can carry it.
707    PendingTextDrag {
708        /// Press position in widget coordinates, for the movement threshold.
709        origin: [f32; 2],
710    },
711    /// Dragging a corner handle of the selected inline image.
712    ///
713    /// Deliberately not a variant of `Selecting`: the two share a pointer
714    /// gesture and nothing else. A resize never moves the caret, never
715    /// auto-scrolls, and ends by reporting a size rather than by leaving a
716    /// selection behind.
717    ResizingImage {
718        /// The image being resized, so the release can name it.
719        name: String,
720        /// Its `U+FFFC`'s document offset — the identity, since a document may
721        /// hold one picture in several places.
722        offset: usize,
723        /// The image's rect when the drag began, in engine-local coordinates.
724        /// Every frame's new size is derived from this rather than from the
725        /// previous frame's, so rounding cannot accumulate over a long drag.
726        origin: [f32; 4],
727        /// The corner that was grabbed, as `(x, y)` unit multipliers: `(0, 0)`
728        /// is top-left, `(1, 1)` bottom-right. The opposite corner is the one
729        /// that stays put while the pointer moves.
730        corner: (f32, f32),
731    },
732}
733
734/// The selected inline image's on-screen rect, recorded by the paint pass.
735///
736/// The pointer handler needs the picture's geometry to know whether a press
737/// landed on a resize handle — and a handle sits *outside* the image, so the
738/// engine's own hit-test cannot answer it (it reports `HitRegion::Image` only
739/// within the picture). The paint pass is the one place that already has both
740/// the rect and the selection, so it writes what it saw.
741#[derive(Debug, Clone, PartialEq)]
742pub struct SelectedImageRect {
743    /// The image's resource name, so a resize can report which picture it was.
744    pub name: String,
745    /// `[x, y, width, height]` in engine-local coordinates — the same space
746    /// `to_engine_local` produces, so a pointer position compares directly.
747    pub rect: [f32; 4],
748    /// Document offset of the image's `U+FFFC`.
749    pub offset: usize,
750}
751
752impl EditorState {
753    /// Tell whoever is listening that `text` just arrived through `source`.
754    ///
755    /// **Call this beside the insertion, with the string that was inserted.**
756    /// Not afterwards from a position delta: an insertion that replaces a
757    /// selection moves the caret by a different number than it wrote, and a
758    /// consumer counting characters wants the second.
759    ///
760    /// Empty insertions report nothing — there is no such thing as zero
761    /// characters arriving, and a consumer would have to filter them out again.
762    pub fn report_inserted(&self, source: super::EditSource, text: &str) {
763        self.report_inserted_chars(source, text.chars().count());
764    }
765
766    /// As [`Self::report_inserted`], for a caller that counted as it went.
767    ///
768    /// Zero reports nothing — there is no such thing as zero characters
769    /// arriving, and a consumer would only have to filter it out again.
770    pub fn report_inserted_chars(&self, source: super::EditSource, chars: usize) {
771        if chars == 0 {
772            return;
773        }
774        if let Some(callback) = self.on_text_inserted.as_ref() {
775            callback(source, chars);
776        }
777    }
778
779    pub fn new(
780        document: TextDocument,
781        engine: RichTextEngine,
782        policy: PolicyBundle,
783        wrap_mode: WrapMode,
784    ) -> SharedState {
785        let cursor = document.cursor();
786
787        let event_queue = Arc::new(Mutex::new(VecDeque::<DocumentEvent>::new()));
788        let subscription = {
789            let queue = event_queue.clone();
790            document.on_change(move |event| {
791                if let Ok(mut q) = queue.lock() {
792                    q.push_back(event);
793                }
794            })
795        };
796
797        let caret_visible = match policy.caret_policy {
798            CaretPolicy::Hidden => Signal::new(false),
799            CaretPolicy::StaticVisible => Signal::new(true),
800            CaretPolicy::Blinking => Signal::new(true),
801        };
802
803        // Seed can_undo/can_redo with the document's current state so
804        // toolbars wired via `bind_to` see the correct value before the
805        // first debounce drain fires.
806        let initial_can_undo = document.can_undo();
807        let initial_can_redo = document.can_redo();
808
809        Rc::new(RefCell::new(Self {
810            document,
811            engine,
812            cursor,
813            policy,
814            annotation_spans: Vec::new(),
815            document_version: Signal::new(0),
816            format_version: Signal::new(0),
817            document_loaded_count: Signal::new(0),
818            on_change: None,
819            on_text_inserted: None,
820            has_selection: Signal::new(false),
821            caret_visible,
822            cursor_position: Signal::new(0),
823            cursor_anchor: Signal::new(0),
824            can_undo: Signal::new(initial_can_undo),
825            can_redo: Signal::new(initial_can_redo),
826            scroll_x: Signal::new(0.0),
827            scroll_y: Signal::new(0.0),
828            max_scroll_x: Signal::new(0.0),
829            max_scroll_y: Signal::new(0.0),
830            viewport_ratio_x: Signal::new(1.0),
831            viewport_ratio_y: Signal::new(1.0),
832            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
833            viewport_width: 0.0,
834            viewport_height: 0.0,
835            viewport_origin: teksilo_canvas::Point::ZERO,
836            node_origin: teksilo_canvas::Point::ZERO,
837            input_tokens: teksilo_tokens::InputTokens::default(),
838            pending_caret_reveal: false,
839            needs_full_layout: true,
840            last_relayout_block_id: None,
841            content_dirty: true,
842            pending_full_render: true,
843            pending_recolor: false,
844            pending_recolor_range: None,
845            caret_highlight: None,
846            caret_highlight_active: false,
847            wrap_mode,
848            show_highlights: true,
849            window_to_clip: false,
850            estimate_height_before_layout: false,
851            widest_measured_width: 0.0,
852            highlight_mask: HighlightMask::all(),
853            last_text_color: None,
854            follow_text_scale: true,
855            font_size_scale: 1.0,
856            last_font_scale: 1.0,
857            last_cursor_color: None,
858            last_selection_color: None,
859            text_color_prop: None,
860            caret_color_prop: None,
861            selection_color_prop: None,
862            background_prop: None,
863            last_code_block_bg: None,
864            last_code_block_fg: None,
865            last_link_fg: None,
866            has_focus: false,
867            drop_caret: false,
868            follow_caret_in_page: true,
869            window_active: true,
870            focus_signal: Signal::new(false),
871            self_id: None,
872            activation: None,
873            event_queue,
874            _event_subscription: subscription,
875            image_cache: ImageCache::new(),
876            preferred_x: None,
877            cursor_affinity: CursorAffinity::default(),
878            blink: CaretBlink::new(),
879            frame_request: None,
880            frame_wake_at: None,
881            pending_chars: String::new(),
882            pending_typed_chars: 0,
883            pending_ime_chars: 0,
884            ime_preedit: None,
885            ime_preedit_range: None,
886            last_chase_pos: None,
887            last_chase_y: None,
888            typewriter: None,
889            mouse_anchored: false,
890            last_ime_area: None,
891            // `Debounce::new` starts already-expired so the first tick
892            // flushes initial state instead of waiting out a window.
893            debounce: Debounce::new(),
894            pending_text_changed: false,
895            pending_format_changed: false,
896            pending_undo_redo: None,
897            drag_state: DragState::Idle,
898            rich_clipboard_fragment: None,
899            rich_clipboard_plain: None,
900            rich_clipboard_marker: None,
901            on_link_activated: None,
902            image_resolver: None,
903            selected_image: RefCell::new(None),
904            resize_preview: std::cell::Cell::new(None),
905            on_files_dropped: None,
906            on_image_resized: None,
907            on_image_activated: None,
908            select_all_level: 0,
909            select_all_anchor_cell: None,
910            accessibility_flow_snapshot: RefCell::new(None),
911            synthetic_to_element: RefCell::new(std::collections::HashMap::new()),
912        }))
913    }
914
915    /// Adopt `bounds` as the body's viewport — the single writer of
916    /// `viewport_origin` / `viewport_width` / `viewport_height`.
917    ///
918    /// Called from BOTH `RichTextEditorBody::place_children` (the authority —
919    /// layout runs before paint, so the engine is sized before the first
920    /// `layout_full` ever runs) and `RichTextEditorBody::paint` (an idempotent
921    /// echo, for any path that paints without a preceding layout). Calling it
922    /// twice in a frame is safe: the second call sees no change and does nothing.
923    ///
924    /// **The four side effects must stay welded together.** `viewport_width` /
925    /// `viewport_height` are themselves the change detector, so a caller that
926    /// writes them *without* also pushing `engine.set_viewport` +
927    /// `needs_full_layout` blinds every later caller: the engine then runs its
928    /// first `layout_full` against an uninitialised viewport, wraps the text at a
929    /// degenerate width, and — because `needs_full_layout` is cleared afterwards
930    /// — keeps that broken layout forever. Keep the write and its consequences in
931    /// this one place.
932    ///
933    /// Returns `true` if the viewport size actually changed.
934    pub fn sync_viewport(&mut self, bounds: teksilo_canvas::Rect) -> bool {
935        self.viewport_origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
936        // Unconditional, unlike the engine write below: installing the scroll
937        // behaviour replaces the scroller object, so an extent published only
938        // on a size *change* would be lost at the next rebuild and never come
939        // back on a surface nobody resizes.
940        self.scroller
941            .borrow_mut()
942            .set_viewport(teksilo_canvas::Vec2::new(bounds.width, bounds.height));
943        let changed = (self.viewport_width - bounds.width).abs() > 0.5
944            || (self.viewport_height - bounds.height).abs() > 0.5;
945        if changed {
946            // A viewport that got smaller can leave the caret outside it — the
947            // on-screen keyboard opening under a focused editor is the case that
948            // makes this a correctness matter rather than a nicety. Recorded
949            // rather than acted on: the shrink forces a relayout, and the caret
950            // cannot be revealed against a layout that has not run yet.
951            self.pending_caret_reveal |= bounds.width < self.viewport_width - 0.5
952                || bounds.height < self.viewport_height - 0.5;
953            self.viewport_width = bounds.width;
954            self.viewport_height = bounds.height;
955            self.engine.set_viewport(bounds.width, bounds.height);
956            self.needs_full_layout = true;
957        }
958        changed
959    }
960
961    /// The sessions this view actually renders: its [`highlight_mask`](Self::highlight_mask),
962    /// or nothing at all when `show_highlights` is off (the master switch a bare preview flips
963    /// to stay clean).
964    pub fn effective_mask(&self) -> HighlightMask {
965        if self.show_highlights {
966            self.highlight_mask.clone()
967        } else {
968            HighlightMask::none()
969        }
970    }
971
972    /// Engine `font_scale` for this frame: a11y text scale (if followed) ×
973    /// per-editor [`font_size_scale`](Self::font_size_scale). Clamped to the
974    /// same band as [`teksilo_text::RichTextEngine::set_font_scale`].
975    pub fn effective_font_scale(&self, text_scale: f32) -> f32 {
976        let a11y = if self.follow_text_scale {
977            text_scale
978        } else {
979            1.0
980        };
981        (a11y * self.font_size_scale).clamp(0.1, 10.0)
982    }
983
984    /// Snapshot the document's flow in this view's highlight flavor — only the sessions
985    /// [`effective_mask`](Self::effective_mask) admits. Every full-layout / a11y snapshot pull
986    /// routes through here, so a bare view never observes the document's highlighting and two
987    /// panes over one document can differ. (A11y correctness rides on the fact that paint-only
988    /// sessions — find, spell — never touch the text fragments the AT tree reads; only
989    /// metric-affecting sessions, e.g. syntax bold, reach it.)
990    pub fn flow_snapshot(&self) -> teksilo_text::text_document::FlowSnapshot {
991        self.document.snapshot_flow_masked(&self.effective_mask())
992    }
993
994    /// The snapshot the accessibility tree is built from — same masked flavor as
995    /// [`flow_snapshot`](Self::flow_snapshot), but without the paint-only overlay
996    /// (`paint_highlights`). The AT walk reads the fragments and their geometry
997    /// and never the overlay, so this is byte-identical for its purposes while
998    /// skipping the per-block `extract_paint_spans` work — the dominant cost of
999    /// rebuilding the a11y tree over a document carrying a spell-checker's tens
1000    /// of thousands of ranges. (Metric sessions still split fragments here, so a
1001    /// syntax-bold run is reported to the reader exactly as before.)
1002    pub fn flow_snapshot_for_a11y(&self) -> teksilo_text::text_document::FlowSnapshot {
1003        self.document
1004            .snapshot_flow_masked_no_paint(&self.effective_mask())
1005    }
1006
1007    /// Drain the local event queue, classifying events for the layout
1008    /// strategy. Returns `(had_events, pending_single_pos)`:
1009    /// `pending_single_pos` is `Some(pos)` only if every event in this
1010    /// batch was a `ContentsChanged { blocks_affected == 1 }` on the
1011    /// same block and `needs_full_layout` was already false — otherwise
1012    /// the frame loop uses `layout_full`.
1013    pub fn drain_events(&mut self) -> (bool, Option<usize>) {
1014        let mut had_events = false;
1015        let mut single_pos: Option<usize> = None;
1016
1017        let drained: Vec<DocumentEvent> = {
1018            let mut q = self.event_queue.lock().expect("event queue mutex poisoned");
1019            q.drain(..).collect()
1020        };
1021
1022        // Invalidate the accessibility flow snapshot + synthetic-id
1023        // map whenever the document actually changes structure or
1024        // content. Format-only edits (FormatChanged) also
1025        // invalidate because a new bold run creates a new TextRun
1026        // node in the accessibility tree with a different
1027        // synthetic NodeId. The document_version bump at the end
1028        // of drain_events drives AccessibilityOnly binding
1029        // propagation, so the widget tree's a11y_dirty flag will
1030        // flip during process_state_changes in the same frame.
1031        let mut a11y_snapshot_dirty = false;
1032        let mut saw_format_change = false;
1033        let mut document_loaded_pulses = 0_u64;
1034        // Track genuine user edits vs programmatic loads/resets, so the
1035        // user `on_change` callback fires only when the *content* was edited
1036        // (not when `set_djot`/`set_markdown` repopulates the document).
1037        let mut saw_content_change = false;
1038        let mut saw_reset_or_load = false;
1039        for event in drained {
1040            had_events = true;
1041            match event {
1042                DocumentEvent::ContentsChanged {
1043                    position,
1044                    blocks_affected,
1045                    ..
1046                } => {
1047                    self.pending_text_changed = true;
1048                    a11y_snapshot_dirty = true;
1049                    saw_content_change = true;
1050                    if blocks_affected > 1 || self.needs_full_layout {
1051                        self.needs_full_layout = true;
1052                        single_pos = None;
1053                    } else if single_pos.is_some_and(|p| p != position) {
1054                        // A second single-block edit, somewhere else, in the same
1055                        // frame. Only one position can be relayouted incrementally
1056                        // and this loop used to keep whichever arrived last — which
1057                        // left the other block holding stale text and, worse, never
1058                        // ran the character-position shift for the blocks after it.
1059                        // Every later block then sat off by the dropped edit's
1060                        // length: clicking near the start of the next paragraph
1061                        // could not reach its first characters, and selecting the
1062                        // word just typed painted a phantom highlight of the same
1063                        // width in the paragraph below.
1064                        //
1065                        // Two events at the *same* position still coalesce — one
1066                        // relayout from the final document is exactly right. Only a
1067                        // genuine second site falls back, and a frame carrying two
1068                        // of those is rare enough not to matter: typing batches to
1069                        // one event per frame, and a multi-block edit already took
1070                        // the branch above.
1071                        self.needs_full_layout = true;
1072                        single_pos = None;
1073                    } else {
1074                        single_pos = Some(position);
1075                    }
1076                }
1077                DocumentEvent::FormatChanged { .. } => {
1078                    self.pending_format_changed = true;
1079                    saw_format_change = true;
1080                    a11y_snapshot_dirty = true;
1081                    self.needs_full_layout = true;
1082                    single_pos = None;
1083                }
1084                DocumentEvent::HighlightPaintChanged { position, length } => {
1085                    // A view with highlights off never shows paint highlights,
1086                    // so this event is a pure no-op for it: don't recolor, don't
1087                    // even dirty the AT snapshot (its clean snapshot is
1088                    // unaffected). This is the "zero work on a search keystroke"
1089                    // win for the bare preview pane.
1090                    if self.show_highlights {
1091                        // Paint-only highlight change: the shaping input is
1092                        // unchanged, so recolor the cached layout without a
1093                        // reshape/reflow. `tick` consumes `pending_recolor`.
1094                        //
1095                        // Accumulate BEFORE setting the flag: `pending_recolor` is what
1096                        // distinguishes "first change this frame" (adopt its extent) from
1097                        // "widen what is already accumulated".
1098                        self.pending_recolor_range = accumulate_recolor_range(
1099                            self.pending_recolor,
1100                            self.pending_recolor_range,
1101                            position,
1102                            length,
1103                        );
1104                        self.pending_recolor = true;
1105                        // Colors on TextRun nodes changed, so the AT snapshot is
1106                        // stale — but node identity is unaffected, so keep the
1107                        // synthetic→element id map (only invalidate the snapshot).
1108                        a11y_snapshot_dirty = true;
1109                        // Deliberately NOT setting needs_full_layout /
1110                        // pending_format_changed / pending_text_changed.
1111                    }
1112                }
1113                // A programmatic repopulation (`set_plain_text` / `clear` /
1114                // `set_djot` / `set_markdown` / `set_html`) is the ONLY thing
1115                // that queues `DocumentReset` — text-document emits it from
1116                // exactly four explicit sites, and never from an edit path.
1117                // So it, alone, is the reliable "this was a load, stay quiet"
1118                // signal for `on_change`.
1119                // So it, alone, is the reliable "this was a load, stay quiet"
1120                // signal for `on_change`.
1121                DocumentEvent::DocumentReset => {
1122                    self.pending_text_changed = true;
1123                    a11y_snapshot_dirty = true;
1124                    saw_reset_or_load = true;
1125                    self.needs_full_layout = true;
1126                    single_pos = None;
1127                }
1128                // Structural edits. These are emitted by text-document's
1129                // GENERIC post-mutation detectors (`check_block_count_changed`
1130                // / `check_flow_changed`), so they fire for genuine user edits
1131                // — pressing Enter, a backspace that merges two paragraphs, a
1132                // multi-paragraph paste, an AT `SetValue` — and must count as
1133                // content changes. Lumping them in with `DocumentReset` (they
1134                // once were) silently suppressed `on_change` for every edit
1135                // that changed the block count.
1136                //
1137                // A load stays suppressed regardless: it queues `DocumentReset`
1138                // in the SAME batch as any `BlockCountChanged` it triggers (and
1139                // emits no `FlowElements*` at all, because the reset paths call
1140                // `reset_cached_child_order`, which resyncs silently).
1141                DocumentEvent::FlowElementsInserted { .. }
1142                | DocumentEvent::FlowElementsRemoved { .. }
1143                | DocumentEvent::BlockCountChanged(_) => {
1144                    self.pending_text_changed = true;
1145                    a11y_snapshot_dirty = true;
1146                    saw_content_change = true;
1147                    self.needs_full_layout = true;
1148                    single_pos = None;
1149                }
1150                DocumentEvent::UndoRedoChanged { can_undo, can_redo } => {
1151                    // Stash for the frame loop's debounce drain — don't
1152                    // fire the signal mid-event so a burst of edits
1153                    // emits one `undo_redo_changed` per debounce window,
1154                    // not per keystroke.
1155                    self.pending_undo_redo = Some((can_undo, can_redo));
1156                }
1157                DocumentEvent::LongOperationFinished { .. } => {
1158                    document_loaded_pulses += 1;
1159                }
1160                // `TextInserted` is attribution, not layout: it says which
1161                // channel some text arrived through, alongside the
1162                // `ContentsChanged` that already told this state everything it
1163                // needs. This widget reports arrivals through its own
1164                // [`EditSource`](crate::rich_text::EditSource) callback, which
1165                // knows the channel at the point of the keystroke rather than
1166                // inferring it from a document event.
1167                DocumentEvent::TextInserted { .. }
1168                | DocumentEvent::ModificationChanged(_)
1169                | DocumentEvent::LongOperationProgress { .. } => {}
1170            }
1171        }
1172
1173        // Bump format_version once per batch if any event in the batch
1174        // was a FormatChanged. Multiple FormatChanged events in the
1175        // same frame collapse into a single pulse — observers see the
1176        // batched count, not per-event fires, which matches how the
1177        // paint pass already batches work.
1178        if saw_format_change {
1179            self.format_version
1180                .set(self.format_version.get().wrapping_add(1));
1181        }
1182        // Document-loaded pulses accumulate: a batch with two
1183        // LongOperationFinished events bumps by 2 so observers can
1184        // count imports correctly (rare but possible if two async
1185        // loads finish in the same tick).
1186        if document_loaded_pulses > 0 {
1187            self.document_loaded_count.set(
1188                self.document_loaded_count
1189                    .get()
1190                    .wrapping_add(document_loaded_pulses),
1191            );
1192            saw_reset_or_load = true;
1193        }
1194
1195        if had_events {
1196            self.content_dirty = true;
1197            self.document_version
1198                .set(self.document_version.get().wrapping_add(1));
1199        }
1200
1201        // Fire the user edit callback only for genuine user edits — not for
1202        // a programmatic load/reset (`set_djot`/`set_markdown` repopulate), and
1203        // not while an IME composition is still in progress: each intermediate
1204        // preedit keystroke (every CJK/Kana candidate change) mutates the
1205        // document through this same `ContentsChanged` path, but it is not yet
1206        // a settled edit. `ime_preedit` is `None` once the composition either
1207        // commits (`clear_ime_preedit` runs before the commit's own insert) or
1208        // is cancelled to empty, so gating on it fires `on_change` exactly once
1209        // for the final, real result.
1210        //
1211        // **A formatting change counts.** It was omitted for as long as this
1212        // callback existed, and the omission was not visible from here: a host
1213        // typically wires `on_change` to "the document has unsaved changes", so
1214        // bolding a word — or linking one, or setting a heading — left the app
1215        // believing nothing had happened. No autosave was scheduled and no
1216        // close guard fired, and the edit survived only if the writer happened
1217        // to type something afterwards. `FormatChanged` is as much the writer's
1218        // work as a keystroke is.
1219        //
1220        // A load is still suppressed, and by the same guard rather than a new
1221        // one: `DocumentReset` lands in the same drained batch as any
1222        // formatting the load applies, so `saw_reset_or_load` covers this arm
1223        // exactly as it already covered content.
1224        if (saw_content_change || saw_format_change)
1225            && !saw_reset_or_load
1226            && self.ime_preedit.is_none()
1227            && let Some(cb) = self.on_change.clone()
1228        {
1229            cb();
1230        }
1231
1232        // Drop the cached flow snapshot and synthetic-id lookup
1233        // whenever the document structure / content / formatting
1234        // changed. The next accessibility walk rebuilds both
1235        // lazily from a fresh `flow_snapshot_for_a11y()`.
1236        if a11y_snapshot_dirty {
1237            self.invalidate_accessibility_cache();
1238        }
1239
1240        (had_events, single_pos)
1241    }
1242
1243    /// Drop the cached accessibility snapshot so the next AT walk rebuilds it from a fresh
1244    /// (masked) `flow_snapshot()`.
1245    ///
1246    /// The document-event path above invalidates this when the document changes; a **per-view**
1247    /// change that fires no document event — a runtime `set_highlight_mask` that drops a
1248    /// metric-affecting session (e.g. syntax bold) out of this pane's view — must invalidate it
1249    /// too, or a screen reader keeps hearing formatting the pane has stopped rendering.
1250    pub fn invalidate_accessibility_cache(&self) {
1251        *self.accessibility_flow_snapshot.borrow_mut() = None;
1252        self.synthetic_to_element.borrow_mut().clear();
1253    }
1254}
1255
1256/// Fold a `HighlightPaintChanged` extent into whatever this frame has accumulated so far.
1257///
1258/// `pending` says whether anything is accumulated yet: the **first** change of a frame adopts
1259/// its own extent, later ones widen it. Without that distinction the initial `None` — which
1260/// means *unknown* — would swallow every real extent and the block-scoped recolor could never
1261/// fire at all.
1262///
1263/// A `length` of `0` is text-document's "unknown — assume the whole document", and it is
1264/// **sticky**: once one lands in a frame the accumulated range collapses to `None` and stays
1265/// there until the recolor consumes it. That is what keeps the fast path safe for the
1266/// operations that still report `0, 0` — installing or retiring a highlighter, a full
1267/// rehighlight — which really do change everything.
1268pub(crate) fn accumulate_recolor_range(
1269    pending: bool,
1270    current: Option<(usize, usize)>,
1271    position: usize,
1272    length: usize,
1273) -> Option<(usize, usize)> {
1274    if length == 0 {
1275        return None;
1276    }
1277    if !pending {
1278        return Some((position, length));
1279    }
1280    match current {
1281        // Already unknown: nothing narrows it back down.
1282        None => None,
1283        Some((start, len)) => {
1284            let lo = start.min(position);
1285            let hi = (start + len).max(position + length);
1286            Some((lo, hi - lo))
1287        }
1288    }
1289}
1290
1291#[cfg(test)]
1292mod recolor_range_tests {
1293    use super::accumulate_recolor_range;
1294
1295    /// The bug this function's `pending` flag exists to prevent: the field starts at `None`
1296    /// (unknown), so a first change that folded into it would collapse to unknown and the
1297    /// block-scoped recolor would never run.
1298    #[test]
1299    fn the_first_change_of_a_frame_adopts_its_own_extent() {
1300        assert_eq!(
1301            accumulate_recolor_range(false, None, 40, 12),
1302            Some((40, 12))
1303        );
1304    }
1305
1306    #[test]
1307    fn later_changes_widen_what_is_accumulated() {
1308        let acc = accumulate_recolor_range(false, None, 40, 12);
1309        assert_eq!(accumulate_recolor_range(true, acc, 10, 5), Some((10, 42)));
1310    }
1311
1312    #[test]
1313    fn an_unknown_extent_is_sticky_in_both_directions() {
1314        // An unknown change poisons an accumulated range…
1315        let acc = accumulate_recolor_range(false, None, 40, 12);
1316        assert_eq!(accumulate_recolor_range(true, acc, 0, 0), None);
1317        // …and a later known change cannot narrow it back down.
1318        assert_eq!(accumulate_recolor_range(true, None, 40, 12), None);
1319    }
1320
1321    #[test]
1322    fn an_unknown_first_change_stays_unknown() {
1323        assert_eq!(accumulate_recolor_range(false, None, 0, 0), None);
1324    }
1325}