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