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