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}