Skip to main content

teksilo_widgets/
rich_text.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Rich text editor and viewer widget.
5//!
6//! Two construction presets share the same implementation: [`RichTextEditor::editor`]
7//! provides a full editing surface (blinking caret, keyboard commands, clipboard,
8//! undo/redo, `Role::MultilineTextInput`) and [`RichTextEditor::read_only`] is a
9//! view-only surface (hidden caret, mutations rejected, `Role::Document`). Both
10//! bind to an external [`TextDocument`]
11//! via `on_change` subscriptions, so any number of editors and viewers can share
12//! one document and observe each other's edits live.
13//!
14//! The widget owns a per-widget `RichTextEngine` (typesetter), and drives its own
15//! scroll bars independently of `ScrollArea` to avoid the wrap/scrollbar circular
16//! measurement dependency. Use [`RichTextEditor::min_lines`] /
17//! [`RichTextEditor::max_lines`] to switch from greedy sizing to intrinsic
18//! (messenger-composer) sizing. A detachable [`EditorHandle`] lets toolbars and
19//! palette panels issue formatting commands from closures that cannot borrow the
20//! editor directly.
21//!
22//! ```ignore
23//! use teksilo_text::text_document::TextDocument;
24//! let doc = TextDocument::new();
25//! let editor = RichTextEditor::editor(doc)
26//!     .min_lines(3)
27//!     .max_lines(8)
28//!     .wrap_mode(WrapMode::Word);
29//! ```
30
31pub mod caret_highlight;
32mod clipboard;
33mod context_menu;
34mod find_session;
35mod frame_loop;
36// `pub(crate)` so the code editor can reuse the hit-test wrapper rather than
37// re-deriving pointer-to-offset resolution. Both surfaces ask the same engine
38// the same question; the answer should not have two implementations.
39pub(crate) mod hit_test;
40pub(crate) mod image_cache;
41mod keyboard;
42mod mouse;
43pub(crate) mod paint;
44mod policy;
45mod state;
46
47#[cfg(test)]
48mod tests;
49#[cfg(test)]
50mod window_tests;
51
52pub use context_menu::{
53    INTENT_COPY, INTENT_CUT, INTENT_PASTE, INTENT_PASTE_UNFORMATTED, INTENT_SELECT_ALL,
54};
55pub use find_session::FindSession;
56pub use hit_test::ContextTarget;
57pub use policy::{
58    AccessibilityRole, CaretPolicy, ClipboardPolicy, CommandFilter, EDITOR_PRESET, EditCommandKind,
59    PolicyBundle, READ_ONLY_PRESET,
60};
61
62use std::cell::Cell;
63use std::rc::Rc;
64
65use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
66use teksilo_core::accessibility::AccessNodeBuilder;
67use teksilo_core::build_context::BuildContext;
68use teksilo_core::color_prop::ColorProp;
69use teksilo_core::signal::Signal;
70use teksilo_core::styles::{
71    RichTextEditorStyle, RichTextEditorStyleConfig, SharedRichTextEditorStyle,
72};
73use teksilo_core::widget::{CursorIcon, LayoutContext, PaintContext, Widget, WidgetPlacement};
74use teksilo_core::widget_builder::HandlerSet;
75use teksilo_core::widget_id::WidgetId;
76use teksilo_text::text_document::{
77    Alignment, BlockFormat, CharVerticalAlignment, LinkExtent, ListStyle, MoveMode, ResourceType,
78    SelectionType, TextDirection, TextDocument, TextFormat,
79};
80use teksilo_text::{
81    EditorTypographyDefaults, FontRegistrar, RichTextEngine, SharedTypesetter, WrapMode,
82};
83
84use self::paint::{PaintParams, paint_frame};
85use self::state::{EditorState, SharedState};
86use crate::common::scroll::OverscrollBehavior;
87use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVariant};
88use crate::styles::RecipeRichTextEditorStyle;
89
90/// Scroll bar visibility policy for [`RichTextEditor`], applied independently per axis.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum ScrollPolicy {
93    /// Show the scroll bar only when content overflows the visible area (default).
94    #[default]
95    Auto,
96    /// Always show the scroll bar, reserving gutter space even when content fits.
97    AlwaysOn,
98    /// Never show the scroll bar; useful when embedding the editor inside an outer
99    /// `ScrollArea` or in headless tests.
100    AlwaysOff,
101}
102
103/// How a piece of text reached the document — the **channel**, not the author.
104///
105/// Deliberately framework-generic, and deliberately small. These are the routes
106/// a toolkit can actually observe: which input path the characters came down.
107/// What that *means* is the application's to decide, and every application will
108/// decide differently — a writing tool cares that dictation is not typing, a
109/// code editor cares that a snippet is not either, and a form cares about none
110/// of it. Teksilo says what it saw; it does not interpret.
111///
112/// ⚠ **Not evidence of who wrote anything.** Text typed one character at a time
113/// was typed one character at a time, and that is the entire claim. Anything
114/// further — who, or whether a person at all — is an inference this cannot make
115/// and no consumer of it should pretend to.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub enum EditSource {
118    /// Typed, one key at a time.
119    Keyboard,
120    /// The settled result of an IME composition — CJK/Kana candidate selection,
121    /// a dead-key accent. Separate from [`Self::Keyboard`] because the
122    /// characters that land are not the keys that were pressed.
123    Ime,
124    /// Pasted, as plain text or as HTML.
125    Clipboard,
126    /// Arrived through an assistive technology: AccessKit's `SetValue` or
127    /// `ReplaceSelectedText`, which is how dictation and a braille display
128    /// write.
129    ///
130    /// **Never folded into [`Self::Keyboard`].** For some people this *is*
131    /// typing, and a toolkit that reported it as something else — or as nothing
132    /// — would be quietly erasing how they work.
133    Accessibility,
134    /// Inserted by the application itself rather than by anything the person at
135    /// the keyboard did: a template, a substitution, a completion.
136    Programmatic,
137}
138
139/// The main rich text widget. Construct via [`RichTextEditor::read_only`]
140/// (view/select only) or [`RichTextEditor::editor`] (full editing).
141pub use self::state::TextAnnotationSpan;
142
143pub struct RichTextEditor {
144    state: SharedState,
145    v_scroll_policy: ScrollPolicy,
146    h_scroll_policy: ScrollPolicy,
147    /// Whether to install the built-in context-menu factory during
148    /// `build()`. Defaults to `true`. Set `false` via
149    /// [`default_context_menu`](Self::default_context_menu) to suppress
150    /// the default entirely (right-click then bubbles past the widget;
151    /// `context_target_at` stays available for apps that render their
152    /// own menu).
153    default_context_menu_enabled: bool,
154    /// User-supplied context-menu factory (see
155    /// [`context_menu`](Self::context_menu)). When set, it takes
156    /// precedence over the default factory regardless of
157    /// `default_context_menu_enabled`. Taken out (via `Option::take`)
158    /// during `build()` because `Box<dyn Fn>` is not `Clone`.
159    custom_context_menu: Option<
160        Box<
161            dyn Fn(
162                teksilo_canvas::Point,
163                &mut teksilo_core::widget::EventContext,
164            ) -> Option<Box<dyn teksilo_core::widget::Widget>>,
165        >,
166    >,
167    /// Minimum visible-text height expressed in lines. When set,
168    /// switches `size_that_fits` from greedy (consume the proposal)
169    /// to **intrinsic** sizing — see [`min_lines`](Self::min_lines).
170    min_lines: Option<u32>,
171    /// Maximum visible-text height expressed in lines. Hard-caps
172    /// the intrinsic height — see [`max_lines`](Self::max_lines).
173    max_lines: Option<u32>,
174    /// Per-call style override for the chrome (border, padding, focus
175    /// ring). Replaces the theme-wide `style_slots.rich_text_editor`
176    /// and the default [`RecipeRichTextEditorStyle`] for just this
177    /// editor.
178    style_override: Option<SharedRichTextEditorStyle>,
179    /// Root of the composed subtree returned by
180    /// [`RichTextEditorStyle::make_body`]. Cached so layout queries
181    /// route through the chrome without re-running the style call.
182    root_child_id: Option<WidgetId>,
183    /// Vertical scrollbar child id. `None` when
184    /// `v_scroll_policy == ScrollPolicy::AlwaysOff` — in that case
185    /// the scrollbar isn't even instantiated.
186    v_scrollbar_id: Option<WidgetId>,
187    /// Horizontal scrollbar child id. `None` when
188    /// `h_scroll_policy == ScrollPolicy::AlwaysOff`.
189    h_scrollbar_id: Option<WidgetId>,
190    /// Scrollbar window-space bounds, written by `place_children` and
191    /// read by the wrapper's `on_pointer_event` handler. Used to bail
192    /// out of the drag-select latch when the press lands over an
193    /// overlay scrollbar — without this guard the preview-pass pointer
194    /// handler on the wrapper runs *before* the scrollbar (its child)
195    /// gets the event, sets `drag_state = Selecting` on text under the
196    /// overlay, and then steals every subsequent `PointerMove` with
197    /// `EventResponse::Handled`, so the scrollbar's gesture arena
198    /// never sees the drag.
199    v_scrollbar_bounds: Rc<Cell<Rect>>,
200    h_scrollbar_bounds: Rc<Cell<Rect>>,
201    /// Per-edge `(top, right, bottom, left)` padding between the text
202    /// content and the chrome. `None` lets the style apply its own
203    /// default (TextInput-style insets for editable, no padding for
204    /// read-only). Set via [`content_padding`](Self::content_padding) /
205    /// [`content_padding_symmetric`](Self::content_padding_symmetric) /
206    /// [`content_padding_each`](Self::content_padding_each).
207    content_padding: Option<(f32, f32, f32, f32)>,
208    /// Wheel scroll-chaining behavior at the editor's scroll boundary.
209    /// [`OverscrollBehavior::Chain`] (the default) declines a wheel event the
210    /// editor can no longer absorb so it bubbles to an ancestor scrollable —
211    /// the editor embedded in a scrolling form/page hands the leftover scroll
212    /// to the page. [`OverscrollBehavior::Contain`] absorbs the event at the
213    /// boundary instead. Mirrors the identical knob on `ScrollArea` /
214    /// `ListView` / `TableView` / `GridView`. See
215    /// [`overscroll_behavior`](Self::overscroll_behavior).
216    overscroll_behavior: OverscrollBehavior,
217}
218
219impl std::fmt::Debug for RichTextEditor {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("RichTextEditor")
222            .field("policy", &self.state.borrow().policy)
223            .finish_non_exhaustive()
224    }
225}
226
227impl RichTextEditor {
228    /// Construct a read-only rich text viewer bound to `document`. The
229    /// document can also back an editable `RichTextEditor::editor` in
230    /// another part of the UI — both widgets receive document events
231    /// independently via `on_change` subscriptions.
232    pub fn read_only(document: TextDocument) -> Self {
233        // A viewer defaults to *bare*: it can mirror the same shared document
234        // as an editor pane, but stays free of the document's search / spell /
235        // syntax highlighting (those are authoring affordances). Opt back in
236        // with `.show_highlights(true)` — e.g. a read-only code viewer that
237        // *wants* syntax coloring.
238        Self::construct(document, READ_ONLY_PRESET).show_highlights(false)
239    }
240
241    /// Construct an editable rich text editor bound to `document`.
242    /// Uses the full editor preset: every command accepted, caret
243    /// blinks, `MultilineTextInput` accessibility role, full clipboard
244    /// support. Multiple editors on the same document share live edits
245    /// via per-widget `on_change` subscriptions.
246    pub fn editor(document: TextDocument) -> Self {
247        Self::construct(document, EDITOR_PRESET)
248    }
249
250    fn construct(document: TextDocument, policy: PolicyBundle) -> Self {
251        // Start with a private engine. `build()` swaps it for one that
252        // shares the application's `SharedTypesetter` when one is
253        // reachable via `ctx.app_state`, so rendered glyphs land in
254        // the atlas that teksilo-render actually uploads to the GPU.
255        // Outside a windowed teksilo-app (headless tests) the private
256        // engine is correct: no renderer is ever invoked.
257        let mut engine = RichTextEngine::private_default();
258        engine.set_wrap_mode(WrapMode::Word);
259        // Prose editor: hyphenate justified paragraphs. Single-line / label
260        // widgets (e.g. TextInputField) deliberately don't enable this.
261        engine.set_hyphenate_justified(true);
262        let state = EditorState::new(document, engine, policy, WrapMode::Word);
263        Self {
264            state,
265            v_scroll_policy: ScrollPolicy::Auto,
266            h_scroll_policy: ScrollPolicy::Auto,
267            default_context_menu_enabled: true,
268            custom_context_menu: None,
269            min_lines: None,
270            max_lines: None,
271            style_override: None,
272            root_child_id: None,
273            v_scrollbar_id: None,
274            h_scrollbar_id: None,
275            v_scrollbar_bounds: Rc::new(Cell::new(Rect::ZERO)),
276            h_scrollbar_bounds: Rc::new(Cell::new(Rect::ZERO)),
277            content_padding: None,
278            overscroll_behavior: OverscrollBehavior::default(),
279        }
280    }
281
282    /// Per-call style override for the editor chrome (border, padding,
283    /// focus ring). Replaces the theme-wide
284    /// `style_slots.rich_text_editor` and the IntUI default
285    /// `RecipeRichTextEditorStyle` for just this editor.
286    pub fn style(mut self, style: impl RichTextEditorStyle) -> Self {
287        self.style_override = Some(Rc::new(style));
288        self
289    }
290
291    /// Set a uniform padding (logical pixels) between the text content
292    /// and the editor's chrome. Replaces the style's default insets
293    /// (TextInput-style for editable, none for read-only). Use
294    /// [`content_padding_symmetric`](Self::content_padding_symmetric) or
295    /// [`content_padding_each`](Self::content_padding_each) for
296    /// per-axis / per-edge control.
297    pub fn content_padding(mut self, amount: f32) -> Self {
298        self.content_padding = Some((amount, amount, amount, amount));
299        self
300    }
301
302    /// Set vertical and horizontal padding (logical pixels) between the
303    /// text content and the editor's chrome. Replaces the style's
304    /// default insets.
305    pub fn content_padding_symmetric(mut self, vertical: f32, horizontal: f32) -> Self {
306        self.content_padding = Some((vertical, horizontal, vertical, horizontal));
307        self
308    }
309
310    /// Set per-edge padding `(top, right, bottom, left)` between the
311    /// text content and the editor's chrome. Replaces the style's
312    /// default insets.
313    pub fn content_padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
314        self.content_padding = Some((top, right, bottom, left));
315        self
316    }
317
318    /// Set just the top inset between the text and the chrome. Leaves
319    /// the other edges at their previously-set values, defaulting to
320    /// `0.0` for any edge never touched.
321    pub fn content_padding_top(mut self, top: f32) -> Self {
322        let (_, r, b, l) = self.content_padding.unwrap_or((0.0, 0.0, 0.0, 0.0));
323        self.content_padding = Some((top, r, b, l));
324        self
325    }
326
327    /// Set just the right inset between the text and the chrome.
328    pub fn content_padding_right(mut self, right: f32) -> Self {
329        let (t, _, b, l) = self.content_padding.unwrap_or((0.0, 0.0, 0.0, 0.0));
330        self.content_padding = Some((t, right, b, l));
331        self
332    }
333
334    /// Set just the bottom inset between the text and the chrome.
335    pub fn content_padding_bottom(mut self, bottom: f32) -> Self {
336        let (t, r, _, l) = self.content_padding.unwrap_or((0.0, 0.0, 0.0, 0.0));
337        self.content_padding = Some((t, r, bottom, l));
338        self
339    }
340
341    /// Set just the left inset between the text and the chrome.
342    pub fn content_padding_left(mut self, left: f32) -> Self {
343        let (t, r, b, _) = self.content_padding.unwrap_or((0.0, 0.0, 0.0, 0.0));
344        self.content_padding = Some((t, r, b, left));
345        self
346    }
347
348    // --- Builder methods ------------------------------------------------
349
350    /// Set the line-wrap mode. `WrapMode::Word` (the default) wraps at word
351    /// boundaries; `WrapMode::None` allows horizontal overflow — pair with
352    /// `.h_scroll_policy(ScrollPolicy::Auto)` to expose a scroll bar.
353    pub fn wrap_mode(self, mode: WrapMode) -> Self {
354        {
355            let mut st = self.state.borrow_mut();
356            st.wrap_mode = mode;
357            st.engine.set_wrap_mode(mode);
358            st.needs_full_layout = true;
359        }
360        self
361    }
362
363    /// Whether this view applies the document's syntax / search / spell
364    /// highlighting. `editor` defaults to `true`; `read_only` defaults to
365    /// `false` (a bare preview). A highlights-off view pulls a *clean*
366    /// snapshot (no highlights at all, even metric ones like keyword bold) and
367    /// ignores paint-only highlight events entirely, so it does zero work when
368    /// the shared document's search/spell highlights change.
369    pub fn show_highlights(self, show: bool) -> Self {
370        {
371            let mut st = self.state.borrow_mut();
372            if st.show_highlights != show {
373                st.show_highlights = show;
374                // Re-pull the snapshot in the new flavor on the next tick.
375                st.needs_full_layout = true;
376            }
377        }
378        self
379    }
380
381    /// Declare the annotations (comment threads) covering ranges of this
382    /// document, for the **accessibility tree only**.
383    ///
384    /// Each span becomes a `Role::Comment` node, and every `Role::TextRun` it
385    /// covers points at it through AccessKit's `details` relation — the W3C
386    /// annotations pattern, and the reason a screen reader can say "has comment"
387    /// and let the user navigate in rather than reciting the thread every time the
388    /// caret crosses the span.
389    ///
390    /// Painting is a separate concern: a highlight session draws the underline. A
391    /// highlight carries no text and this carries no colour, so neither is
392    /// derivable from the other and both are supplied independently.
393    pub fn annotation_spans(self, spans: Vec<TextAnnotationSpan>) -> Self {
394        self.state.borrow_mut().annotation_spans = spans;
395        self
396    }
397
398    /// Set which highlight sessions **this view** renders, at runtime.
399    ///
400    /// [`HighlightMask::all`](teksilo_text::text_document::HighlightMask::all) shows every
401    /// session on the document (the default);
402    /// [`HighlightMask::only`](teksilo_text::text_document::HighlightMask::only) shows a
403    /// chosen set — which is how a per-editor find banner
404    /// keeps one pane's find highlighting out of another pane over the same document.
405    /// `show_highlights(false)` still overrides this to nothing.
406    ///
407    /// Forces a re-pull on the next tick so the change is visible immediately.
408    pub fn set_highlight_mask(&self, mask: teksilo_text::text_document::HighlightMask) {
409        let mut st = self.state.borrow_mut();
410        if st.highlight_mask != mask {
411            st.highlight_mask = mask;
412            st.needs_full_layout = true;
413            // A mask change fires no document event, so the AT-cache invalidation the
414            // event path does won't run — do it here. Dropping a metric session (syntax
415            // bold) out of this view changes what the AT tree should report, and a stale
416            // cached tree would keep announcing formatting the pane no longer draws.
417            st.invalidate_accessibility_cache();
418        }
419    }
420
421    /// Set the initial non-destructive default typography (font family / line
422    /// height / first-line indent) applied to runs and blocks that carry no
423    /// explicit override. Applied before the first layout. These are display
424    /// defaults — they never mutate the bound document (no undo entry, no
425    /// `modified`); use [`set_typography_defaults`](Self::set_typography_defaults)
426    /// or [`EditorHandle::set_typography_defaults`] to change them after mount.
427    /// Preferred text size is [`font_size_scale`](Self::font_size_scale).
428    pub fn typography_defaults(self, defaults: EditorTypographyDefaults) -> Self {
429        {
430            let mut st = self.state.borrow_mut();
431            st.engine.set_typography_defaults(defaults);
432            st.needs_full_layout = true;
433        }
434        self
435    }
436
437    /// Override the editor background fill. Accepts a `Color`, a theme role
438    /// (`SurfaceRole::Content`, …), or a `Signal`. Threaded into the active
439    /// [`RichTextEditorStyle`]'s `make_body`, so the common case ("give the
440    /// editor a surface") needs no custom style. `None` uses the style's
441    /// default surface.
442    pub fn background(self, color: impl Into<ColorProp>) -> Self {
443        self.state.borrow_mut().background_prop = Some(color.into());
444        self
445    }
446
447    /// Override the selection-highlight color. Accepts a `Color`, theme role,
448    /// or `Signal`. Resolved against the active theme on every paint; `None`
449    /// uses the engine/theme default.
450    pub fn selection_color(self, color: impl Into<ColorProp>) -> Self {
451        self.state.borrow_mut().selection_color_prop = Some(color.into());
452        self
453    }
454
455    /// Override the caret / insertion-point color. Accepts a `Color`, theme
456    /// role, or `Signal`. Resolved against the active theme on every paint;
457    /// `None` tracks the theme's `editor_caret` role.
458    pub fn caret_color(self, color: impl Into<ColorProp>) -> Self {
459        self.state.borrow_mut().caret_color_prop = Some(color.into());
460        self
461    }
462
463    /// Override the default text color. Accepts a `Color`, theme role, or
464    /// `Signal`. Resolved against the active theme on every paint; `None`
465    /// tracks the theme's `editor_fg` role (so dark / light swaps follow
466    /// automatically). A role or `Signal` stays reactive; a bare `Color` pins
467    /// it.
468    pub fn text_color(self, color: impl Into<ColorProp>) -> Self {
469        self.state.borrow_mut().text_color_prop = Some(color.into());
470        self
471    }
472
473    /// Set the vertical scroll-bar visibility policy.
474    pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
475        self.v_scroll_policy = policy;
476        self
477    }
478
479    /// Set the horizontal scroll-bar visibility policy.
480    pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
481        self.h_scroll_policy = policy;
482        self
483    }
484
485    /// Window paint-time culling to the accumulated ancestor clip rather than
486    /// this editor's own bounds.
487    ///
488    /// Enable this **only** for an editor deliberately laid out at its full
489    /// document height inside an outer [`ScrollArea`](crate::ScrollArea)
490    /// (`v_scroll_policy(ScrollPolicy::AlwaysOff)`, no `max_lines`) — "dubious
491    /// mode". Such an editor's own viewport spans the whole document, so the
492    /// viewport-derived render cull keeps nothing; this makes it cull to the
493    /// visible clip band instead, so a huge document only rasterizes the rows on
494    /// screen. Correct under nested ScrollAreas (the clip is the intersection of
495    /// all clipping ancestors), and positioning / hit-testing are unaffected.
496    ///
497    /// A normal self-scrolling editor already culls correctly from its own scroll
498    /// offset and doesn't need this — leave it **off** (the default). (The window
499    /// is computed relative to the editor's own scroll offset as well, so enabling
500    /// it on a self-scroller degrades to a correct-but-redundant cull rather than
501    /// rendering the wrong rows.)
502    /// Guess this editor's height from its text until something has laid it out.
503    ///
504    /// `content_height()` is `0` until `layout_full` has run, and that waits for the
505    /// editor to have been through a frame on screen. The zero falls through to the
506    /// `min_lines` floor, so an editor that has never been shown claims the same few
507    /// lines whatever it holds.
508    ///
509    /// For an editor that **is** on screen that is invisible — it lays out on the
510    /// first frame and the floor never shows. Turn this on for one that may not be:
511    /// a row of a long column, most of which is below the fold. There the page's
512    /// height is the sum of its rows' claims, so the scroll extent starts wrong by an
513    /// order of magnitude and settles a row at a time as the reader arrives — and
514    /// anything drawing that extent draws the settling.
515    ///
516    /// Off by default, deliberately. The estimate is crude by construction, and an
517    /// editor that lays out immediately gains nothing from it while every consumer of
518    /// its first-frame size pays for the guess — including the windowed-render path,
519    /// whose culling is derived from the editor's own bounds.
520    ///
521    /// Never a floor: it goes through the same clamp a real height does, so
522    /// `max_lines` still caps it and an over-estimate corrects downwards when the
523    /// layout lands.
524    pub fn estimate_height_before_layout(self, on: bool) -> Self {
525        self.state.borrow_mut().estimate_height_before_layout = on;
526        self
527    }
528
529    pub fn window_to_clip(self, on: bool) -> Self {
530        self.state.borrow_mut().window_to_clip = on;
531        self
532    }
533
534    /// Set the same scroll-bar visibility policy on both axes.
535    pub fn scroll_policy(mut self, policy: ScrollPolicy) -> Self {
536        self.v_scroll_policy = policy;
537        self.h_scroll_policy = policy;
538        self
539    }
540
541    /// Whether moving the caret also scrolls any *enclosing* scroll area to
542    /// keep the caret on screen — the standard editor "caret stays visible as
543    /// you type / navigate" behaviour. **On by default.**
544    ///
545    /// It fires only on a caret *move*, never on a plain wheel / scrollbar
546    /// scroll, so the reader can still scroll freely away from the caret and the
547    /// view holds until the caret next moves. This is what makes an editor that
548    /// **grows** to its content with its own scroll suppressed (a flowing page
549    /// inside an outer `ScrollArea`) track the caret at all — there the editor's
550    /// internal caret-visibility is a no-op, so the enclosing-page follow is the
551    /// only mechanism that reveals the caret. Pass `false` for the rare layout
552    /// where a caret change must never move the surrounding page.
553    pub fn follow_caret_in_page(self, follow: bool) -> Self {
554        self.state.borrow_mut().follow_caret_in_page = follow;
555        self
556    }
557
558    /// **Typewriter scrolling**: pin the caret's line at `fraction` of the way
559    /// down the enclosing scroll area — `0.0` at the top, `0.5` centred, `1.0`
560    /// at the bottom — and let the document scroll under it. `None` (the
561    /// default) leaves the ordinary minimal-reveal follow in charge.
562    ///
563    /// Unlike that follow, which only acts once the caret would leave the
564    /// viewport, a pin re-asserts on every caret move, so the line being written
565    /// holds a constant height on screen. The classic writing-app feature.
566    ///
567    /// Three behaviours come with it, each of them the consensus answer among
568    /// the editors that ship this well:
569    ///
570    /// - **The pointer stands the pin down.** A click places the caret without
571    ///   scrolling, and that position becomes the new resting place; a
572    ///   drag-selection is never interrupted. The next keystroke resumes
573    ///   pinning. Editors that re-centre on pointer input instead have open bugs
574    ///   about the view fighting the mouse and about drag-selection becoming
575    ///   unusable.
576    /// - **The rendered row is pinned, not the paragraph.** Under soft wrap a
577    ///   long paragraph spans several visual rows; pinning the logical line
578    ///   would leave the caret far from the mark.
579    /// - **Typing snaps, page jumps glide.** Animating a pin that updates on
580    ///   every keystroke is what produces the "screen bouncing" complaint other
581    ///   implementations attract.
582    ///
583    /// Requires [`follow_caret_in_page`](Self::follow_caret_in_page) (on by
584    /// default). `fraction` is clamped to `0.0..=1.0`.
585    ///
586    /// Near the start of the document the pin gives way to the scroll range —
587    /// the caret rides above its line until there is room — and near the end it
588    /// would do the same, which is usually not what you want: pair this with
589    /// `ScrollArea::scroll_past_end(1.0 - fraction)` so the last line can still
590    /// reach the pin.
591    ///
592    /// Takes a plain value, like [`typography_defaults`](Self::typography_defaults);
593    /// to follow a setting live, push changes onto the handle with
594    /// [`EditorHandle::set_typewriter`].
595    pub fn typewriter(self, anchor: Option<f32>) -> Self {
596        self.state.borrow_mut().typewriter = anchor.map(|f| f.clamp(0.0, 1.0));
597        self
598    }
599
600    /// Set the wheel scroll-chaining behavior at the editor's boundary
601    /// (default [`OverscrollBehavior::Chain`]). With `Chain`, a wheel event the
602    /// editor can no longer absorb (already at the top/bottom, or content that
603    /// fits so there is nothing to scroll) is declined so it bubbles to an
604    /// ancestor scrollable — an editor embedded in a scrolling form/page lets
605    /// the page scroll once the editor reaches its edge.
606    /// [`OverscrollBehavior::Contain`] keeps the event at the editor instead.
607    /// Mirrors the identical knob on `ScrollArea` / `ListView` / `TableView` /
608    /// `GridView`.
609    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
610        self.overscroll_behavior = behavior;
611        self
612    }
613
614    /// Set a minimum height (in lines of text) for the editor's
615    /// **intrinsic** size.
616    ///
617    /// Setting either `min_lines` or [`max_lines`](Self::max_lines)
618    /// switches the editor from greedy sizing (consume the
619    /// proposal) to intrinsic sizing: `size_that_fits` returns
620    /// `clamp(content_height, min_lines × line_height, max_lines × line_height)`
621    /// for the dimension the parent leaves unspecified. A parent
622    /// like `VStack` proposes unbounded height to non-Expand
623    /// children, so the editor lands at its intrinsic height —
624    /// exactly the messenger-composer / chat-input pattern.
625    ///
626    /// A parent that *forces* the height (e.g. `FixedSize`) wins
627    /// regardless. This is intentional and matches Teksilo's
628    /// general layout discipline: parents always have the final
629    /// say on the dimensions they pin.
630    ///
631    /// `min_lines` measures the *visible text area*, not the outer
632    /// widget — `min_lines(1)` reports a height equal to one line
633    /// of text at the typesetter's default font + size, even
634    /// before the document has any content.
635    pub fn min_lines(mut self, n: u32) -> Self {
636        self.min_lines = Some(n);
637        self
638    }
639
640    /// Set a maximum height (in lines of text) for the editor's
641    /// intrinsic size. Past this cap the vertical scroll bar
642    /// absorbs further content growth.
643    ///
644    /// See [`min_lines`](Self::min_lines) for the intrinsic-mode
645    /// switch and the parent-proposal interaction. `max_lines`
646    /// measures the visible text area, not the outer widget.
647    pub fn max_lines(mut self, n: u32) -> Self {
648        self.max_lines = Some(n);
649        self
650    }
651
652    /// Whether this editor's text grows with the global accessibility text
653    /// scale (`ctx.text_scale`). Defaults to `true` — like every other text
654    /// surface, the editor magnifies when the user raises the app-wide text
655    /// size. Pass `false` for an editor whose font sizes are **document
656    /// content** (a WYSIWYG / print-layout editor) that must stay at its true
657    /// point size regardless of the reader's UI accessibility setting.
658    ///
659    /// Composed with [`font_size_scale`](Self::font_size_scale):  
660    /// `engine.font_scale = (follow ? text_scale : 1.0) × font_size_scale`.
661    pub fn follow_text_scale(self, follow: bool) -> Self {
662        self.state.borrow_mut().follow_text_scale = follow;
663        self
664    }
665
666    /// Per-editor logical font-size multiplier (`1.0` = 100 %). Applied
667    /// *before* shaping (same channel as accessibility text scale), so text
668    /// grows, re-wraps, and stays sharp — the knob for a "Text size"
669    /// preference. Composed as
670    /// `(follow_text_scale ? ctx.text_scale : 1.0) × font_size_scale`.
671    /// Clamped to `[0.1, 10.0]`. Use [`set_font_size_scale`](Self::set_font_size_scale)
672    /// after mount.
673    pub fn font_size_scale(self, scale: f32) -> Self {
674        {
675            let mut st = self.state.borrow_mut();
676            st.font_size_scale = scale.clamp(0.1, 10.0);
677            st.needs_full_layout = true;
678            st.content_dirty = true;
679        }
680        self
681    }
682
683    /// Replace the built-in right-click context menu with a
684    /// user-provided factory. Same shape as the framework's
685    /// [`teksilo_core::widget_builder::ContextMenuFactory`]: the
686    /// closure receives the click position (widget-local) and a full
687    /// [`EventContext`](teksilo_core::widget::EventContext), and returns
688    /// `Some(menu_widget)` to mount or `None` to decline (falling
689    /// through to the next ancestor with a factory).
690    ///
691    /// Taking this branch disables the default menu unconditionally.
692    /// The framework's
693    /// [`show_context_menu_for`](teksilo_core::widget_tree) handles
694    /// the overlay lifecycle (open at pointer, dismiss on
695    /// click-outside / Escape, focus-restore on dismiss), so the
696    /// factory only needs to build the menu content.
697    ///
698    /// This is an **inherent method**: it shadows the blanket
699    /// [`WidgetBuilder::context_menu`](teksilo_core::widget_builder::WidgetBuilder::context_menu)
700    /// trait method so the user can chain it directly on the editor.
701    /// Internally, the factory is installed on the editor's arena
702    /// node via the same `HandlerSet::context_menu` plumbing.
703    pub fn context_menu(
704        mut self,
705        factory: impl Fn(
706            teksilo_canvas::Point,
707            &mut teksilo_core::widget::EventContext,
708        ) -> Option<Box<dyn teksilo_core::widget::Widget>>
709        + 'static,
710    ) -> Self {
711        self.custom_context_menu = Some(Box::new(factory));
712        self
713    }
714
715    /// Enable (default) or disable the widget's built-in right-click
716    /// context menu (Cut / Copy / Paste / Paste Unformatted / Select
717    /// All). When disabled, right-click bubbles past the widget
718    /// unhandled and
719    /// [`context_target_at`](Self::context_target_at) stays
720    /// available for applications that render their own menu.
721    ///
722    /// Note: if a user factory is installed via
723    /// [`context_menu`](Self::context_menu), that factory wins
724    /// regardless of this flag — this setter only governs the
725    /// *default* menu.
726    pub fn default_context_menu(mut self, enabled: bool) -> Self {
727        self.default_context_menu_enabled = enabled;
728        self
729    }
730
731    /// Install a custom font registrar for the fallback private
732    /// engine. Only has effect when the editor is built outside a
733    /// windowed teksilo-app — once `build()` sees a `SharedTypesetter`
734    /// in `app_state`, the private engine is replaced with one that
735    /// shares the app's typesetter and this registrar is ignored.
736    pub fn font_registrar(self, registrar: &dyn FontRegistrar) -> Self {
737        {
738            let mut st = self.state.borrow_mut();
739            let mut engine = RichTextEngine::private_with_registrar(registrar);
740            engine.set_wrap_mode(st.wrap_mode);
741            engine.set_hyphenate_justified(true);
742            st.engine = engine;
743            st.needs_full_layout = true;
744        }
745        self
746    }
747
748    /// Install a callback fired once per batch of genuine **user content
749    /// edits** (typing, paste, cut, delete) — and *not* on a programmatic
750    /// `set_djot` / `set_markdown` / `set_html` load or a document reset, and
751    /// *not* while an IME composition (CJK/Kana candidate preview, dead-key
752    /// accent) is still in progress — only the settled result of a commit
753    /// fires it. The callback runs on the UI thread during the editor's frame
754    /// drain, so it may touch `Signal`s directly — e.g. flip a "dirty" flag or
755    /// kick a debounced autosave. Replaces any prior change callback on this
756    /// editor.
757    ///
758    /// For a reactive change *token* (which also bumps on loads/format-only
759    /// changes, and on intermediate IME composition steps), observe
760    /// [`document_version`](Self::document_version) instead.
761    pub fn on_change(self, f: impl Fn() + 'static) -> Self {
762        self.state.borrow_mut().on_change = Some(Rc::new(f));
763        self
764    }
765
766    /// Install a callback fired **at each insertion**, with the
767    /// [`EditSource`] the text came through and how many characters it was.
768    ///
769    /// Additive to [`on_change`](Self::on_change) rather than a replacement for
770    /// it, because they answer different questions. `on_change` fires once per
771    /// drain batch and says *that* the document changed — the right shape for a
772    /// dirty flag and a debounced autosave, and the wrong one for counting: a
773    /// batch can carry a typed run and a paste, and after the fact nothing can
774    /// tell them apart.
775    ///
776    /// **Reported where the text is, not derived afterwards.** Every site below
777    /// holds the literal `&str` about to be inserted, so the count is what was
778    /// actually written rather than a position delta — which is a different
779    /// number the moment an insertion replaces a selection.
780    ///
781    /// Fires for text arriving through:
782    ///
783    /// - the keyboard, once per batched run of typed characters;
784    /// - an IME commit, once for the settled result and never for the
785    ///   intermediate composition states;
786    /// - a paste, of plain text or HTML;
787    /// - an assistive technology, through AccessKit's `SetValue` and
788    ///   `ReplaceSelectedText`.
789    ///
790    /// It does **not** fire for a programmatic `set_djot` / `set_markdown` /
791    /// `set_html` load, for undo or redo, or for a format-only change: none of
792    /// those is text arriving.
793    ///
794    /// Replaces any prior callback on this editor. Runs on the UI thread.
795    pub fn on_text_inserted(self, f: impl Fn(EditSource, usize) + 'static) -> Self {
796        self.state.borrow_mut().on_text_inserted = Some(Rc::new(f));
797        self
798    }
799
800    // --- Observable signals ---------------------------------------------
801
802    /// Reactive counter that bumps on every document change (content edits,
803    /// format changes, load events). Starts at `0`. Use as a change token to
804    /// invalidate external caches.
805    pub fn document_version(&self) -> Signal<u64> {
806        self.state.borrow().document_version.clone()
807    }
808
809    /// Current cursor position in the document, in character units.
810    /// Exposed for tests and for applications that need to mirror the
811    /// caret position externally (status bar, outline panel, etc.).
812    pub fn cursor_position(&self) -> usize {
813        self.state.borrow().cursor.position()
814    }
815
816    /// Current selection anchor (equal to `cursor_position` when there
817    /// is no selection).
818    pub fn cursor_anchor(&self) -> usize {
819        self.state.borrow().cursor.anchor()
820    }
821
822    /// `true` while an IME composition (CJK/Kana candidate preview, dead-key
823    /// accent) is actively in progress — i.e. [`on_change`](Self::on_change)
824    /// is currently suppressed for this editor. Exposed so a caller doing its
825    /// own while-typing scanning (e.g. an autocorrect feature) can gate its
826    /// own trigger logic the same way, as defense-in-depth alongside
827    /// `on_change`'s own gate.
828    pub fn is_composing(&self) -> bool {
829        self.state.borrow().ime_preedit.is_some()
830    }
831
832    /// Reactive cursor position signal. Observers fire whenever the
833    /// cursor moves (arrow keys, click, Home/End, …). Useful for
834    /// status bars and tests.
835    pub fn cursor_position_signal(&self) -> Signal<usize> {
836        self.state.borrow().cursor_position.clone()
837    }
838
839    /// Reactive selection anchor signal.
840    pub fn cursor_anchor_signal(&self) -> Signal<usize> {
841        self.state.borrow().cursor_anchor.clone()
842    }
843
844    /// Reactive signal — `true` whenever the editor has a non-empty
845    /// selection. Updates synchronously after every cursor mutation.
846    pub fn has_selection(&self) -> Signal<bool> {
847        self.state.borrow().has_selection.clone()
848    }
849
850    /// Reactive undo-availability signal, suitable for toolbar button
851    /// enable-state. Updated through the frame loop's debounce drain
852    /// so toolbars don't flicker during rapid editing.
853    pub fn can_undo(&self) -> Signal<bool> {
854        self.state.borrow().can_undo.clone()
855    }
856
857    /// Reactive redo-availability signal.
858    pub fn can_redo(&self) -> Signal<bool> {
859        self.state.borrow().can_redo.clone()
860    }
861
862    /// Read the current character format at the widget's caret —
863    /// the right source for toolbars that mirror bold/italic/underline
864    /// state.
865    ///
866    /// When a selection is active, the format is read from
867    /// [`selection_start()`](teksilo_text::text_document::TextCursor::selection_start)
868    /// rather than [`position()`](teksilo_text::text_document::TextCursor::position).
869    /// Rationale (matches godot-rich-text's `query_char_format`):
870    /// `position()` lands at the **end** of the selection and may fall
871    /// on a run with different formatting (or past the last character,
872    /// on an empty virtual element) — a toolbar observing that value
873    /// would flicker or lie. `selection_start()` always points at the
874    /// first character of the selected range, so the reading is
875    /// stable and matches what a user would expect from "tell me the
876    /// format of what I have selected."
877    pub fn caret_char_format(&self) -> TextFormat {
878        let st = self.state.borrow();
879        let probe_pos = if st.cursor.has_selection() {
880            st.cursor.selection_start()
881        } else {
882            st.cursor.position()
883        };
884        // Read through a fresh cursor so we don't disturb the widget's
885        // own cursor (the widget's own cursor has its own position /
886        // anchor state that we must not move).
887        let probe = st.document.cursor();
888        probe.set_position(probe_pos, teksilo_text::text_document::MoveMode::MoveAnchor);
889        probe.char_format().unwrap_or_default()
890    }
891
892    /// Clone the internal shared state handle for test observation.
893    /// Tests take this before `tree.add(editor)` moves the widget
894    /// into the arena, so they can read the widget's live cursor,
895    /// signal state, and debounce fields through the very same
896    /// `Rc<RefCell<EditorState>>` that the arena-stored editor is
897    /// mutating.
898    #[cfg(test)]
899    pub(crate) fn state_handle(&self) -> SharedState {
900        self.state.clone()
901    }
902
903    /// Reactive vertical scroll offset in logical pixels. Bind to a
904    /// scroll bar or observe for scroll-position persistence.
905    pub fn scroll_y(&self) -> Signal<f32> {
906        self.state.borrow().scroll_y.clone()
907    }
908
909    /// Reactive horizontal scroll offset in logical pixels. Non-zero
910    /// only when [`wrap_mode`](Self::wrap_mode) is `WrapMode::None`.
911    pub fn scroll_x(&self) -> Signal<f32> {
912        self.state.borrow().scroll_x.clone()
913    }
914
915    // --- Context-menu support (external menus) --------------------------
916
917    /// Classify what is under `point` in the widget's local coordinates
918    /// (origin at the widget's top-left, scroll offset handled
919    /// internally by the typesetter), for applications building an
920    /// external context menu. Returns `None` if the point does not
921    /// land on any hit region.
922    pub fn context_target_at(&self, point: Point) -> Option<hit_test::ContextTarget> {
923        let st = self.state.borrow();
924        let hit = hit_test::hit_test_at(&st.engine, point, 0.0, 0.0)?;
925        let selection = Some((st.cursor.anchor(), st.cursor.position()));
926        Some(hit_test::classify(&hit, selection, &st.document))
927    }
928
929    // --- Selection helpers (allowed under both presets) -----------------
930
931    /// Currently selected text, or an empty string if nothing is selected.
932    pub fn selected_text(&self) -> String {
933        self.state
934            .borrow()
935            .cursor
936            .selected_text()
937            .unwrap_or_default()
938    }
939
940    /// Select the entire document programmatically. Equivalent to
941    /// the final step of the Ctrl+A ladder; resets the ladder state
942    /// so a subsequent Ctrl+A starts fresh at level 1.
943    pub fn select_all(&self) {
944        {
945            let mut st = self.state.borrow_mut();
946            st.cursor.select(SelectionType::Document);
947            st.select_all_level = 0;
948            st.select_all_anchor_cell = None;
949        }
950        sync_cursor_signals(&self.state);
951    }
952
953    /// Clear any current selection.
954    pub fn deselect(&self) {
955        {
956            let mut st = self.state.borrow_mut();
957            st.cursor.clear_selection();
958            st.select_all_level = 0;
959            st.select_all_anchor_cell = None;
960        }
961        sync_cursor_signals(&self.state);
962    }
963
964    // --- Cursor mirror API -------------------------------------------------
965    //
966    // These mirror the corresponding `TextCursor` methods but act on the
967    // widget's **internal** cursor (the one tied to caret rendering /
968    // blink / focus) rather than a fresh `doc.cursor()`. An application
969    // that reaches through `TextDocument::cursor()` gets an independent
970    // cursor whose position is decoupled from the widget's caret — any
971    // mutation would be invisible to the paint pass. Use these methods
972    // when you want programmatic effects to feel like user-typed edits.
973
974    /// Insert plain text at the widget's caret. Replaces any selection.
975    pub fn insert_text(&self, text: &str) {
976        let st = self.state.borrow();
977        let _ = st.cursor.insert_text(text);
978        drop(st);
979        sync_cursor_signals(&self.state);
980    }
981
982    /// Insert a fragment parsed from HTML at the widget's caret.
983    /// Replaces any selection. Uses text-document's
984    /// [`TextCursor::insert_html`](teksilo_text::text_document::TextCursor::insert_html),
985    /// which parses the HTML into a `DocumentFragment` and inserts it.
986    pub fn insert_html(&self, html: &str) {
987        let st = self.state.borrow();
988        let _ = st.cursor.insert_html(html);
989        drop(st);
990        sync_cursor_signals(&self.state);
991    }
992
993    /// Insert a fragment parsed from djot at the widget's caret.
994    /// Replaces any selection. Uses text-document's
995    /// [`TextCursor::insert_djot`](teksilo_text::text_document::TextCursor::insert_djot),
996    /// which parses the djot into a `DocumentFragment` and inserts it — so
997    /// unlike [`insert_text`](Self::insert_text), block-level source really
998    /// does produce new blocks rather than literal newlines in one paragraph.
999    pub fn insert_djot(&self, djot: &str) {
1000        let st = self.state.borrow();
1001        let _ = st.cursor.insert_djot(djot);
1002        drop(st);
1003        sync_cursor_signals(&self.state);
1004    }
1005
1006    /// Split the current block at the widget's caret, as pressing Enter does.
1007    pub fn insert_block(&self) {
1008        let st = self.state.borrow();
1009        let _ = st.cursor.insert_block();
1010        drop(st);
1011        sync_cursor_signals(&self.state);
1012    }
1013
1014    /// Insert an inline image by logical resource name. `width` and
1015    /// `height` are in logical pixels.
1016    ///
1017    /// `alt` is the image's accessible description and its export representation. It is
1018    /// passed straight through rather than defaulted here: the caller is the only layer
1019    /// that knows what the picture shows, and an empty string chosen on its behalf would
1020    /// be an accessibility decision made silently by a widget wrapper.
1021    pub fn insert_image(&self, name: &str, alt: &str, width: u32, height: u32) {
1022        let st = self.state.borrow();
1023        let _ = st.cursor.insert_image(name, alt, width, height);
1024        drop(st);
1025        sync_cursor_signals(&self.state);
1026    }
1027
1028    /// Delete the current selection. No-op when nothing is selected.
1029    pub fn delete_selection(&self) {
1030        let st = self.state.borrow();
1031        if st.cursor.has_selection() {
1032            let _ = st.cursor.remove_selected_text();
1033        }
1034        drop(st);
1035        sync_cursor_signals(&self.state);
1036    }
1037
1038    /// Select the word under the widget's caret.
1039    pub fn select_word(&self) {
1040        {
1041            let st = self.state.borrow();
1042            st.cursor.select(SelectionType::WordUnderCursor);
1043        }
1044        sync_cursor_signals(&self.state);
1045    }
1046
1047    /// Select the paragraph / block under the widget's caret.
1048    pub fn select_line(&self) {
1049        {
1050            let st = self.state.borrow();
1051            st.cursor.select(SelectionType::LineUnderCursor);
1052        }
1053        sync_cursor_signals(&self.state);
1054    }
1055
1056    /// Move the caret to an absolute character position. Collapses any
1057    /// existing selection (passes [`MoveMode::MoveAnchor`]). Resets
1058    /// `CursorAffinity` to `Downstream` — programmatic placement
1059    /// can't know whether the caller wanted the upstream side of a
1060    /// wrap boundary, so we default to the same placement that
1061    /// existed before affinity was introduced.
1062    pub fn set_caret_position(&self, position: usize) {
1063        {
1064            let mut st = self.state.borrow_mut();
1065            st.cursor.set_position(position, MoveMode::MoveAnchor);
1066            st.cursor_affinity = teksilo_text::CursorAffinity::Downstream;
1067        }
1068        sync_cursor_signals(&self.state);
1069    }
1070
1071    // --- Search / find-banner support (B3) --------------------------------
1072
1073    /// Reactive signal — `true` while **this** editor holds keyboard focus.
1074    ///
1075    /// A per-editor find banner (Ctrl+F) targets whichever editor is focused, and the split
1076    /// view has two of them; `focused_side` only names the Primary/Secondary *pane*, not which
1077    /// editor. This is the per-editor answer, mirroring [`has_selection`](Self::has_selection).
1078    pub fn focused_signal(&self) -> Signal<bool> {
1079        self.state.borrow().focus_signal.clone()
1080    }
1081
1082    /// Select the character range `[start, end)`, **without** collapsing — unlike
1083    /// [`set_caret_position`](Self::set_caret_position), which always moves both ends together.
1084    ///
1085    /// The anchor lands at `start` and the caret (focus) at `end`, so the standard selection
1086    /// highlight marks the range and a subsequent replace acts on it. Used to select a search
1087    /// match. (The non-collapsing two-call shape is the same one the AccessKit
1088    /// `SetTextSelection` handler uses.)
1089    pub fn select_range(&self, start: usize, end: usize) {
1090        {
1091            let mut st = self.state.borrow_mut();
1092            st.cursor.set_position(start, MoveMode::MoveAnchor);
1093            st.cursor.set_position(end, MoveMode::KeepAnchor);
1094            // The caret sits at `end`; downstream affinity matches placement at a range end.
1095            st.cursor_affinity = teksilo_text::CursorAffinity::Downstream;
1096        }
1097        sync_cursor_signals(&self.state);
1098    }
1099
1100    /// Scroll the character range `[start, end)` into view within the enclosing scroll area.
1101    ///
1102    /// Reveals an **arbitrary** offset range — the current search match — rather than the live
1103    /// caret the follow-into-view path tracks, and works whether or not the editor is focused.
1104    ///
1105    /// **Returns whether it could.** `false` means this editor has no layout to locate the
1106    /// range in — never laid out, or parked dormant in a tab that is not on screen — and
1107    /// nothing was requested. A caller holding several editors over one document (two split
1108    /// panes; a stream row and that row's own tab) must try the next rather than take the
1109    /// first as the answer: revealing through a dormant one silently does nothing, which
1110    /// reads as "the viewport does not follow".
1111    ///
1112    /// Under [`typewriter`](Self::typewriter) scrolling the range is *pinned* to
1113    /// the anchor rather than merely revealed, so a search walks matches to the
1114    /// same height the caret writes at instead of leaving them wherever they
1115    /// happened to fall. Because a search jump is a deliberate, screen-sized
1116    /// move, it glides.
1117    pub fn reveal_range(
1118        &self,
1119        ctx: &mut teksilo_core::widget::EventContext,
1120        start: usize,
1121        end: usize,
1122    ) -> bool {
1123        reveal_range_impl(&self.state, ctx, start, end)
1124    }
1125
1126    // --- Character-format commands ----------------------------------------
1127    //
1128    // Each setter writes to `TextCursor::merge_char_format`, which
1129    // applies to the current selection (or acts as a typing format when
1130    // there is no selection — see text-document's semantics). Toggle
1131    // variants (`toggle_bold`, `toggle_italic`, `toggle_underline`,
1132    // `toggle_strikethrough`) read the current state via
1133    // [`caret_char_format`](Self::caret_char_format) first and flip,
1134    // which matches the Ctrl+B / Ctrl+I / Ctrl+U keyboard shortcuts.
1135
1136    fn apply_char_format(&self, fmt: TextFormat) {
1137        let st = self.state.borrow();
1138        let _ = st.cursor.merge_char_format(&fmt);
1139        // `pending_format_changed` gets set by `drain_events` when the
1140        // document emits its `FormatChanged` event in response to the
1141        // cursor mutation, so no manual bookkeeping is needed here.
1142    }
1143
1144    /// Apply **bold** to the current selection (or set the typing bold
1145    /// state when no selection is active). Pairs with
1146    /// [`is_bold`](Self::is_bold) and [`toggle_bold`](Self::toggle_bold).
1147    pub fn set_bold(&self, enabled: bool) {
1148        self.apply_char_format(TextFormat {
1149            font_bold: Some(enabled),
1150            ..Default::default()
1151        });
1152    }
1153
1154    /// Apply *italic* to the current selection.
1155    pub fn set_italic(&self, enabled: bool) {
1156        self.apply_char_format(TextFormat {
1157            font_italic: Some(enabled),
1158            ..Default::default()
1159        });
1160    }
1161
1162    /// Apply underline to the current selection.
1163    pub fn set_underline(&self, enabled: bool) {
1164        self.apply_char_format(TextFormat {
1165            font_underline: Some(enabled),
1166            ..Default::default()
1167        });
1168    }
1169
1170    /// Apply strikethrough to the current selection.
1171    pub fn set_strikethrough(&self, enabled: bool) {
1172        self.apply_char_format(TextFormat {
1173            font_strikeout: Some(enabled),
1174            ..Default::default()
1175        });
1176    }
1177
1178    /// Set the font size (in points) for the current selection.
1179    pub fn set_font_size(&self, size: u32) {
1180        self.apply_char_format(TextFormat {
1181            font_point_size: Some(size),
1182            ..Default::default()
1183        });
1184    }
1185
1186    /// Set the font family for the current selection. `family` must be
1187    /// a name resolvable by the shared typesetter's font registrar.
1188    pub fn set_font_family(&self, family: impl Into<String>) {
1189        self.apply_char_format(TextFormat {
1190            font_family: Some(family.into()),
1191            ..Default::default()
1192        });
1193    }
1194
1195    /// Toggle bold on the current selection, reading the current state
1196    /// via [`caret_char_format`](Self::caret_char_format). Matches the
1197    /// Ctrl+B keyboard shortcut's behaviour.
1198    pub fn toggle_bold(&self) {
1199        let current = self.caret_char_format().font_bold.unwrap_or(false);
1200        self.set_bold(!current);
1201    }
1202
1203    /// Toggle italic; see [`toggle_bold`](Self::toggle_bold).
1204    pub fn toggle_italic(&self) {
1205        let current = self.caret_char_format().font_italic.unwrap_or(false);
1206        self.set_italic(!current);
1207    }
1208
1209    /// Toggle underline; see [`toggle_bold`](Self::toggle_bold).
1210    pub fn toggle_underline(&self) {
1211        let current = self.caret_char_format().font_underline.unwrap_or(false);
1212        self.set_underline(!current);
1213    }
1214
1215    /// Toggle strikethrough; see [`toggle_bold`](Self::toggle_bold).
1216    pub fn toggle_strikethrough(&self) {
1217        let current = self.caret_char_format().font_strikeout.unwrap_or(false);
1218        self.set_strikethrough(!current);
1219    }
1220
1221    // --- Vertical alignment (super / subscript) ---------------------------
1222    //
1223    // One property with three meaningful states, surfaced as two independent
1224    // toggles because that is how a toolbar presents it. Setting one clears
1225    // the other, since a run cannot be both.
1226
1227    /// Raise the selection to superscript, or drop it back to the baseline.
1228    pub fn set_superscript(&self, enabled: bool) {
1229        self.set_vertical_alignment(if enabled {
1230            CharVerticalAlignment::SuperScript
1231        } else {
1232            CharVerticalAlignment::Normal
1233        });
1234    }
1235
1236    /// Lower the selection to subscript, or drop it back to the baseline.
1237    pub fn set_subscript(&self, enabled: bool) {
1238        self.set_vertical_alignment(if enabled {
1239            CharVerticalAlignment::SubScript
1240        } else {
1241            CharVerticalAlignment::Normal
1242        });
1243    }
1244
1245    /// Set the selection's vertical alignment directly. `Normal` is the
1246    /// baseline; `Middle` exists in the model but has no toolbar affordance.
1247    pub fn set_vertical_alignment(&self, alignment: CharVerticalAlignment) {
1248        self.apply_char_format(TextFormat {
1249            vertical_alignment: Some(alignment),
1250            ..Default::default()
1251        });
1252    }
1253
1254    /// The caret's vertical alignment, `Normal` when unset.
1255    pub fn get_vertical_alignment(&self) -> CharVerticalAlignment {
1256        self.caret_char_format()
1257            .vertical_alignment
1258            .unwrap_or(CharVerticalAlignment::Normal)
1259    }
1260
1261    /// True while the caret sits in superscript text.
1262    pub fn is_superscript(&self) -> bool {
1263        self.get_vertical_alignment() == CharVerticalAlignment::SuperScript
1264    }
1265
1266    /// True while the caret sits in subscript text.
1267    pub fn is_subscript(&self) -> bool {
1268        self.get_vertical_alignment() == CharVerticalAlignment::SubScript
1269    }
1270
1271    /// Flip superscript on the selection. Turning it on replaces subscript.
1272    pub fn toggle_superscript(&self) {
1273        self.set_superscript(!self.is_superscript());
1274    }
1275
1276    /// Flip subscript on the selection. Turning it on replaces superscript.
1277    pub fn toggle_subscript(&self) {
1278        self.set_subscript(!self.is_subscript());
1279    }
1280
1281    // --- Block-format commands --------------------------------------------
1282
1283    /// Set an arbitrary [`BlockFormat`] on the caret's current block.
1284    /// The higher-level helpers [`set_alignment`](Self::set_alignment)
1285    /// and [`set_heading_level`](Self::set_heading_level) go through
1286    /// this method. Exposed so apps that need less common fields
1287    /// (`indent`, `left_margin`, `line_height`, …) don't have to
1288    /// reach through `TextDocument::cursor()` and lose the widget's
1289    /// caret continuity.
1290    pub fn apply_block_format(&self, fmt: BlockFormat) {
1291        let st = self.state.borrow();
1292        let _ = st.cursor.set_block_format(&fmt);
1293        // See `apply_char_format` — `FormatChanged` propagates
1294        // through `drain_events` and updates `pending_format_changed`
1295        // + `format_version` there.
1296    }
1297
1298    /// Set an arbitrary [`TextFormat`] on the current selection.
1299    /// Public counterpart of the private `apply_char_format` helper,
1300    /// for apps that need fields beyond the dedicated
1301    /// `set_bold` / `set_italic` / … setters (e.g. `letter_spacing`,
1302    /// `foreground_color`).
1303    pub fn apply_text_format(&self, fmt: TextFormat) {
1304        self.apply_char_format(fmt);
1305    }
1306
1307    /// Set the paragraph alignment for the current block (or the block
1308    /// containing the selection anchor).
1309    pub fn set_alignment(&self, alignment: Alignment) {
1310        self.apply_block_format(BlockFormat {
1311            alignment: Some(alignment),
1312            ..Default::default()
1313        });
1314    }
1315
1316    /// Unset the block's direction, handing the paragraph back to
1317    /// automatic detection.
1318    ///
1319    /// Not the same as setting left-to-right. An explicit direction
1320    /// *pins* the paragraph and overrides the bidi algorithm, so
1321    /// "clearing" a direction by writing `LeftToRight` would force
1322    /// Arabic and Hebrew prose to lay out backwards. Only an unset
1323    /// direction lets the text speak for itself.
1324    pub fn clear_direction(&self) {
1325        self.apply_block_format(BlockFormat {
1326            clear_direction: true,
1327            ..Default::default()
1328        });
1329    }
1330
1331    /// Set the base reading direction of the current block.
1332    ///
1333    /// This is the *paragraph* direction, not a character property: it
1334    /// decides which edge unaligned text sits against and, more
1335    /// importantly, overrides the bidi algorithm's first-strong-character
1336    /// guess — which misreads an Arabic paragraph opening with a Latin
1337    /// acronym as left-to-right.
1338    pub fn set_direction(&self, direction: TextDirection) {
1339        self.apply_block_format(BlockFormat {
1340            direction: Some(direction),
1341            ..Default::default()
1342        });
1343    }
1344
1345    /// Set the heading level of the current block. `0` = plain
1346    /// paragraph; `1..=6` follow the HTML `<h1>..<h6>` convention.
1347    pub fn set_heading_level(&self, level: u8) {
1348        self.apply_block_format(BlockFormat {
1349            heading_level: Some(level),
1350            ..Default::default()
1351        });
1352    }
1353
1354    // --- List commands ----------------------------------------------------
1355
1356    /// Create a list at the current selection. `ordered = true` uses
1357    /// decimal numbering; `ordered = false` uses a bullet disc.
1358    /// Choose a specific style with [`create_list`](Self::create_list).
1359    pub fn insert_list(&self, ordered: bool) {
1360        let style = if ordered {
1361            ListStyle::Decimal
1362        } else {
1363            ListStyle::Disc
1364        };
1365        self.create_list(style);
1366    }
1367
1368    /// Create a list with an explicit [`ListStyle`]. Exposed for
1369    /// applications that want e.g. lowercase Roman numerals or circle
1370    /// bullets.
1371    pub fn create_list(&self, style: ListStyle) {
1372        {
1373            let st = self.state.borrow();
1374            let _ = st.cursor.create_list(style);
1375        }
1376        sync_cursor_signals(&self.state);
1377    }
1378
1379    /// Increase the nesting depth of the caret's current list item by
1380    /// one. No-op when the caret is not inside a list. Equivalent to
1381    /// pressing Tab while the caret is on a list item — same behaviour,
1382    /// same `nest_current_list_item` codepath, exposed for toolbar
1383    /// buttons that do not want to synthesise key events.
1384    pub fn indent(&self) {
1385        keyboard::indent_current_block(&mut self.state.borrow_mut());
1386        sync_cursor_signals(&self.state);
1387    }
1388
1389    /// Decrease the nesting depth of the caret's current list item by
1390    /// one. No-op at depth 0 (use `Backspace` at block-start to exit
1391    /// the list entirely). Toolbar counterpart of Shift+Tab.
1392    pub fn outdent(&self) {
1393        keyboard::dedent_current_block(&mut self.state.borrow_mut());
1394        sync_cursor_signals(&self.state);
1395    }
1396
1397    /// Take the caret's block out of its list entirely, leaving a plain
1398    /// paragraph. No-op when the caret is not inside a list.
1399    ///
1400    /// [`outdent`](Self::outdent) deliberately stops at depth 0 — Shift+Tab
1401    /// should not silently destroy the list — so a toolbar that offers
1402    /// "remove list formatting" needs this instead. Backspace at block-start
1403    /// reaches the same codepath from the keyboard.
1404    pub fn remove_from_list(&self) {
1405        let _ = self.state.borrow().cursor.remove_current_block_from_list();
1406        sync_cursor_signals(&self.state);
1407    }
1408
1409    // --- Blockquote commands ----------------------------------------------
1410
1411    /// True iff the caret currently sits inside a blockquote frame at
1412    /// any nesting depth. Used by the toolbar to drive the toggle
1413    /// button's pressed state and the context menu's label.
1414    pub fn is_in_blockquote(&self) -> bool {
1415        let st = self.state.borrow();
1416        st.cursor.is_in_blockquote()
1417    }
1418
1419    /// True iff the current selection spans more than one frame. The
1420    /// "Toggle blockquote" affordance is disabled in this case because
1421    /// wrapping a cross-frame range has no well-defined semantics
1422    /// (different blocks already belong to different containers).
1423    pub fn selection_spans_multiple_frames(&self) -> bool {
1424        let st = self.state.borrow();
1425        st.cursor.selection_spans_multiple_frames()
1426    }
1427
1428    /// Wrap the current block (or selection) in a blockquote, or
1429    /// unwrap the innermost enclosing blockquote if already inside one.
1430    /// No-op (returns silently) when the selection spans multiple
1431    /// frames.
1432    pub fn toggle_blockquote(&self) {
1433        {
1434            let st = self.state.borrow();
1435            let _ = st.cursor.toggle_blockquote();
1436        }
1437        sync_cursor_signals(&self.state);
1438    }
1439
1440    /// Equivalent to pressing Tab inside a blockquote — wraps the
1441    /// current block in a deeper nested quote. No-op when the caret is
1442    /// not in a quote.
1443    pub fn increase_blockquote_depth(&self) {
1444        {
1445            let st = self.state.borrow();
1446            let _ = st.cursor.increase_blockquote_depth();
1447        }
1448        sync_cursor_signals(&self.state);
1449    }
1450
1451    /// Equivalent to pressing Shift+Tab inside a blockquote — pops one
1452    /// nesting level. At depth 1 unwraps the block to a plain
1453    /// paragraph. No-op when the caret is not in a quote.
1454    pub fn decrease_blockquote_depth(&self) {
1455        {
1456            let st = self.state.borrow();
1457            let _ = st.cursor.decrease_blockquote_depth();
1458        }
1459        sync_cursor_signals(&self.state);
1460    }
1461
1462    // --- Table commands ---------------------------------------------------
1463    //
1464    // Each table command drops through `sync_cursor_signals` because
1465    // the underlying `cursor.*` calls move the caret (insert_table
1466    // lands past the new table; row/column ops may shift the caret's
1467    // logical position). Callers observing `cursor_position_signal`
1468    // see the post-operation position without waiting for the next
1469    // frame tick.
1470
1471    /// Insert a fresh `rows × columns` table at the caret. Any
1472    /// existing selection is replaced.
1473    pub fn insert_table(&self, rows: usize, columns: usize) {
1474        {
1475            let st = self.state.borrow();
1476            let _ = st.cursor.insert_table(rows, columns);
1477        }
1478        sync_cursor_signals(&self.state);
1479    }
1480
1481    /// Remove the table containing the caret (if any). No-op when the
1482    /// caret is not inside a table.
1483    pub fn remove_current_table(&self) {
1484        {
1485            let st = self.state.borrow();
1486            let _ = st.cursor.remove_current_table();
1487        }
1488        sync_cursor_signals(&self.state);
1489    }
1490
1491    /// Insert a row above the caret's current table row. No-op when
1492    /// outside a table.
1493    pub fn insert_row_above(&self) {
1494        {
1495            let st = self.state.borrow();
1496            let _ = st.cursor.insert_row_above();
1497        }
1498        sync_cursor_signals(&self.state);
1499    }
1500
1501    /// Insert a row below the caret's current table row.
1502    pub fn insert_row_below(&self) {
1503        {
1504            let st = self.state.borrow();
1505            let _ = st.cursor.insert_row_below();
1506        }
1507        sync_cursor_signals(&self.state);
1508    }
1509
1510    /// Insert a column before the caret's current table column.
1511    pub fn insert_column_before(&self) {
1512        {
1513            let st = self.state.borrow();
1514            let _ = st.cursor.insert_column_before();
1515        }
1516        sync_cursor_signals(&self.state);
1517    }
1518
1519    /// Insert a column after the caret's current table column.
1520    pub fn insert_column_after(&self) {
1521        {
1522            let st = self.state.borrow();
1523            let _ = st.cursor.insert_column_after();
1524        }
1525        sync_cursor_signals(&self.state);
1526    }
1527
1528    /// Remove the caret's current table row.
1529    pub fn remove_current_row(&self) {
1530        {
1531            let st = self.state.borrow();
1532            let _ = st.cursor.remove_current_row();
1533        }
1534        sync_cursor_signals(&self.state);
1535    }
1536
1537    /// Remove the caret's current table column.
1538    pub fn remove_current_column(&self) {
1539        {
1540            let st = self.state.borrow();
1541            let _ = st.cursor.remove_current_column();
1542        }
1543        sync_cursor_signals(&self.state);
1544    }
1545
1546    /// Whether the caret is currently inside a table cell.
1547    pub fn is_in_table(&self) -> bool {
1548        self.state.borrow().cursor.current_table().is_some()
1549    }
1550
1551    // --- Format query methods (toolbar state) -----------------------------
1552    //
1553    // Every query goes through [`caret_char_format`](Self::caret_char_format)
1554    // which honours the selection-start rule — toolbar buttons reflect
1555    // "the format of what's selected," not "the format after the
1556    // selection ends."
1557
1558    /// Whether the current selection / typing position is bold.
1559    pub fn is_bold(&self) -> bool {
1560        self.caret_char_format().font_bold.unwrap_or(false)
1561    }
1562
1563    /// Whether italic.
1564    pub fn is_italic(&self) -> bool {
1565        self.caret_char_format().font_italic.unwrap_or(false)
1566    }
1567
1568    // ── Hyperlinks ───────────────────────────────────────────────
1569    //
1570    // A link is a character format, not an object: applying one merges a
1571    // destination onto a range, so any bold or italic already there survives
1572    // and no markup has to be escaped. What it does not get for free is
1573    // removal — every field of a merge means "leave this alone" when unset —
1574    // hence `clear_link` rather than "set the destination to nothing".
1575
1576    /// Point the selection at `href`.
1577    ///
1578    /// Merges, so formatting already on the range is kept. A collapsed
1579    /// selection formats nothing (as everywhere else), so a caller linking
1580    /// existing text should select it first — see
1581    /// [`link_at_caret`](Self::link_at_caret) for the range of a link already
1582    /// there.
1583    pub fn set_link(&self, href: &str) {
1584        self.apply_char_format(TextFormat {
1585            anchor_href: Some(href.to_string()),
1586            ..Default::default()
1587        });
1588    }
1589
1590    /// Take the link off the selection, leaving its text.
1591    pub fn clear_link(&self) {
1592        self.apply_char_format(TextFormat {
1593            clear_link: true,
1594            ..Default::default()
1595        });
1596    }
1597
1598    /// The link the caret is in, and how far it reaches.
1599    ///
1600    /// Coalesced across the runs an inner mark splits a link into, so the
1601    /// range covers the whole link rather than the piece under the caret.
1602    /// `None` when the caret is not on a link.
1603    pub fn link_at_caret(&self) -> Option<LinkExtent> {
1604        self.state.borrow().cursor.link_at_caret()
1605    }
1606
1607    /// Whether the caret / selection sits on a link.
1608    pub fn is_link(&self) -> bool {
1609        self.caret_char_format().is_anchor.unwrap_or(false)
1610    }
1611
1612    /// Whether underline.
1613    pub fn is_underline(&self) -> bool {
1614        self.caret_char_format().font_underline.unwrap_or(false)
1615    }
1616
1617    /// Whether strikethrough.
1618    pub fn is_strikethrough(&self) -> bool {
1619        self.caret_char_format().font_strikeout.unwrap_or(false)
1620    }
1621
1622    /// Current heading level (0 = plain paragraph). Reads the caret's
1623    /// current block format.
1624    pub fn get_heading_level(&self) -> u8 {
1625        self.state
1626            .borrow()
1627            .cursor
1628            .block_format()
1629            .ok()
1630            .and_then(|f| f.heading_level)
1631            .unwrap_or(0)
1632    }
1633
1634    /// Current block alignment.
1635    pub fn get_alignment(&self) -> Alignment {
1636        self.state
1637            .borrow()
1638            .cursor
1639            .block_format()
1640            .ok()
1641            .and_then(|f| f.alignment)
1642            .unwrap_or(Alignment::Left)
1643    }
1644
1645    /// The block's explicitly-set reading direction, if it has one.
1646    /// `None` means the bidi algorithm decides from the text.
1647    pub fn get_direction(&self) -> Option<TextDirection> {
1648        self.state
1649            .borrow()
1650            .cursor
1651            .block_format()
1652            .ok()
1653            .and_then(|f| f.direction)
1654    }
1655
1656    // --- History ---------------------------------------------------------
1657    //
1658    // Programmatic Undo / Redo. Failures (e.g. empty undo stack) are
1659    // silently discarded — toolbars gate the buttons on
1660    // [`can_undo`](Self::can_undo) / [`can_redo`](Self::can_redo)
1661    // signals so the error path is unreachable in normal use, and the
1662    // keyboard handlers at `keyboard.rs:357-366` use the same
1663    // `let _ =` discipline.
1664
1665    /// Undo the most recent edit. Mirrors Ctrl+Z. No-op when the undo
1666    /// stack is empty.
1667    pub fn undo(&self) {
1668        let _ = self.state.borrow().document.undo();
1669        sync_cursor_signals(&self.state);
1670    }
1671
1672    /// Close the current undo entry, so the next edit starts a new one.
1673    ///
1674    /// Typing coalesces into word-sized undo steps by looking only at the shape
1675    /// of two edits — adjacent, moments apart. It cannot see that the user did
1676    /// something else in between, somewhere else in the application, that they
1677    /// would remember as a dividing line. A host that knows one was crossed says
1678    /// so here, and the burst before it stops merging with the burst after.
1679    pub fn break_undo_merge(&self) {
1680        self.state.borrow().document.break_undo_merge();
1681    }
1682
1683    /// Redo the most recently undone edit. Mirrors Ctrl+Y /
1684    /// Ctrl+Shift+Z. No-op when the redo stack is empty.
1685    pub fn redo(&self) {
1686        let _ = self.state.borrow().document.redo();
1687        sync_cursor_signals(&self.state);
1688    }
1689
1690    // --- Edit blocks (composite undo) ------------------------------------
1691    //
1692    // Every command on this type is its own transaction, so a caller that
1693    // composes several of them into one user-visible action — "clear
1694    // formatting" turning off four marks and flattening a heading — leaves
1695    // the user pressing Ctrl+Z once per property. Wrapping the sequence in
1696    // an edit block makes it one entry.
1697    //
1698    // The editor already groups this way internally for IME composition
1699    // (`keyboard.rs`) and for list nesting; these expose the same primitive
1700    // to external toolbars. Composites nest, so it is safe to wrap calls
1701    // that open one of their own.
1702
1703    /// Begin grouping subsequent edits into a single undo entry.
1704    ///
1705    /// Must be paired with [`end_edit_block`](Self::end_edit_block). Prefer
1706    /// [`edit_block`](Self::edit_block), which pairs them for you.
1707    pub fn begin_edit_block(&self) {
1708        self.state.borrow().cursor.begin_edit_block();
1709    }
1710
1711    /// Close the group opened by [`begin_edit_block`](Self::begin_edit_block).
1712    pub fn end_edit_block(&self) {
1713        self.state.borrow().cursor.end_edit_block();
1714    }
1715
1716    /// Run `edits` as one undo entry.
1717    ///
1718    /// The scoped form of [`begin_edit_block`](Self::begin_edit_block) — the
1719    /// block is closed even if `edits` returns early, which hand-pairing gets
1720    /// wrong eventually.
1721    pub fn edit_block<R>(&self, edits: impl FnOnce() -> R) -> R {
1722        self.begin_edit_block();
1723        let result = edits();
1724        self.end_edit_block();
1725        result
1726    }
1727
1728    /// Set the document-wide default language (ISO 639-1 code, e.g. "en",
1729    /// "fr", "de"). Blocks that don't set their own language inherit it
1730    /// for hyphenation. Forces a full re-layout so the change takes effect
1731    /// on the next frame. No-op-safe if the document rejects the update.
1732    pub fn set_default_language(&self, language: &str) {
1733        let _ = self.state.borrow().document.set_default_language(language);
1734        self.state.borrow_mut().needs_full_layout = true;
1735    }
1736
1737    /// The document-wide default language (ISO 639-1 code). Defaults to
1738    /// `"en"` when never set.
1739    pub fn default_language(&self) -> String {
1740        self.state.borrow().document.default_language()
1741    }
1742
1743    // --- External handle -------------------------------------------------
1744
1745    /// Cheap clone-able handle for external toolbars / palettes — see
1746    /// [`EditorHandle`]. The handle shares the editor's internal
1747    /// state (same `Rc<RefCell<…>>`), so mutations through the handle
1748    /// are immediately observable through the editor's reactive
1749    /// signals (and vice versa).
1750    ///
1751    /// Use this when the caller needs to invoke editor commands from
1752    /// `on_activate_fn` / `ctx.effect` closures that outlive the
1753    /// borrow of `&editor`: `RichTextEditor` itself is move-only
1754    /// (the optional context-menu factory holds a `Box<dyn Fn>`,
1755    /// which prevents `Clone`).
1756    pub fn handle(&self) -> EditorHandle {
1757        EditorHandle {
1758            state: self.state.clone(),
1759        }
1760    }
1761
1762    // --- Clipboard (programmatic) -----------------------------------------
1763    //
1764    // Direct programmatic counterparts of Ctrl+C / Ctrl+X / Ctrl+V /
1765    // Ctrl+Shift+V. The `ctx` argument is the active
1766    // [`EventContext`](teksilo_core::widget::EventContext) — the clipboard
1767    // lookup flows through `ctx.app_state::<ClipboardHandle>()` which
1768    // only has a value during event dispatch. Callers outside that
1769    // scope (e.g. ambient "restore from file" flows) should operate on
1770    // the `TextDocument` and the app-level clipboard directly.
1771
1772    /// Copy the current selection to the system clipboard (plain +
1773    /// HTML payloads). No-op when there is no selection.
1774    ///
1775    /// All clipboard methods take `&EventContext` because they only
1776    /// need read access — the clipboard handle is looked up via
1777    /// `ctx.app_state::<ClipboardHandle>()`. A call site that holds
1778    /// `&mut EventContext` can pass `&ctx` directly; Rust reborrows
1779    /// automatically.
1780    pub fn copy(&self, ctx: &teksilo_core::widget::EventContext) {
1781        let mut st = self.state.borrow_mut();
1782        clipboard::copy(&mut st, ctx);
1783    }
1784
1785    /// Cut the current selection: copy first, then remove.
1786    pub fn cut(&self, ctx: &teksilo_core::widget::EventContext) {
1787        {
1788            let mut st = self.state.borrow_mut();
1789            clipboard::cut(&mut st, ctx);
1790        }
1791        sync_cursor_signals(&self.state);
1792    }
1793
1794    /// Paste from the system clipboard. Prefers an in-process fragment
1795    /// over HTML over plain text — see
1796    /// `rich_text/clipboard.rs`.
1797    pub fn paste(&self, ctx: &teksilo_core::widget::EventContext) {
1798        {
1799            let mut st = self.state.borrow_mut();
1800            clipboard::paste(&mut st, ctx);
1801        }
1802        sync_cursor_signals(&self.state);
1803    }
1804
1805    /// Paste plain text only, stripping any rich payload.
1806    pub fn paste_unformatted(&self, ctx: &teksilo_core::widget::EventContext) {
1807        {
1808            let mut st = self.state.borrow_mut();
1809            clipboard::paste_unformatted(&mut st, ctx);
1810        }
1811        sync_cursor_signals(&self.state);
1812    }
1813
1814    /// Whether a paste would insert anything — `true` iff the system
1815    /// clipboard carries text **or** an HTML payload (the shapes
1816    /// [`paste`](Self::paste) can consume; an HTML-only clipboard pastes
1817    /// fine, so probing plain text alone would under-report).
1818    ///
1819    /// Clipboard contents are not reactively observable, so this is a
1820    /// **point-in-time query** rather than a `Signal`: pass the active
1821    /// [`EventContext`](teksilo_core::widget::EventContext). It probes
1822    /// the clipboard (an X11 HTML probe can round-trip to the selection
1823    /// owner), so a menu / toolbar builder should re-query when the menu
1824    /// opens, not per frame. Returns `false` when no clipboard backend
1825    /// is installed (headless or feature-off builds) — the same
1826    /// "silently no-op" degradation the paste path itself uses.
1827    pub fn can_paste(&self, ctx: &teksilo_core::widget::EventContext) -> bool {
1828        clipboard::can_paste(ctx)
1829    }
1830
1831    /// Set the per-editor logical font-size multiplier (`1.0` = 100 %).
1832    /// Composed with accessibility text scale at paint; forces relayout.
1833    /// See [`font_size_scale`](Self::font_size_scale).
1834    pub fn set_font_size_scale(&self, scale: f32) {
1835        let mut st = self.state.borrow_mut();
1836        let scale = scale.clamp(0.1, 10.0);
1837        if (st.font_size_scale - scale).abs() <= f32::EPSILON {
1838            return;
1839        }
1840        st.font_size_scale = scale;
1841        // Force the paint pass to re-push engine font_scale (it compares
1842        // against `last_font_scale` only).
1843        st.last_font_scale = f32::NAN;
1844        st.needs_full_layout = true;
1845        st.content_dirty = true;
1846        if let Some(handle) = &st.frame_request {
1847            handle.set(true);
1848        }
1849    }
1850
1851    /// Current per-editor font-size scale (`1.0` = 100 %).
1852    pub fn get_font_size_scale(&self) -> f32 {
1853        self.state.borrow().font_size_scale
1854    }
1855
1856    /// Set the non-destructive default typography at runtime. Re-lays out and
1857    /// schedules a repaint. Never mutates the document.
1858    pub fn set_typography_defaults(&self, defaults: EditorTypographyDefaults) {
1859        let mut st = self.state.borrow_mut();
1860        st.engine.set_typography_defaults(defaults);
1861        st.needs_full_layout = true;
1862        st.content_dirty = true;
1863        if let Some(handle) = &st.frame_request {
1864            handle.set(true);
1865        }
1866    }
1867
1868    /// Current default typography (see [`typography_defaults`](Self::typography_defaults)).
1869    pub fn get_typography_defaults(&self) -> EditorTypographyDefaults {
1870        self.state.borrow().engine.typography_defaults().clone()
1871    }
1872
1873    /// Set the typewriter-scrolling anchor at runtime — see
1874    /// [`typewriter`](Self::typewriter). `None` turns pinning off.
1875    ///
1876    /// Takes effect on the next caret move rather than scrolling immediately: a
1877    /// pin is a follow rule, and re-anchoring the page the instant a setting
1878    /// changes would jump the view under a reader who is not even typing.
1879    pub fn set_typewriter(&self, anchor: Option<f32>) {
1880        let mut st = self.state.borrow_mut();
1881        st.typewriter = anchor.map(|f| f.clamp(0.0, 1.0));
1882        // Drop the pin's dedup memory: the *next* caret move must re-pin even if
1883        // it lands where the last chase already was.
1884        st.last_chase_y = None;
1885    }
1886
1887    /// Current typewriter anchor (see [`typewriter`](Self::typewriter)).
1888    pub fn get_typewriter(&self) -> Option<f32> {
1889        self.state.borrow().typewriter
1890    }
1891
1892    /// Narrow (or restore) what the keyboard may do on this mounted editor.
1893    ///
1894    /// The other three policy dimensions — caret, accessibility role, clipboard
1895    /// surface — describe what *kind* of surface this is and are fixed at
1896    /// construction; only the command filter is a mode the host can change
1897    /// while the writer is looking at it. Swapping in
1898    /// [`CommandFilter::ForwardOnly`] gives a forward-only drafting mode;
1899    /// [`CommandFilter::All`] restores ordinary editing.
1900    ///
1901    /// Every gate reads the filter live — the keyboard dispatch, the default
1902    /// context menu, and drag-and-drop — so this takes effect on the next
1903    /// event without rebuilding the widget.
1904    pub fn set_command_filter(&self, filter: policy::CommandFilter) {
1905        self.state.borrow_mut().policy.command_filter = filter;
1906    }
1907
1908    /// The filter currently in force (see
1909    /// [`set_command_filter`](Self::set_command_filter)).
1910    pub fn command_filter(&self) -> policy::CommandFilter {
1911        self.state.borrow().policy.command_filter
1912    }
1913
1914    /// Draw an ambient band behind the sentence — or paragraph — the caret is in.
1915    ///
1916    /// `None` (the default) draws nothing and registers no session on the document. The band
1917    /// shows only while **this** editor has focus, so two panes over one document never band
1918    /// twice, and it disappears when focus leaves the editor entirely.
1919    ///
1920    /// The band is registered below every other highlight layer, so a find match or a spell
1921    /// squiggle always paints over it. Give it a paint-only `format` — a background colour —
1922    /// or it will force a reshape on every caret move.
1923    pub fn set_caret_highlight(&self, highlight: Option<caret_highlight::CaretHighlight>) {
1924        set_caret_highlight(&self.state, highlight);
1925    }
1926
1927    /// What this editor's caret band is currently configured to draw.
1928    pub fn get_caret_highlight(&self) -> Option<caret_highlight::CaretHighlight> {
1929        self.state
1930            .borrow()
1931            .caret_highlight
1932            .as_ref()
1933            .and_then(|s| s.config())
1934    }
1935
1936    /// The caret's rectangle in **absolute window (tree) coordinates**, or
1937    /// `None` when the editor is unfocused or has not been laid out yet.
1938    ///
1939    /// The same rect the OS-IME reporting and the caret follow use, exposed for
1940    /// hosts that need to position something against the caret (and for tests
1941    /// that need to assert where a pin actually put it).
1942    pub fn caret_window_rect(&self) -> Option<teksilo_canvas::Rect> {
1943        self::keyboard::caret_window_rect(&self.state.borrow())
1944    }
1945
1946    // --- Observability: reactive version counters -------------------------
1947
1948    /// Signal that bumps on every format-only document event (bold /
1949    /// italic / heading / alignment / list style changes …).
1950    /// Distinct from [`document_version`](Self::document_version),
1951    /// which also bumps on content changes. Useful for toolbar
1952    /// observers that want to refresh button state on format changes
1953    /// without flickering during plain typing.
1954    pub fn format_version(&self) -> Signal<u64> {
1955        self.state.borrow().format_version.clone()
1956    }
1957
1958    /// Signal that bumps once per document-loaded event (fires when
1959    /// an async `set_html` / `set_markdown` import completes). Starts
1960    /// at 0; observers see a new value each time a long import
1961    /// finishes.
1962    pub fn document_loaded_count(&self) -> Signal<u64> {
1963        self.state.borrow().document_loaded_count.clone()
1964    }
1965
1966    // --- Link / image click callbacks -------------------------------------
1967    //
1968    // Installed via builder methods (below). The widget fires these
1969    // on a Primary PointerDown whose hit lands on a `HitRegion::Link`
1970    // or `HitRegion::Image`, before any caret placement.
1971
1972    /// Install a callback fired when the user Primary-clicks a link
1973    /// (an element with an anchor `href`). The callback receives the
1974    /// href string and the active `EventContext`.
1975    ///
1976    /// The callback replaces any prior link-click callback on this
1977    /// builder chain. To stop observing, reconstruct the editor
1978    /// without the setter.
1979    pub fn on_link_activated(
1980        self,
1981        handler: impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static,
1982    ) -> Self {
1983        self.state.borrow_mut().on_link_activated = Some(std::rc::Rc::new(handler));
1984        self
1985    }
1986
1987    /// Supply an image's bytes on demand, when the document has no resource
1988    /// under that name.
1989    ///
1990    /// An inline image references its pixels by name, and those pixels live on
1991    /// the *document*. So a name that arrives without them — which is exactly
1992    /// what pasting an image into a second editor is, since the interchange
1993    /// format carries the reference and not the bytes — lays out at its full
1994    /// size and paints nothing.
1995    ///
1996    /// Rather than make every host re-scan its document after every edit for
1997    /// names that have appeared, the editor asks for what it is missing, once,
1998    /// at the moment it needs it. The bytes are written onto the document, so
1999    /// the answer is permanent and every later reader (a save, an export, a
2000    /// second view of the same document) sees them too.
2001    ///
2002    /// One hook serves paste, drag-and-drop, and an undo that re-inserts a
2003    /// deleted image, without any of them knowing it exists.
2004    pub fn on_image_missing(
2005        self,
2006        resolve: impl Fn(&str) -> Option<(String, Vec<u8>)> + 'static,
2007    ) -> Self {
2008        self.state.borrow_mut().image_resolver = Some(std::rc::Rc::new(resolve));
2009        self
2010    }
2011
2012    /// Install a callback fired when files are dropped on the editor.
2013    ///
2014    /// The editor places the caret at the drop point and then hands the paths
2015    /// over: what a dropped file *means* — a picture to embed, a link to write,
2016    /// a document to include — is the host's policy, and a text editor that
2017    /// guessed would be wrong for every host but one.
2018    ///
2019    /// Without this, file drops are declined, and the drag bubbles to whatever
2020    /// ancestor claims it.
2021    pub fn on_files_dropped(
2022        self,
2023        handler: impl Fn(&[std::path::PathBuf], &mut teksilo_core::widget::EventContext) + 'static,
2024    ) -> Self {
2025        self.state.borrow_mut().on_files_dropped = Some(std::rc::Rc::new(handler));
2026        self
2027    }
2028
2029    /// Install a callback fired when the reader finishes dragging one of a
2030    /// selected image's corner grips.
2031    ///
2032    /// The widget does not resize the picture itself. It cannot: an image's
2033    /// display size lives in the host's own document format (an attribute, a
2034    /// style, a column of a table), and only the host knows how to write it
2035    /// there so it survives a save. So the drag reports a size and the host
2036    /// decides what that means — the same division of labour as
2037    /// [`on_image_activated`](Self::on_image_activated).
2038    ///
2039    /// Fired once, on release. During the drag the widget shows an outline at
2040    /// the proposed size, which costs no relayout and keeps one gesture to one
2041    /// entry on the host's undo stack.
2042    pub fn on_image_resized(
2043        self,
2044        handler: impl Fn(&ImageResize, &mut teksilo_core::widget::EventContext) + 'static,
2045    ) -> Self {
2046        self.state.borrow_mut().on_image_resized = Some(std::rc::Rc::new(handler));
2047        self
2048    }
2049
2050    /// Install a callback fired when the user Primary-clicks an inline
2051    /// image. The callback receives the activation (see
2052    /// [`ImageActivation`]) and the active `EventContext`.
2053    pub fn on_image_activated(
2054        self,
2055        handler: impl Fn(&ImageActivation, &mut teksilo_core::widget::EventContext) + 'static,
2056    ) -> Self {
2057        self.state.borrow_mut().on_image_activated = Some(std::rc::Rc::new(handler));
2058        self
2059    }
2060}
2061
2062// =============================================================================
2063// EditorHandle — external toolbar / palette handle
2064// =============================================================================
2065
2066/// A clone-able, `'static` handle to a [`RichTextEditor`]'s shared
2067/// state.
2068///
2069/// Use this when a toolbar, palette, command panel, or other external
2070/// widget needs to invoke editor commands from `on_activate_fn` /
2071/// `ctx.effect` closures that outlive the borrow of `&editor`.
2072/// [`RichTextEditor`] itself is move-only (the optional
2073/// `custom_context_menu` factory holds a `Box<dyn Fn>`, which prevents
2074/// `Clone`), so a closure cannot just capture `editor.clone()`.
2075/// Obtain a handle via [`RichTextEditor::handle()`] and clone it into
2076/// each closure that needs to issue commands.
2077///
2078/// `EditorHandle` mirrors the toolbar-relevant subset of the editor's
2079/// public API:
2080///
2081/// * Inline character formatting — [`set_bold`](Self::set_bold) /
2082///   [`toggle_bold`](Self::toggle_bold) / [`is_bold`](Self::is_bold)
2083///   and the italic / underline / strikethrough variants.
2084/// * Block-level formatting — [`set_alignment`](Self::set_alignment),
2085///   [`set_heading_level`](Self::set_heading_level),
2086///   [`apply_block_format`](Self::apply_block_format),
2087///   [`insert_list`](Self::insert_list),
2088///   [`indent`](Self::indent) / [`outdent`](Self::outdent).
2089/// * Tables — [`insert_table`](Self::insert_table) and the per-row /
2090///   per-column / remove operations, plus [`is_in_table`](Self::is_in_table)
2091///   for contextual UI enable state.
2092/// * History — [`undo`](Self::undo) / [`redo`](Self::redo).
2093/// * Clipboard — [`copy`](Self::copy) / [`cut`](Self::cut) /
2094///   [`paste`](Self::paste) /
2095///   [`paste_unformatted`](Self::paste_unformatted), plus
2096///   [`can_paste`](Self::can_paste) for Paste enable-state — so a
2097///   context-menu factory (which can only capture a handle, never the
2098///   editor that owns it) can rebuild Cut / Copy / Paste /
2099///   Paste-Unformatted.
2100/// * Selection — [`select_all`](Self::select_all) /
2101///   [`delete_selection`](Self::delete_selection).
2102/// * Reactive signal accessors —
2103///   [`format_version`](Self::format_version),
2104///   [`cursor_position_signal`](Self::cursor_position_signal),
2105///   [`cursor_anchor_signal`](Self::cursor_anchor_signal),
2106///   [`has_selection`](Self::has_selection),
2107///   [`can_undo`](Self::can_undo) / [`can_redo`](Self::can_redo) — so
2108///   callers that hold only an `EditorHandle` can derive bound signals
2109///   without keeping a separate `RichTextEditor` reference.
2110///
2111/// Cloning is cheap (an `Rc` clone). All clones share the same
2112/// underlying state — mutations through any clone, through other
2113/// clones, or through the originating `RichTextEditor` are all
2114/// immediately observable through the same signals.
2115#[derive(Clone)]
2116pub struct EditorHandle {
2117    state: SharedState,
2118}
2119
2120impl std::fmt::Debug for EditorHandle {
2121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2122        f.debug_struct("EditorHandle").finish_non_exhaustive()
2123    }
2124}
2125
2126/// An inline image the user clicked.
2127///
2128/// Carries the offset as well as the name because a document may hold the same
2129/// picture more than once — a name alone cannot say *which* one was clicked, so
2130/// a host acting on the click (selecting it, editing its size, replacing it)
2131/// would be guessing. The offset addresses the image's single `U+FFFC`, so
2132/// `select_range(offset, offset + 1)` selects exactly it.
2133#[derive(Debug, Clone, PartialEq, Eq)]
2134pub struct ImageActivation {
2135    /// The image's resource name — the `src` the document stores.
2136    pub name: String,
2137    /// Character offset of the image within the document.
2138    pub offset: usize,
2139}
2140
2141/// Rich text being dragged out of an editor.
2142///
2143/// The typed fast path for editor-to-editor drags: it carries the
2144/// `DocumentFragment` itself, so formatting, tables and inline images survive a
2145/// move the way they survive a copy/paste — where the `text/plain` MIME
2146/// alternative the drag also advertises (for other applications) could only
2147/// carry the words.
2148///
2149/// `source` and `range` are what let the drop tell a *move* from a *copy*:
2150/// dropped back into the editor it came from, the original has to be removed,
2151/// and only the source editor can say which range that was.
2152#[derive(Debug, Clone)]
2153pub struct EditorTextDrag {
2154    /// The editor the text was picked up from.
2155    pub source: teksilo_core::WidgetId,
2156    /// The dragged range in that editor, as document offsets.
2157    pub range: (usize, usize),
2158    /// The dragged content, with its formatting.
2159    pub fragment: teksilo_text::text_document::DocumentFragment,
2160    /// The same content as plain text — the drop's fallback, and the bytes
2161    /// handed to another application when the drag leaves the window.
2162    pub text: String,
2163}
2164
2165/// Whether this payload is one the editor can take.
2166///
2167/// Text, files, and an [`EditorTextDrag`] from any editor. Any other typed
2168/// payload belongs to whichever widget understands that type — a binder row
2169/// dropped on the prose should still open a document, not paste its debug
2170/// representation.
2171///
2172/// **Optimistic while the drag is still in the air.** On Wayland the concrete
2173/// `files` / `text` arrive only at drop; during hover the payload carries just
2174/// the *advertised* formats. Judging by content alone therefore refuses every
2175/// external drag for its whole flight — the drop is forbidden everywhere right
2176/// up to the release that would have filled it in. So an advertised
2177/// `text/uri-list` or text format counts as acceptance, and the real check
2178/// happens at drop, where there is finally something to check. This is the same
2179/// rule `DropTarget::accept_external_files` / `accept_external_text` apply.
2180fn droppable(payload: &teksilo_core::DragPayload) -> bool {
2181    if payload.get_typed::<EditorTextDrag>().is_some() {
2182        return true;
2183    }
2184    if !payload.files().is_empty() || payload.text().is_some_and(|t| !t.is_empty()) {
2185        return true;
2186    }
2187    payload.formats().iter().any(|f| {
2188        f.starts_with("text/uri-list")
2189            || f.starts_with("text/plain")
2190            || matches!(f.as_str(), "UTF8_STRING" | "STRING" | "TEXT")
2191    })
2192}
2193
2194/// A resize the reader finished dragging.
2195///
2196/// Reported once, on release, rather than continuously: the document is the
2197/// durable record and rewriting it on every pointer move would put a hundred
2198/// entries on the undo stack for one gesture.
2199#[derive(Debug, Clone, PartialEq, Eq)]
2200pub struct ImageResize {
2201    /// The image's resource name.
2202    pub name: String,
2203    /// Character offset of its `U+FFFC` — the identity, since a document may
2204    /// hold one picture in several places.
2205    pub offset: usize,
2206    /// The new display size in logical pixels, proportions preserved.
2207    pub width: u32,
2208    pub height: u32,
2209}
2210
2211impl EditorHandle {
2212    // --- Search / find-banner support (B3, handle mirror) ------------------
2213    //
2214    // These mirror the same-named [`RichTextEditor`] methods (which operate on
2215    // the same `state`), so a per-editor find banner built *above* the editor
2216    // can drive selection / scroll-into-view on the current match through the
2217    // handle it captured — the widget itself is long gone into the tree by then.
2218
2219    /// This editor's content as Djot.
2220    ///
2221    /// The counterpart to [`insert_djot`](Self::insert_djot): a toolbar or command that can
2222    /// write into an editor it did not build should be able to read it back the same way.
2223    /// Without this the only route to the text is the host's own document bookkeeping,
2224    /// which knows about the editors it *mounted* and not about the ones a list or a card
2225    /// grid created — so a command ends up working on some surfaces and silently doing
2226    /// nothing on others.
2227    ///
2228    /// Empty string on a serialisation error, matching `TextDocument::to_djot`'s own
2229    /// callers: a command reading an editor has no better answer than "nothing there", and
2230    /// propagating a `Result` here would push that decision onto every call site.
2231    pub fn to_djot(&self) -> String {
2232        self.state.borrow().document.to_djot().unwrap_or_default()
2233    }
2234
2235    /// This editor's content as the *addressable* plain text — the view whose
2236    /// character offsets are the document's own.
2237    ///
2238    /// The counterpart to [`to_djot`](Self::to_djot) for a caller that has an
2239    /// offset (a caret, a selection, a click) and needs to know what is there.
2240    /// An inline image appears as its `U+FFFC`, so offsets into this string are
2241    /// offsets into the document, character for character — which the `.txt`
2242    /// export's view deliberately is not.
2243    ///
2244    /// Empty string on error, for the same reason `to_djot` returns one.
2245    pub fn to_plain_text(&self) -> String {
2246        self.state
2247            .borrow()
2248            .document
2249            .to_plain_text()
2250            .unwrap_or_default()
2251    }
2252
2253    /// Whether this editor holds no text at all.
2254    ///
2255    /// `character_count() == 0`, so a document of one empty paragraph is empty but one
2256    /// holding only spaces is not — the distinction a caller usually wants is
2257    /// `to_djot().trim().is_empty()`, and this is the cheap O(1) pre-check.
2258    pub fn is_empty(&self) -> bool {
2259        self.state.borrow().document.is_empty()
2260    }
2261
2262    /// Reactive signal — `true` while **this** editor holds keyboard focus.
2263    /// See [`RichTextEditor::focused_signal`].
2264    pub fn focused_signal(&self) -> Signal<bool> {
2265        self.state.borrow().focus_signal.clone()
2266    }
2267
2268    /// Select the character range `[start, end)` without collapsing (anchor at
2269    /// `start`, caret at `end`). See [`RichTextEditor::select_range`].
2270    pub fn select_range(&self, start: usize, end: usize) {
2271        {
2272            let mut st = self.state.borrow_mut();
2273            st.cursor.set_position(start, MoveMode::MoveAnchor);
2274            st.cursor.set_position(end, MoveMode::KeepAnchor);
2275            st.cursor_affinity = teksilo_text::CursorAffinity::Downstream;
2276        }
2277        sync_cursor_signals(&self.state);
2278    }
2279
2280    /// Replace the character range `[start, end)` with `text`, leaving the caret
2281    /// after the inserted text.
2282    ///
2283    /// The counterpart to [`select_range`](Self::select_range) for callers that
2284    /// must *rewrite* a span rather than merely reveal it — a spell-check
2285    /// correction picked from a context menu, an autocorrect, a
2286    /// replace-this-occurrence action. It goes through the widget's **internal**
2287    /// cursor, so the edit behaves exactly like typed text: it lands on the
2288    /// editor's undo stack as one entry (the replacement is a single
2289    /// insert-over-selection), fires the document's change notifications, and
2290    /// leaves the caret where the user would expect it.
2291    ///
2292    /// Offsets are **character** positions, the same space
2293    /// [`cursor_position`](Self::cursor_position) and `select_range` use. The
2294    /// inserted text inherits the character format at `start`, so correcting a
2295    /// word inside italic prose stays italic.
2296    ///
2297    /// Reaching through [`TextDocument::cursor`](teksilo_text::text_document::TextDocument::cursor)
2298    /// instead would mutate the document behind the widget's back, leaving the
2299    /// caret decoupled from the edit — use this.
2300    pub fn replace_range(&self, start: usize, end: usize, text: &str) {
2301        self.replace_range_from(start, end, text, EditSource::Programmatic);
2302    }
2303
2304    /// As [`replace_range`](Self::replace_range), saying which channel the text
2305    /// came through for [`on_text_inserted`](RichTextEditor::on_text_inserted).
2306    ///
2307    /// `replace_range` itself reports [`EditSource::Programmatic`], which is
2308    /// what a handle-driven edit is by default: a toolbar, a menu command, a
2309    /// substitution the application made. **An application that knows better
2310    /// should say so here rather than let the default stand.** The distinction
2311    /// that matters most is an edit which merely puts back what the person
2312    /// typed — undoing an autocorrect, say. Those characters were typed, they
2313    /// are being typed again, and reporting them as the application's own work
2314    /// would credit the application with the writer's words.
2315    ///
2316    /// One call rather than an insert plus a separate report, so the two cannot
2317    /// drift apart at a call site that later grows a second early return.
2318    pub fn replace_range_from(&self, start: usize, end: usize, text: &str, source: EditSource) {
2319        // Select, then insert over the selection — each step in its own borrow
2320        // scope, mirroring `select_range` / `RichTextEditor::insert_text`. The
2321        // insert must not run while a `borrow_mut` is held: it notifies document
2322        // observers, which are free to read the state back.
2323        self.select_range(start, end);
2324        {
2325            let st = self.state.borrow();
2326            let _ = st.cursor.insert_text(text);
2327            st.report_inserted(source, text);
2328        }
2329        sync_cursor_signals(&self.state);
2330    }
2331
2332    /// Insert plain text at the caret, replacing any selection. The
2333    /// [`EditorHandle`] counterpart of
2334    /// [`RichTextEditor::insert_text`](RichTextEditor::insert_text), for callers
2335    /// that hold only a handle — a toolbar button or a global menu command.
2336    pub fn insert_text(&self, text: &str) {
2337        {
2338            let st = self.state.borrow();
2339            let _ = st.cursor.insert_text(text);
2340        }
2341        sync_cursor_signals(&self.state);
2342    }
2343
2344    /// Register an image's bytes on this editor's document, under `name`.
2345    ///
2346    /// An inline image stores only a name; the paint pass resolves it to pixels
2347    /// through the document's resource table. So an image inserted without this
2348    /// lays out and stays blank — and the name is also what a *reload* resolves
2349    /// against, which is why a host restoring a document has to register its
2350    /// images before the first paint rather than at insertion time only.
2351    ///
2352    /// On the handle rather than only on the widget because commands operate on
2353    /// whichever editor has focus, including ones a list or card grid built that
2354    /// the host never mounted itself.
2355    pub fn add_image_resource(&self, name: &str, mime_type: &str, bytes: &[u8]) -> bool {
2356        let st = self.state.borrow();
2357        st.document
2358            .add_resource(ResourceType::Image, name, mime_type, bytes)
2359            .is_ok()
2360    }
2361
2362    /// The natural pixel size of a registered image, decoded from its bytes.
2363    ///
2364    /// What the file actually is, not what the document asks it to be shown at
2365    /// — so a host offering "reset to the original size" restores the picture's
2366    /// own dimensions rather than a number remembered from when it was inserted,
2367    /// which is wrong the moment the file behind the name is replaced.
2368    ///
2369    /// Decodes on call. That is deliberate: this answers an explicit, rare
2370    /// request, and caching it would mean holding a second copy of every image
2371    /// in the document for a question almost nobody asks.
2372    pub fn image_resource_size(&self, name: &str) -> Option<(u32, u32)> {
2373        let bytes = self.state.borrow().document.resource(name).ok()??;
2374        let icon = teksilo_canvas::RasterIcon::decode(&bytes).ok()?;
2375        Some((icon.width(), icon.height()))
2376    }
2377
2378    /// Whether this editor's document already has an image under `name`.
2379    ///
2380    /// Registering the same name twice appends a second resource row, so a host
2381    /// re-registering on every paint would grow the document without bound.
2382    pub fn has_image_resource(&self, name: &str) -> bool {
2383        let st = self.state.borrow();
2384        st.document.resource(name).ok().flatten().is_some()
2385    }
2386
2387    /// Insert a fragment parsed from djot at the caret, replacing any selection.
2388    ///
2389    /// Unlike [`insert_text`](Self::insert_text), which drops its bytes into the
2390    /// current block verbatim (a `\n` becomes literal content, not a new
2391    /// paragraph), this parses block-level djot into a `DocumentFragment`, so
2392    /// inserting a standalone paragraph really does create one.
2393    pub fn insert_djot(&self, djot: &str) {
2394        {
2395            let st = self.state.borrow();
2396            let _ = st.cursor.insert_djot(djot);
2397        }
2398        sync_cursor_signals(&self.state);
2399    }
2400
2401    /// Split the current block at the caret, as pressing Enter does.
2402    pub fn insert_block(&self) {
2403        {
2404            let st = self.state.borrow();
2405            let _ = st.cursor.insert_block();
2406        }
2407        sync_cursor_signals(&self.state);
2408    }
2409
2410    /// Insert `text` as a **paragraph of its own** at the caret: split here, fill
2411    /// the new block, split again, so whatever followed the caret continues in a
2412    /// third block.
2413    ///
2414    /// Deliberately one call rather than three. Composing
2415    /// `insert_block` + `insert_text` + `insert_block` from outside re-enters the
2416    /// widget three times, and an application that rebuilds its editor in
2417    /// response to the first change notification is left driving a handle that
2418    /// no longer points at the mounted widget — the split lands and the text
2419    /// silently does not. Doing the whole edit under a single borrow, with one
2420    /// signal sync at the end, makes it atomic from the caller's side.
2421    /// Returns `false` if any step failed, leaving the document as far as it
2422    /// got. Steps are **not** attempted after a failure: filling and re-splitting
2423    /// on top of a split that did not happen produces a mangled paragraph rather
2424    /// than a partial one, and the caller has no way to tell.
2425    pub fn insert_paragraph(&self, text: &str) -> bool {
2426        let ok = {
2427            let st = self.state.borrow();
2428            st.cursor.insert_block().is_ok()
2429                && st.cursor.insert_text(text).is_ok()
2430                && st.cursor.insert_block().is_ok()
2431        };
2432        sync_cursor_signals(&self.state);
2433        ok
2434    }
2435
2436    /// The live selection as `(anchor, position)`, unordered — `anchor` is where the
2437    /// selection started, `position` is where the caret is, so a backwards drag
2438    /// reports `anchor > position`. Equal values mean no selection.
2439    ///
2440    /// Both ends are read under a **single** borrow, so the pair cannot tear. That is
2441    /// the reason to prefer this over pairing [`cursor_position`](Self::cursor_position)
2442    /// with [`cursor_anchor_signal`](Self::cursor_anchor_signal): the former is a live
2443    /// read of the cursor while the latter is a mirror refreshed on sync, so combining
2444    /// them mixes two different moments in time and can invent — or miss — a selection
2445    /// if the mirror lags. A caller deciding *"is there a selection, and over what"*
2446    /// wants one consistent answer.
2447    pub fn selection(&self) -> (usize, usize) {
2448        let st = self.state.borrow();
2449        (st.cursor.anchor(), st.cursor.position())
2450    }
2451
2452    /// The selected text, or an empty string when nothing is selected.
2453    ///
2454    /// O(selection), not O(document). Pairs with [`selection`](Self::selection)
2455    /// for a caller that needs the range *and* what is in it — a link dialog
2456    /// pre-filling its display name from what the writer highlighted, say.
2457    pub fn selected_text(&self) -> String {
2458        self.state
2459            .borrow()
2460            .cursor
2461            .selected_text()
2462            .unwrap_or_default()
2463    }
2464
2465    /// The **window-space** rectangle enclosing the character range `[start, end)`.
2466    ///
2467    /// The inverse of [`offset_at_point`](Self::offset_at_point): that maps a point
2468    /// to an offset, this maps offsets back to a point. It is what a decoration
2469    /// drawn *outside* the editor — a margin annotation, a connector leader, a
2470    /// bracket spanning a paragraph — needs in order to line itself up with the
2471    /// text it refers to.
2472    ///
2473    /// Coordinates match what the arena stores (`viewport_origin` + engine-local −
2474    /// scroll), so the result can be compared with any other widget's bounds
2475    /// directly, and it tracks scrolling for free.
2476    ///
2477    /// `None` before the first full layout. Focus is **not** required — a margin
2478    /// annotation must stay aligned whether or not the writer is typing.
2479    pub fn range_rect(&self, start: usize, end: usize) -> Option<Rect> {
2480        let st = self.state.borrow();
2481        keyboard::range_window_rect(&st, start, end)
2482    }
2483
2484    /// The **window-space** caret rectangle at one offset — a zero-width
2485    /// [`range_rect`](Self::range_rect), and the anchor point for a marker drawn at
2486    /// one end of a span (the triangle at a comment's tail).
2487    pub fn offset_rect(&self, offset: usize) -> Option<Rect> {
2488        self.range_rect(offset, offset)
2489    }
2490
2491    /// The **content-space** rectangle enclosing `[start, end)` — y = 0 at the top
2492    /// of the laid-out text, unaffected by scrolling and by where the editor sits
2493    /// in the window.
2494    ///
2495    /// The scroll-free counterpart to [`range_rect`](Self::range_rect), and the one
2496    /// to reach for when the question is *what proportion of the document is this*
2497    /// rather than *where is this on screen*. Divided by
2498    /// [`content_height`](Self::content_height) it gives a fraction an overview
2499    /// strip can draw against, for offsets the writer has long scrolled past —
2500    /// which window space cannot express at all, since it reports those relative to
2501    /// a viewport they are nowhere near.
2502    ///
2503    /// `None` before the first full layout. Focus is not required.
2504    pub fn range_content_rect(&self, start: usize, end: usize) -> Option<Rect> {
2505        let st = self.state.borrow();
2506        keyboard::range_content_rect(&st, start, end)
2507    }
2508
2509    /// The **content-space** caret rectangle at one offset — a zero-width
2510    /// [`range_content_rect`](Self::range_content_rect).
2511    pub fn offset_content_rect(&self, offset: usize) -> Option<Rect> {
2512        self.range_content_rect(offset, offset)
2513    }
2514
2515    /// Reactive counter that bumps on every document change — the handle mirror of
2516    /// [`RichTextEditor::document_version`].
2517    ///
2518    /// The change token a decoration drawn *outside* the editor binds, so it
2519    /// re-derives when the text moves under it. Without it such a widget has only
2520    /// the scroll metrics to go on, and those move on a reflow but not on an edit
2521    /// that leaves the height alone — which is most edits, and exactly the ones that
2522    /// shift the offsets a mark is anchored to.
2523    pub fn document_version(&self) -> Signal<u64> {
2524        self.state.borrow().document_version.clone()
2525    }
2526
2527    /// Height of the laid-out text, in the same space
2528    /// [`range_content_rect`](Self::range_content_rect) reports.
2529    ///
2530    /// The denominator that turns a content rect into a fraction of the document.
2531    /// `None` before the first full layout — the same gate the rect queries use, so
2532    /// a caller that has one has the other and the division is never against a
2533    /// stale height.
2534    ///
2535    /// This is the *text's* height, not the widget's: an editor laid out taller
2536    /// than its content (a short scene in a tall pane) reports the text.
2537    pub fn content_height(&self) -> Option<f32> {
2538        let st = self.state.borrow();
2539        st.engine
2540            .has_full_layout()
2541            .then(|| st.engine.content_height())
2542    }
2543
2544    /// Hit-test a point — **in window coordinates**, as a
2545    /// [`context_menu`](RichTextEditor::context_menu) factory receives it — to a
2546    /// document character offset. `None` when the point resolves to no text
2547    /// (past the last glyph on an empty line, outside the body, etc.).
2548    ///
2549    /// Lets a custom context-menu factory resolve "the word under the pointer"
2550    /// from the right-click position, since a bare right-click does not move the
2551    /// caret on its own.
2552    pub fn offset_at_point(&self, window_point: Point) -> Option<usize> {
2553        mouse::offset_at_window_point(&self.state, window_point)
2554    }
2555
2556    /// Reposition the caret to a right-click point (**window coordinates**)
2557    /// unless the click lands inside the current selection (then the selection
2558    /// is preserved). Call this at the top of a custom
2559    /// [`context_menu`](RichTextEditor::context_menu) factory so the menu's Paste
2560    /// — and any caret-relative action — operates where the user clicked, exactly
2561    /// as the built-in menu and the single-line field do.
2562    pub fn reposition_caret_for_context_menu(&self, window_point: Point) {
2563        mouse::reposition_caret_for_context_menu(&self.state, window_point);
2564    }
2565
2566    /// Scroll the character range `[start, end)` into view, reporting whether this editor
2567    /// could — it has a layout to locate the range in, and is on screen rather than parked
2568    /// dormant. See [`RichTextEditor::reveal_range`].
2569    ///
2570    /// When it answers `false` because there is no layout yet, the coarser
2571    /// [`reveal_widget`](Self::reveal_widget) is the way to get one.
2572    pub fn reveal_range(
2573        &self,
2574        ctx: &mut teksilo_core::widget::EventContext,
2575        start: usize,
2576        end: usize,
2577    ) -> bool {
2578        reveal_range_impl(&self.state, ctx, start, end)
2579    }
2580
2581    /// Scroll **the editor itself** into view — the coarse fallback for the one case
2582    /// [`reveal_range`](Self::reveal_range) cannot serve at all. Reports whether this
2583    /// editor could: it has been built, so the arena knows a widget to scroll to, and
2584    /// it is on screen rather than parked dormant.
2585    ///
2586    /// A row of a stream that has never been painted has no full layout, so there is
2587    /// no rect to locate an offset in and `reveal_range` answers `false` — for ever,
2588    /// because the row only gets a layout when it is painted and it is only painted
2589    /// when it comes on screen. That is a deadlock a range reveal has no way out of:
2590    /// a match found in row 31 of a Book leaves the page exactly where it was, with
2591    /// the counter cheerfully reading `1 of 40`.
2592    ///
2593    /// Revealing by *widget* breaks it, because the arena knows where row 31 is laid
2594    /// out whether or not its text has been shaped. The row comes on screen, the next
2595    /// paint gives it a layout, and a later `reveal_range` can then put the match
2596    /// itself where the caller wants it. Coarser on purpose: this reveals the row,
2597    /// not the offset inside it.
2598    pub fn reveal_widget(&self, ctx: &mut teksilo_core::widget::EventContext) -> bool {
2599        let id = {
2600            let st = self.state.borrow();
2601            // The same dormancy gate `reveal_range` applies, and for the same reason:
2602            // a parked editor's bounds are still in the arena, so the walk would
2603            // happily scroll a container nobody can see and answer `true` — and a
2604            // caller told `true` stops looking for the editor that is on screen.
2605            if st.activation.as_ref().is_some_and(|a| !a.get()) {
2606                return false;
2607            }
2608            // `None` only before the editor's first build: nothing is mounted, so
2609            // there is no widget for the arena to resolve bounds for.
2610            match st.self_id {
2611                Some(id) => id,
2612                None => return false,
2613            }
2614        };
2615        ctx.ensure_widget_visible(id);
2616        true
2617    }
2618
2619    /// Move keyboard focus onto the editor. Lets a control built *above* the
2620    /// editor — a find banner returning focus to the prose on Escape — put the
2621    /// caret back where the user expects. A no-op until the editor has built at
2622    /// least once (its wrapper id is stashed then).
2623    pub fn focus(&self, ctx: &mut teksilo_core::widget::EventContext) {
2624        if let Some(id) = self.state.borrow().self_id {
2625            ctx.request_focus(id);
2626        }
2627    }
2628
2629    // --- Character-format query / apply ------------------------------------
2630
2631    /// Read the current character format at the caret. When a selection
2632    /// is active, reads from `selection_start()` rather than
2633    /// `position()` so toolbar bistate stays stable across selection
2634    /// extension (same rule as
2635    /// [`RichTextEditor::caret_char_format`]).
2636    pub fn caret_char_format(&self) -> TextFormat {
2637        let st = self.state.borrow();
2638        let probe_pos = if st.cursor.has_selection() {
2639            st.cursor.selection_start()
2640        } else {
2641            st.cursor.position()
2642        };
2643        let probe = st.document.cursor();
2644        probe.set_position(probe_pos, MoveMode::MoveAnchor);
2645        probe.char_format().unwrap_or_default()
2646    }
2647
2648    fn apply_char_format(&self, fmt: TextFormat) {
2649        let st = self.state.borrow();
2650        let _ = st.cursor.merge_char_format(&fmt);
2651    }
2652
2653    /// Apply **bold** to the current selection.
2654    pub fn set_bold(&self, enabled: bool) {
2655        self.apply_char_format(TextFormat {
2656            font_bold: Some(enabled),
2657            ..Default::default()
2658        });
2659    }
2660
2661    /// Apply *italic* to the current selection.
2662    pub fn set_italic(&self, enabled: bool) {
2663        self.apply_char_format(TextFormat {
2664            font_italic: Some(enabled),
2665            ..Default::default()
2666        });
2667    }
2668
2669    /// Apply underline to the current selection.
2670    pub fn set_underline(&self, enabled: bool) {
2671        self.apply_char_format(TextFormat {
2672            font_underline: Some(enabled),
2673            ..Default::default()
2674        });
2675    }
2676
2677    /// Apply strikethrough to the current selection.
2678    pub fn set_strikethrough(&self, enabled: bool) {
2679        self.apply_char_format(TextFormat {
2680            font_strikeout: Some(enabled),
2681            ..Default::default()
2682        });
2683    }
2684
2685    /// Set the font family for the current selection (a character-format
2686    /// change applied over the selected range). Like the other char-format
2687    /// setters (`set_bold`, …), this is a **no-op when there is no
2688    /// selection** — the document model has no typing/pending format, so a
2689    /// bare caret has no range to format. `family` must be a name resolvable
2690    /// by the shared typesetter's font registrar — e.g. a value chosen from
2691    /// a [`FontPicker`](crate::font_picker::FontPicker).
2692    pub fn set_font_family(&self, family: impl Into<String>) {
2693        self.apply_char_format(TextFormat {
2694            font_family: Some(family.into()),
2695            ..Default::default()
2696        });
2697    }
2698
2699    /// Set the font size (in points) for the current selection.
2700    pub fn set_font_size(&self, size: u32) {
2701        self.apply_char_format(TextFormat {
2702            font_point_size: Some(size),
2703            ..Default::default()
2704        });
2705    }
2706
2707    // --- Default typography / font size (non-destructive, whole editor) ---
2708
2709    /// Set the non-destructive default typography (font family / line height /
2710    /// first-line indent) filled onto runs and blocks with no explicit
2711    /// override. Unlike [`set_font_family`](Self::set_font_family) /
2712    /// [`set_font_size`](Self::set_font_size) — which mutate the selected text —
2713    /// this is a display-time default: it never touches the document, undo
2714    /// stack, or `modified` flag. Schedules a relayout + repaint.
2715    pub fn set_typography_defaults(&self, defaults: EditorTypographyDefaults) {
2716        let mut st = self.state.borrow_mut();
2717        st.engine.set_typography_defaults(defaults);
2718        st.needs_full_layout = true;
2719        st.content_dirty = true;
2720        if let Some(handle) = &st.frame_request {
2721            handle.set(true);
2722        }
2723    }
2724
2725    /// Current default typography.
2726    pub fn get_typography_defaults(&self) -> EditorTypographyDefaults {
2727        self.state.borrow().engine.typography_defaults().clone()
2728    }
2729
2730    /// Set the per-editor logical font-size multiplier. See
2731    /// [`RichTextEditor::set_font_size_scale`].
2732    pub fn set_font_size_scale(&self, scale: f32) {
2733        let mut st = self.state.borrow_mut();
2734        let scale = scale.clamp(0.1, 10.0);
2735        if (st.font_size_scale - scale).abs() <= f32::EPSILON {
2736            return;
2737        }
2738        st.font_size_scale = scale;
2739        st.last_font_scale = f32::NAN;
2740        st.needs_full_layout = true;
2741        st.content_dirty = true;
2742        if let Some(handle) = &st.frame_request {
2743            handle.set(true);
2744        }
2745    }
2746
2747    /// Current per-editor font-size scale (`1.0` = 100 %).
2748    pub fn get_font_size_scale(&self) -> f32 {
2749        self.state.borrow().font_size_scale
2750    }
2751
2752    /// Set the typewriter-scrolling anchor — the [`EditorHandle`] counterpart of
2753    /// [`RichTextEditor::set_typewriter`]. `None` turns pinning off.
2754    ///
2755    /// This is the door a host uses to keep the pin following a live setting,
2756    /// the same way [`set_typography_defaults`](Self::set_typography_defaults)
2757    /// keeps typography following one.
2758    pub fn set_typewriter(&self, anchor: Option<f32>) {
2759        let mut st = self.state.borrow_mut();
2760        st.typewriter = anchor.map(|f| f.clamp(0.0, 1.0));
2761        st.last_chase_y = None;
2762    }
2763
2764    /// Current typewriter anchor.
2765    pub fn get_typewriter(&self) -> Option<f32> {
2766        self.state.borrow().typewriter
2767    }
2768
2769    /// Narrow (or restore) what the keyboard may do — the [`EditorHandle`]
2770    /// counterpart of [`RichTextEditor::set_command_filter`], for hosts that
2771    /// drive a drafting mode from a settings or session effect after the editor
2772    /// is mounted.
2773    pub fn set_command_filter(&self, filter: policy::CommandFilter) {
2774        self.state.borrow_mut().policy.command_filter = filter;
2775    }
2776
2777    /// The filter currently in force on this editor.
2778    pub fn command_filter(&self) -> policy::CommandFilter {
2779        self.state.borrow().policy.command_filter
2780    }
2781
2782    /// Draw an ambient band behind the caret's sentence or paragraph — the [`EditorHandle`]
2783    /// counterpart of [`RichTextEditor::set_caret_highlight`], for hosts that re-push it from a
2784    /// settings or theme effect after the editor is mounted.
2785    pub fn set_caret_highlight(&self, highlight: Option<caret_highlight::CaretHighlight>) {
2786        set_caret_highlight(&self.state, highlight);
2787    }
2788
2789    /// What this editor's caret band is currently configured to draw.
2790    pub fn get_caret_highlight(&self) -> Option<caret_highlight::CaretHighlight> {
2791        self.state
2792            .borrow()
2793            .caret_highlight
2794            .as_ref()
2795            .and_then(|s| s.config())
2796    }
2797
2798    /// The caret's rectangle in **absolute window (tree) coordinates** — the
2799    /// [`EditorHandle`] counterpart of [`RichTextEditor::caret_window_rect`].
2800    /// `None` when unfocused or not yet laid out.
2801    pub fn caret_window_rect(&self) -> Option<teksilo_canvas::Rect> {
2802        self::keyboard::caret_window_rect(&self.state.borrow())
2803    }
2804
2805    /// Apply an arbitrary [`TextFormat`] (escape hatch for fields not
2806    /// covered by the dedicated setters: `letter_spacing`,
2807    /// `foreground_color`, …).
2808    pub fn apply_text_format(&self, fmt: TextFormat) {
2809        self.apply_char_format(fmt);
2810    }
2811
2812    /// Toggle bold on the current selection.
2813    pub fn toggle_bold(&self) {
2814        let current = self.caret_char_format().font_bold.unwrap_or(false);
2815        self.set_bold(!current);
2816    }
2817
2818    /// Toggle italic on the current selection.
2819    pub fn toggle_italic(&self) {
2820        let current = self.caret_char_format().font_italic.unwrap_or(false);
2821        self.set_italic(!current);
2822    }
2823
2824    /// Toggle underline on the current selection.
2825    pub fn toggle_underline(&self) {
2826        let current = self.caret_char_format().font_underline.unwrap_or(false);
2827        self.set_underline(!current);
2828    }
2829
2830    /// Toggle strikethrough on the current selection.
2831    pub fn toggle_strikethrough(&self) {
2832        let current = self.caret_char_format().font_strikeout.unwrap_or(false);
2833        self.set_strikethrough(!current);
2834    }
2835
2836    /// Whether the selection / typing position is bold.
2837    pub fn is_bold(&self) -> bool {
2838        self.caret_char_format().font_bold.unwrap_or(false)
2839    }
2840
2841    /// Whether italic.
2842    pub fn is_italic(&self) -> bool {
2843        self.caret_char_format().font_italic.unwrap_or(false)
2844    }
2845
2846    // ── Hyperlinks ───────────────────────────────────────────────
2847    //
2848    // A link is a character format, not an object: applying one merges a
2849    // destination onto a range, so any bold or italic already there survives
2850    // and no markup has to be escaped. What it does not get for free is
2851    // removal — every field of a merge means "leave this alone" when unset —
2852    // hence `clear_link` rather than "set the destination to nothing".
2853
2854    /// Point the selection at `href`.
2855    ///
2856    /// Merges, so formatting already on the range is kept. A collapsed
2857    /// selection formats nothing (as everywhere else), so a caller linking
2858    /// existing text should select it first — see
2859    /// [`link_at_caret`](Self::link_at_caret) for the range of a link already
2860    /// there.
2861    pub fn set_link(&self, href: &str) {
2862        self.apply_char_format(TextFormat {
2863            anchor_href: Some(href.to_string()),
2864            ..Default::default()
2865        });
2866    }
2867
2868    /// Take the link off the selection, leaving its text.
2869    pub fn clear_link(&self) {
2870        self.apply_char_format(TextFormat {
2871            clear_link: true,
2872            ..Default::default()
2873        });
2874    }
2875
2876    /// The link the caret is in, and how far it reaches.
2877    ///
2878    /// Coalesced across the runs an inner mark splits a link into, so the
2879    /// range covers the whole link rather than the piece under the caret.
2880    /// `None` when the caret is not on a link.
2881    pub fn link_at_caret(&self) -> Option<LinkExtent> {
2882        self.state.borrow().cursor.link_at_caret()
2883    }
2884
2885    /// Whether the caret / selection sits on a link.
2886    pub fn is_link(&self) -> bool {
2887        self.caret_char_format().is_anchor.unwrap_or(false)
2888    }
2889
2890    /// Whether underline.
2891    pub fn is_underline(&self) -> bool {
2892        self.caret_char_format().font_underline.unwrap_or(false)
2893    }
2894
2895    /// Whether strikethrough.
2896    pub fn is_strikethrough(&self) -> bool {
2897        self.caret_char_format().font_strikeout.unwrap_or(false)
2898    }
2899
2900    // --- Vertical alignment (super / subscript) ----------------------------
2901    //
2902    // See [`RichTextEditor::set_superscript`]: one tri-state property shown as
2903    // two toggles, because a run cannot be both raised and lowered.
2904
2905    /// Raise the selection to superscript, or return it to the baseline.
2906    pub fn set_superscript(&self, enabled: bool) {
2907        self.set_vertical_alignment(if enabled {
2908            CharVerticalAlignment::SuperScript
2909        } else {
2910            CharVerticalAlignment::Normal
2911        });
2912    }
2913
2914    /// Lower the selection to subscript, or return it to the baseline.
2915    pub fn set_subscript(&self, enabled: bool) {
2916        self.set_vertical_alignment(if enabled {
2917            CharVerticalAlignment::SubScript
2918        } else {
2919            CharVerticalAlignment::Normal
2920        });
2921    }
2922
2923    /// Set the selection's vertical alignment directly.
2924    pub fn set_vertical_alignment(&self, alignment: CharVerticalAlignment) {
2925        self.apply_char_format(TextFormat {
2926            vertical_alignment: Some(alignment),
2927            ..Default::default()
2928        });
2929    }
2930
2931    /// The caret's vertical alignment, `Normal` when unset.
2932    pub fn get_vertical_alignment(&self) -> CharVerticalAlignment {
2933        self.caret_char_format()
2934            .vertical_alignment
2935            .unwrap_or(CharVerticalAlignment::Normal)
2936    }
2937
2938    /// True while the caret sits in superscript text.
2939    pub fn is_superscript(&self) -> bool {
2940        self.get_vertical_alignment() == CharVerticalAlignment::SuperScript
2941    }
2942
2943    /// True while the caret sits in subscript text.
2944    pub fn is_subscript(&self) -> bool {
2945        self.get_vertical_alignment() == CharVerticalAlignment::SubScript
2946    }
2947
2948    /// Flip superscript on the selection. Turning it on replaces subscript.
2949    pub fn toggle_superscript(&self) {
2950        self.set_superscript(!self.is_superscript());
2951    }
2952
2953    /// Flip subscript on the selection. Turning it on replaces superscript.
2954    pub fn toggle_subscript(&self) {
2955        self.set_subscript(!self.is_subscript());
2956    }
2957
2958    // --- Block-format query / apply ----------------------------------------
2959
2960    /// Apply an arbitrary [`BlockFormat`] to the caret's block.
2961    pub fn apply_block_format(&self, fmt: BlockFormat) {
2962        let st = self.state.borrow();
2963        let _ = st.cursor.set_block_format(&fmt);
2964    }
2965
2966    /// Set paragraph alignment for the caret's block.
2967    pub fn set_alignment(&self, alignment: Alignment) {
2968        self.apply_block_format(BlockFormat {
2969            alignment: Some(alignment),
2970            ..Default::default()
2971        });
2972    }
2973
2974    /// Unset the block's direction, handing the paragraph back to
2975    /// automatic detection.
2976    ///
2977    /// Not the same as setting left-to-right. An explicit direction
2978    /// *pins* the paragraph and overrides the bidi algorithm, so
2979    /// "clearing" a direction by writing `LeftToRight` would force
2980    /// Arabic and Hebrew prose to lay out backwards. Only an unset
2981    /// direction lets the text speak for itself.
2982    pub fn clear_direction(&self) {
2983        self.apply_block_format(BlockFormat {
2984            clear_direction: true,
2985            ..Default::default()
2986        });
2987    }
2988
2989    /// Set the base reading direction of the caret's block. See
2990    /// [`RichTextEditor::set_direction`].
2991    pub fn set_direction(&self, direction: TextDirection) {
2992        self.apply_block_format(BlockFormat {
2993            direction: Some(direction),
2994            ..Default::default()
2995        });
2996    }
2997
2998    /// Set heading level for the caret's block. `0` = plain paragraph,
2999    /// `1..=6` follow the HTML `<h1>..<h6>` convention.
3000    pub fn set_heading_level(&self, level: u8) {
3001        self.apply_block_format(BlockFormat {
3002            heading_level: Some(level),
3003            ..Default::default()
3004        });
3005    }
3006
3007    /// Current block alignment.
3008    pub fn get_alignment(&self) -> Alignment {
3009        self.state
3010            .borrow()
3011            .cursor
3012            .block_format()
3013            .ok()
3014            .and_then(|f| f.alignment)
3015            .unwrap_or(Alignment::Left)
3016    }
3017
3018    /// The block's explicitly-set reading direction, if it has one.
3019    ///
3020    /// `None` means the writer never chose — the bidi algorithm decides
3021    /// from the text. That is a genuinely different state from an
3022    /// explicit left-to-right, so it is reported rather than defaulted:
3023    /// a toggle needs to show "auto" as its own setting.
3024    pub fn get_direction(&self) -> Option<TextDirection> {
3025        self.state
3026            .borrow()
3027            .cursor
3028            .block_format()
3029            .ok()
3030            .and_then(|f| f.direction)
3031    }
3032
3033    /// Current heading level (0 = plain paragraph).
3034    pub fn get_heading_level(&self) -> u8 {
3035        self.state
3036            .borrow()
3037            .cursor
3038            .block_format()
3039            .ok()
3040            .and_then(|f| f.heading_level)
3041            .unwrap_or(0)
3042    }
3043
3044    // --- Lists -------------------------------------------------------------
3045
3046    /// Wrap the caret's block in a list. `ordered = true` uses decimal
3047    /// numbering, `false` uses bullet discs.
3048    pub fn insert_list(&self, ordered: bool) {
3049        let style = if ordered {
3050            ListStyle::Decimal
3051        } else {
3052            ListStyle::Disc
3053        };
3054        self.create_list(style);
3055    }
3056
3057    /// Wrap the caret's block in a list with an explicit
3058    /// [`ListStyle`].
3059    pub fn create_list(&self, style: ListStyle) {
3060        {
3061            let st = self.state.borrow();
3062            let _ = st.cursor.create_list(style);
3063        }
3064        sync_cursor_signals(&self.state);
3065    }
3066
3067    /// Indent the caret's current list item by one nesting level.
3068    /// No-op when the caret is not inside a list. Equivalent to Tab.
3069    pub fn indent(&self) {
3070        keyboard::indent_current_block(&mut self.state.borrow_mut());
3071        sync_cursor_signals(&self.state);
3072    }
3073
3074    /// Outdent the caret's current list item by one nesting level.
3075    /// No-op at depth 0. Equivalent to Shift+Tab.
3076    pub fn outdent(&self) {
3077        keyboard::dedent_current_block(&mut self.state.borrow_mut());
3078        sync_cursor_signals(&self.state);
3079    }
3080
3081    /// Take the caret's block out of its list entirely, leaving a plain
3082    /// paragraph. No-op when the caret is not inside a list.
3083    ///
3084    /// See [`RichTextEditor::remove_from_list`] for why this is separate from
3085    /// [`outdent`](Self::outdent), which stops at depth 0 by design.
3086    pub fn remove_from_list(&self) {
3087        {
3088            let st = self.state.borrow();
3089            let _ = st.cursor.remove_current_block_from_list();
3090        }
3091        sync_cursor_signals(&self.state);
3092    }
3093
3094    // --- Blockquotes -------------------------------------------------------
3095
3096    /// True iff the caret currently sits inside a blockquote frame at
3097    /// any nesting depth.
3098    pub fn is_in_blockquote(&self) -> bool {
3099        let st = self.state.borrow();
3100        st.cursor.is_in_blockquote()
3101    }
3102
3103    /// True iff the selection spans more than one frame — the
3104    /// "Toggle blockquote" affordance should be disabled in this case.
3105    pub fn selection_spans_multiple_frames(&self) -> bool {
3106        let st = self.state.borrow();
3107        st.cursor.selection_spans_multiple_frames()
3108    }
3109
3110    /// Wrap the current block/selection in a blockquote, or unwrap the
3111    /// innermost enclosing blockquote if already inside one. Toolbar
3112    /// counterpart for a Ctrl+Shift+Q-style toggle.
3113    pub fn toggle_blockquote(&self) {
3114        {
3115            let st = self.state.borrow();
3116            let _ = st.cursor.toggle_blockquote();
3117        }
3118        sync_cursor_signals(&self.state);
3119    }
3120
3121    /// Wrap the current block in a deeper nested quote. Equivalent to
3122    /// Tab inside a blockquote.
3123    pub fn increase_blockquote_depth(&self) {
3124        {
3125            let st = self.state.borrow();
3126            let _ = st.cursor.increase_blockquote_depth();
3127        }
3128        sync_cursor_signals(&self.state);
3129    }
3130
3131    /// Pop the caret out of one blockquote nesting level. Equivalent to
3132    /// Shift+Tab inside a blockquote.
3133    pub fn decrease_blockquote_depth(&self) {
3134        {
3135            let st = self.state.borrow();
3136            let _ = st.cursor.decrease_blockquote_depth();
3137        }
3138        sync_cursor_signals(&self.state);
3139    }
3140
3141    // --- Tables ------------------------------------------------------------
3142
3143    /// Insert a fresh `rows × columns` table at the caret.
3144    pub fn insert_table(&self, rows: usize, columns: usize) {
3145        {
3146            let st = self.state.borrow();
3147            let _ = st.cursor.insert_table(rows, columns);
3148        }
3149        sync_cursor_signals(&self.state);
3150    }
3151
3152    /// Remove the table containing the caret. No-op outside a table.
3153    pub fn remove_current_table(&self) {
3154        {
3155            let st = self.state.borrow();
3156            let _ = st.cursor.remove_current_table();
3157        }
3158        sync_cursor_signals(&self.state);
3159    }
3160
3161    /// Insert a row above the caret's current table row.
3162    pub fn insert_row_above(&self) {
3163        {
3164            let st = self.state.borrow();
3165            let _ = st.cursor.insert_row_above();
3166        }
3167        sync_cursor_signals(&self.state);
3168    }
3169
3170    /// Insert a row below the caret's current table row.
3171    pub fn insert_row_below(&self) {
3172        {
3173            let st = self.state.borrow();
3174            let _ = st.cursor.insert_row_below();
3175        }
3176        sync_cursor_signals(&self.state);
3177    }
3178
3179    /// Insert a column before the caret's current table column.
3180    pub fn insert_column_before(&self) {
3181        {
3182            let st = self.state.borrow();
3183            let _ = st.cursor.insert_column_before();
3184        }
3185        sync_cursor_signals(&self.state);
3186    }
3187
3188    /// Insert a column after the caret's current table column.
3189    pub fn insert_column_after(&self) {
3190        {
3191            let st = self.state.borrow();
3192            let _ = st.cursor.insert_column_after();
3193        }
3194        sync_cursor_signals(&self.state);
3195    }
3196
3197    /// Remove the caret's current table row.
3198    pub fn remove_current_row(&self) {
3199        {
3200            let st = self.state.borrow();
3201            let _ = st.cursor.remove_current_row();
3202        }
3203        sync_cursor_signals(&self.state);
3204    }
3205
3206    /// Remove the caret's current table column.
3207    pub fn remove_current_column(&self) {
3208        {
3209            let st = self.state.borrow();
3210            let _ = st.cursor.remove_current_column();
3211        }
3212        sync_cursor_signals(&self.state);
3213    }
3214
3215    /// Whether the caret is currently inside a table cell.
3216    pub fn is_in_table(&self) -> bool {
3217        self.state.borrow().cursor.current_table().is_some()
3218    }
3219
3220    // --- History -----------------------------------------------------------
3221
3222    /// Undo the most recent edit. No-op when the undo stack is empty.
3223    pub fn undo(&self) {
3224        let _ = self.state.borrow().document.undo();
3225        sync_cursor_signals(&self.state);
3226    }
3227
3228    /// Close the current undo entry, so the next edit starts a new one.
3229    ///
3230    /// Typing coalesces into word-sized undo steps by looking only at the shape
3231    /// of two edits — adjacent, moments apart. It cannot see that the user did
3232    /// something else in between, somewhere else in the application, that they
3233    /// would remember as a dividing line. A host that knows one was crossed says
3234    /// so here, and the burst before it stops merging with the burst after.
3235    pub fn break_undo_merge(&self) {
3236        self.state.borrow().document.break_undo_merge();
3237    }
3238
3239    /// Redo the most recently undone edit. No-op when the redo stack
3240    /// is empty.
3241    pub fn redo(&self) {
3242        let _ = self.state.borrow().document.redo();
3243        sync_cursor_signals(&self.state);
3244    }
3245
3246    // --- Edit blocks (composite undo) --------------------------------------
3247    //
3248    // See [`RichTextEditor::begin_edit_block`] for the rationale: a toolbar
3249    // action composed of several commands should cost one Ctrl+Z, not one per
3250    // property it touched.
3251
3252    /// Begin grouping subsequent edits into a single undo entry. Pair with
3253    /// [`end_edit_block`](Self::end_edit_block), or prefer the scoped
3254    /// [`edit_block`](Self::edit_block).
3255    pub fn begin_edit_block(&self) {
3256        self.state.borrow().cursor.begin_edit_block();
3257    }
3258
3259    /// Close the group opened by [`begin_edit_block`](Self::begin_edit_block).
3260    pub fn end_edit_block(&self) {
3261        self.state.borrow().cursor.end_edit_block();
3262    }
3263
3264    /// Run `edits` as one undo entry — the pairing-safe form.
3265    pub fn edit_block<R>(&self, edits: impl FnOnce() -> R) -> R {
3266        self.begin_edit_block();
3267        let result = edits();
3268        self.end_edit_block();
3269        result
3270    }
3271
3272    // --- Clipboard ---------------------------------------------------------
3273    //
3274    // Programmatic counterparts of Ctrl+C / Ctrl+X / Ctrl+V /
3275    // Ctrl+Shift+V, mirroring [`RichTextEditor::copy`] / `cut` / `paste` /
3276    // `paste_unformatted` body-for-body. Each takes the active
3277    // [`EventContext`](teksilo_core::widget::EventContext) because the
3278    // clipboard handle is looked up via `ctx.app_state::<ClipboardHandle>()`,
3279    // which only has a value during event dispatch — so these are callable
3280    // from an `on_activate_fn` / context-menu closure that captured just a
3281    // handle. A call site holding `&mut EventContext` can pass `&ctx`
3282    // directly; Rust reborrows automatically.
3283
3284    /// Copy the current selection to the system clipboard (plain + HTML
3285    /// payloads). No-op when there is no selection. See
3286    /// [`RichTextEditor::copy`].
3287    pub fn copy(&self, ctx: &teksilo_core::widget::EventContext) {
3288        let mut st = self.state.borrow_mut();
3289        clipboard::copy(&mut st, ctx);
3290    }
3291
3292    /// Cut the current selection: copy first, then remove. See
3293    /// [`RichTextEditor::cut`].
3294    pub fn cut(&self, ctx: &teksilo_core::widget::EventContext) {
3295        {
3296            let mut st = self.state.borrow_mut();
3297            clipboard::cut(&mut st, ctx);
3298        }
3299        sync_cursor_signals(&self.state);
3300    }
3301
3302    /// Paste from the system clipboard. Prefers an in-process fragment
3303    /// over HTML over plain text. See [`RichTextEditor::paste`].
3304    pub fn paste(&self, ctx: &teksilo_core::widget::EventContext) {
3305        {
3306            let mut st = self.state.borrow_mut();
3307            clipboard::paste(&mut st, ctx);
3308        }
3309        sync_cursor_signals(&self.state);
3310    }
3311
3312    /// Paste plain text only, stripping any rich payload. See
3313    /// [`RichTextEditor::paste_unformatted`].
3314    pub fn paste_unformatted(&self, ctx: &teksilo_core::widget::EventContext) {
3315        {
3316            let mut st = self.state.borrow_mut();
3317            clipboard::paste_unformatted(&mut st, ctx);
3318        }
3319        sync_cursor_signals(&self.state);
3320    }
3321
3322    /// Whether a paste would insert anything — `true` iff the system
3323    /// clipboard carries text **or** an HTML payload. A point-in-time
3324    /// query (clipboard contents are not reactively observable), taking
3325    /// the active [`EventContext`](teksilo_core::widget::EventContext).
3326    /// Use it to drive a context-menu / toolbar Paste enable-state,
3327    /// re-querying on menu-open. Mirrors [`RichTextEditor::can_paste`].
3328    pub fn can_paste(&self, ctx: &teksilo_core::widget::EventContext) -> bool {
3329        clipboard::can_paste(ctx)
3330    }
3331
3332    // --- Selection ---------------------------------------------------------
3333
3334    /// Select the entire document programmatically. Resets the Ctrl+A
3335    /// ladder so a subsequent Ctrl+A starts fresh at level 1. Mirrors
3336    /// [`RichTextEditor::select_all`].
3337    pub fn select_all(&self) {
3338        {
3339            let mut st = self.state.borrow_mut();
3340            st.cursor.select(SelectionType::Document);
3341            st.select_all_level = 0;
3342            st.select_all_anchor_cell = None;
3343        }
3344        sync_cursor_signals(&self.state);
3345    }
3346
3347    /// Delete the current selection. No-op when nothing is selected.
3348    /// Mirrors [`RichTextEditor::delete_selection`].
3349    pub fn delete_selection(&self) {
3350        {
3351            let st = self.state.borrow();
3352            if st.cursor.has_selection() {
3353                let _ = st.cursor.remove_selected_text();
3354            }
3355        }
3356        sync_cursor_signals(&self.state);
3357    }
3358
3359    // --- Reactive signal accessors -----------------------------------------
3360
3361    /// Bumps on every format-only document event (bold / italic /
3362    /// heading / alignment / list-style changes). See
3363    /// [`RichTextEditor::format_version`].
3364    pub fn format_version(&self) -> Signal<u64> {
3365        self.state.borrow().format_version.clone()
3366    }
3367
3368    /// The **live** caret offset — reads `cursor.position()` directly, unbatched. Unlike
3369    /// [`cursor_position_signal`](Self::cursor_position_signal), whose stored value lags one frame
3370    /// behind a just-typed printable character (the insert is deferred to the frame loop and the
3371    /// signal is only re-synced on the *next* caret event), this always reflects the true caret —
3372    /// what a host that recomputes highlights on a frame tick must read. Mirrors
3373    /// [`RichTextEditor::cursor_position`].
3374    pub fn cursor_position(&self) -> usize {
3375        self.state.borrow().cursor.position()
3376    }
3377
3378    /// `true` while an IME composition is actively in progress. Mirrors
3379    /// [`RichTextEditor::is_composing`].
3380    pub fn is_composing(&self) -> bool {
3381        self.state.borrow().ime_preedit.is_some()
3382    }
3383
3384    /// Reactive caret position signal.
3385    pub fn cursor_position_signal(&self) -> Signal<usize> {
3386        self.state.borrow().cursor_position.clone()
3387    }
3388
3389    /// Reactive selection anchor signal.
3390    pub fn cursor_anchor_signal(&self) -> Signal<usize> {
3391        self.state.borrow().cursor_anchor.clone()
3392    }
3393
3394    /// Reactive selection-non-empty signal.
3395    pub fn has_selection(&self) -> Signal<bool> {
3396        self.state.borrow().has_selection.clone()
3397    }
3398
3399    /// Reactive undo-availability signal (toolbar enable-state source).
3400    pub fn can_undo(&self) -> Signal<bool> {
3401        self.state.borrow().can_undo.clone()
3402    }
3403
3404    /// Reactive redo-availability signal.
3405    pub fn can_redo(&self) -> Signal<bool> {
3406        self.state.borrow().can_redo.clone()
3407    }
3408}
3409
3410/// Private leaf body for [`RichTextEditor`].
3411///
3412/// Pure rendering surface: layout (intrinsic / greedy via
3413/// `min_lines` / `max_lines`), `place_children` (records the
3414/// viewport on `state`), `paint` (glyph runs, caret, selection),
3415/// `accessibility` (Role::MultilineTextInput / Role::Document plus
3416/// the flow-snapshot walk that emits paragraph + text-run children).
3417///
3418/// Handlers, focus, the context-menu factory, and per-frame ticking
3419/// all live on the composing outer [`RichTextEditor`]; the body
3420/// itself is non-focusable and has no event handlers. The shared
3421/// `state` is what links them — both widgets hold an `Rc` to the
3422/// same [`EditorState`], so a key event on the wrapper mutates the
3423/// state and the body re-paints on the next frame.
3424pub(crate) struct RichTextEditorBody {
3425    state: SharedState,
3426    min_lines: Option<u32>,
3427    max_lines: Option<u32>,
3428}
3429
3430impl std::fmt::Debug for RichTextEditorBody {
3431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3432        f.debug_struct("RichTextEditorBody")
3433            .field("policy", &self.state.borrow().policy)
3434            .finish_non_exhaustive()
3435    }
3436}
3437
3438/// How tall this text is likely to be, before anything has laid it out.
3439///
3440/// See the call site in [`RichTextEditorBody::layout_response`] for why a guess
3441/// beats the zero it replaces. Two O(1) document reads and some arithmetic; no
3442/// shaping, no glyph cache, nothing that could be slow enough to matter in a
3443/// layout pass.
3444///
3445/// **The typography is the half that decides whether this is useful.** A first cut
3446/// counted bare lines at the font's natural height and came out well under the
3447/// truth for manuscript prose, which is set with a line-height multiplier and space
3448/// between paragraphs — the estimate was missing a third of the page and the rows it
3449/// sized still visibly grew when they finally laid out. So:
3450///
3451/// * lines are counted at the font's own advance, since that is what decides how
3452///   many characters fit on one, and
3453/// * each line is then given the **multiplied** height, and each block the space
3454///   above and below it that a body paragraph gets.
3455///
3456/// The mean advance is taken as half the font's line height. That is roughly right
3457/// for proportional Latin text at ordinary sizes and roughly wrong for everything
3458/// else, which is acceptable for a number whose only competition is a constant and
3459/// whose lifetime is one frame.
3460/// Mean glyph advance as a fraction of the font's natural line height.
3461///
3462/// **Measured, not derived.** Thirty-two real manuscript scenes were laid out in a
3463/// running window and compared against what this function claimed for each, at a
3464/// 447 px measure in Literata at 1.6 line height:
3465///
3466/// | scene | guess | real | ratio |
3467/// |---|---|---|---|
3468/// | 23 443 chars | 19 586 | 17 768 | 1.10 |
3469/// | 20 798 chars | 17 028 | 15 578 | 1.09 |
3470/// | 16 493 chars | 13 860 | 12 827 | 1.08 |
3471///
3472/// The bias was 1.06–1.12 across a 1.4× range of scene sizes: a scale error, not
3473/// noise, and 0.37 solved back to 0.335 on every one of them. Two earlier values
3474/// were reasoned about rather than measured — 0.5 from "half the font size", then
3475/// 0.37 from dividing that by a nominal line height — and both were wrong by more
3476/// than this whole correction.
3477///
3478/// It is a *typical* value, and it is font-dependent: a wider or narrower face moves
3479/// it, which is why the accuracy test allows ±25% rather than pretending otherwise.
3480/// If that stops being good enough, the answer is to learn it from the first real
3481/// layout the process performs rather than to tune the constant again.
3482const MEAN_ADVANCE_OVER_LINE_HEIGHT: f32 = 0.335;
3483
3484fn estimated_content_height(
3485    document: &teksilo_text::text_document::TextDocument,
3486    width: f32,
3487    font_line_h: f32,
3488    typography: &teksilo_text::EditorTypographyDefaults,
3489) -> f32 {
3490    if width <= 0.0 || font_line_h <= 0.0 {
3491        return 0.0;
3492    }
3493    // Characters per line from the **font's** line height: the multiplier below
3494    // spaces lines further apart, it does not make the glyphs wider.
3495    let per_line = (width / (font_line_h * MEAN_ADVANCE_OVER_LINE_HEIGHT)).max(1.0);
3496    let chars = document.character_count() as f32;
3497    let blocks = document.block_count().max(1) as f32;
3498    // Wrapped lines, plus **half** a line per block for the ragged last one of each.
3499    // Half rather than one: a block takes `ceil(chars / per_line)` lines, which
3500    // averages half a line more than the division, and charging a whole one over-
3501    // counted a scene of many short paragraphs by more than the wrapping itself.
3502    // Floored at one line per block, because an empty paragraph still takes a line.
3503    let lines = (chars / per_line + blocks * 0.5).max(blocks);
3504    let line_h = font_line_h * typography.line_height.max(0.1);
3505    let per_block = typography.paragraph_spacing_before + typography.paragraph_spacing_after;
3506    let h = lines * line_h + blocks * per_block.max(0.0);
3507    #[cfg(feature = "debug-traces")]
3508    if height_debug() {
3509        eprintln!(
3510            "HEIGHT-EST chars={chars:.0} blocks={blocks:.0} width={width:.1} \
3511             font_lh={font_line_h:.2} mult={:.2} per_line={per_line:.1} -> {h:.1}",
3512            typography.line_height
3513        );
3514    }
3515    h
3516}
3517
3518/// Whether to print what the height guess and the real layout each came up with.
3519///
3520/// `TEKSILO_HEIGHT_DEBUG=1`, and only in a build with the `debug-traces` feature.
3521/// Read once — this sits in a layout pass, and an environment lookup per
3522/// measurement would be a real cost for a diagnostic that is off for everyone.
3523///
3524/// It earns its place: the guess above was wrong three separate ways before anyone
3525/// could see it, and the one that mattered most — being asked to measure at 76 px
3526/// when the text wraps at 447 — was invisible to every test and obvious in one line
3527/// of this output.
3528///
3529/// Behind a feature as well as a variable, and the traces are `#[cfg]` out rather
3530/// than merely switched off: a runtime `false` still leaves every format string in
3531/// the binary, which is measurable. A diagnostic nobody can switch on has no
3532/// business being reachable in a release, and an environment variable is reachable
3533/// by anyone.
3534#[cfg(feature = "debug-traces")]
3535fn height_debug() -> bool {
3536    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3537    *ON.get_or_init(|| std::env::var_os("TEKSILO_HEIGHT_DEBUG").is_some())
3538}
3539
3540impl Widget for RichTextEditorBody {
3541    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3542        // Bind `caret_visible` to the framework's repaint tracker so
3543        // that every toggle in the frame-tick effect marks **this
3544        // body** widget `needs_paint` — the caret is painted in
3545        // `RichTextEditorBody::paint`. Skipped for `CaretPolicy::Hidden`.
3546        {
3547            let st = self.state.borrow();
3548            let caret_policy = st.policy.caret_policy;
3549            let caret_visible = st.caret_visible.clone();
3550            drop(st);
3551            if caret_policy != CaretPolicy::Hidden {
3552                let self_id = ctx.self_id();
3553                caret_visible.bind_to(
3554                    self_id,
3555                    ctx.binding_registry(),
3556                    teksilo_core::binding::BindingLevel::RepaintOnly,
3557                );
3558            }
3559        }
3560
3561        // Bind document_version at `BindingLevel::AccessibilityOnly` so
3562        // text / format edits flip the tree's `a11y_dirty` flag through
3563        // **this body** — its `accessibility()` is the one that emits
3564        // the editor's Role::MultilineTextInput / Role::Document and
3565        // walks the flow snapshot.
3566        //
3567        // ALSO bind at `RepaintOnly` so the widget's needs_paint flips
3568        // on every text / format change. Without this, paint() only
3569        // ran on caret-blink (the only other RepaintOnly binding), and
3570        // the post-fix dispatch's `last_relayout_block_id.take()` was
3571        // consumed on the wrong tick — leaving text edits invisible
3572        // until a resize forced a full re-layout.
3573        {
3574            let st = self.state.borrow();
3575            let document_version = st.document_version.clone();
3576            drop(st);
3577            let self_id = ctx.self_id();
3578            document_version.bind_to(
3579                self_id,
3580                ctx.binding_registry(),
3581                teksilo_core::binding::BindingLevel::AccessibilityOnly,
3582            );
3583            document_version.bind_to(
3584                self_id,
3585                ctx.binding_registry(),
3586                teksilo_core::binding::BindingLevel::RepaintOnly,
3587            );
3588        }
3589
3590        // Bind `scroll_y`, `scroll_x`, `cursor_position`, `cursor_anchor`,
3591        // and `has_selection` at RepaintOnly so the widget marks
3592        // needs_paint immediately on scroll, cursor move, and selection
3593        // change. Without these, paint() only ran on caret-blink and
3594        // text-version bumps — so scroll/selection changes appeared
3595        // delayed by up to 500ms (in sync with the next caret toggle).
3596        //
3597        // The cursor_only render path inside text-typeset falls back to
3598        // a full render automatically when scroll drifted since
3599        // the last full render, so this binding is correctness-safe.
3600        {
3601            let st = self.state.borrow();
3602            let scroll_y = st.scroll_y.clone();
3603            let scroll_x = st.scroll_x.clone();
3604            let cursor_position = st.cursor_position.clone();
3605            let cursor_anchor = st.cursor_anchor.clone();
3606            let has_selection = st.has_selection.clone();
3607            drop(st);
3608            let self_id = ctx.self_id();
3609            for signal in [&scroll_y, &scroll_x] {
3610                signal.bind_to(
3611                    self_id,
3612                    ctx.binding_registry(),
3613                    teksilo_core::binding::BindingLevel::RepaintOnly,
3614                );
3615            }
3616            // Caret and anchor are repaint-only for geometry, but they ALSO
3617            // change what the a11y walk reports via `set_text_selection_to`. A
3618            // caret-only move (arrow key, click, drag-select) emits no document
3619            // event, so `document_version` never bumps; without an
3620            // `AccessibilityOnly` binding here `a11y_dirty` never flips and a
3621            // screen reader hears the caret frozen at the last edit. Bind both
3622            // levels — the two-level pattern `document_version` uses. Selecting
3623            // moves the caret and/or anchor, so `has_selection` (derived from
3624            // them) needs no separate a11y binding.
3625            for signal in [&cursor_position, &cursor_anchor] {
3626                signal.bind_to(
3627                    self_id,
3628                    ctx.binding_registry(),
3629                    teksilo_core::binding::BindingLevel::RepaintOnly,
3630                );
3631                signal.bind_to(
3632                    self_id,
3633                    ctx.binding_registry(),
3634                    teksilo_core::binding::BindingLevel::AccessibilityOnly,
3635                );
3636            }
3637            has_selection.bind_to(
3638                self_id,
3639                ctx.binding_registry(),
3640                teksilo_core::binding::BindingLevel::RepaintOnly,
3641            );
3642        }
3643
3644        Vec::new()
3645    }
3646
3647    fn layout_response(
3648        &self,
3649        proposal: SizeProposal,
3650        ctx: &LayoutContext,
3651    ) -> teksilo_core::widget::LayoutResponse {
3652        let w = proposal.width.unwrap_or(200.0).max(0.0);
3653
3654        // Greedy mode (default, behaviour unchanged): both knobs
3655        // unset → consume the proposal exactly as before.
3656        if self.min_lines.is_none() && self.max_lines.is_none() {
3657            let h = proposal.height.unwrap_or(100.0).max(0.0);
3658            return (Size::new(w, h)).into();
3659        }
3660
3661        // Intrinsic mode: clamp content height to `[min_h, max_h]`
3662        // where each bound is `n * line_height`. The clamp is a
3663        // hard cap — we ignore the proposal's height and let the
3664        // vertical scroll bar take over past `max_lines`.
3665        // Remember the widest measure anything has asked for, before reading the
3666        // state below — see where it is used for why the widest and not this pass's.
3667        {
3668            let mut st = self.state.borrow_mut();
3669            if let Some(w) = proposal.width
3670                && w > st.widest_measured_width
3671            {
3672                st.widest_measured_width = w;
3673            }
3674        }
3675        let st = self.state.borrow();
3676        // `default_line_height()` is the *unscaled* line height (its standalone
3677        // shaper path uses font_scale = 1.0), but `content_height()` carries the
3678        // engine's font_scale. Scale the per-line bound to match, or a
3679        // text-scaled editor would clip at `max_lines` / under-size at
3680        // `min_lines`.
3681        let line_scale = st.effective_font_scale(ctx.text_scale);
3682        let line_h = st.engine.default_line_height() * line_scale;
3683        // **An estimate rather than a zero before the text has been laid out.**
3684        //
3685        // `content_height()` is `0` until `layout_full` has run, and that does not
3686        // happen until the editor has been through a frame on screen. A zero then
3687        // falls through to the `min_lines` floor below, so *every* unlaid-out editor
3688        // claims the same ten lines whatever it holds — a three-thousand-word scene
3689        // and an empty one measure identically.
3690        //
3691        // On a single editor that is invisible: it is on screen, so it lays out.
3692        // Down a **stream** it is not. A Full Book is a column of editors, most of
3693        // them below the fold, and the page's height is the sum of their claims —
3694        // so the scroll extent is wrong by an order of magnitude and settles, a row
3695        // at a time, as the writer reads. Anything drawing that extent draws the
3696        // settling: a margin lane gives each row a slice to match the claim, then
3697        // watches it grow tenfold the moment the row is reached.
3698        //
3699        // The estimate is deliberately crude — a mean advance of half the line
3700        // height, one extra line per block for the ragged last line of each — and
3701        // being crude is the point. It is thrown away the instant a real layout
3702        // exists, so its only job is to be closer than a constant, which is not a
3703        // demanding standard. It is **not** a floor: an over-estimate corrects
3704        // downwards when the layout lands, where a too-large `min_lines` would
3705        // leave blank space under short text for the life of the widget.
3706        //
3707        // ⚠ **Only when the width is actually known.** `w` above falls back to 200
3708        // for a proposal that carries none, which is fine for a width but ruinous
3709        // for a line count: `CenterColumnFlowing` measures its child width-only, and
3710        // estimating against the fallback made a scene wrap at a quarter of its real
3711        // measure and claim nearly twice its real height. An unbounded measure gets
3712        // the old answer — the floor — because without a measure there is genuinely
3713        // no way to know how many lines the text takes.
3714        let content_h = match (st.engine.has_full_layout(), proposal.width) {
3715            (true, _) => st.engine.content_height(),
3716            // **At the width the text will actually wrap at**, which is the viewport
3717            // the body was last *placed* at — not the width of whichever measurement
3718            // pass happens to be asking.
3719            //
3720            // Measured in a real window those are not the same number, and the
3721            // difference is not small: a stream row was asked to measure at 76 px
3722            // during an early pass, estimated eight characters to a line and so six
3723            // times its true height, while the layout that followed wrapped it at
3724            // 447. A guess taken at the wrong measure is worse than no guess — it is
3725            // the same jump it was meant to remove, pointing the other way.
3726            //
3727            // Zero before the body has ever been placed, and then the proposal is the
3728            // only thing on offer; after the first placement the viewport is the
3729            // truth. `w` above is deliberately not reused: its 200 px fallback is a
3730            // sane default for a width and a ruinous one for a line count.
3731            (false, _) if st.estimate_height_before_layout => {
3732                // The viewport if this body has been placed, else the **widest**
3733                // width anything has asked it to measure at.
3734                //
3735                // Not the width of the pass that happens to be asking: a real window
3736                // proposes 76 px to a stream row whose text wraps at 447, and
3737                // guessing against that claimed six times the true height. Nor the
3738                // viewport alone, which was tried and is worse — a stream's rows are
3739                // rebuilt often enough that it is almost always still zero, so the
3740                // guess simply never ran and every row fell back to the floor it was
3741                // meant to replace.
3742                let width = st.viewport_width.max(st.widest_measured_width);
3743                if width > 0.0 {
3744                    estimated_content_height(
3745                        &st.document,
3746                        width,
3747                        line_h,
3748                        st.engine.typography_defaults(),
3749                    )
3750                } else {
3751                    0.0
3752                }
3753            }
3754            (false, _) => 0.0,
3755        };
3756        drop(st);
3757
3758        let min_h = self.min_lines.map(|n| n as f32 * line_h).unwrap_or(0.0);
3759        let max_h = self
3760            .max_lines
3761            .map(|n| n as f32 * line_h)
3762            .unwrap_or(f32::INFINITY);
3763        let intrinsic_h = content_h.clamp(min_h, max_h);
3764        Size::new(w, intrinsic_h.max(0.0)).into()
3765    }
3766
3767    fn place_children(
3768        &self,
3769        bounds: Rect,
3770        _proposal: SizeProposal,
3771        _children: &mut [WidgetPlacement],
3772        _ctx: &LayoutContext,
3773    ) {
3774        // The body is a leaf, but the layout walker hands every widget its final
3775        // bounds here — and layout runs before paint, so this is the earliest
3776        // (hence authoritative) point at which the viewport can be adopted.
3777        // `sync_viewport` owns the whole handoff, including `engine.set_viewport`
3778        // and the relayout flag; paint calls it again as an idempotent echo. See
3779        // its docs for why the writes must not be split.
3780        self.state.borrow_mut().sync_viewport(bounds);
3781    }
3782
3783    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
3784        let mut st = self.state.borrow_mut();
3785
3786        // Sync the engine's default text color with the active theme
3787        // so dark / light mode swaps reach the rendered glyphs. The
3788        // engine reads `text_color` fresh on every `render()` and
3789        // does not bake it into a glyph cache, so a per-paint write
3790        // is cheap. Skipped when the app pinned a color via
3791        // `RichTextEditor::text_color(...)`.
3792        //
3793        // The render frame DOES cache colors baked into glyph quads,
3794        // though — the cursor-only and block-only render paths reuse
3795        // those cached quads. So when the theme colour actually
3796        // changes, we must dispatch a full render this frame, or the
3797        // visible glyphs keep painting in the old colour until the
3798        // next typing / scroll event happens to bump up to a Full
3799        // path on its own.
3800        // An app-set `text_color` (Color / role / Signal) is resolved against
3801        // the active theme each paint; otherwise track the theme's `editor_fg`.
3802        {
3803            let new_color = match &st.text_color_prop {
3804                Some(prop) => prop.resolve(ctx.theme, true).to_array(),
3805                None => ctx.theme.colors.editor_fg.to_array(),
3806            };
3807            st.engine.set_text_color(new_color);
3808            if st.last_text_color != Some(new_color) {
3809                st.last_text_color = Some(new_color);
3810                st.pending_full_render = true;
3811            }
3812        }
3813
3814        // Caret colour: app override resolved each paint, else the theme's
3815        // `editor_caret` role. The engine defaults the cursor to opaque black,
3816        // so without this the blinking caret stays black under a dark theme.
3817        // Cursor decorations are regenerated on every render (the cursor-only
3818        // path included), so a colour change only needs a render this frame —
3819        // force one so a swap doesn't wait for the next blink toggle.
3820        {
3821            let new_caret = match &st.caret_color_prop {
3822                Some(prop) => prop.resolve(ctx.theme, true).to_array(),
3823                None => ctx.theme.colors.editor_caret.to_array(),
3824            };
3825            st.engine.set_cursor_color(new_caret);
3826            if st.last_cursor_color != Some(new_caret) {
3827                st.last_cursor_color = Some(new_caret);
3828                st.pending_full_render = true;
3829            }
3830        }
3831
3832        // Selection highlight. A custom colour (set via `.selection_color`) is
3833        // used as-is and is NOT auto-desaturated when the window goes inactive
3834        // — matching macOS, where an explicit selection colour opts out of
3835        // system management. Otherwise the theme drives it, window-aware: the
3836        // vivid `editor_selection_bg` while the window is active, the muted
3837        // `selection_bg_inactive` while it is not. Resolved each paint and
3838        // cached, so a change (theme, custom colour, or window-active flip)
3839        // just needs a render this frame.
3840        let new_sel = if let Some(prop) = st.selection_color_prop.as_ref() {
3841            prop.resolve(ctx.theme, true).to_array()
3842        } else if ctx.window_active {
3843            ctx.theme.colors.editor_selection_bg.to_array()
3844        } else {
3845            ctx.theme.colors.selection_bg_inactive.to_array()
3846        };
3847        if st.last_selection_color != Some(new_sel) {
3848            st.engine.set_selection_color(new_sel);
3849            st.last_selection_color = Some(new_sel);
3850            st.pending_full_render = true;
3851        }
3852
3853        // Code block surface colours come from the same theme path
3854        // (`editor_code_block_bg` / `editor_code_block_fg`). Unlike
3855        // `text_color`, these are baked into the converted
3856        // `BlockLayoutParams` at `layout_full` / `relayout_block`
3857        // time, so the typesetter does NOT pick them up on a render
3858        // pass — we need a full re-layout when they change. Setting
3859        // `needs_full_layout = true` schedules that for the same
3860        // frame; `pending_full_render` covers the render side.
3861        let new_code_bg = ctx.theme.colors.editor_code_block_bg.to_array();
3862        let new_code_fg = Some(ctx.theme.colors.editor_code_block_fg.to_array());
3863        st.engine.set_code_block_background(new_code_bg);
3864        st.engine.set_code_block_foreground(new_code_fg);
3865        if st.last_code_block_bg != Some(new_code_bg) || st.last_code_block_fg != new_code_fg {
3866            st.last_code_block_bg = Some(new_code_bg);
3867            st.last_code_block_fg = new_code_fg;
3868            st.needs_full_layout = true;
3869            st.pending_full_render = true;
3870        }
3871
3872        // Link colour rides the same path, and for the same reason: it is
3873        // baked into the shaped runs at layout time, so a theme swap needs a
3874        // full re-layout rather than a repaint. Sharing `TextRole::Link` with
3875        // every other link in the app is the point — a hyperlink in prose and
3876        // one in a panel should not be two different blues.
3877        let new_link_fg = Some(ctx.theme.colors.text_link.to_array());
3878        st.engine.set_link_foreground(new_link_fg);
3879        if st.last_link_fg != new_link_fg {
3880            st.last_link_fg = new_link_fg;
3881            st.needs_full_layout = true;
3882            st.pending_full_render = true;
3883        }
3884
3885        // The engine reads the HiDPI display scale factor from the
3886        // shared `TypesetterBridge` on every `layout_full`, exactly
3887        // like `TextWidget` does internally. No widget-side plumbing
3888        // — this is a render-pipeline concern, invisible to the
3889        // widget author.
3890
3891        // Logical font scale: a11y text scale (if followed) × per-editor
3892        // `font_size_scale`. Baked at `layout_full`, so a change forces a
3893        // relayout + render this frame.
3894        {
3895            let target = st.effective_font_scale(ctx.text_scale);
3896            if st.last_font_scale.is_nan() || (st.last_font_scale - target).abs() > f32::EPSILON {
3897                st.last_font_scale = target;
3898                st.engine.set_font_scale(target);
3899                st.needs_full_layout = true;
3900                st.pending_full_render = true;
3901            }
3902        }
3903
3904        // Idempotent echo — `place_children` already adopted these exact bounds
3905        // during layout, so this is normally a no-op. It stays so that any path
3906        // which paints without a preceding layout still sizes the engine.
3907        st.sync_viewport(bounds);
3908
3909        // First-frame guard + viewport-change guard: (re)run the
3910        // full layout so the render call produces glyphs sized
3911        // for the current bounds. With per-widget `DocumentFlow`
3912        // state inside the engine, `has_full_layout()` only
3913        // reports `false` when this widget has never laid out
3914        // or when the shared service's HiDPI scale factor has
3915        // changed since the last layout — there is no
3916        // cross-widget trampling left to guard against.
3917        //
3918        // `did_full_layout` is true on this paint iff we just ran
3919        // `layout_full` above — which means the render frame must
3920        // be rebuilt from scratch via `with_render_frame`. The
3921        // incremental render paths (`with_render_block_only`,
3922        // `with_render_cursor_only`) assume a valid prior full
3923        // render exists.
3924        let did_full_layout = st.needs_full_layout || !st.engine.has_full_layout();
3925        if did_full_layout {
3926            let flow = st.flow_snapshot();
3927            st.engine.layout_full(&flow);
3928            st.needs_full_layout = false;
3929            st.content_dirty = true;
3930            #[cfg(feature = "debug-traces")]
3931            if height_debug() {
3932                eprintln!(
3933                    "HEIGHT-REAL chars={} blocks={} width={:.1} -> {:.1}",
3934                    st.document.character_count(),
3935                    st.document.block_count(),
3936                    st.engine.layout_width(),
3937                    st.engine.content_height()
3938                );
3939            }
3940        }
3941
3942        // Update the cursor display every paint so selection
3943        // highlights follow the caret without needing a frame tick.
3944        // The caret is suppressed in an inactive window for every policy — the
3945        // authoritative final gate, covering the one frame between a
3946        // window-active flip and the build-time effect running.
3947        let caret_on_now = if st.drop_caret && st.policy.caret_policy != CaretPolicy::Hidden {
3948            // A drag is overhead: show where it would land. Focus is still
3949            // wherever the drag started — often another editor entirely — so
3950            // the focus gate below would hide precisely the caret the writer
3951            // is aiming with. Steady, not blinking, and never in a read-only
3952            // editor (`Hidden`), which takes no drop anyway.
3953            st.window_active
3954        } else {
3955            match st.policy.caret_policy {
3956                CaretPolicy::Hidden => false,
3957                CaretPolicy::StaticVisible => st.has_focus && st.window_active,
3958                CaretPolicy::Blinking => st.caret_visible.get() && st.has_focus && st.window_active,
3959            }
3960        };
3961        let cursor_display = teksilo_text::CursorDisplay {
3962            position: st.cursor.position(),
3963            anchor: st.cursor.anchor(),
3964            affinity: st.cursor_affinity,
3965            visible: caret_on_now,
3966            selected_cells: Vec::new(),
3967        };
3968        st.engine.set_cursor(&cursor_display);
3969
3970        // Forward the widget's scroll state to the typesetter so
3971        // viewport culling knows where the visible window is. text-
3972        // typeset's `render()` only emits glyphs whose flow Y falls
3973        // inside `[scroll_offset, scroll_offset + viewport_height]`,
3974        // and the emitted screen coordinates already have
3975        // `scroll_offset` subtracted — so the paint walker doesn't
3976        // apply any further offset beyond the widget origin.
3977        let scroll_y_logical = st.scroll_y.get();
3978        st.engine.set_scroll_offset(scroll_y_logical);
3979
3980        // Window the render to the visible clip when opted in (dubious mode).
3981        // The editor is laid out at its full document height inside an outer
3982        // ScrollArea, so its own viewport spans the whole document and the
3983        // viewport-derived cull keeps everything. `ctx.clip_bounds` is the
3984        // accumulated ancestor clip — the intersection of every clipping
3985        // ancestor, so this is correct under nested ScrollAreas — mapped into
3986        // the editor's content space to the band actually on screen. A
3987        // half-viewport margin each side pre-renders content just off-screen so
3988        // scrolling never flashes a blank edge. Positioning and hit-testing are
3989        // untouched: `set_render_window` overrides culling only, and
3990        // `scroll_offset` stays as set above.
3991        let render_window = if st.window_to_clip {
3992            ctx.clip_bounds.map(|clip| {
3993                // `clip` and `bounds` are screen-space; the render cull works in
3994                // content space. The visible band's top is the editor's own scroll
3995                // offset plus however far its top sits above the clip: in dubious
3996                // mode `scroll_offset` is pinned to 0, but including it keeps the
3997                // window correct (rather than mis-culling) even for a self-scrolling
3998                // editor, so this can't silently render the wrong rows.
3999                let vis_top = (scroll_y_logical + (clip.y - bounds.y)).max(0.0);
4000                let vis_h = clip.height.max(0.0);
4001                let margin = vis_h * 0.5;
4002                ((vis_top - margin).max(0.0), vis_h + 2.0 * margin)
4003            })
4004        } else {
4005            None
4006        };
4007        st.engine.set_render_window(render_window);
4008
4009        // Captured before the split-borrow below (which holds `st` mutably
4010        // for the rest of the method) so the preedit underline pass can
4011        // still see them. `cursor_affinity` matches what `caret_rect`
4012        // queries elsewhere.
4013        let scroll_x_logical = st.scroll_x.get();
4014        let ime_preedit_range = st.ime_preedit_range.clone();
4015        let ime_affinity = st.cursor_affinity;
4016
4017        // Clip to bounds so overflowing glyphs don't bleed into siblings.
4018        canvas.set_clip(bounds);
4019
4020        // Choose the cheapest render path that produces a correct
4021        // frame for this paint:
4022        // - Full render: we just rebuilt the layout (no prior frame
4023        //   to incrementally update), so emit everything from scratch.
4024        // - Block-only: the frame_loop relayed out exactly one block
4025        //   since the last paint (single-block edit). Reuse cached
4026        //   glyphs for the other N-1 blocks.
4027        // - Cursor-only: nothing structural changed since last
4028        //   paint — only the cursor blink or selection updated.
4029        //   Reuses every cached glyph and just refreshes cursor /
4030        //   selection decorations. Falls back to full render
4031        //   internally if scroll drifted.
4032        //
4033        // Pre-fix, paint() unconditionally called `with_render_frame`,
4034        // which walked every block on every paint — visible as a
4035        // ~17% chunk in `rasterize_glyph` / `render_run_glyphs` on
4036        // the flamegraph because caret blinks and signal updates
4037        // were forcing a full re-render at ~60 Hz.
4038        let block_relayout = st.last_relayout_block_id.take();
4039        let pending_full = std::mem::replace(&mut st.pending_full_render, false);
4040        enum RenderChoice {
4041            Full,
4042            Block(usize),
4043            CursorOnly,
4044        }
4045        // `pending_full` covers the case where `frame_loop::tick`
4046        // already ran `layout_full` this frame (e.g. on FormatChanged
4047        // or FlowElementsInserted events from a list-indent edit or
4048        // Enter key) but cleared `needs_full_layout` before paint ran.
4049        // Without it, paint would fall through to CursorOnly and the
4050        // new layout wouldn't render until something else forced a
4051        // Full pass (resize, scroll out and back into view).
4052        let choice = if did_full_layout || pending_full {
4053            RenderChoice::Full
4054        } else if let Some(bid) = block_relayout {
4055            RenderChoice::Block(bid)
4056        } else {
4057            RenderChoice::CursorOnly
4058        };
4059
4060        // Split-borrow the state fields so the paint walker can hold
4061        // `&engine.with_render_frame(...)`, `&document`, and
4062        // `&mut image_cache` simultaneously.
4063        let state_ref: &mut EditorState = &mut st;
4064        // Read before the split borrow below, which reborrows `state_ref`
4065        // field by field.
4066        let selection_range = {
4067            let (s, e) = (
4068                state_ref.cursor.selection_start(),
4069                state_ref.cursor.selection_end(),
4070            );
4071            (s != e).then_some((s, e))
4072        };
4073        let EditorState {
4074            ref mut engine,
4075            ref document,
4076            ref mut image_cache,
4077            ref image_resolver,
4078            ref selected_image,
4079            ref resize_preview,
4080            ..
4081        } = *state_ref;
4082        let image_resolver = image_resolver.as_ref();
4083        let resize_preview_rect = resize_preview.get();
4084        let paint_closure = |frame: &teksilo_text::RenderFrame| {
4085            paint_frame(
4086                canvas,
4087                PaintParams {
4088                    frame,
4089                    origin: Point::new(bounds.x, bounds.y),
4090                    document,
4091                    image_cache,
4092                    image_resolver,
4093                    selection: selection_range,
4094                    // The same colour the typesetter drew underneath, resolved
4095                    // above for `engine.set_selection_color`.
4096                    selection_color: new_sel,
4097                    // The paint pass is the one place that has both the image
4098                    // rects and the selection, so it is what tells the pointer
4099                    // handler where the grips are.
4100                    selected_image_out: Some(selected_image),
4101                    resize_preview: resize_preview_rect,
4102                    draw_caret: caret_on_now,
4103                },
4104            );
4105        };
4106        match choice {
4107            RenderChoice::Full => engine.with_render_frame(paint_closure),
4108            RenderChoice::Block(bid) => engine.with_render_block_only(bid, paint_closure),
4109            RenderChoice::CursorOnly => engine.with_render_cursor_only(paint_closure),
4110        };
4111
4112        // IME preedit underline. Walk the composing range char-by-char,
4113        // emitting one underline segment per visual line so a wrapped
4114        // composition underlines correctly. Engine coords are content-
4115        // space; screen = bounds + content − scroll (matches the glyphs).
4116        // On a read-only viewer there is never a preedit, so this is inert.
4117        if let Some(range) = ime_preedit_range
4118            && engine.has_full_layout()
4119            && range.start < range.end
4120        {
4121            let color = ctx.theme.colors.text_primary;
4122            let underline = |canvas: &mut Canvas, x0: f32, x1: f32, y: f32, h: f32| {
4123                let uy = y + h - 1.0;
4124                canvas.draw_line(
4125                    Point::new(x0, uy),
4126                    Point::new(x1, uy),
4127                    color,
4128                    teksilo_canvas::StrokeStyle::solid(1.0),
4129                );
4130            };
4131            let mut seg_x0: Option<f32> = None;
4132            let (mut seg_y, mut seg_h, mut last_x) = (0.0_f32, 0.0_f32, 0.0_f32);
4133            for p in range.start..=range.end {
4134                let c = engine.caret_rect(p, ime_affinity);
4135                let x = bounds.x + c[0] - scroll_x_logical;
4136                let y = bounds.y + c[1] - scroll_y_logical;
4137                match seg_x0 {
4138                    None => {
4139                        seg_x0 = Some(x);
4140                        seg_y = y;
4141                        seg_h = c[3];
4142                        last_x = x;
4143                    }
4144                    Some(x0) => {
4145                        if (y - seg_y).abs() > 0.5 {
4146                            underline(canvas, x0, last_x, seg_y, seg_h);
4147                            seg_x0 = Some(x);
4148                            seg_y = y;
4149                            seg_h = c[3];
4150                        }
4151                        last_x = x;
4152                    }
4153                }
4154            }
4155            if let Some(x0) = seg_x0 {
4156                underline(canvas, x0, last_x, seg_y, seg_h);
4157            }
4158        }
4159
4160        canvas.clear_clip();
4161    }
4162
4163    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
4164        use self::policy::AccessibilityRole;
4165        use self::state::SyntheticElementRef;
4166        use teksilo_core::accesskit::{Action, NodeId, Role};
4167        use teksilo_text::text_document::{FlowElementSnapshot, FragmentContent};
4168
4169        let st = self.state.borrow();
4170
4171        let role = match st.policy.access_role {
4172            AccessibilityRole::Editor => Role::MultilineTextInput,
4173            AccessibilityRole::Document => Role::Document,
4174        };
4175        builder.set_role(role);
4176        if st.policy.is_read_only() {
4177            builder.set_read_only();
4178        }
4179
4180        // Walk the cached flow snapshot (or rebuild it if the last
4181        // edit cleared the cache). For each block we emit a
4182        // Role::Paragraph child (or Role::Heading when the block's
4183        // heading_level is set), then for each text fragment we
4184        // emit a Role::TextRun child carrying value,
4185        // character_lengths, word_starts, and per-character
4186        // geometry from text-typeset. Widget-local
4187        // synthetic_to_element map is populated so the on-access
4188        // handler can convert AccessKit TextSelection back into
4189        // document-absolute cursor positions.
4190        let snap = {
4191            let mut cache = st.accessibility_flow_snapshot.borrow_mut();
4192            if cache.is_none() {
4193                // A bare view (show_highlights=false) builds its AT tree from a
4194                // clean snapshot too, so screen readers never hear highlight-
4195                // driven formatting that no sighted user sees. The paint-only
4196                // overlay is skipped: the AT walk reads fragments, never the
4197                // overlay, so computing a paint span per spell/find range here
4198                // would be pure waste (it dominated the a11y rebuild on a large
4199                // spell-checked document).
4200                *cache = Some(st.flow_snapshot_for_a11y());
4201            }
4202            cache.as_ref().cloned()
4203        };
4204
4205        // While composing (IME preedit active), expose the composition as
4206        // the AT selection so screen readers / braille track the tentative
4207        // text — the composing characters are already in the runs / value.
4208        // Falls back to the live cursor/selection otherwise.
4209        let (user_anchor, user_pos) = match st.ime_preedit_range.clone() {
4210            Some(range) => (range.start, range.end),
4211            None => (st.cursor.anchor(), st.cursor.position()),
4212        };
4213        let mut caret_pair: Option<(NodeId, usize)> = None;
4214        let mut anchor_pair: Option<(NodeId, usize)> = None;
4215        let mut syn_map: std::collections::HashMap<NodeId, SyntheticElementRef> =
4216            std::collections::HashMap::new();
4217
4218        if let Some(snap) = snap {
4219            for elem in &snap.elements {
4220                if let FlowElementSnapshot::Block(block) = elem {
4221                    let para_id = builder.push_paragraph_child(block.block_id as u64);
4222                    if let Some(level) = block.block_format.heading_level {
4223                        builder.set_paragraph_as_heading(para_id, level);
4224                    }
4225                    for frag in &block.fragments {
4226                        if let FragmentContent::Text {
4227                            text,
4228                            offset,
4229                            length,
4230                            element_id,
4231                            word_starts,
4232                            format,
4233                            ..
4234                        } = frag
4235                        {
4236                            // Text attributes for AT (WCAG 1.3.1 / EN 301 549
4237                            // 11.5.2.9): bold / italic / underline / strikethrough
4238                            // per formatting run. AccessKit has no bold flag, so
4239                            // an explicit weight wins, else bold folds to 700.
4240                            let attrs = teksilo_core::accessibility::TextRunAttributes {
4241                                font_weight: format.font_weight.map(|w| w as u16),
4242                                bold: format.font_bold.unwrap_or(false),
4243                                italic: format.font_italic.unwrap_or(false),
4244                                underline: format.font_underline.unwrap_or(false),
4245                                strikethrough: format.font_strikeout.unwrap_or(false),
4246                            };
4247                            // character_lengths: UTF-8 byte length of each char.
4248                            // AccessKit indexes by char, each entry is byte count.
4249                            let char_lengths: Vec<u8> =
4250                                text.chars().map(|c| c.len_utf8() as u8).collect();
4251
4252                            // Per-character geometry from text-typeset. char_start
4253                            // / char_end are block-relative character offsets
4254                            // (matches LayoutLine::char_range's coordinate space).
4255                            let char_start = *offset;
4256                            let char_end = char_start + *length;
4257                            let geom =
4258                                st.engine
4259                                    .character_geometry(block.block_id, char_start, char_end);
4260                            let char_positions: Vec<f32> =
4261                                geom.iter().map(|g| g.position).collect();
4262                            let char_widths: Vec<f32> = geom.iter().map(|g| g.width).collect();
4263
4264                            let node_id = builder.push_text_run_child(
4265                                para_id,
4266                                *element_id,
4267                                *offset,
4268                                text.clone(),
4269                                char_lengths,
4270                                Some(word_starts.clone()),
4271                                if char_positions.is_empty() {
4272                                    None
4273                                } else {
4274                                    Some(char_positions)
4275                                },
4276                                if char_widths.is_empty() {
4277                                    None
4278                                } else {
4279                                    Some(char_widths)
4280                                },
4281                                attrs,
4282                            );
4283
4284                            // Annotations covering this run: one Role::Comment
4285                            // node each, linked from the run through `details`.
4286                            // Emitted per run rather than once per span because a
4287                            // span can cross runs (a bold word inside a commented
4288                            // sentence splits it), and every covered run must
4289                            // carry the relation or the announcement drops out
4290                            // halfway through the phrase.
4291                            let run_start = block.position + *offset;
4292                            let run_end = run_start + *length;
4293                            for span in &st.annotation_spans {
4294                                if span.start < run_end && span.end > run_start {
4295                                    let detail = builder
4296                                        .push_annotation_child(span.group_id, span.summary.clone());
4297                                    builder.push_detail_on_child(node_id, detail);
4298                                }
4299                            }
4300
4301                            // Remember where this run lives in the document so
4302                            // the on-access handler can resolve
4303                            // SetTextSelection(TextRun NodeId, char_index).
4304                            let absolute_start = block.position + *offset;
4305                            syn_map.insert(
4306                                node_id,
4307                                SyntheticElementRef {
4308                                    element_id: *element_id,
4309                                    absolute_start,
4310                                    text: text.clone(),
4311                                },
4312                            );
4313
4314                            // Resolve user cursor / anchor to this run if they
4315                            // fall within its absolute character range
4316                            // [absolute_start, absolute_start + length].
4317                            let absolute_end = absolute_start + *length;
4318                            if user_pos >= absolute_start && user_pos <= absolute_end {
4319                                let char_idx = char_index_in_text(text, user_pos - absolute_start);
4320                                caret_pair = Some((node_id, char_idx));
4321                            }
4322                            if user_anchor >= absolute_start && user_anchor <= absolute_end {
4323                                let char_idx =
4324                                    char_index_in_text(text, user_anchor - absolute_start);
4325                                anchor_pair = Some((node_id, char_idx));
4326                            }
4327                        }
4328
4329                        // Inline objects: one document character each, rendered
4330                        // as something a reader sees but cannot read out of the
4331                        // text — an image, or a footnote's marker.
4332                        //
4333                        // Announced as a single-character text run whose value
4334                        // is that description. `character_lengths` is one entry
4335                        // spanning the whole string on purpose: the object *is*
4336                        // one character of the document, however many letters
4337                        // stand in for it, and telling AccessKit otherwise would
4338                        // put every caret offset after it out by the difference.
4339                        //
4340                        // Images were reaching no assistive technology at all
4341                        // until now — their `alt` was carried the whole way
4342                        // through the pipeline and then dropped here, at the
4343                        // last step, because this loop only ever matched `Text`.
4344                        let object_run = match frag {
4345                            FragmentContent::Image {
4346                                alt,
4347                                offset,
4348                                element_id,
4349                                format,
4350                                ..
4351                            } => Some((alt.clone(), *offset, *element_id, format)),
4352                            FragmentContent::FootnoteReference {
4353                                marker,
4354                                offset,
4355                                element_id,
4356                                format,
4357                                ..
4358                            } => Some((marker.clone(), *offset, *element_id, format)),
4359                            FragmentContent::Text { .. } => None,
4360                        };
4361
4362                        if let Some((value, offset, element_id, format)) = object_run {
4363                            let attrs = teksilo_core::accessibility::TextRunAttributes {
4364                                font_weight: format.font_weight.map(|w| w as u16),
4365                                bold: format.font_bold.unwrap_or(false),
4366                                italic: format.font_italic.unwrap_or(false),
4367                                underline: format.font_underline.unwrap_or(false),
4368                                strikethrough: format.font_strikeout.unwrap_or(false),
4369                            };
4370                            // An empty description would announce nothing at
4371                            // all, which is indistinguishable from a rendering
4372                            // fault. A single space is at least a spoken pause.
4373                            let value = if value.is_empty() {
4374                                " ".to_string()
4375                            } else {
4376                                value
4377                            };
4378                            let geom =
4379                                st.engine
4380                                    .character_geometry(block.block_id, offset, offset + 1);
4381                            let node_id = builder.push_text_run_child(
4382                                para_id,
4383                                element_id,
4384                                offset,
4385                                value.clone(),
4386                                vec![value.len().min(u8::MAX as usize) as u8],
4387                                None,
4388                                if geom.is_empty() {
4389                                    None
4390                                } else {
4391                                    Some(geom.iter().map(|g| g.position).collect())
4392                                },
4393                                if geom.is_empty() {
4394                                    None
4395                                } else {
4396                                    Some(geom.iter().map(|g| g.width).collect())
4397                                },
4398                                attrs,
4399                            );
4400
4401                            let absolute_start = block.position + offset;
4402                            syn_map.insert(
4403                                node_id,
4404                                SyntheticElementRef {
4405                                    element_id,
4406                                    absolute_start,
4407                                    text: value,
4408                                },
4409                            );
4410                            if user_pos >= absolute_start && user_pos <= absolute_start + 1 {
4411                                caret_pair = Some((node_id, user_pos - absolute_start));
4412                            }
4413                            if user_anchor >= absolute_start && user_anchor <= absolute_start + 1 {
4414                                anchor_pair = Some((node_id, user_anchor - absolute_start));
4415                            }
4416                        }
4417                    }
4418                }
4419            }
4420        }
4421
4422        // Attach the text selection on the editor itself, referencing
4423        // the appropriate TextRun children. If we couldn't resolve
4424        // either endpoint (empty document, cursor in no fragment),
4425        // fall back to a self-targeted selection so screen readers
4426        // still see *something*.
4427        if let (Some(a), Some(c)) = (anchor_pair, caret_pair) {
4428            builder.set_text_selection_to(a, c);
4429        } else {
4430            builder.set_text_selection_on_self(user_anchor, user_pos);
4431        }
4432
4433        *st.synthetic_to_element.borrow_mut() = syn_map;
4434
4435        builder.add_action(Action::Focus);
4436        builder.add_action(Action::ScrollIntoView);
4437        builder.add_action(Action::SetTextSelection);
4438        if matches!(st.policy.access_role, AccessibilityRole::Editor) {
4439            builder.add_action(Action::SetValue);
4440            builder.add_action(Action::ReplaceSelectedText);
4441        }
4442    }
4443
4444    fn clips_children(&self) -> bool {
4445        true
4446    }
4447}
4448
4449impl Widget for RichTextEditor {
4450    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4451        // Tell the framework this widget edits text.
4452        //
4453        // What it buys: an application may take `Ctrl+Z`, `Ctrl+C` and friends
4454        // for itself — a single Undo command over the whole app has to — and
4455        // registered shortcuts resolve before any widget sees the raw key. This
4456        // is how the host can tell that the caret is *here*, and either drive
4457        // this surface or step aside so it keeps its own keys. Without it, an
4458        // application that routes those chords silently breaks every text
4459        // widget it does not personally know about. See
4460        // `teksilo_core::text_surface`.
4461        ctx.register_text_surface(std::rc::Rc::new(self.handle()));
4462        // Engine swap: replace the private fallback with one sharing
4463        // the application's `SharedTypesetter` so rendered glyphs end
4464        // up in the atlas teksilo-render uploads to the GPU. Headless
4465        // tests without a `SharedTypesetter` keep the private engine
4466        // untouched. Lives on the wrapper because state mutation
4467        // doesn't depend on `ctx.self_id()`.
4468        if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
4469            let mut st = self.state.borrow_mut();
4470            let wrap = st.wrap_mode;
4471            // Carry over builder-set engine config that the swap would otherwise
4472            // drop — `.typography_defaults()`, `.echo_char()` are set on the
4473            // private engine before mount, and this runs on every rebuild.
4474            // (Theme colours / font-scale re-derive themselves in `paint()`.)
4475            let typography = st.engine.typography_defaults().clone();
4476            let echo = st.engine.echo_char();
4477            let mut engine = RichTextEngine::from_shared(shared.clone());
4478            engine.set_wrap_mode(wrap);
4479            engine.set_hyphenate_justified(true);
4480            engine.set_typography_defaults(typography);
4481            engine.set_echo_char(echo);
4482            st.engine = engine;
4483            st.needs_full_layout = true;
4484        }
4485
4486        // Stash the tree's frame-request handle on the state so the
4487        // frame-tick effect can self-chain (caret blink, drag
4488        // auto-scroll) without mutable access to the tree.
4489        {
4490            let mut st = self.state.borrow_mut();
4491            st.frame_request = Some(ctx.frame_request_handle());
4492            st.frame_wake_at = Some(ctx.wake_at_handle());
4493            // Remember this build's wrapper id — the `.focusable(true)` node — so a
4494            // held handle can request focus back onto the editor.
4495            st.self_id = Some(ctx.self_id());
4496        }
4497
4498        // Kick off the first frame so the initial layout/paint runs
4499        // through the tick path and populates max_scroll / content
4500        // metrics. Gated by activation: a tab content pane parked in a
4501        // non-selected `Switcher` branch must not keep the event loop
4502        // awake just because it was built (TabWidget pre-mounts every
4503        // open tab).
4504        let activation = ctx.activation_signal(ctx.self_id());
4505        // Stash it too: `reveal_range` has no other way to tell an on-screen
4506        // editor from one parked dormant, because the engine's layout survives
4507        // the parking. Taken from the signal rather than set by the dormancy
4508        // effect below, which fires only on a *transition* — an editor built
4509        // dormant (a TabWidget pre-mounts every open tab) never transitions.
4510        self.state.borrow_mut().activation = Some(activation.clone());
4511        if activation.get() {
4512            ctx.request_frame();
4513        }
4514
4515        // When this editor is parked dormant (tab switch, collapsed
4516        // pane, …) clear local focus state synchronously. The tree may
4517        // also dispatch FocusLost via revalidate, but a race between
4518        // selection change and pointer focus — or a programmatic
4519        // selection change that never moves focus — used to leave
4520        // `has_focus = true` on every visited tab. Each stuck editor
4521        // kept scheduling caret `wake_at`s, and every open tab's
4522        // frame-tick effect still ran on those wakes (observers are
4523        // not dormancy-gated). Rapid tab switching made CPU climb.
4524        {
4525            let state = self.state.clone();
4526            ctx.effect(&activation, move |&active| {
4527                if active {
4528                    // **Re-activated** — re-arm the frame loop.
4529                    //
4530                    // The dormant branch below deliberately does not re-arm
4531                    // `frame_request`, and the frame-tick effect is skipped
4532                    // entirely while dormant, so nothing restarts the tick on the
4533                    // way back: the editor paints once and then goes quiet. The
4534                    // caret is what makes that visible — `on_focus` restarts the
4535                    // blink, but only the tick pushes the cursor through to the
4536                    // engine, so a re-activated editor that is then focused shows
4537                    // **no caret at all** and reads as a broken surface.
4538                    //
4539                    // The in-tree modal path hits this on every open: it builds
4540                    // the content, marks it dormant, mounts it, activates it and
4541                    // *then* moves focus in (`present_in_tree_modal_request`). A
4542                    // tab switch and a collapsed pane take the same route back.
4543                    //
4544                    // Cheap and self-limiting: one frame request, after which the
4545                    // ordinary tick loop re-arms itself only while it has work.
4546                    let st = state.borrow();
4547                    if let Some(handle) = &st.frame_request {
4548                        handle.set(true);
4549                    }
4550                    return;
4551                }
4552                let mut st = state.borrow_mut();
4553                if st.has_focus {
4554                    st.has_focus = false;
4555                    st.focus_signal.set(false);
4556                }
4557                if st.caret_visible.get() {
4558                    st.caret_visible.set(false);
4559                }
4560                st.blink.reset();
4561                // Retire the caret band here too. Only `frame_loop::tick` pushes the band's
4562                // focus state through to the document, and the tick effect below is skipped
4563                // entirely while dormant — so a parked editor would keep its last band
4564                // registered on a document its siblings are still showing, and a split pane
4565                // over the same document would show two. Clearing `has_focus` above is not
4566                // enough; nothing would ever act on it.
4567                if let Some(band) = &st.caret_highlight {
4568                    band.set_active(false);
4569                }
4570                st.caret_highlight_active = false;
4571                // Do not re-arm frame_request here: a dormant editor has
4572                // nothing to paint, and re-arming is exactly the leak
4573                // this gate exists to stop.
4574            });
4575        }
4576
4577        // Frame-tick effect — drains document events, blinks the
4578        // caret, runs drag auto-scroll. Re-arms the tree's
4579        // frame-request flag while there's still pending work.
4580        // Skipped entirely while dormant so a multi-tab TabWidget does
4581        // not pay O(open tabs) per wake for editors nobody can see.
4582        {
4583            let state = self.state.clone();
4584            let active = activation.clone();
4585            let tick_signal = ctx.frame_tick();
4586            ctx.effect(&tick_signal, move |delta| {
4587                if !active.get() {
4588                    return;
4589                }
4590                let mut st = state.borrow_mut();
4591                let more = frame_loop::tick(&mut st, *delta);
4592                // Signal::set is unconditional (clones+invokes every
4593                // observer even when value unchanged), so only call it
4594                // when the bool actually flipped. Avoids per-tick fanout
4595                // to chrome widgets that watch the selection state.
4596                let new_has_selection = st.cursor.has_selection();
4597                if st.has_selection.get() != new_has_selection {
4598                    st.has_selection.set(new_has_selection);
4599                }
4600                if more && let Some(handle) = &st.frame_request {
4601                    handle.set(true);
4602                }
4603                drop(st);
4604            });
4605        }
4606
4607        // Window-active effect — mirror the tree's window-active state onto the
4608        // editor state so the frame loop (which has no context) can gate the
4609        // caret. The frame loop may not tick while the window is inactive (the
4610        // animation scheduler is parked), so on deactivation we hide the caret
4611        // *synchronously* here rather than waiting for a tick, and request a
4612        // frame so the change reaches a paint pass — but only while this
4613        // editor is itself active. A dormant tab must not re-arm the frame
4614        // loop just because the host window blinked.
4615        {
4616            let state = self.state.clone();
4617            let active = activation.clone();
4618            let wa_signal = ctx.window_active_signal();
4619            ctx.effect(&wa_signal, move |&window_active| {
4620                let mut st = state.borrow_mut();
4621                st.window_active = window_active;
4622                if window_active {
4623                    // Reactivated: if the editor still holds focus, show the
4624                    // caret immediately (restart the blink phase) rather than
4625                    // waiting up to one blink interval. `Hidden` policy stays
4626                    // hidden — the paint gate suppresses it anyway.
4627                    let show =
4628                        st.has_focus && !matches!(st.policy.caret_policy, CaretPolicy::Hidden);
4629                    if show && !st.caret_visible.get() {
4630                        st.caret_visible.set(true);
4631                    }
4632                    st.blink.reset();
4633                } else {
4634                    // Deactivated: hide the caret synchronously (the frame loop
4635                    // may not tick while the window is inactive).
4636                    if st.caret_visible.get() {
4637                        st.caret_visible.set(false);
4638                    }
4639                    st.blink.reset();
4640                }
4641                if active.get()
4642                    && let Some(handle) = &st.frame_request
4643                {
4644                    handle.set(true);
4645                }
4646                drop(st);
4647            });
4648        }
4649
4650        // Attach handlers on the WRAPPER — making the composing
4651        // widget itself the focus + event target. The body is a
4652        // pure leaf so users can wrap it in arbitrary chrome via
4653        // `RichTextEditorStyle::make_body` without losing focus
4654        // semantics.
4655        let mut handlers = HandlerSet::new();
4656        // Editable editors are text-input surfaces — enable the OS IME
4657        // while focused. Read-only viewers stay focusable for selection but
4658        // accept no text input, so they leave the IME descriptor unset.
4659        if !self.state.borrow().policy.is_read_only() {
4660            handlers = handlers.ime_input(teksilo_core::ime::ImeContext::text());
4661        }
4662        handlers = handlers
4663            // Text and files dropped onto the editor land at the caret, and the
4664            // caret follows the drag so the writer can see where that is. An
4665            // editor with no drop handling at all is not merely inert: the drag
4666            // bubbles to whatever ancestor claims it, and the pane's own
4667            // `DropTarget` paints a reject tint across the whole surface, which
4668            // reads as the editor refusing the drop rather than never being
4669            // offered it.
4670            .on_drag_hover({
4671                let state = self.state.clone();
4672                move |payload, pos, ctx| {
4673                    // Read the policy live rather than snapshotting it here: the
4674                    // command filter is swappable on a mounted editor
4675                    // (`set_command_filter`), and a value captured at build time
4676                    // would keep promising a drop the drop handler then refuses.
4677                    let read_only = state.borrow().policy.is_read_only();
4678                    if read_only || !droppable(payload) {
4679                        // `NoFeedback` rather than a reject visual: the drag
4680                        // must keep bubbling so an ancestor that does want this
4681                        // payload — a binder row dropped on the editor pane —
4682                        // still gets it.
4683                        return teksilo_core::DropFeedback::NoFeedback;
4684                    }
4685                    if !self::mouse::move_caret_for_drag(&state, pos) {
4686                        self::mouse::clear_drop_caret(&state);
4687                        return teksilo_core::DropFeedback::NoFeedback;
4688                    }
4689                    ctx.request_frame();
4690                    // The caret IS the feedback — a framework insertion line
4691                    // would draw a second, differently-placed promise about
4692                    // where the drop lands.
4693                    teksilo_core::DropFeedback::Accept
4694                }
4695            })
4696            // The drag moved off this editor (or was cancelled over it): stop
4697            // promising a landing place. Without this the drop caret is left
4698            // burnt into an editor the drag has already left.
4699            .on_drag_leave({
4700                let state = self.state.clone();
4701                move |ctx| {
4702                    self::mouse::clear_drop_caret(&state);
4703                    ctx.request_frame();
4704                }
4705            })
4706            .on_drop({
4707                let state = self.state.clone();
4708                move |payload, pos, ctx| {
4709                    self::mouse::clear_drop_caret(&state);
4710                    // Live, for the same reason as `on_drag_hover` above.
4711                    let read_only = state.borrow().policy.is_read_only();
4712                    if read_only || !droppable(&payload) {
4713                        return false;
4714                    }
4715                    // Place the caret one last time: a drop can arrive without a
4716                    // final hover at the same point (a fast release, or a
4717                    // backend that only fills the payload at drop time).
4718                    self::mouse::move_caret_for_drag(&state, pos);
4719                    // Text dragged out of an editor. Dropped back into the one
4720                    // it came from it is a *move* — the original goes away —
4721                    // and dropped into any other editor it is a copy, which is
4722                    // what a writer means by carrying a phrase to a second
4723                    // document rather than emptying it out of the first.
4724                    if let Some(drag) = payload.get_typed::<EditorTextDrag>() {
4725                        let same_editor = state.borrow().self_id == Some(drag.source);
4726                        let moved = self::mouse::apply_text_drop(&state, drag, same_editor);
4727                        if moved {
4728                            sync_cursor_signals(&state);
4729                            state.borrow_mut().pending_text_changed = true;
4730                            // Take the caret with the text. Focus is still in
4731                            // the editor the drag *started* in, so without this
4732                            // the writer is left looking at text they just
4733                            // placed here while typing into somewhere else.
4734                            let self_id = state.borrow().self_id;
4735                            if let Some(id) = self_id {
4736                                ctx.request_focus(id);
4737                            }
4738                            ctx.request_frame();
4739                        }
4740                        return moved;
4741                    }
4742                    let files: Vec<std::path::PathBuf> = payload.files().to_vec();
4743                    if !files.is_empty() {
4744                        // Files mean nothing to a text editor on their own —
4745                        // whether a path becomes a picture, a link, or an
4746                        // include is the host's policy. Hand them over.
4747                        let cb = state.borrow().on_files_dropped.clone();
4748                        let Some(cb) = cb else { return false };
4749                        cb(&files, ctx);
4750                        ctx.request_frame();
4751                        return true;
4752                    }
4753                    // Advertised but delivered nothing: decline, so the drag
4754                    // bubbles rather than being silently eaten.
4755                    let Some(text) = payload.text().filter(|t| !t.is_empty()) else {
4756                        return false;
4757                    };
4758                    {
4759                        let st = state.borrow();
4760                        let _ = st.cursor.insert_text(text);
4761                    }
4762                    sync_cursor_signals(&state);
4763                    state.borrow_mut().pending_text_changed = true;
4764                    ctx.request_frame();
4765                    true
4766                }
4767            })
4768            .focusable(true)
4769            .cursor(CursorIcon::Text)
4770            .on_focus({
4771                let state = self.state.clone();
4772                move |gained, ctx| {
4773                    let mut st = state.borrow_mut();
4774                    st.has_focus = gained;
4775                    // Mirror onto the reactive signal so chrome
4776                    // installed by `RichTextEditorStyle::make_body`
4777                    // (focus-aware border / ring) re-renders.
4778                    st.focus_signal.set(gained);
4779                    if gained && matches!(st.policy.caret_policy, CaretPolicy::Blinking) {
4780                        st.blink.restart();
4781                        st.caret_visible.set(true);
4782                    }
4783                    drop(st);
4784                    if gained {
4785                        // Seed the OS IME candidate area at the caret.
4786                        self::keyboard::report_ime_cursor_area(&state, ctx);
4787                    } else {
4788                        // Abandon any in-progress composition on blur, and drop
4789                        // the IME-area / caret-chase caches. The OS IME candidate
4790                        // area is a single *per-window* resource a sibling field
4791                        // may have re-pointed while we were unfocused; clearing
4792                        // `last_ime_area` forces the next focus-gain report to
4793                        // re-seed it (the dedup must not swallow that re-seed).
4794                        // Clearing `last_chase_pos` lets a refocus re-reveal the
4795                        // caret even if it has not moved since we lost focus.
4796                        self::keyboard::clear_ime_preedit(&state);
4797                        let mut st = state.borrow_mut();
4798                        st.last_ime_area = None;
4799                        st.last_chase_pos = None;
4800                    }
4801                    ctx.request_frame();
4802                }
4803            })
4804            .on_pointer_event({
4805                let state = self.state.clone();
4806                let v_sb = self.v_scrollbar_bounds.clone();
4807                let h_sb = self.h_scrollbar_bounds.clone();
4808                move |event, ctx| {
4809                    self::mouse::handle_pointer_event(&state, &v_sb, &h_sb, event, ctx)
4810                }
4811            })
4812            .on_scroll({
4813                let state = self.state.clone();
4814                let overscroll = self.overscroll_behavior;
4815                move |event, ctx| self::mouse::handle_scroll(&state, overscroll, event, ctx)
4816            })
4817            .on_key({
4818                let state = self.state.clone();
4819                move |event, ctx| self::keyboard::handle_key(&state, event, ctx)
4820            })
4821            .on_double_tap({
4822                let state = self.state.clone();
4823                move |event, ctx| self::mouse::handle_double_tap(&state, event.position, ctx)
4824            })
4825            .on_triple_tap({
4826                let state = self.state.clone();
4827                move |event, ctx| self::mouse::handle_triple_tap(&state, event.position, ctx)
4828            })
4829            .on_access_action_request({
4830                let state = self.state.clone();
4831                move |action, target_node, data, ctx| {
4832                    handle_access_action_request(&state, action, target_node, data, ctx)
4833                }
4834            });
4835
4836        // Context-menu factory — same shape as before, just hosted on
4837        // the wrapper. The factory reads the policy from the shared state on
4838        // each right-click, so a filter swapped in after mount is honoured.
4839        if let Some(factory) = context_menu::resolve_factory(
4840            self.custom_context_menu.take(),
4841            self.default_context_menu_enabled,
4842            self.state.clone(),
4843        ) {
4844            handlers = handlers.context_menu(move |pos, ctx| factory(pos, ctx));
4845        }
4846
4847        ctx.apply_self_handlers(handlers);
4848
4849        // Build the pure-paint leaf body. The body carries
4850        // layout/paint/accessibility (using its own `self_id()` for
4851        // `caret_visible` + `document_version` bindings); the shared
4852        // `state` propagates handler-driven mutations into it.
4853        let body = RichTextEditorBody {
4854            state: self.state.clone(),
4855            min_lines: self.min_lines,
4856            max_lines: self.max_lines,
4857        };
4858        let viewport_id = ctx.add(body);
4859
4860        // Reactive colour overrides: a signal/role-bound `ColorProp` must
4861        // repaint the body (the leaf that resolves + applies them in `paint`)
4862        // when it changes. Bind to `viewport_id`, not the wrapper — the painter
4863        // owns its prop bindings (the `RectWidget` pattern). Theme-role changes
4864        // already dirty every node via the reactive theme; this covers
4865        // `Signal`-bound props. The background prop is reactive through the
4866        // `RectWidget` the style builds, so it isn't registered here.
4867        {
4868            let props = {
4869                let st = self.state.borrow();
4870                [
4871                    st.text_color_prop.clone(),
4872                    st.caret_color_prop.clone(),
4873                    st.selection_color_prop.clone(),
4874                ]
4875            };
4876            let registry = ctx.binding_registry();
4877            for prop in props.iter().flatten() {
4878                prop.register_if_bound(
4879                    viewport_id,
4880                    registry,
4881                    teksilo_core::binding::BindingLevel::RepaintOnly,
4882                );
4883            }
4884        }
4885
4886        // Snapshot focus + read-only state for the chrome. `is_focused`
4887        // is the reactive mirror updated by `on_focus`; `is_read_only`
4888        // is sampled from the policy bundle.
4889        let (is_focused, is_read_only) = {
4890            let st = self.state.borrow();
4891            (st.focus_signal.clone(), st.policy.is_read_only())
4892        };
4893
4894        let style: SharedRichTextEditorStyle = self
4895            .style_override
4896            .clone()
4897            .or_else(|| ctx.theme().style_slots.rich_text_editor.clone())
4898            .unwrap_or_else(|| Rc::new(RecipeRichTextEditorStyle));
4899        let cfg = RichTextEditorStyleConfig {
4900            viewport: viewport_id,
4901            is_focused,
4902            is_read_only,
4903            content_padding: self.content_padding,
4904            background: self.state.borrow().background_prop.clone(),
4905        };
4906        let root = style.make_body(&cfg, ctx);
4907        self.root_child_id = Some(root);
4908
4909        // Overlay scrollbars — floated on top of the chrome at the
4910        // right / bottom edges. Driven by the same signals the frame
4911        // loop publishes (`scroll_*`, `max_scroll_*`, `viewport_ratio_*`).
4912        // ScrollPolicy::AlwaysOff suppresses the widget entirely so it
4913        // doesn't sit in the children list as a zero-sized stub.
4914        let (scroll_x, scroll_y, max_scroll_x, max_scroll_y, vr_x, vr_y) = {
4915            let st = self.state.borrow();
4916            (
4917                st.scroll_x.clone(),
4918                st.scroll_y.clone(),
4919                st.max_scroll_x.clone(),
4920                st.max_scroll_y.clone(),
4921                st.viewport_ratio_x.clone(),
4922                st.viewport_ratio_y.clone(),
4923            )
4924        };
4925
4926        let mut children = vec![root];
4927        if self.v_scroll_policy != ScrollPolicy::AlwaysOff {
4928            let v_sb = ScrollBar::new(
4929                ScrollBarOrientation::Vertical,
4930                scroll_y,
4931                max_scroll_y.clone(),
4932                vr_y,
4933            )
4934            .visual(ScrollBarVariant::Overlay);
4935            let v_id = ctx.add(v_sb);
4936            self.v_scrollbar_id = Some(v_id);
4937            children.push(v_id);
4938        }
4939        if self.h_scroll_policy != ScrollPolicy::AlwaysOff {
4940            let h_sb = ScrollBar::new(
4941                ScrollBarOrientation::Horizontal,
4942                scroll_x,
4943                max_scroll_x.clone(),
4944                vr_x,
4945            )
4946            .visual(ScrollBarVariant::Overlay);
4947            let h_id = ctx.add(h_sb);
4948            self.h_scrollbar_id = Some(h_id);
4949            children.push(h_id);
4950        }
4951
4952        // `place_children` reads `max_scroll_y` / `max_scroll_x`
4953        // synchronously to decide whether to give the overlay
4954        // scrollbars a non-zero rect under `ScrollPolicy::Auto`. The
4955        // frame loop publishes those values from `Step 7` on every
4956        // tick — without a Relayout binding the wrapper wouldn't
4957        // re-place its children when the values cross zero, so the
4958        // bars would stay sized 0×0 until something else (scroll
4959        // wheel, resize) forced a layout pass.
4960        let self_id = ctx.self_id();
4961        let registry = ctx.binding_registry();
4962        max_scroll_y.bind_to(
4963            self_id,
4964            registry,
4965            teksilo_core::binding::BindingLevel::Relayout,
4966        );
4967        max_scroll_x.bind_to(
4968            self_id,
4969            registry,
4970            teksilo_core::binding::BindingLevel::Relayout,
4971        );
4972
4973        children
4974    }
4975
4976    fn layout_response(
4977        &self,
4978        proposal: SizeProposal,
4979        ctx: &LayoutContext,
4980    ) -> teksilo_core::widget::LayoutResponse {
4981        self.root_child_id
4982            .and_then(|id| ctx.child_size(id, proposal))
4983            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
4984            .into()
4985    }
4986
4987    fn place_children(
4988        &self,
4989        bounds: Rect,
4990        _proposal: SizeProposal,
4991        children: &mut [WidgetPlacement],
4992        _ctx: &LayoutContext,
4993    ) {
4994        // Chrome (first child) fills the entire bounds. Overlay
4995        // scrollbars float on top at the right (vertical) and
4996        // bottom (horizontal) edges — collapsed to zero when the
4997        // axis policy is `Auto` and there's nothing to scroll.
4998        let sb_thickness = self::frame_loop::SCROLLBAR_THICKNESS;
4999        {
5000            // Record the wrapper node's window-space origin so the pointer
5001            // handlers can reconstruct window coords from the now
5002            // wrapper-node-local positions (see `State::node_origin`).
5003            let mut st = self.state.borrow_mut();
5004            st.node_origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5005        }
5006        let st = self.state.borrow();
5007        let max_y = st.max_scroll_y.get();
5008        let max_x = st.max_scroll_x.get();
5009        drop(st);
5010        let show_v = match self.v_scroll_policy {
5011            ScrollPolicy::AlwaysOn => true,
5012            ScrollPolicy::Auto => max_y > 0.0,
5013            ScrollPolicy::AlwaysOff => false,
5014        };
5015        let show_h = match self.h_scroll_policy {
5016            ScrollPolicy::AlwaysOn => true,
5017            ScrollPolicy::Auto => max_x > 0.0,
5018            ScrollPolicy::AlwaysOff => false,
5019        };
5020        let mut v_rect = Rect::ZERO;
5021        let mut h_rect = Rect::ZERO;
5022        for (idx, child) in children.iter_mut().enumerate() {
5023            if idx == 0 {
5024                child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5025                child.size = Size::new(bounds.width, bounds.height);
5026            } else if Some(child.id) == self.v_scrollbar_id {
5027                if show_v {
5028                    let h = if show_h {
5029                        (bounds.height - sb_thickness).max(0.0)
5030                    } else {
5031                        bounds.height
5032                    };
5033                    child.origin = teksilo_canvas::Point::new(
5034                        bounds.x + bounds.width - sb_thickness,
5035                        bounds.y,
5036                    );
5037                    child.size = Size::new(sb_thickness, h);
5038                    // Widget-local: pointer events arrive widget-local, so
5039                    // the published bounds the press-bypass test compares
5040                    // against must be local too (subtract the widget origin).
5041                    v_rect = Rect::new(
5042                        child.origin.x - bounds.x,
5043                        child.origin.y - bounds.y,
5044                        sb_thickness,
5045                        h,
5046                    );
5047                } else {
5048                    child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5049                    child.size = Size::ZERO;
5050                }
5051            } else if Some(child.id) == self.h_scrollbar_id {
5052                if show_h {
5053                    let w = if show_v {
5054                        (bounds.width - sb_thickness).max(0.0)
5055                    } else {
5056                        bounds.width
5057                    };
5058                    child.origin = teksilo_canvas::Point::new(
5059                        bounds.x,
5060                        bounds.y + bounds.height - sb_thickness,
5061                    );
5062                    child.size = Size::new(w, sb_thickness);
5063                    // Widget-local (see the v_scrollbar branch).
5064                    h_rect = Rect::new(
5065                        child.origin.x - bounds.x,
5066                        child.origin.y - bounds.y,
5067                        w,
5068                        sb_thickness,
5069                    );
5070                } else {
5071                    child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5072                    child.size = Size::ZERO;
5073                }
5074            }
5075        }
5076        // Published to the wrapper's `on_pointer_event` so a press over
5077        // an overlay scrollbar bypasses the drag-select latch — see
5078        // [`v_scrollbar_bounds`](Self::v_scrollbar_bounds).
5079        self.v_scrollbar_bounds.set(v_rect);
5080        self.h_scrollbar_bounds.set(h_rect);
5081    }
5082
5083    fn children(&self) -> Vec<WidgetId> {
5084        let mut ids = Vec::with_capacity(3);
5085        if let Some(id) = self.root_child_id {
5086            ids.push(id);
5087        }
5088        if let Some(id) = self.v_scrollbar_id {
5089            ids.push(id);
5090        }
5091        if let Some(id) = self.h_scrollbar_id {
5092            ids.push(id);
5093        }
5094        ids
5095    }
5096
5097    fn clips_children(&self) -> bool {
5098        // Mirror the body's clipping so chrome around the editor
5099        // doesn't leak the body's overflow.
5100        true
5101    }
5102
5103    fn focus_reveal_rect(&self, _bounds: Rect) -> Option<Rect> {
5104        // On focus gain the framework reveals the focused widget into any
5105        // enclosing ScrollArea. Reveal the caret *line*, not the (potentially
5106        // page-tall, own-scroll-suppressed) whole editor: a click that only
5107        // placed the caret near the top must not jump the page to the editor's
5108        // bottom. Returns the exact absolute caret rect the in-page caret-follow
5109        // uses (viewport_origin + caret − scroll); `scroll_rect_into_view`
5110        // excludes the editor itself, so this targets the enclosing ScrollArea
5111        // with no double-scroll. `None` (→ reveal whole bounds) before the first
5112        // layout or while unfocused.
5113        self::keyboard::caret_window_rect(&self.state.borrow())
5114    }
5115
5116    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
5117        // Transparent container in the AT tree — the inner
5118        // `RichTextEditorBody` carries the real role
5119        // (`MultilineTextInput` / `Document`) plus the paragraph and
5120        // text-run children. Without this method the wrapper would
5121        // emit a `Role::Unknown` node (the `AccessNodeBuilder`
5122        // default), which screen readers can't classify. Same
5123        // pattern as [`TextInput`](crate::TextInput), which also
5124        // wraps a focusable inner field.
5125        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
5126    }
5127}
5128
5129// ---------------------------------------------------------------------------
5130// Event handlers — take `&SharedState` so they can be boxed into handler
5131// closures without borrowing `self`.
5132// ---------------------------------------------------------------------------
5133
5134/// Shared body of [`RichTextEditor::reveal_range`] and its
5135/// [`EditorHandle`] twin — one implementation so the two can never drift on the
5136/// typewriter-pin rule.
5137///
5138/// The pin applies here even though the range is not the caret: with typewriter
5139/// scrolling on, walking search hits should bring each one to the same height
5140/// the writer works at. Unlike the caret chase, a pointer anchor does *not*
5141/// suppress it — the user asked for this jump explicitly by pressing Find Next,
5142/// so there is no gesture to fight.
5143fn reveal_range_impl(
5144    state: &SharedState,
5145    ctx: &mut teksilo_core::widget::EventContext,
5146    start: usize,
5147    end: usize,
5148) -> bool {
5149    // **Named as the rect's owner**, so the scroll walk climbs the *editor's*
5150    // ancestors and not the handler's. The two are the same widget when the editor
5151    // reveals its own caret, and different every time something built beside it asks
5152    // — a find banner's Next button, a mention counter's chevron. Those sit outside
5153    // the scrolling page, so a walk from them leaves through the strip and never
5154    // meets the scroll container: the match was selected, the counter moved, and the
5155    // viewport stayed exactly where it was. `self_id` is `None` only before the
5156    // editor's first build, and there is nothing laid out to reveal then anyway.
5157    let (area, pin, owner) = {
5158        let st = state.borrow();
5159        // **Dormant is "no layout to do it in"**, even though the engine still holds
5160        // one: `has_full_layout` is set at the first full layout and never cleared, and
5161        // parking an editor clears its focus, caret and band but not its layout. So the
5162        // rect below resolves perfectly for a tab nobody can see, the ancestor walk
5163        // finds a scroll container that is not on screen, and the answer `true` tells a
5164        // caller holding several editors over one document to stop looking — while the
5165        // visible one, never asked, stays exactly where it was.
5166        if st.activation.as_ref().is_some_and(|a| !a.get()) {
5167            return false;
5168        }
5169        match self::keyboard::range_window_rect(&st, start, end) {
5170            Some(a) => (a, st.typewriter, st.self_id),
5171            None => return false,
5172        }
5173    };
5174    match (pin, owner) {
5175        (Some(fraction), Some(owner)) => ctx.ensure_visible_aligned_from(
5176            owner,
5177            area,
5178            fraction,
5179            teksilo_core::event::ScrollMotion::Smooth,
5180        ),
5181        (Some(fraction), None) => {
5182            ctx.ensure_visible_aligned(area, fraction, teksilo_core::event::ScrollMotion::Smooth)
5183        }
5184        (None, Some(owner)) => ctx.ensure_visible_from(owner, area),
5185        (None, None) => ctx.ensure_visible(area),
5186    }
5187    true
5188}
5189
5190/// Set (or clear) an editor's ambient caret band. Shared by
5191/// [`RichTextEditor::set_caret_highlight`] and its [`EditorHandle`] mirror.
5192///
5193/// The session is created on first use and torn down when the band is cleared, so an editor
5194/// that never asks for one registers nothing on the document at all — which matters, since
5195/// every read-only preview pane shares the documents the writing panes are editing.
5196fn set_caret_highlight(state: &SharedState, highlight: Option<caret_highlight::CaretHighlight>) {
5197    let mut st = state.borrow_mut();
5198    match (&st.caret_highlight, &highlight) {
5199        (None, None) => return,
5200        (None, Some(_)) => {
5201            let session = caret_highlight::CaretHighlightSession::new(&st.document);
5202            session.set_config(highlight);
5203            // The frame loop hands it the focus state and the caret on the next tick, so a band
5204            // switched on mid-session appears without the editor having to be touched.
5205            let active = st.has_focus && !st.cursor.has_selection();
5206            session.set_active(active);
5207            st.caret_highlight_active = active;
5208            st.caret_highlight = Some(session);
5209        }
5210        (Some(_), None) => {
5211            // Dropping the session retires its highlight layer.
5212            st.caret_highlight = None;
5213            st.caret_highlight_active = false;
5214        }
5215        (Some(session), Some(_)) => {
5216            session.set_config(highlight);
5217        }
5218    }
5219    // A band that appeared, vanished or changed colour needs a frame to draw it — and the
5220    // resolve-and-push itself only happens in `frame_loop::tick`, so without waking the tree an
5221    // idle editor stays configured-but-unbanded until some unrelated interaction pumps a frame.
5222    // Same poke `set_typography_defaults` / `set_font_size_scale` make, for the same reason:
5223    // these are the ctx-less setters a host calls from a settings or theme effect.
5224    st.content_dirty = true;
5225    if let Some(handle) = &st.frame_request {
5226        handle.set(true);
5227    }
5228}
5229
5230/// Push the current cursor position / anchor / selection flag into
5231/// the state's reactive signals. Called after every cursor mutation
5232/// so external observers (status bars, tests) see the change on the
5233/// next signal propagation. Exported to `keyboard` and `mouse`
5234/// because every event handler ends with a signal publish.
5235pub(super) fn sync_cursor_signals(state: &SharedState) {
5236    let mut st = state.borrow_mut();
5237    let pos = st.cursor.position();
5238    let anc = st.cursor.anchor();
5239    let has_sel = st.cursor.has_selection();
5240    let pos_sig = st.cursor_position.clone();
5241    let anc_sig = st.cursor_anchor.clone();
5242    let sel_sig = st.has_selection.clone();
5243    let caret_vis_sig = st.caret_visible.clone();
5244    // Restart the blink phase on every cursor mutation: a steady-visible
5245    // caret while typing or holding an arrow key, blinking only
5246    // resumes after the user stops moving. Mirrors focus-gain behavior
5247    // (see the FocusChanged handler around rich_text.rs:2041). The frame
5248    // loop only toggles once a full interval has elapsed since the phase
5249    // start, so restarting here delays the next toggle by a full interval.
5250    let blink_reset = st.has_focus && matches!(st.policy.caret_policy, CaretPolicy::Blinking);
5251    if blink_reset {
5252        st.blink.restart();
5253    }
5254    drop(st);
5255    pos_sig.set(pos);
5256    anc_sig.set(anc);
5257    sel_sig.set(has_sel);
5258    if blink_reset && !caret_vis_sig.get() {
5259        caret_vis_sig.set(true);
5260    }
5261}
5262
5263/// Dispatch an AccessKit `ActionRequest` payload for the rich text
5264/// editor. Handles `SetTextSelection` (screen-reader-initiated
5265/// caret moves), `SetValue` (programmatic text replacement), and
5266/// `ScrollIntoView` (scroll so the caret is visible).
5267fn handle_access_action_request(
5268    state: &SharedState,
5269    action: teksilo_core::accesskit::Action,
5270    _target_node: teksilo_core::accesskit::NodeId,
5271    data: Option<teksilo_core::accesskit::ActionData>,
5272    ctx: &mut teksilo_core::widget::EventContext,
5273) -> teksilo_core::event::EventResponse {
5274    use self::policy::EditCommandKind;
5275    use teksilo_core::accesskit::{Action, ActionData};
5276    use teksilo_core::event::EventResponse;
5277    use teksilo_text::text_document::{MoveMode, SelectionType};
5278
5279    match (action, data) {
5280        (Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
5281            let filter = state.borrow().policy.command_filter;
5282            // Screen-reader-initiated caret moves are "navigation",
5283            // filtered under the same rule as arrow keys.
5284            if !filter.accepts(EditCommandKind::MoveLeft) {
5285                return EventResponse::Ignored;
5286            }
5287            let resolve = |pos: teksilo_core::accesskit::TextPosition| -> Option<usize> {
5288                let st = state.borrow();
5289                let map = st.synthetic_to_element.borrow();
5290                let er = map.get(&pos.node)?.clone();
5291                // Convert character_index (char units within the run)
5292                // to a byte offset within the run's text, then add
5293                // absolute_start to get the document position.
5294                let byte_off = er
5295                    .text
5296                    .char_indices()
5297                    .nth(pos.character_index)
5298                    .map(|(i, _)| i)
5299                    .unwrap_or(er.text.len());
5300                Some(er.absolute_start + byte_off)
5301            };
5302            if let (Some(a), Some(f)) = (resolve(sel.anchor), resolve(sel.focus)) {
5303                let st = state.borrow();
5304                st.cursor.set_position(a, MoveMode::MoveAnchor);
5305                st.cursor.set_position(f, MoveMode::KeepAnchor);
5306                drop(st);
5307                sync_cursor_signals(state);
5308                ctx.request_frame();
5309                EventResponse::Handled
5310            } else {
5311                EventResponse::Ignored
5312            }
5313        }
5314        (Action::SetValue, Some(ActionData::Value(value))) => {
5315            let filter = state.borrow().policy.command_filter;
5316            // `SetValue` swaps the *whole document* for the supplied string, so
5317            // accepting `InsertChar` is not enough on its own: under a
5318            // forward-only filter this is the single most destructive edit
5319            // available, however additive the incoming text looks. Dictation
5320            // that wants to add rather than replace arrives as
5321            // `ReplaceSelectedText` below.
5322            if !filter.accepts(EditCommandKind::InsertChar)
5323                || !filter.allows_wholesale_replacement()
5324            {
5325                return EventResponse::Ignored;
5326            }
5327            let st = state.borrow();
5328            st.cursor.select(SelectionType::Document);
5329            let _ = st.cursor.insert_text(value.as_ref());
5330            // For some people this **is** typing — dictation, a braille display —
5331            // and it is reported as itself rather than as `Keyboard` or as
5332            // nothing at all. A toolkit that folded it into typing would erase
5333            // how they work; one that reported nothing would leave anything
5334            // counting arrivals silently short for exactly those writers.
5335            st.report_inserted(EditSource::Accessibility, value.as_ref());
5336            drop(st);
5337            sync_cursor_signals(state);
5338            ctx.request_frame();
5339            EventResponse::Handled
5340        }
5341        (Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
5342            // Insert at the caret, replacing the active selection (if
5343            // any) — NOT the whole document like `SetValue`. The AT-SPI
5344            // (Linux) / UIA (Windows) braille-keyboard & dictation
5345            // insertion path; macOS routes insertion through `SetValue`.
5346            // We advertise the action in `accessibility()`, so service it.
5347            let filter = state.borrow().policy.command_filter;
5348            if !filter.accepts(EditCommandKind::InsertChar) {
5349                return EventResponse::Ignored;
5350            }
5351            let st = state.borrow();
5352            self::keyboard::collapse_selection_before_insert(&st);
5353            let _ = st.cursor.insert_text(value.as_ref());
5354            // The AT-SPI / UIA insertion path, which is how a braille keyboard
5355            // and most dictation write. Same reason as `SetValue` above.
5356            st.report_inserted(EditSource::Accessibility, value.as_ref());
5357            drop(st);
5358            sync_cursor_signals(state);
5359            ctx.request_frame();
5360            EventResponse::Handled
5361        }
5362        (Action::ScrollIntoView, _) => {
5363            let mut st = state.borrow_mut();
5364            if let Some(new_y) = st.engine.ensure_caret_visible() {
5365                st.scroll_y.set(new_y);
5366            }
5367            drop(st);
5368            ctx.request_frame();
5369            EventResponse::Handled
5370        }
5371        _ => EventResponse::Ignored,
5372    }
5373}
5374
5375/// Convert an intra-fragment byte offset into a character index.
5376/// Used by `accessibility()` to map the user's document-absolute
5377/// cursor position into AccessKit's `TextPosition.character_index`
5378/// (which indexes into the target TextRun's `character_lengths`,
5379/// i.e., one entry per Rust `char`).
5380fn char_index_in_text(text: &str, byte_offset: usize) -> usize {
5381    // Walk char_indices until we pass byte_offset; the count at
5382    // that point is the character index. Fall back to the char
5383    // count when byte_offset >= text.len().
5384    if byte_offset >= text.len() {
5385        return text.chars().count();
5386    }
5387    let mut count = 0usize;
5388    for (i, _) in text.char_indices() {
5389        if i >= byte_offset {
5390            return count;
5391        }
5392        count += 1;
5393    }
5394    count
5395}
5396
5397// ── The framework's uniform view of a text-editing widget ────────────────────
5398
5399impl teksilo_core::text_surface::TextSurface for EditorHandle {
5400    fn can_undo(&self) -> bool {
5401        EditorHandle::can_undo(self).get()
5402    }
5403
5404    fn can_redo(&self) -> bool {
5405        EditorHandle::can_redo(self).get()
5406    }
5407
5408    fn undo(&self) {
5409        EditorHandle::undo(self);
5410    }
5411
5412    fn redo(&self) {
5413        EditorHandle::redo(self);
5414    }
5415
5416    /// The editor's own [`CommandFilter`]
5417    /// is the authority: a host that has imposed `ForwardOnly` or `ReadOnly` on
5418    /// this editor must not be able to route around it from a menu.
5419    fn history_frozen(&self) -> bool {
5420        !self.command_filter().accepts(EditCommandKind::Undo)
5421    }
5422
5423    fn has_selection(&self) -> bool {
5424        EditorHandle::has_selection(self).get()
5425    }
5426
5427    fn is_read_only(&self) -> bool {
5428        !self.command_filter().accepts(EditCommandKind::InsertChar)
5429    }
5430
5431    fn allows_copy(&self) -> bool {
5432        self.command_filter().accepts(EditCommandKind::Copy)
5433    }
5434
5435    fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5436        EditorHandle::cut(self, ctx);
5437    }
5438
5439    fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5440        EditorHandle::copy(self, ctx);
5441    }
5442
5443    fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5444        EditorHandle::paste(self, ctx);
5445    }
5446
5447    fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5448        EditorHandle::paste_unformatted(self, ctx);
5449    }
5450
5451    fn select_all(&self) {
5452        EditorHandle::select_all(self);
5453    }
5454}