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