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 [`EditorHandle`] that does not keep its editor alive.
2127///
2128/// **For a callback the editor itself stores.** `on_image_activated`,
2129/// `on_image_resized`, `on_link_activated`, `on_files_dropped`, `on_change`,
2130/// `on_text_inserted` and the image resolver are all kept on the editor's own
2131/// state, so a handler that captures an [`EditorHandle`] by value makes the state
2132/// own itself. Nothing can break that ring afterwards: the widget can be
2133/// destroyed, its tree dropped and its window closed, and the editor — with its
2134/// document, its cursor and its shaped layout — stays resident for the life of
2135/// the process. It is a leak with no owner left to blame, and it is easy to write,
2136/// because reaching for `editor.handle()` is the obvious way for such a handler to
2137/// act on the editor it belongs to.
2138///
2139/// Capture this instead and [`upgrade`](Self::upgrade) inside the handler. The
2140/// handler runs only while the editor is alive, which is the only time it could
2141/// have done anything anyway.
2142///
2143/// ```ignore
2144/// let editor = RichTextEditor::editor(doc);
2145/// let weak = editor.handle().downgrade();
2146/// let editor = editor.on_image_activated(move |activation, _ctx| {
2147///     let Some(handle) = weak.upgrade() else { return };
2148///     handle.select_range(activation.offset, activation.offset + 1);
2149/// });
2150/// ```
2151///
2152/// A factory the *builder* stores rather than the state —
2153/// [`context_menu`](RichTextEditor::context_menu) is the one today — may hold a
2154/// strong handle safely, because it dies with the widget.
2155#[derive(Clone)]
2156pub struct WeakEditorHandle {
2157    state: std::rc::Weak<std::cell::RefCell<EditorState>>,
2158}
2159
2160impl WeakEditorHandle {
2161    /// The handle, if its editor is still alive.
2162    pub fn upgrade(&self) -> Option<EditorHandle> {
2163        self.state.upgrade().map(|state| EditorHandle { state })
2164    }
2165}
2166
2167impl std::fmt::Debug for WeakEditorHandle {
2168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2169        f.debug_struct("WeakEditorHandle")
2170            .field("alive", &self.state.strong_count().min(1))
2171            .finish()
2172    }
2173}
2174
2175/// An inline image the user clicked.
2176///
2177/// Carries the offset as well as the name because a document may hold the same
2178/// picture more than once — a name alone cannot say *which* one was clicked, so
2179/// a host acting on the click (selecting it, editing its size, replacing it)
2180/// would be guessing. The offset addresses the image's single `U+FFFC`, so
2181/// `select_range(offset, offset + 1)` selects exactly it.
2182#[derive(Debug, Clone, PartialEq, Eq)]
2183pub struct ImageActivation {
2184    /// The image's resource name — the `src` the document stores.
2185    pub name: String,
2186    /// Character offset of the image within the document.
2187    pub offset: usize,
2188}
2189
2190/// Rich text being dragged out of an editor.
2191///
2192/// The typed fast path for editor-to-editor drags: it carries the
2193/// `DocumentFragment` itself, so formatting, tables and inline images survive a
2194/// move the way they survive a copy/paste — where the `text/plain` MIME
2195/// alternative the drag also advertises (for other applications) could only
2196/// carry the words.
2197///
2198/// `source` and `range` are what let the drop tell a *move* from a *copy*:
2199/// dropped back into the editor it came from, the original has to be removed,
2200/// and only the source editor can say which range that was.
2201#[derive(Debug, Clone)]
2202pub struct EditorTextDrag {
2203    /// The editor the text was picked up from.
2204    pub source: teksilo_core::WidgetId,
2205    /// The dragged range in that editor, as document offsets.
2206    pub range: (usize, usize),
2207    /// The dragged content, with its formatting.
2208    pub fragment: teksilo_text::text_document::DocumentFragment,
2209    /// The same content as plain text — the drop's fallback, and the bytes
2210    /// handed to another application when the drag leaves the window.
2211    pub text: String,
2212}
2213
2214/// Whether this payload is one the editor can take.
2215///
2216/// Text, files, and an [`EditorTextDrag`] from any editor. Any other typed
2217/// payload belongs to whichever widget understands that type — a binder row
2218/// dropped on the prose should still open a document, not paste its debug
2219/// representation.
2220///
2221/// **Optimistic while the drag is still in the air.** On Wayland the concrete
2222/// `files` / `text` arrive only at drop; during hover the payload carries just
2223/// the *advertised* formats. Judging by content alone therefore refuses every
2224/// external drag for its whole flight — the drop is forbidden everywhere right
2225/// up to the release that would have filled it in. So an advertised
2226/// `text/uri-list` or text format counts as acceptance, and the real check
2227/// happens at drop, where there is finally something to check. This is the same
2228/// rule `DropTarget::accept_external_files` / `accept_external_text` apply.
2229fn droppable(payload: &teksilo_core::DragPayload) -> bool {
2230    if payload.get_typed::<EditorTextDrag>().is_some() {
2231        return true;
2232    }
2233    if !payload.files().is_empty() || payload.text().is_some_and(|t| !t.is_empty()) {
2234        return true;
2235    }
2236    payload.formats().iter().any(|f| {
2237        f.starts_with("text/uri-list")
2238            || f.starts_with("text/plain")
2239            || matches!(f.as_str(), "UTF8_STRING" | "STRING" | "TEXT")
2240    })
2241}
2242
2243/// A resize the reader finished dragging.
2244///
2245/// Reported once, on release, rather than continuously: the document is the
2246/// durable record and rewriting it on every pointer move would put a hundred
2247/// entries on the undo stack for one gesture.
2248#[derive(Debug, Clone, PartialEq, Eq)]
2249pub struct ImageResize {
2250    /// The image's resource name.
2251    pub name: String,
2252    /// Character offset of its `U+FFFC` — the identity, since a document may
2253    /// hold one picture in several places.
2254    pub offset: usize,
2255    /// The new display size in logical pixels, proportions preserved.
2256    pub width: u32,
2257    pub height: u32,
2258}
2259
2260impl EditorHandle {
2261    /// A handle that does not keep this editor alive.
2262    ///
2263    /// Capture this, not `self`, in any handler the editor stores — see
2264    /// [`WeakEditorHandle`] for which those are and what a strong capture costs.
2265    pub fn downgrade(&self) -> WeakEditorHandle {
2266        WeakEditorHandle {
2267            state: Rc::downgrade(&self.state),
2268        }
2269    }
2270
2271    // --- Search / find-banner support (B3, handle mirror) ------------------
2272    //
2273    // These mirror the same-named [`RichTextEditor`] methods (which operate on
2274    // the same `state`), so a per-editor find banner built *above* the editor
2275    // can drive selection / scroll-into-view on the current match through the
2276    // handle it captured — the widget itself is long gone into the tree by then.
2277
2278    /// This editor's content as Djot.
2279    ///
2280    /// The counterpart to [`insert_djot`](Self::insert_djot): a toolbar or command that can
2281    /// write into an editor it did not build should be able to read it back the same way.
2282    /// Without this the only route to the text is the host's own document bookkeeping,
2283    /// which knows about the editors it *mounted* and not about the ones a list or a card
2284    /// grid created — so a command ends up working on some surfaces and silently doing
2285    /// nothing on others.
2286    ///
2287    /// Empty string on a serialisation error, matching `TextDocument::to_djot`'s own
2288    /// callers: a command reading an editor has no better answer than "nothing there", and
2289    /// propagating a `Result` here would push that decision onto every call site.
2290    pub fn to_djot(&self) -> String {
2291        self.state.borrow().document.to_djot().unwrap_or_default()
2292    }
2293
2294    /// This editor's content as the *addressable* plain text — the view whose
2295    /// character offsets are the document's own.
2296    ///
2297    /// The counterpart to [`to_djot`](Self::to_djot) for a caller that has an
2298    /// offset (a caret, a selection, a click) and needs to know what is there.
2299    /// An inline image appears as its `U+FFFC`, so offsets into this string are
2300    /// offsets into the document, character for character — which the `.txt`
2301    /// export's view deliberately is not.
2302    ///
2303    /// Empty string on error, for the same reason `to_djot` returns one.
2304    pub fn to_plain_text(&self) -> String {
2305        self.state
2306            .borrow()
2307            .document
2308            .to_plain_text()
2309            .unwrap_or_default()
2310    }
2311
2312    /// Whether this editor holds no text at all.
2313    ///
2314    /// `character_count() == 0`, so a document of one empty paragraph is empty but one
2315    /// holding only spaces is not — the distinction a caller usually wants is
2316    /// `to_djot().trim().is_empty()`, and this is the cheap O(1) pre-check.
2317    pub fn is_empty(&self) -> bool {
2318        self.state.borrow().document.is_empty()
2319    }
2320
2321    /// Reactive signal — `true` while **this** editor holds keyboard focus.
2322    /// See [`RichTextEditor::focused_signal`].
2323    pub fn focused_signal(&self) -> Signal<bool> {
2324        self.state.borrow().focus_signal.clone()
2325    }
2326
2327    /// Select the character range `[start, end)` without collapsing (anchor at
2328    /// `start`, caret at `end`). See [`RichTextEditor::select_range`].
2329    pub fn select_range(&self, start: usize, end: usize) {
2330        {
2331            let mut st = self.state.borrow_mut();
2332            st.cursor.set_position(start, MoveMode::MoveAnchor);
2333            st.cursor.set_position(end, MoveMode::KeepAnchor);
2334            st.cursor_affinity = teksilo_text::CursorAffinity::Downstream;
2335        }
2336        sync_cursor_signals(&self.state);
2337    }
2338
2339    /// Replace the character range `[start, end)` with `text`, leaving the caret
2340    /// after the inserted text.
2341    ///
2342    /// The counterpart to [`select_range`](Self::select_range) for callers that
2343    /// must *rewrite* a span rather than merely reveal it — a spell-check
2344    /// correction picked from a context menu, an autocorrect, a
2345    /// replace-this-occurrence action. It goes through the widget's **internal**
2346    /// cursor, so the edit behaves exactly like typed text: it lands on the
2347    /// editor's undo stack as one entry (the replacement is a single
2348    /// insert-over-selection), fires the document's change notifications, and
2349    /// leaves the caret where the user would expect it.
2350    ///
2351    /// Offsets are **character** positions, the same space
2352    /// [`cursor_position`](Self::cursor_position) and `select_range` use. The
2353    /// inserted text inherits the character format at `start`, so correcting a
2354    /// word inside italic prose stays italic.
2355    ///
2356    /// Reaching through [`TextDocument::cursor`](teksilo_text::text_document::TextDocument::cursor)
2357    /// instead would mutate the document behind the widget's back, leaving the
2358    /// caret decoupled from the edit — use this.
2359    pub fn replace_range(&self, start: usize, end: usize, text: &str) {
2360        self.replace_range_from(start, end, text, EditSource::Programmatic);
2361    }
2362
2363    /// As [`replace_range`](Self::replace_range), saying which channel the text
2364    /// came through for [`on_text_inserted`](RichTextEditor::on_text_inserted).
2365    ///
2366    /// `replace_range` itself reports [`EditSource::Programmatic`], which is
2367    /// what a handle-driven edit is by default: a toolbar, a menu command, a
2368    /// substitution the application made. **An application that knows better
2369    /// should say so here rather than let the default stand.** The distinction
2370    /// that matters most is an edit which merely puts back what the person
2371    /// typed — undoing an autocorrect, say. Those characters were typed, they
2372    /// are being typed again, and reporting them as the application's own work
2373    /// would credit the application with the writer's words.
2374    ///
2375    /// One call rather than an insert plus a separate report, so the two cannot
2376    /// drift apart at a call site that later grows a second early return.
2377    pub fn replace_range_from(&self, start: usize, end: usize, text: &str, source: EditSource) {
2378        // Select, then insert over the selection — each step in its own borrow
2379        // scope, mirroring `select_range` / `RichTextEditor::insert_text`. The
2380        // insert must not run while a `borrow_mut` is held: it notifies document
2381        // observers, which are free to read the state back.
2382        self.select_range(start, end);
2383        {
2384            let st = self.state.borrow();
2385            let _ = st.cursor.insert_text(text);
2386            st.report_inserted(source, text);
2387        }
2388        sync_cursor_signals(&self.state);
2389    }
2390
2391    /// Insert plain text at the caret, replacing any selection. The
2392    /// [`EditorHandle`] counterpart of
2393    /// [`RichTextEditor::insert_text`](RichTextEditor::insert_text), for callers
2394    /// that hold only a handle — a toolbar button or a global menu command.
2395    pub fn insert_text(&self, text: &str) {
2396        {
2397            let st = self.state.borrow();
2398            let _ = st.cursor.insert_text(text);
2399        }
2400        sync_cursor_signals(&self.state);
2401    }
2402
2403    /// Register an image's bytes on this editor's document, under `name`.
2404    ///
2405    /// An inline image stores only a name; the paint pass resolves it to pixels
2406    /// through the document's resource table. So an image inserted without this
2407    /// lays out and stays blank — and the name is also what a *reload* resolves
2408    /// against, which is why a host restoring a document has to register its
2409    /// images before the first paint rather than at insertion time only.
2410    ///
2411    /// On the handle rather than only on the widget because commands operate on
2412    /// whichever editor has focus, including ones a list or card grid built that
2413    /// the host never mounted itself.
2414    pub fn add_image_resource(&self, name: &str, mime_type: &str, bytes: &[u8]) -> bool {
2415        let st = self.state.borrow();
2416        st.document
2417            .add_resource(ResourceType::Image, name, mime_type, bytes)
2418            .is_ok()
2419    }
2420
2421    /// The natural pixel size of a registered image, decoded from its bytes.
2422    ///
2423    /// What the file actually is, not what the document asks it to be shown at
2424    /// — so a host offering "reset to the original size" restores the picture's
2425    /// own dimensions rather than a number remembered from when it was inserted,
2426    /// which is wrong the moment the file behind the name is replaced.
2427    ///
2428    /// Decodes on call. That is deliberate: this answers an explicit, rare
2429    /// request, and caching it would mean holding a second copy of every image
2430    /// in the document for a question almost nobody asks.
2431    pub fn image_resource_size(&self, name: &str) -> Option<(u32, u32)> {
2432        let bytes = self.state.borrow().document.resource(name).ok()??;
2433        let icon = teksilo_canvas::RasterIcon::decode(&bytes).ok()?;
2434        Some((icon.width(), icon.height()))
2435    }
2436
2437    /// Whether this editor's document already has an image under `name`.
2438    ///
2439    /// Registering the same name twice appends a second resource row, so a host
2440    /// re-registering on every paint would grow the document without bound.
2441    pub fn has_image_resource(&self, name: &str) -> bool {
2442        let st = self.state.borrow();
2443        st.document.resource(name).ok().flatten().is_some()
2444    }
2445
2446    /// Insert a fragment parsed from djot at the caret, replacing any selection.
2447    ///
2448    /// Unlike [`insert_text`](Self::insert_text), which drops its bytes into the
2449    /// current block verbatim (a `\n` becomes literal content, not a new
2450    /// paragraph), this parses block-level djot into a `DocumentFragment`, so
2451    /// inserting a standalone paragraph really does create one.
2452    pub fn insert_djot(&self, djot: &str) {
2453        {
2454            let st = self.state.borrow();
2455            let _ = st.cursor.insert_djot(djot);
2456        }
2457        sync_cursor_signals(&self.state);
2458    }
2459
2460    /// Split the current block at the caret, as pressing Enter does.
2461    pub fn insert_block(&self) {
2462        {
2463            let st = self.state.borrow();
2464            let _ = st.cursor.insert_block();
2465        }
2466        sync_cursor_signals(&self.state);
2467    }
2468
2469    /// Insert `text` as a **paragraph of its own** at the caret: split here, fill
2470    /// the new block, split again, so whatever followed the caret continues in a
2471    /// third block.
2472    ///
2473    /// Deliberately one call rather than three. Composing
2474    /// `insert_block` + `insert_text` + `insert_block` from outside re-enters the
2475    /// widget three times, and an application that rebuilds its editor in
2476    /// response to the first change notification is left driving a handle that
2477    /// no longer points at the mounted widget — the split lands and the text
2478    /// silently does not. Doing the whole edit under a single borrow, with one
2479    /// signal sync at the end, makes it atomic from the caller's side.
2480    /// Returns `false` if any step failed, leaving the document as far as it
2481    /// got. Steps are **not** attempted after a failure: filling and re-splitting
2482    /// on top of a split that did not happen produces a mangled paragraph rather
2483    /// than a partial one, and the caller has no way to tell.
2484    pub fn insert_paragraph(&self, text: &str) -> bool {
2485        let ok = {
2486            let st = self.state.borrow();
2487            st.cursor.insert_block().is_ok()
2488                && st.cursor.insert_text(text).is_ok()
2489                && st.cursor.insert_block().is_ok()
2490        };
2491        sync_cursor_signals(&self.state);
2492        ok
2493    }
2494
2495    /// The live selection as `(anchor, position)`, unordered — `anchor` is where the
2496    /// selection started, `position` is where the caret is, so a backwards drag
2497    /// reports `anchor > position`. Equal values mean no selection.
2498    ///
2499    /// Both ends are read under a **single** borrow, so the pair cannot tear. That is
2500    /// the reason to prefer this over pairing [`cursor_position`](Self::cursor_position)
2501    /// with [`cursor_anchor_signal`](Self::cursor_anchor_signal): the former is a live
2502    /// read of the cursor while the latter is a mirror refreshed on sync, so combining
2503    /// them mixes two different moments in time and can invent — or miss — a selection
2504    /// if the mirror lags. A caller deciding *"is there a selection, and over what"*
2505    /// wants one consistent answer.
2506    pub fn selection(&self) -> (usize, usize) {
2507        let st = self.state.borrow();
2508        (st.cursor.anchor(), st.cursor.position())
2509    }
2510
2511    /// The selected text, or an empty string when nothing is selected.
2512    ///
2513    /// O(selection), not O(document). Pairs with [`selection`](Self::selection)
2514    /// for a caller that needs the range *and* what is in it — a link dialog
2515    /// pre-filling its display name from what the writer highlighted, say.
2516    pub fn selected_text(&self) -> String {
2517        self.state
2518            .borrow()
2519            .cursor
2520            .selected_text()
2521            .unwrap_or_default()
2522    }
2523
2524    /// The **window-space** rectangle enclosing the character range `[start, end)`.
2525    ///
2526    /// The inverse of [`offset_at_point`](Self::offset_at_point): that maps a point
2527    /// to an offset, this maps offsets back to a point. It is what a decoration
2528    /// drawn *outside* the editor — a margin annotation, a connector leader, a
2529    /// bracket spanning a paragraph — needs in order to line itself up with the
2530    /// text it refers to.
2531    ///
2532    /// Coordinates match what the arena stores (`viewport_origin` + engine-local −
2533    /// scroll), so the result can be compared with any other widget's bounds
2534    /// directly, and it tracks scrolling for free.
2535    ///
2536    /// `None` before the first full layout. Focus is **not** required — a margin
2537    /// annotation must stay aligned whether or not the writer is typing.
2538    pub fn range_rect(&self, start: usize, end: usize) -> Option<Rect> {
2539        let st = self.state.borrow();
2540        keyboard::range_window_rect(&st, start, end)
2541    }
2542
2543    /// The **window-space** caret rectangle at one offset — a zero-width
2544    /// [`range_rect`](Self::range_rect), and the anchor point for a marker drawn at
2545    /// one end of a span (the triangle at a comment's tail).
2546    pub fn offset_rect(&self, offset: usize) -> Option<Rect> {
2547        self.range_rect(offset, offset)
2548    }
2549
2550    /// The **content-space** rectangle enclosing `[start, end)` — y = 0 at the top
2551    /// of the laid-out text, unaffected by scrolling and by where the editor sits
2552    /// in the window.
2553    ///
2554    /// The scroll-free counterpart to [`range_rect`](Self::range_rect), and the one
2555    /// to reach for when the question is *what proportion of the document is this*
2556    /// rather than *where is this on screen*. Divided by
2557    /// [`content_height`](Self::content_height) it gives a fraction an overview
2558    /// strip can draw against, for offsets the writer has long scrolled past —
2559    /// which window space cannot express at all, since it reports those relative to
2560    /// a viewport they are nowhere near.
2561    ///
2562    /// `None` before the first full layout. Focus is not required.
2563    pub fn range_content_rect(&self, start: usize, end: usize) -> Option<Rect> {
2564        let st = self.state.borrow();
2565        keyboard::range_content_rect(&st, start, end)
2566    }
2567
2568    /// The **content-space** caret rectangle at one offset — a zero-width
2569    /// [`range_content_rect`](Self::range_content_rect).
2570    pub fn offset_content_rect(&self, offset: usize) -> Option<Rect> {
2571        self.range_content_rect(offset, offset)
2572    }
2573
2574    /// Reactive counter that bumps on every document change — the handle mirror of
2575    /// [`RichTextEditor::document_version`].
2576    ///
2577    /// The change token a decoration drawn *outside* the editor binds, so it
2578    /// re-derives when the text moves under it. Without it such a widget has only
2579    /// the scroll metrics to go on, and those move on a reflow but not on an edit
2580    /// that leaves the height alone — which is most edits, and exactly the ones that
2581    /// shift the offsets a mark is anchored to.
2582    pub fn document_version(&self) -> Signal<u64> {
2583        self.state.borrow().document_version.clone()
2584    }
2585
2586    /// Height of the laid-out text, in the same space
2587    /// [`range_content_rect`](Self::range_content_rect) reports.
2588    ///
2589    /// The denominator that turns a content rect into a fraction of the document.
2590    /// `None` before the first full layout — the same gate the rect queries use, so
2591    /// a caller that has one has the other and the division is never against a
2592    /// stale height.
2593    ///
2594    /// This is the *text's* height, not the widget's: an editor laid out taller
2595    /// than its content (a short scene in a tall pane) reports the text.
2596    pub fn content_height(&self) -> Option<f32> {
2597        let st = self.state.borrow();
2598        st.engine
2599            .has_full_layout()
2600            .then(|| st.engine.content_height())
2601    }
2602
2603    /// Hit-test a point — **in window coordinates**, as a
2604    /// [`context_menu`](RichTextEditor::context_menu) factory receives it — to a
2605    /// document character offset. `None` when the point resolves to no text
2606    /// (past the last glyph on an empty line, outside the body, etc.).
2607    ///
2608    /// Lets a custom context-menu factory resolve "the word under the pointer"
2609    /// from the right-click position, since a bare right-click does not move the
2610    /// caret on its own.
2611    pub fn offset_at_point(&self, window_point: Point) -> Option<usize> {
2612        mouse::offset_at_window_point(&self.state, window_point)
2613    }
2614
2615    /// Reposition the caret to a right-click point (**window coordinates**)
2616    /// unless the click lands inside the current selection (then the selection
2617    /// is preserved). Call this at the top of a custom
2618    /// [`context_menu`](RichTextEditor::context_menu) factory so the menu's Paste
2619    /// — and any caret-relative action — operates where the user clicked, exactly
2620    /// as the built-in menu and the single-line field do.
2621    pub fn reposition_caret_for_context_menu(&self, window_point: Point) {
2622        mouse::reposition_caret_for_context_menu(&self.state, window_point);
2623    }
2624
2625    /// Scroll the character range `[start, end)` into view, reporting whether this editor
2626    /// could — it has a layout to locate the range in, and is on screen rather than parked
2627    /// dormant. See [`RichTextEditor::reveal_range`].
2628    ///
2629    /// When it answers `false` because there is no layout yet, the coarser
2630    /// [`reveal_widget`](Self::reveal_widget) is the way to get one.
2631    pub fn reveal_range(
2632        &self,
2633        ctx: &mut teksilo_core::widget::EventContext,
2634        start: usize,
2635        end: usize,
2636    ) -> bool {
2637        reveal_range_impl(&self.state, ctx, start, end)
2638    }
2639
2640    /// Scroll **the editor itself** into view — the coarse fallback for the one case
2641    /// [`reveal_range`](Self::reveal_range) cannot serve at all. Reports whether this
2642    /// editor could: it has been built, so the arena knows a widget to scroll to, and
2643    /// it is on screen rather than parked dormant.
2644    ///
2645    /// A row of a stream that has never been painted has no full layout, so there is
2646    /// no rect to locate an offset in and `reveal_range` answers `false` — for ever,
2647    /// because the row only gets a layout when it is painted and it is only painted
2648    /// when it comes on screen. That is a deadlock a range reveal has no way out of:
2649    /// a match found in row 31 of a Book leaves the page exactly where it was, with
2650    /// the counter cheerfully reading `1 of 40`.
2651    ///
2652    /// Revealing by *widget* breaks it, because the arena knows where row 31 is laid
2653    /// out whether or not its text has been shaped. The row comes on screen, the next
2654    /// paint gives it a layout, and a later `reveal_range` can then put the match
2655    /// itself where the caller wants it. Coarser on purpose: this reveals the row,
2656    /// not the offset inside it.
2657    pub fn reveal_widget(&self, ctx: &mut teksilo_core::widget::EventContext) -> bool {
2658        let id = {
2659            let st = self.state.borrow();
2660            // The same dormancy gate `reveal_range` applies, and for the same reason:
2661            // a parked editor's bounds are still in the arena, so the walk would
2662            // happily scroll a container nobody can see and answer `true` — and a
2663            // caller told `true` stops looking for the editor that is on screen.
2664            if st.activation.as_ref().is_some_and(|a| !a.get()) {
2665                return false;
2666            }
2667            // `None` only before the editor's first build: nothing is mounted, so
2668            // there is no widget for the arena to resolve bounds for.
2669            match st.self_id {
2670                Some(id) => id,
2671                None => return false,
2672            }
2673        };
2674        ctx.ensure_widget_visible(id);
2675        true
2676    }
2677
2678    /// Move keyboard focus onto the editor. Lets a control built *above* the
2679    /// editor — a find banner returning focus to the prose on Escape — put the
2680    /// caret back where the user expects. A no-op until the editor has built at
2681    /// least once (its wrapper id is stashed then).
2682    pub fn focus(&self, ctx: &mut teksilo_core::widget::EventContext) {
2683        if let Some(id) = self.state.borrow().self_id {
2684            ctx.request_focus(id);
2685        }
2686    }
2687
2688    // --- Character-format query / apply ------------------------------------
2689
2690    /// Read the current character format at the caret. When a selection
2691    /// is active, reads from `selection_start()` rather than
2692    /// `position()` so toolbar bistate stays stable across selection
2693    /// extension (same rule as
2694    /// [`RichTextEditor::caret_char_format`]).
2695    pub fn caret_char_format(&self) -> TextFormat {
2696        let st = self.state.borrow();
2697        let probe_pos = if st.cursor.has_selection() {
2698            st.cursor.selection_start()
2699        } else {
2700            st.cursor.position()
2701        };
2702        let probe = st.document.cursor();
2703        probe.set_position(probe_pos, MoveMode::MoveAnchor);
2704        probe.char_format().unwrap_or_default()
2705    }
2706
2707    fn apply_char_format(&self, fmt: TextFormat) {
2708        let st = self.state.borrow();
2709        let _ = st.cursor.merge_char_format(&fmt);
2710    }
2711
2712    /// Apply **bold** to the current selection.
2713    pub fn set_bold(&self, enabled: bool) {
2714        self.apply_char_format(TextFormat {
2715            font_bold: Some(enabled),
2716            ..Default::default()
2717        });
2718    }
2719
2720    /// Apply *italic* to the current selection.
2721    pub fn set_italic(&self, enabled: bool) {
2722        self.apply_char_format(TextFormat {
2723            font_italic: Some(enabled),
2724            ..Default::default()
2725        });
2726    }
2727
2728    /// Apply underline to the current selection.
2729    pub fn set_underline(&self, enabled: bool) {
2730        self.apply_char_format(TextFormat {
2731            font_underline: Some(enabled),
2732            ..Default::default()
2733        });
2734    }
2735
2736    /// Apply strikethrough to the current selection.
2737    pub fn set_strikethrough(&self, enabled: bool) {
2738        self.apply_char_format(TextFormat {
2739            font_strikeout: Some(enabled),
2740            ..Default::default()
2741        });
2742    }
2743
2744    /// Set the font family for the current selection (a character-format
2745    /// change applied over the selected range). Like the other char-format
2746    /// setters (`set_bold`, …), this is a **no-op when there is no
2747    /// selection** — the document model has no typing/pending format, so a
2748    /// bare caret has no range to format. `family` must be a name resolvable
2749    /// by the shared typesetter's font registrar — e.g. a value chosen from
2750    /// a [`FontPicker`](crate::font_picker::FontPicker).
2751    pub fn set_font_family(&self, family: impl Into<String>) {
2752        self.apply_char_format(TextFormat {
2753            font_family: Some(family.into()),
2754            ..Default::default()
2755        });
2756    }
2757
2758    /// Set the font size (in points) for the current selection.
2759    pub fn set_font_size(&self, size: u32) {
2760        self.apply_char_format(TextFormat {
2761            font_point_size: Some(size),
2762            ..Default::default()
2763        });
2764    }
2765
2766    // --- Default typography / font size (non-destructive, whole editor) ---
2767
2768    /// Set the non-destructive default typography (font family / line height /
2769    /// first-line indent) filled onto runs and blocks with no explicit
2770    /// override. Unlike [`set_font_family`](Self::set_font_family) /
2771    /// [`set_font_size`](Self::set_font_size) — which mutate the selected text —
2772    /// this is a display-time default: it never touches the document, undo
2773    /// stack, or `modified` flag. Schedules a relayout + repaint.
2774    pub fn set_typography_defaults(&self, defaults: EditorTypographyDefaults) {
2775        let mut st = self.state.borrow_mut();
2776        st.engine.set_typography_defaults(defaults);
2777        st.needs_full_layout = true;
2778        st.content_dirty = true;
2779        if let Some(handle) = &st.frame_request {
2780            handle.set(true);
2781        }
2782    }
2783
2784    /// Current default typography.
2785    pub fn get_typography_defaults(&self) -> EditorTypographyDefaults {
2786        self.state.borrow().engine.typography_defaults().clone()
2787    }
2788
2789    /// Set the per-editor logical font-size multiplier. See
2790    /// [`RichTextEditor::set_font_size_scale`].
2791    pub fn set_font_size_scale(&self, scale: f32) {
2792        let mut st = self.state.borrow_mut();
2793        let scale = scale.clamp(0.1, 10.0);
2794        if (st.font_size_scale - scale).abs() <= f32::EPSILON {
2795            return;
2796        }
2797        st.font_size_scale = scale;
2798        st.last_font_scale = f32::NAN;
2799        st.needs_full_layout = true;
2800        st.content_dirty = true;
2801        if let Some(handle) = &st.frame_request {
2802            handle.set(true);
2803        }
2804    }
2805
2806    /// Current per-editor font-size scale (`1.0` = 100 %).
2807    pub fn get_font_size_scale(&self) -> f32 {
2808        self.state.borrow().font_size_scale
2809    }
2810
2811    /// Set the typewriter-scrolling anchor — the [`EditorHandle`] counterpart of
2812    /// [`RichTextEditor::set_typewriter`]. `None` turns pinning off.
2813    ///
2814    /// This is the door a host uses to keep the pin following a live setting,
2815    /// the same way [`set_typography_defaults`](Self::set_typography_defaults)
2816    /// keeps typography following one.
2817    pub fn set_typewriter(&self, anchor: Option<f32>) {
2818        let mut st = self.state.borrow_mut();
2819        st.typewriter = anchor.map(|f| f.clamp(0.0, 1.0));
2820        st.last_chase_y = None;
2821    }
2822
2823    /// Current typewriter anchor.
2824    pub fn get_typewriter(&self) -> Option<f32> {
2825        self.state.borrow().typewriter
2826    }
2827
2828    /// Narrow (or restore) what the keyboard may do — the [`EditorHandle`]
2829    /// counterpart of [`RichTextEditor::set_command_filter`], for hosts that
2830    /// drive a drafting mode from a settings or session effect after the editor
2831    /// is mounted.
2832    pub fn set_command_filter(&self, filter: policy::CommandFilter) {
2833        self.state.borrow_mut().policy.command_filter = filter;
2834    }
2835
2836    /// The filter currently in force on this editor.
2837    pub fn command_filter(&self) -> policy::CommandFilter {
2838        self.state.borrow().policy.command_filter
2839    }
2840
2841    /// Draw an ambient band behind the caret's sentence or paragraph — the [`EditorHandle`]
2842    /// counterpart of [`RichTextEditor::set_caret_highlight`], for hosts that re-push it from a
2843    /// settings or theme effect after the editor is mounted.
2844    pub fn set_caret_highlight(&self, highlight: Option<caret_highlight::CaretHighlight>) {
2845        set_caret_highlight(&self.state, highlight);
2846    }
2847
2848    /// What this editor's caret band is currently configured to draw.
2849    pub fn get_caret_highlight(&self) -> Option<caret_highlight::CaretHighlight> {
2850        self.state
2851            .borrow()
2852            .caret_highlight
2853            .as_ref()
2854            .and_then(|s| s.config())
2855    }
2856
2857    /// The caret's rectangle in **absolute window (tree) coordinates** — the
2858    /// [`EditorHandle`] counterpart of [`RichTextEditor::caret_window_rect`].
2859    /// `None` when unfocused or not yet laid out.
2860    pub fn caret_window_rect(&self) -> Option<teksilo_canvas::Rect> {
2861        self::keyboard::caret_window_rect(&self.state.borrow())
2862    }
2863
2864    /// Apply an arbitrary [`TextFormat`] (escape hatch for fields not
2865    /// covered by the dedicated setters: `letter_spacing`,
2866    /// `foreground_color`, …).
2867    pub fn apply_text_format(&self, fmt: TextFormat) {
2868        self.apply_char_format(fmt);
2869    }
2870
2871    /// Toggle bold on the current selection.
2872    pub fn toggle_bold(&self) {
2873        let current = self.caret_char_format().font_bold.unwrap_or(false);
2874        self.set_bold(!current);
2875    }
2876
2877    /// Toggle italic on the current selection.
2878    pub fn toggle_italic(&self) {
2879        let current = self.caret_char_format().font_italic.unwrap_or(false);
2880        self.set_italic(!current);
2881    }
2882
2883    /// Toggle underline on the current selection.
2884    pub fn toggle_underline(&self) {
2885        let current = self.caret_char_format().font_underline.unwrap_or(false);
2886        self.set_underline(!current);
2887    }
2888
2889    /// Toggle strikethrough on the current selection.
2890    pub fn toggle_strikethrough(&self) {
2891        let current = self.caret_char_format().font_strikeout.unwrap_or(false);
2892        self.set_strikethrough(!current);
2893    }
2894
2895    /// Whether the selection / typing position is bold.
2896    pub fn is_bold(&self) -> bool {
2897        self.caret_char_format().font_bold.unwrap_or(false)
2898    }
2899
2900    /// Whether italic.
2901    pub fn is_italic(&self) -> bool {
2902        self.caret_char_format().font_italic.unwrap_or(false)
2903    }
2904
2905    // ── Hyperlinks ───────────────────────────────────────────────
2906    //
2907    // A link is a character format, not an object: applying one merges a
2908    // destination onto a range, so any bold or italic already there survives
2909    // and no markup has to be escaped. What it does not get for free is
2910    // removal — every field of a merge means "leave this alone" when unset —
2911    // hence `clear_link` rather than "set the destination to nothing".
2912
2913    /// Point the selection at `href`.
2914    ///
2915    /// Merges, so formatting already on the range is kept. A collapsed
2916    /// selection formats nothing (as everywhere else), so a caller linking
2917    /// existing text should select it first — see
2918    /// [`link_at_caret`](Self::link_at_caret) for the range of a link already
2919    /// there.
2920    pub fn set_link(&self, href: &str) {
2921        self.apply_char_format(TextFormat {
2922            anchor_href: Some(href.to_string()),
2923            ..Default::default()
2924        });
2925    }
2926
2927    /// Take the link off the selection, leaving its text.
2928    pub fn clear_link(&self) {
2929        self.apply_char_format(TextFormat {
2930            clear_link: true,
2931            ..Default::default()
2932        });
2933    }
2934
2935    /// The link the caret is in, and how far it reaches.
2936    ///
2937    /// Coalesced across the runs an inner mark splits a link into, so the
2938    /// range covers the whole link rather than the piece under the caret.
2939    /// `None` when the caret is not on a link.
2940    pub fn link_at_caret(&self) -> Option<LinkExtent> {
2941        self.state.borrow().cursor.link_at_caret()
2942    }
2943
2944    /// Whether the caret / selection sits on a link.
2945    pub fn is_link(&self) -> bool {
2946        self.caret_char_format().is_anchor.unwrap_or(false)
2947    }
2948
2949    /// Whether underline.
2950    pub fn is_underline(&self) -> bool {
2951        self.caret_char_format().font_underline.unwrap_or(false)
2952    }
2953
2954    /// Whether strikethrough.
2955    pub fn is_strikethrough(&self) -> bool {
2956        self.caret_char_format().font_strikeout.unwrap_or(false)
2957    }
2958
2959    // --- Vertical alignment (super / subscript) ----------------------------
2960    //
2961    // See [`RichTextEditor::set_superscript`]: one tri-state property shown as
2962    // two toggles, because a run cannot be both raised and lowered.
2963
2964    /// Raise the selection to superscript, or return it to the baseline.
2965    pub fn set_superscript(&self, enabled: bool) {
2966        self.set_vertical_alignment(if enabled {
2967            CharVerticalAlignment::SuperScript
2968        } else {
2969            CharVerticalAlignment::Normal
2970        });
2971    }
2972
2973    /// Lower the selection to subscript, or return it to the baseline.
2974    pub fn set_subscript(&self, enabled: bool) {
2975        self.set_vertical_alignment(if enabled {
2976            CharVerticalAlignment::SubScript
2977        } else {
2978            CharVerticalAlignment::Normal
2979        });
2980    }
2981
2982    /// Set the selection's vertical alignment directly.
2983    pub fn set_vertical_alignment(&self, alignment: CharVerticalAlignment) {
2984        self.apply_char_format(TextFormat {
2985            vertical_alignment: Some(alignment),
2986            ..Default::default()
2987        });
2988    }
2989
2990    /// The caret's vertical alignment, `Normal` when unset.
2991    pub fn get_vertical_alignment(&self) -> CharVerticalAlignment {
2992        self.caret_char_format()
2993            .vertical_alignment
2994            .unwrap_or(CharVerticalAlignment::Normal)
2995    }
2996
2997    /// True while the caret sits in superscript text.
2998    pub fn is_superscript(&self) -> bool {
2999        self.get_vertical_alignment() == CharVerticalAlignment::SuperScript
3000    }
3001
3002    /// True while the caret sits in subscript text.
3003    pub fn is_subscript(&self) -> bool {
3004        self.get_vertical_alignment() == CharVerticalAlignment::SubScript
3005    }
3006
3007    /// Flip superscript on the selection. Turning it on replaces subscript.
3008    pub fn toggle_superscript(&self) {
3009        self.set_superscript(!self.is_superscript());
3010    }
3011
3012    /// Flip subscript on the selection. Turning it on replaces superscript.
3013    pub fn toggle_subscript(&self) {
3014        self.set_subscript(!self.is_subscript());
3015    }
3016
3017    // --- Block-format query / apply ----------------------------------------
3018
3019    /// Apply an arbitrary [`BlockFormat`] to the caret's block.
3020    pub fn apply_block_format(&self, fmt: BlockFormat) {
3021        let st = self.state.borrow();
3022        let _ = st.cursor.set_block_format(&fmt);
3023    }
3024
3025    /// Set paragraph alignment for the caret's block.
3026    pub fn set_alignment(&self, alignment: Alignment) {
3027        self.apply_block_format(BlockFormat {
3028            alignment: Some(alignment),
3029            ..Default::default()
3030        });
3031    }
3032
3033    /// Unset the block's direction, handing the paragraph back to
3034    /// automatic detection.
3035    ///
3036    /// Not the same as setting left-to-right. An explicit direction
3037    /// *pins* the paragraph and overrides the bidi algorithm, so
3038    /// "clearing" a direction by writing `LeftToRight` would force
3039    /// Arabic and Hebrew prose to lay out backwards. Only an unset
3040    /// direction lets the text speak for itself.
3041    pub fn clear_direction(&self) {
3042        self.apply_block_format(BlockFormat {
3043            clear_direction: true,
3044            ..Default::default()
3045        });
3046    }
3047
3048    /// Set the base reading direction of the caret's block. See
3049    /// [`RichTextEditor::set_direction`].
3050    pub fn set_direction(&self, direction: TextDirection) {
3051        self.apply_block_format(BlockFormat {
3052            direction: Some(direction),
3053            ..Default::default()
3054        });
3055    }
3056
3057    /// Set heading level for the caret's block. `0` = plain paragraph,
3058    /// `1..=6` follow the HTML `<h1>..<h6>` convention.
3059    pub fn set_heading_level(&self, level: u8) {
3060        self.apply_block_format(BlockFormat {
3061            heading_level: Some(level),
3062            ..Default::default()
3063        });
3064    }
3065
3066    /// Current block alignment.
3067    pub fn get_alignment(&self) -> Alignment {
3068        self.state
3069            .borrow()
3070            .cursor
3071            .block_format()
3072            .ok()
3073            .and_then(|f| f.alignment)
3074            .unwrap_or(Alignment::Left)
3075    }
3076
3077    /// The block's explicitly-set reading direction, if it has one.
3078    ///
3079    /// `None` means the writer never chose — the bidi algorithm decides
3080    /// from the text. That is a genuinely different state from an
3081    /// explicit left-to-right, so it is reported rather than defaulted:
3082    /// a toggle needs to show "auto" as its own setting.
3083    pub fn get_direction(&self) -> Option<TextDirection> {
3084        self.state
3085            .borrow()
3086            .cursor
3087            .block_format()
3088            .ok()
3089            .and_then(|f| f.direction)
3090    }
3091
3092    /// Current heading level (0 = plain paragraph).
3093    pub fn get_heading_level(&self) -> u8 {
3094        self.state
3095            .borrow()
3096            .cursor
3097            .block_format()
3098            .ok()
3099            .and_then(|f| f.heading_level)
3100            .unwrap_or(0)
3101    }
3102
3103    // --- Lists -------------------------------------------------------------
3104
3105    /// Wrap the caret's block in a list. `ordered = true` uses decimal
3106    /// numbering, `false` uses bullet discs.
3107    pub fn insert_list(&self, ordered: bool) {
3108        let style = if ordered {
3109            ListStyle::Decimal
3110        } else {
3111            ListStyle::Disc
3112        };
3113        self.create_list(style);
3114    }
3115
3116    /// Wrap the caret's block in a list with an explicit
3117    /// [`ListStyle`].
3118    pub fn create_list(&self, style: ListStyle) {
3119        {
3120            let st = self.state.borrow();
3121            let _ = st.cursor.create_list(style);
3122        }
3123        sync_cursor_signals(&self.state);
3124    }
3125
3126    /// Indent the caret's current list item by one nesting level.
3127    /// No-op when the caret is not inside a list. Equivalent to Tab.
3128    pub fn indent(&self) {
3129        keyboard::indent_current_block(&mut self.state.borrow_mut());
3130        sync_cursor_signals(&self.state);
3131    }
3132
3133    /// Outdent the caret's current list item by one nesting level.
3134    /// No-op at depth 0. Equivalent to Shift+Tab.
3135    pub fn outdent(&self) {
3136        keyboard::dedent_current_block(&mut self.state.borrow_mut());
3137        sync_cursor_signals(&self.state);
3138    }
3139
3140    /// Take the caret's block out of its list entirely, leaving a plain
3141    /// paragraph. No-op when the caret is not inside a list.
3142    ///
3143    /// See [`RichTextEditor::remove_from_list`] for why this is separate from
3144    /// [`outdent`](Self::outdent), which stops at depth 0 by design.
3145    pub fn remove_from_list(&self) {
3146        {
3147            let st = self.state.borrow();
3148            let _ = st.cursor.remove_current_block_from_list();
3149        }
3150        sync_cursor_signals(&self.state);
3151    }
3152
3153    // --- Blockquotes -------------------------------------------------------
3154
3155    /// True iff the caret currently sits inside a blockquote frame at
3156    /// any nesting depth.
3157    pub fn is_in_blockquote(&self) -> bool {
3158        let st = self.state.borrow();
3159        st.cursor.is_in_blockquote()
3160    }
3161
3162    /// True iff the selection spans more than one frame — the
3163    /// "Toggle blockquote" affordance should be disabled in this case.
3164    pub fn selection_spans_multiple_frames(&self) -> bool {
3165        let st = self.state.borrow();
3166        st.cursor.selection_spans_multiple_frames()
3167    }
3168
3169    /// Wrap the current block/selection in a blockquote, or unwrap the
3170    /// innermost enclosing blockquote if already inside one. Toolbar
3171    /// counterpart for a Ctrl+Shift+Q-style toggle.
3172    pub fn toggle_blockquote(&self) {
3173        {
3174            let st = self.state.borrow();
3175            let _ = st.cursor.toggle_blockquote();
3176        }
3177        sync_cursor_signals(&self.state);
3178    }
3179
3180    /// Wrap the current block in a deeper nested quote. Equivalent to
3181    /// Tab inside a blockquote.
3182    pub fn increase_blockquote_depth(&self) {
3183        {
3184            let st = self.state.borrow();
3185            let _ = st.cursor.increase_blockquote_depth();
3186        }
3187        sync_cursor_signals(&self.state);
3188    }
3189
3190    /// Pop the caret out of one blockquote nesting level. Equivalent to
3191    /// Shift+Tab inside a blockquote.
3192    pub fn decrease_blockquote_depth(&self) {
3193        {
3194            let st = self.state.borrow();
3195            let _ = st.cursor.decrease_blockquote_depth();
3196        }
3197        sync_cursor_signals(&self.state);
3198    }
3199
3200    // --- Tables ------------------------------------------------------------
3201
3202    /// Insert a fresh `rows × columns` table at the caret.
3203    pub fn insert_table(&self, rows: usize, columns: usize) {
3204        {
3205            let st = self.state.borrow();
3206            let _ = st.cursor.insert_table(rows, columns);
3207        }
3208        sync_cursor_signals(&self.state);
3209    }
3210
3211    /// Remove the table containing the caret. No-op outside a table.
3212    pub fn remove_current_table(&self) {
3213        {
3214            let st = self.state.borrow();
3215            let _ = st.cursor.remove_current_table();
3216        }
3217        sync_cursor_signals(&self.state);
3218    }
3219
3220    /// Insert a row above the caret's current table row.
3221    pub fn insert_row_above(&self) {
3222        {
3223            let st = self.state.borrow();
3224            let _ = st.cursor.insert_row_above();
3225        }
3226        sync_cursor_signals(&self.state);
3227    }
3228
3229    /// Insert a row below the caret's current table row.
3230    pub fn insert_row_below(&self) {
3231        {
3232            let st = self.state.borrow();
3233            let _ = st.cursor.insert_row_below();
3234        }
3235        sync_cursor_signals(&self.state);
3236    }
3237
3238    /// Insert a column before the caret's current table column.
3239    pub fn insert_column_before(&self) {
3240        {
3241            let st = self.state.borrow();
3242            let _ = st.cursor.insert_column_before();
3243        }
3244        sync_cursor_signals(&self.state);
3245    }
3246
3247    /// Insert a column after the caret's current table column.
3248    pub fn insert_column_after(&self) {
3249        {
3250            let st = self.state.borrow();
3251            let _ = st.cursor.insert_column_after();
3252        }
3253        sync_cursor_signals(&self.state);
3254    }
3255
3256    /// Remove the caret's current table row.
3257    pub fn remove_current_row(&self) {
3258        {
3259            let st = self.state.borrow();
3260            let _ = st.cursor.remove_current_row();
3261        }
3262        sync_cursor_signals(&self.state);
3263    }
3264
3265    /// Remove the caret's current table column.
3266    pub fn remove_current_column(&self) {
3267        {
3268            let st = self.state.borrow();
3269            let _ = st.cursor.remove_current_column();
3270        }
3271        sync_cursor_signals(&self.state);
3272    }
3273
3274    /// Whether the caret is currently inside a table cell.
3275    pub fn is_in_table(&self) -> bool {
3276        self.state.borrow().cursor.current_table().is_some()
3277    }
3278
3279    // --- History -----------------------------------------------------------
3280
3281    /// Undo the most recent edit. No-op when the undo stack is empty.
3282    pub fn undo(&self) {
3283        let _ = self.state.borrow().document.undo();
3284        sync_cursor_signals(&self.state);
3285    }
3286
3287    /// Close the current undo entry, so the next edit starts a new one.
3288    ///
3289    /// Typing coalesces into word-sized undo steps by looking only at the shape
3290    /// of two edits — adjacent, moments apart. It cannot see that the user did
3291    /// something else in between, somewhere else in the application, that they
3292    /// would remember as a dividing line. A host that knows one was crossed says
3293    /// so here, and the burst before it stops merging with the burst after.
3294    pub fn break_undo_merge(&self) {
3295        self.state.borrow().document.break_undo_merge();
3296    }
3297
3298    /// Redo the most recently undone edit. No-op when the redo stack
3299    /// is empty.
3300    pub fn redo(&self) {
3301        let _ = self.state.borrow().document.redo();
3302        sync_cursor_signals(&self.state);
3303    }
3304
3305    // --- Edit blocks (composite undo) --------------------------------------
3306    //
3307    // See [`RichTextEditor::begin_edit_block`] for the rationale: a toolbar
3308    // action composed of several commands should cost one Ctrl+Z, not one per
3309    // property it touched.
3310
3311    /// Begin grouping subsequent edits into a single undo entry. Pair with
3312    /// [`end_edit_block`](Self::end_edit_block), or prefer the scoped
3313    /// [`edit_block`](Self::edit_block).
3314    pub fn begin_edit_block(&self) {
3315        self.state.borrow().cursor.begin_edit_block();
3316    }
3317
3318    /// Close the group opened by [`begin_edit_block`](Self::begin_edit_block).
3319    pub fn end_edit_block(&self) {
3320        self.state.borrow().cursor.end_edit_block();
3321    }
3322
3323    /// Run `edits` as one undo entry — the pairing-safe form.
3324    pub fn edit_block<R>(&self, edits: impl FnOnce() -> R) -> R {
3325        self.begin_edit_block();
3326        let result = edits();
3327        self.end_edit_block();
3328        result
3329    }
3330
3331    // --- Clipboard ---------------------------------------------------------
3332    //
3333    // Programmatic counterparts of Ctrl+C / Ctrl+X / Ctrl+V /
3334    // Ctrl+Shift+V, mirroring [`RichTextEditor::copy`] / `cut` / `paste` /
3335    // `paste_unformatted` body-for-body. Each takes the active
3336    // [`EventContext`](teksilo_core::widget::EventContext) because the
3337    // clipboard handle is looked up via `ctx.app_state::<ClipboardHandle>()`,
3338    // which only has a value during event dispatch — so these are callable
3339    // from an `on_activate_fn` / context-menu closure that captured just a
3340    // handle. A call site holding `&mut EventContext` can pass `&ctx`
3341    // directly; Rust reborrows automatically.
3342
3343    /// Copy the current selection to the system clipboard (plain + HTML
3344    /// payloads). No-op when there is no selection. See
3345    /// [`RichTextEditor::copy`].
3346    pub fn copy(&self, ctx: &teksilo_core::widget::EventContext) {
3347        let mut st = self.state.borrow_mut();
3348        clipboard::copy(&mut st, ctx);
3349    }
3350
3351    /// Cut the current selection: copy first, then remove. See
3352    /// [`RichTextEditor::cut`].
3353    pub fn cut(&self, ctx: &teksilo_core::widget::EventContext) {
3354        {
3355            let mut st = self.state.borrow_mut();
3356            clipboard::cut(&mut st, ctx);
3357        }
3358        sync_cursor_signals(&self.state);
3359    }
3360
3361    /// Paste from the system clipboard. Prefers an in-process fragment
3362    /// over HTML over plain text. See [`RichTextEditor::paste`].
3363    pub fn paste(&self, ctx: &teksilo_core::widget::EventContext) {
3364        {
3365            let mut st = self.state.borrow_mut();
3366            clipboard::paste(&mut st, ctx);
3367        }
3368        sync_cursor_signals(&self.state);
3369    }
3370
3371    /// Paste plain text only, stripping any rich payload. See
3372    /// [`RichTextEditor::paste_unformatted`].
3373    pub fn paste_unformatted(&self, ctx: &teksilo_core::widget::EventContext) {
3374        {
3375            let mut st = self.state.borrow_mut();
3376            clipboard::paste_unformatted(&mut st, ctx);
3377        }
3378        sync_cursor_signals(&self.state);
3379    }
3380
3381    /// Whether a paste would insert anything — `true` iff the system
3382    /// clipboard carries text **or** an HTML payload. A point-in-time
3383    /// query (clipboard contents are not reactively observable), taking
3384    /// the active [`EventContext`](teksilo_core::widget::EventContext).
3385    /// Use it to drive a context-menu / toolbar Paste enable-state,
3386    /// re-querying on menu-open. Mirrors [`RichTextEditor::can_paste`].
3387    pub fn can_paste(&self, ctx: &teksilo_core::widget::EventContext) -> bool {
3388        clipboard::can_paste(ctx)
3389    }
3390
3391    // --- Selection ---------------------------------------------------------
3392
3393    /// Select the entire document programmatically. Resets the Ctrl+A
3394    /// ladder so a subsequent Ctrl+A starts fresh at level 1. Mirrors
3395    /// [`RichTextEditor::select_all`].
3396    pub fn select_all(&self) {
3397        {
3398            let mut st = self.state.borrow_mut();
3399            st.cursor.select(SelectionType::Document);
3400            st.select_all_level = 0;
3401            st.select_all_anchor_cell = None;
3402        }
3403        sync_cursor_signals(&self.state);
3404    }
3405
3406    /// Delete the current selection. No-op when nothing is selected.
3407    /// Mirrors [`RichTextEditor::delete_selection`].
3408    pub fn delete_selection(&self) {
3409        {
3410            let st = self.state.borrow();
3411            if st.cursor.has_selection() {
3412                let _ = st.cursor.remove_selected_text();
3413            }
3414        }
3415        sync_cursor_signals(&self.state);
3416    }
3417
3418    // --- Reactive signal accessors -----------------------------------------
3419
3420    /// Bumps on every format-only document event (bold / italic /
3421    /// heading / alignment / list-style changes). See
3422    /// [`RichTextEditor::format_version`].
3423    pub fn format_version(&self) -> Signal<u64> {
3424        self.state.borrow().format_version.clone()
3425    }
3426
3427    /// The **live** caret offset — reads `cursor.position()` directly, unbatched. Unlike
3428    /// [`cursor_position_signal`](Self::cursor_position_signal), whose stored value lags one frame
3429    /// behind a just-typed printable character (the insert is deferred to the frame loop and the
3430    /// signal is only re-synced on the *next* caret event), this always reflects the true caret —
3431    /// what a host that recomputes highlights on a frame tick must read. Mirrors
3432    /// [`RichTextEditor::cursor_position`].
3433    pub fn cursor_position(&self) -> usize {
3434        self.state.borrow().cursor.position()
3435    }
3436
3437    /// `true` while an IME composition is actively in progress. Mirrors
3438    /// [`RichTextEditor::is_composing`].
3439    pub fn is_composing(&self) -> bool {
3440        self.state.borrow().ime_preedit.is_some()
3441    }
3442
3443    /// Reactive caret position signal.
3444    pub fn cursor_position_signal(&self) -> Signal<usize> {
3445        self.state.borrow().cursor_position.clone()
3446    }
3447
3448    /// Reactive selection anchor signal.
3449    pub fn cursor_anchor_signal(&self) -> Signal<usize> {
3450        self.state.borrow().cursor_anchor.clone()
3451    }
3452
3453    /// Reactive selection-non-empty signal.
3454    pub fn has_selection(&self) -> Signal<bool> {
3455        self.state.borrow().has_selection.clone()
3456    }
3457
3458    /// Reactive undo-availability signal (toolbar enable-state source).
3459    pub fn can_undo(&self) -> Signal<bool> {
3460        self.state.borrow().can_undo.clone()
3461    }
3462
3463    /// Reactive redo-availability signal.
3464    pub fn can_redo(&self) -> Signal<bool> {
3465        self.state.borrow().can_redo.clone()
3466    }
3467}
3468
3469/// Private leaf body for [`RichTextEditor`].
3470///
3471/// Pure rendering surface: layout (intrinsic / greedy via
3472/// `min_lines` / `max_lines`), `place_children` (records the
3473/// viewport on `state`), `paint` (glyph runs, caret, selection),
3474/// `accessibility` (Role::MultilineTextInput / Role::Document plus
3475/// the flow-snapshot walk that emits paragraph + text-run children).
3476///
3477/// Handlers, focus, the context-menu factory, and per-frame ticking
3478/// all live on the composing outer [`RichTextEditor`]; the body
3479/// itself is non-focusable and has no event handlers. The shared
3480/// `state` is what links them — both widgets hold an `Rc` to the
3481/// same [`EditorState`], so a key event on the wrapper mutates the
3482/// state and the body re-paints on the next frame.
3483pub(crate) struct RichTextEditorBody {
3484    state: SharedState,
3485    min_lines: Option<u32>,
3486    max_lines: Option<u32>,
3487}
3488
3489impl std::fmt::Debug for RichTextEditorBody {
3490    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3491        f.debug_struct("RichTextEditorBody")
3492            .field("policy", &self.state.borrow().policy)
3493            .finish_non_exhaustive()
3494    }
3495}
3496
3497/// How tall this text is likely to be, before anything has laid it out.
3498///
3499/// See the call site in [`RichTextEditorBody::layout_response`] for why a guess
3500/// beats the zero it replaces. Two O(1) document reads and some arithmetic; no
3501/// shaping, no glyph cache, nothing that could be slow enough to matter in a
3502/// layout pass.
3503///
3504/// **The typography is the half that decides whether this is useful.** A first cut
3505/// counted bare lines at the font's natural height and came out well under the
3506/// truth for manuscript prose, which is set with a line-height multiplier and space
3507/// between paragraphs — the estimate was missing a third of the page and the rows it
3508/// sized still visibly grew when they finally laid out. So:
3509///
3510/// * lines are counted at the font's own advance, since that is what decides how
3511///   many characters fit on one, and
3512/// * each line is then given the **multiplied** height, and each block the space
3513///   above and below it that a body paragraph gets.
3514///
3515/// The mean advance is taken as half the font's line height. That is roughly right
3516/// for proportional Latin text at ordinary sizes and roughly wrong for everything
3517/// else, which is acceptable for a number whose only competition is a constant and
3518/// whose lifetime is one frame.
3519/// Mean glyph advance as a fraction of the font's natural line height.
3520///
3521/// **Measured, not derived.** Thirty-two real manuscript scenes were laid out in a
3522/// running window and compared against what this function claimed for each, at a
3523/// 447 px measure in Literata at 1.6 line height:
3524///
3525/// | scene | guess | real | ratio |
3526/// |---|---|---|---|
3527/// | 23 443 chars | 19 586 | 17 768 | 1.10 |
3528/// | 20 798 chars | 17 028 | 15 578 | 1.09 |
3529/// | 16 493 chars | 13 860 | 12 827 | 1.08 |
3530///
3531/// The bias was 1.06–1.12 across a 1.4× range of scene sizes: a scale error, not
3532/// noise, and 0.37 solved back to 0.335 on every one of them. Two earlier values
3533/// were reasoned about rather than measured — 0.5 from "half the font size", then
3534/// 0.37 from dividing that by a nominal line height — and both were wrong by more
3535/// than this whole correction.
3536///
3537/// It is a *typical* value, and it is font-dependent: a wider or narrower face moves
3538/// it, which is why the accuracy test allows ±25% rather than pretending otherwise.
3539/// If that stops being good enough, the answer is to learn it from the first real
3540/// layout the process performs rather than to tune the constant again.
3541const MEAN_ADVANCE_OVER_LINE_HEIGHT: f32 = 0.335;
3542
3543fn estimated_content_height(
3544    document: &teksilo_text::text_document::TextDocument,
3545    width: f32,
3546    font_line_h: f32,
3547    typography: &teksilo_text::EditorTypographyDefaults,
3548) -> f32 {
3549    if width <= 0.0 || font_line_h <= 0.0 {
3550        return 0.0;
3551    }
3552    // Characters per line from the **font's** line height: the multiplier below
3553    // spaces lines further apart, it does not make the glyphs wider.
3554    let per_line = (width / (font_line_h * MEAN_ADVANCE_OVER_LINE_HEIGHT)).max(1.0);
3555    let chars = document.character_count() as f32;
3556    let blocks = document.block_count().max(1) as f32;
3557    // Wrapped lines, plus **half** a line per block for the ragged last one of each.
3558    // Half rather than one: a block takes `ceil(chars / per_line)` lines, which
3559    // averages half a line more than the division, and charging a whole one over-
3560    // counted a scene of many short paragraphs by more than the wrapping itself.
3561    // Floored at one line per block, because an empty paragraph still takes a line.
3562    let lines = (chars / per_line + blocks * 0.5).max(blocks);
3563    let line_h = font_line_h * typography.line_height.max(0.1);
3564    let per_block = typography.paragraph_spacing_before + typography.paragraph_spacing_after;
3565    let h = lines * line_h + blocks * per_block.max(0.0);
3566    #[cfg(feature = "debug-traces")]
3567    if height_debug() {
3568        eprintln!(
3569            "HEIGHT-EST chars={chars:.0} blocks={blocks:.0} width={width:.1} \
3570             font_lh={font_line_h:.2} mult={:.2} per_line={per_line:.1} -> {h:.1}",
3571            typography.line_height
3572        );
3573    }
3574    h
3575}
3576
3577/// Whether to print what the height guess and the real layout each came up with.
3578///
3579/// `TEKSILO_HEIGHT_DEBUG=1`, and only in a build with the `debug-traces` feature.
3580/// Read once — this sits in a layout pass, and an environment lookup per
3581/// measurement would be a real cost for a diagnostic that is off for everyone.
3582///
3583/// It earns its place: the guess above was wrong three separate ways before anyone
3584/// could see it, and the one that mattered most — being asked to measure at 76 px
3585/// when the text wraps at 447 — was invisible to every test and obvious in one line
3586/// of this output.
3587///
3588/// Behind a feature as well as a variable, and the traces are `#[cfg]` out rather
3589/// than merely switched off: a runtime `false` still leaves every format string in
3590/// the binary, which is measurable. A diagnostic nobody can switch on has no
3591/// business being reachable in a release, and an environment variable is reachable
3592/// by anyone.
3593#[cfg(feature = "debug-traces")]
3594fn height_debug() -> bool {
3595    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3596    *ON.get_or_init(|| std::env::var_os("TEKSILO_HEIGHT_DEBUG").is_some())
3597}
3598
3599impl Widget for RichTextEditorBody {
3600    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3601        // Bind `caret_visible` to the framework's repaint tracker so
3602        // that every toggle in the frame-tick effect marks **this
3603        // body** widget `needs_paint` — the caret is painted in
3604        // `RichTextEditorBody::paint`. Skipped for `CaretPolicy::Hidden`.
3605        {
3606            let st = self.state.borrow();
3607            let caret_policy = st.policy.caret_policy;
3608            let caret_visible = st.caret_visible.clone();
3609            drop(st);
3610            if caret_policy != CaretPolicy::Hidden {
3611                let self_id = ctx.self_id();
3612                caret_visible.bind_to(
3613                    self_id,
3614                    ctx.binding_registry(),
3615                    teksilo_core::binding::BindingLevel::RepaintOnly,
3616                );
3617            }
3618        }
3619
3620        // Bind document_version at `BindingLevel::AccessibilityOnly` so
3621        // text / format edits flip the tree's `a11y_dirty` flag through
3622        // **this body** — its `accessibility()` is the one that emits
3623        // the editor's Role::MultilineTextInput / Role::Document and
3624        // walks the flow snapshot.
3625        //
3626        // ALSO bind at `RepaintOnly` so the widget's needs_paint flips
3627        // on every text / format change. Without this, paint() only
3628        // ran on caret-blink (the only other RepaintOnly binding), and
3629        // the post-fix dispatch's `last_relayout_block_id.take()` was
3630        // consumed on the wrong tick — leaving text edits invisible
3631        // until a resize forced a full re-layout.
3632        {
3633            let st = self.state.borrow();
3634            let document_version = st.document_version.clone();
3635            drop(st);
3636            let self_id = ctx.self_id();
3637            document_version.bind_to(
3638                self_id,
3639                ctx.binding_registry(),
3640                teksilo_core::binding::BindingLevel::AccessibilityOnly,
3641            );
3642            document_version.bind_to(
3643                self_id,
3644                ctx.binding_registry(),
3645                teksilo_core::binding::BindingLevel::RepaintOnly,
3646            );
3647        }
3648
3649        // Bind `scroll_y`, `scroll_x`, `cursor_position`, `cursor_anchor`,
3650        // and `has_selection` at RepaintOnly so the widget marks
3651        // needs_paint immediately on scroll, cursor move, and selection
3652        // change. Without these, paint() only ran on caret-blink and
3653        // text-version bumps — so scroll/selection changes appeared
3654        // delayed by up to 500ms (in sync with the next caret toggle).
3655        //
3656        // The cursor_only render path inside text-typeset falls back to
3657        // a full render automatically when scroll drifted since
3658        // the last full render, so this binding is correctness-safe.
3659        {
3660            let st = self.state.borrow();
3661            let scroll_y = st.scroll_y.clone();
3662            let scroll_x = st.scroll_x.clone();
3663            let cursor_position = st.cursor_position.clone();
3664            let cursor_anchor = st.cursor_anchor.clone();
3665            let has_selection = st.has_selection.clone();
3666            drop(st);
3667            let self_id = ctx.self_id();
3668            for signal in [&scroll_y, &scroll_x] {
3669                signal.bind_to(
3670                    self_id,
3671                    ctx.binding_registry(),
3672                    teksilo_core::binding::BindingLevel::RepaintOnly,
3673                );
3674            }
3675            // Caret and anchor are repaint-only for geometry, but they ALSO
3676            // change what the a11y walk reports via `set_text_selection_to`. A
3677            // caret-only move (arrow key, click, drag-select) emits no document
3678            // event, so `document_version` never bumps; without an
3679            // `AccessibilityOnly` binding here `a11y_dirty` never flips and a
3680            // screen reader hears the caret frozen at the last edit. Bind both
3681            // levels — the two-level pattern `document_version` uses. Selecting
3682            // moves the caret and/or anchor, so `has_selection` (derived from
3683            // them) needs no separate a11y binding.
3684            for signal in [&cursor_position, &cursor_anchor] {
3685                signal.bind_to(
3686                    self_id,
3687                    ctx.binding_registry(),
3688                    teksilo_core::binding::BindingLevel::RepaintOnly,
3689                );
3690                signal.bind_to(
3691                    self_id,
3692                    ctx.binding_registry(),
3693                    teksilo_core::binding::BindingLevel::AccessibilityOnly,
3694                );
3695            }
3696            has_selection.bind_to(
3697                self_id,
3698                ctx.binding_registry(),
3699                teksilo_core::binding::BindingLevel::RepaintOnly,
3700            );
3701        }
3702
3703        Vec::new()
3704    }
3705
3706    fn layout_response(
3707        &self,
3708        proposal: SizeProposal,
3709        ctx: &LayoutContext,
3710    ) -> teksilo_core::widget::LayoutResponse {
3711        let w = proposal.width.unwrap_or(200.0).max(0.0);
3712
3713        // Greedy mode (default, behaviour unchanged): both knobs
3714        // unset → consume the proposal exactly as before.
3715        if self.min_lines.is_none() && self.max_lines.is_none() {
3716            let h = proposal.height.unwrap_or(100.0).max(0.0);
3717            return (Size::new(w, h)).into();
3718        }
3719
3720        // Intrinsic mode: clamp content height to `[min_h, max_h]`
3721        // where each bound is `n * line_height`. The clamp is a
3722        // hard cap — we ignore the proposal's height and let the
3723        // vertical scroll bar take over past `max_lines`.
3724        // Remember the widest measure anything has asked for, before reading the
3725        // state below — see where it is used for why the widest and not this pass's.
3726        {
3727            let mut st = self.state.borrow_mut();
3728            if let Some(w) = proposal.width
3729                && w > st.widest_measured_width
3730            {
3731                st.widest_measured_width = w;
3732            }
3733        }
3734        let st = self.state.borrow();
3735        // `default_line_height()` is the *unscaled* line height (its standalone
3736        // shaper path uses font_scale = 1.0), but `content_height()` carries the
3737        // engine's font_scale. Scale the per-line bound to match, or a
3738        // text-scaled editor would clip at `max_lines` / under-size at
3739        // `min_lines`.
3740        let line_scale = st.effective_font_scale(ctx.text_scale);
3741        let line_h = st.engine.default_line_height() * line_scale;
3742        // **An estimate rather than a zero before the text has been laid out.**
3743        //
3744        // `content_height()` is `0` until `layout_full` has run, and that does not
3745        // happen until the editor has been through a frame on screen. A zero then
3746        // falls through to the `min_lines` floor below, so *every* unlaid-out editor
3747        // claims the same ten lines whatever it holds — a three-thousand-word scene
3748        // and an empty one measure identically.
3749        //
3750        // On a single editor that is invisible: it is on screen, so it lays out.
3751        // Down a **stream** it is not. A Full Book is a column of editors, most of
3752        // them below the fold, and the page's height is the sum of their claims —
3753        // so the scroll extent is wrong by an order of magnitude and settles, a row
3754        // at a time, as the writer reads. Anything drawing that extent draws the
3755        // settling: a margin lane gives each row a slice to match the claim, then
3756        // watches it grow tenfold the moment the row is reached.
3757        //
3758        // The estimate is deliberately crude — a mean advance of half the line
3759        // height, one extra line per block for the ragged last line of each — and
3760        // being crude is the point. It is thrown away the instant a real layout
3761        // exists, so its only job is to be closer than a constant, which is not a
3762        // demanding standard. It is **not** a floor: an over-estimate corrects
3763        // downwards when the layout lands, where a too-large `min_lines` would
3764        // leave blank space under short text for the life of the widget.
3765        //
3766        // ⚠ **Only when the width is actually known.** `w` above falls back to 200
3767        // for a proposal that carries none, which is fine for a width but ruinous
3768        // for a line count: `CenterColumnFlowing` measures its child width-only, and
3769        // estimating against the fallback made a scene wrap at a quarter of its real
3770        // measure and claim nearly twice its real height. An unbounded measure gets
3771        // the old answer — the floor — because without a measure there is genuinely
3772        // no way to know how many lines the text takes.
3773        let content_h = match (st.engine.has_full_layout(), proposal.width) {
3774            (true, _) => st.engine.content_height(),
3775            // **At the width the text will actually wrap at**, which is the viewport
3776            // the body was last *placed* at — not the width of whichever measurement
3777            // pass happens to be asking.
3778            //
3779            // Measured in a real window those are not the same number, and the
3780            // difference is not small: a stream row was asked to measure at 76 px
3781            // during an early pass, estimated eight characters to a line and so six
3782            // times its true height, while the layout that followed wrapped it at
3783            // 447. A guess taken at the wrong measure is worse than no guess — it is
3784            // the same jump it was meant to remove, pointing the other way.
3785            //
3786            // Zero before the body has ever been placed, and then the proposal is the
3787            // only thing on offer; after the first placement the viewport is the
3788            // truth. `w` above is deliberately not reused: its 200 px fallback is a
3789            // sane default for a width and a ruinous one for a line count.
3790            (false, _) if st.estimate_height_before_layout => {
3791                // The viewport if this body has been placed, else the **widest**
3792                // width anything has asked it to measure at.
3793                //
3794                // Not the width of the pass that happens to be asking: a real window
3795                // proposes 76 px to a stream row whose text wraps at 447, and
3796                // guessing against that claimed six times the true height. Nor the
3797                // viewport alone, which was tried and is worse — a stream's rows are
3798                // rebuilt often enough that it is almost always still zero, so the
3799                // guess simply never ran and every row fell back to the floor it was
3800                // meant to replace.
3801                let width = st.viewport_width.max(st.widest_measured_width);
3802                if width > 0.0 {
3803                    estimated_content_height(
3804                        &st.document,
3805                        width,
3806                        line_h,
3807                        st.engine.typography_defaults(),
3808                    )
3809                } else {
3810                    0.0
3811                }
3812            }
3813            (false, _) => 0.0,
3814        };
3815        drop(st);
3816
3817        let min_h = self.min_lines.map(|n| n as f32 * line_h).unwrap_or(0.0);
3818        let max_h = self
3819            .max_lines
3820            .map(|n| n as f32 * line_h)
3821            .unwrap_or(f32::INFINITY);
3822        let intrinsic_h = content_h.clamp(min_h, max_h);
3823        Size::new(w, intrinsic_h.max(0.0)).into()
3824    }
3825
3826    fn place_children(
3827        &self,
3828        bounds: Rect,
3829        _proposal: SizeProposal,
3830        _children: &mut [WidgetPlacement],
3831        _ctx: &LayoutContext,
3832    ) {
3833        // The body is a leaf, but the layout walker hands every widget its final
3834        // bounds here — and layout runs before paint, so this is the earliest
3835        // (hence authoritative) point at which the viewport can be adopted.
3836        // `sync_viewport` owns the whole handoff, including `engine.set_viewport`
3837        // and the relayout flag; paint calls it again as an idempotent echo. See
3838        // its docs for why the writes must not be split.
3839        self.state.borrow_mut().sync_viewport(bounds);
3840    }
3841
3842    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
3843        let mut st = self.state.borrow_mut();
3844
3845        // Sync the engine's default text color with the active theme
3846        // so dark / light mode swaps reach the rendered glyphs. The
3847        // engine reads `text_color` fresh on every `render()` and
3848        // does not bake it into a glyph cache, so a per-paint write
3849        // is cheap. Skipped when the app pinned a color via
3850        // `RichTextEditor::text_color(...)`.
3851        //
3852        // The render frame DOES cache colors baked into glyph quads,
3853        // though — the cursor-only and block-only render paths reuse
3854        // those cached quads. So when the theme colour actually
3855        // changes, we must dispatch a full render this frame, or the
3856        // visible glyphs keep painting in the old colour until the
3857        // next typing / scroll event happens to bump up to a Full
3858        // path on its own.
3859        // An app-set `text_color` (Color / role / Signal) is resolved against
3860        // the active theme each paint; otherwise track the theme's `editor_fg`.
3861        {
3862            let new_color = match &st.text_color_prop {
3863                Some(prop) => prop.resolve(ctx.theme, true).to_array(),
3864                None => ctx.theme.colors.editor_fg.to_array(),
3865            };
3866            st.engine.set_text_color(new_color);
3867            if st.last_text_color != Some(new_color) {
3868                st.last_text_color = Some(new_color);
3869                st.pending_full_render = true;
3870            }
3871        }
3872
3873        // Caret colour: app override resolved each paint, else the theme's
3874        // `editor_caret` role. The engine defaults the cursor to opaque black,
3875        // so without this the blinking caret stays black under a dark theme.
3876        // Cursor decorations are regenerated on every render (the cursor-only
3877        // path included), so a colour change only needs a render this frame —
3878        // force one so a swap doesn't wait for the next blink toggle.
3879        {
3880            let new_caret = match &st.caret_color_prop {
3881                Some(prop) => prop.resolve(ctx.theme, true).to_array(),
3882                None => ctx.theme.colors.editor_caret.to_array(),
3883            };
3884            st.engine.set_cursor_color(new_caret);
3885            if st.last_cursor_color != Some(new_caret) {
3886                st.last_cursor_color = Some(new_caret);
3887                st.pending_full_render = true;
3888            }
3889        }
3890
3891        // Selection highlight. A custom colour (set via `.selection_color`) is
3892        // used as-is and is NOT auto-desaturated when the window goes inactive
3893        // — matching macOS, where an explicit selection colour opts out of
3894        // system management. Otherwise the theme drives it, window-aware: the
3895        // vivid `editor_selection_bg` while the window is active, the muted
3896        // `selection_bg_inactive` while it is not. Resolved each paint and
3897        // cached, so a change (theme, custom colour, or window-active flip)
3898        // just needs a render this frame.
3899        let new_sel = if let Some(prop) = st.selection_color_prop.as_ref() {
3900            prop.resolve(ctx.theme, true).to_array()
3901        } else if ctx.window_active {
3902            ctx.theme.colors.editor_selection_bg.to_array()
3903        } else {
3904            ctx.theme.colors.selection_bg_inactive.to_array()
3905        };
3906        if st.last_selection_color != Some(new_sel) {
3907            st.engine.set_selection_color(new_sel);
3908            st.last_selection_color = Some(new_sel);
3909            st.pending_full_render = true;
3910        }
3911
3912        // Code block surface colours come from the same theme path
3913        // (`editor_code_block_bg` / `editor_code_block_fg`). Unlike
3914        // `text_color`, these are baked into the converted
3915        // `BlockLayoutParams` at `layout_full` / `relayout_block`
3916        // time, so the typesetter does NOT pick them up on a render
3917        // pass — we need a full re-layout when they change. Setting
3918        // `needs_full_layout = true` schedules that for the same
3919        // frame; `pending_full_render` covers the render side.
3920        let new_code_bg = ctx.theme.colors.editor_code_block_bg.to_array();
3921        let new_code_fg = Some(ctx.theme.colors.editor_code_block_fg.to_array());
3922        st.engine.set_code_block_background(new_code_bg);
3923        st.engine.set_code_block_foreground(new_code_fg);
3924        if st.last_code_block_bg != Some(new_code_bg) || st.last_code_block_fg != new_code_fg {
3925            st.last_code_block_bg = Some(new_code_bg);
3926            st.last_code_block_fg = new_code_fg;
3927            st.needs_full_layout = true;
3928            st.pending_full_render = true;
3929        }
3930
3931        // Link colour rides the same path, and for the same reason: it is
3932        // baked into the shaped runs at layout time, so a theme swap needs a
3933        // full re-layout rather than a repaint. Sharing `TextRole::Link` with
3934        // every other link in the app is the point — a hyperlink in prose and
3935        // one in a panel should not be two different blues.
3936        let new_link_fg = Some(ctx.theme.colors.text_link.to_array());
3937        st.engine.set_link_foreground(new_link_fg);
3938        if st.last_link_fg != new_link_fg {
3939            st.last_link_fg = new_link_fg;
3940            st.needs_full_layout = true;
3941            st.pending_full_render = true;
3942        }
3943
3944        // The engine reads the HiDPI display scale factor from the
3945        // shared `TypesetterBridge` on every `layout_full`, exactly
3946        // like `TextWidget` does internally. No widget-side plumbing
3947        // — this is a render-pipeline concern, invisible to the
3948        // widget author.
3949
3950        // Logical font scale: a11y text scale (if followed) × per-editor
3951        // `font_size_scale`. Baked at `layout_full`, so a change forces a
3952        // relayout + render this frame.
3953        {
3954            let target = st.effective_font_scale(ctx.text_scale);
3955            if st.last_font_scale.is_nan() || (st.last_font_scale - target).abs() > f32::EPSILON {
3956                st.last_font_scale = target;
3957                st.engine.set_font_scale(target);
3958                st.needs_full_layout = true;
3959                st.pending_full_render = true;
3960            }
3961        }
3962
3963        // Idempotent echo — `place_children` already adopted these exact bounds
3964        // during layout, so this is normally a no-op. It stays so that any path
3965        // which paints without a preceding layout still sizes the engine.
3966        st.sync_viewport(bounds);
3967
3968        // First-frame guard + viewport-change guard: (re)run the
3969        // full layout so the render call produces glyphs sized
3970        // for the current bounds. With per-widget `DocumentFlow`
3971        // state inside the engine, `has_full_layout()` only
3972        // reports `false` when this widget has never laid out
3973        // or when the shared service's HiDPI scale factor has
3974        // changed since the last layout — there is no
3975        // cross-widget trampling left to guard against.
3976        //
3977        // `did_full_layout` is true on this paint iff we just ran
3978        // `layout_full` above — which means the render frame must
3979        // be rebuilt from scratch via `with_render_frame`. The
3980        // incremental render paths (`with_render_block_only`,
3981        // `with_render_cursor_only`) assume a valid prior full
3982        // render exists.
3983        let did_full_layout = st.needs_full_layout || !st.engine.has_full_layout();
3984        if did_full_layout {
3985            let flow = st.flow_snapshot();
3986            st.engine.layout_full(&flow);
3987            st.needs_full_layout = false;
3988            st.content_dirty = true;
3989            #[cfg(feature = "debug-traces")]
3990            if height_debug() {
3991                eprintln!(
3992                    "HEIGHT-REAL chars={} blocks={} width={:.1} -> {:.1}",
3993                    st.document.character_count(),
3994                    st.document.block_count(),
3995                    st.engine.layout_width(),
3996                    st.engine.content_height()
3997                );
3998            }
3999        }
4000
4001        // Update the cursor display every paint so selection
4002        // highlights follow the caret without needing a frame tick.
4003        // The caret is suppressed in an inactive window for every policy — the
4004        // authoritative final gate, covering the one frame between a
4005        // window-active flip and the build-time effect running.
4006        let caret_on_now = if st.drop_caret && st.policy.caret_policy != CaretPolicy::Hidden {
4007            // A drag is overhead: show where it would land. Focus is still
4008            // wherever the drag started — often another editor entirely — so
4009            // the focus gate below would hide precisely the caret the writer
4010            // is aiming with. Steady, not blinking, and never in a read-only
4011            // editor (`Hidden`), which takes no drop anyway.
4012            st.window_active
4013        } else {
4014            match st.policy.caret_policy {
4015                CaretPolicy::Hidden => false,
4016                CaretPolicy::StaticVisible => st.has_focus && st.window_active,
4017                CaretPolicy::Blinking => st.caret_visible.get() && st.has_focus && st.window_active,
4018            }
4019        };
4020        let cursor_display = teksilo_text::CursorDisplay {
4021            position: st.cursor.position(),
4022            anchor: st.cursor.anchor(),
4023            affinity: st.cursor_affinity,
4024            visible: caret_on_now,
4025            selected_cells: Vec::new(),
4026        };
4027        st.engine.set_cursor(&cursor_display);
4028
4029        // Forward the widget's scroll state to the typesetter so
4030        // viewport culling knows where the visible window is. text-
4031        // typeset's `render()` only emits glyphs whose flow Y falls
4032        // inside `[scroll_offset, scroll_offset + viewport_height]`,
4033        // and the emitted screen coordinates already have
4034        // `scroll_offset` subtracted — so the paint walker doesn't
4035        // apply any further offset beyond the widget origin.
4036        let scroll_y_logical = st.scroll_y.get();
4037        st.engine.set_scroll_offset(scroll_y_logical);
4038
4039        // Window the render to the visible clip when opted in (dubious mode).
4040        // The editor is laid out at its full document height inside an outer
4041        // ScrollArea, so its own viewport spans the whole document and the
4042        // viewport-derived cull keeps everything. `ctx.clip_bounds` is the
4043        // accumulated ancestor clip — the intersection of every clipping
4044        // ancestor, so this is correct under nested ScrollAreas — mapped into
4045        // the editor's content space to the band actually on screen. A
4046        // half-viewport margin each side pre-renders content just off-screen so
4047        // scrolling never flashes a blank edge. Positioning and hit-testing are
4048        // untouched: `set_render_window` overrides culling only, and
4049        // `scroll_offset` stays as set above.
4050        let render_window = if st.window_to_clip {
4051            ctx.clip_bounds.map(|clip| {
4052                // `clip` and `bounds` are screen-space; the render cull works in
4053                // content space. The visible band's top is the editor's own scroll
4054                // offset plus however far its top sits above the clip: in dubious
4055                // mode `scroll_offset` is pinned to 0, but including it keeps the
4056                // window correct (rather than mis-culling) even for a self-scrolling
4057                // editor, so this can't silently render the wrong rows.
4058                let vis_top = (scroll_y_logical + (clip.y - bounds.y)).max(0.0);
4059                let vis_h = clip.height.max(0.0);
4060                let margin = vis_h * 0.5;
4061                ((vis_top - margin).max(0.0), vis_h + 2.0 * margin)
4062            })
4063        } else {
4064            None
4065        };
4066        st.engine.set_render_window(render_window);
4067
4068        // Captured before the split-borrow below (which holds `st` mutably
4069        // for the rest of the method) so the preedit underline pass can
4070        // still see them. `cursor_affinity` matches what `caret_rect`
4071        // queries elsewhere.
4072        let scroll_x_logical = st.scroll_x.get();
4073        let ime_preedit_range = st.ime_preedit_range.clone();
4074        let ime_affinity = st.cursor_affinity;
4075
4076        // Clip to bounds so overflowing glyphs don't bleed into siblings.
4077        canvas.set_clip(bounds);
4078
4079        // Choose the cheapest render path that produces a correct
4080        // frame for this paint:
4081        // - Full render: we just rebuilt the layout (no prior frame
4082        //   to incrementally update), so emit everything from scratch.
4083        // - Block-only: the frame_loop relayed out exactly one block
4084        //   since the last paint (single-block edit). Reuse cached
4085        //   glyphs for the other N-1 blocks.
4086        // - Cursor-only: nothing structural changed since last
4087        //   paint — only the cursor blink or selection updated.
4088        //   Reuses every cached glyph and just refreshes cursor /
4089        //   selection decorations. Falls back to full render
4090        //   internally if scroll drifted.
4091        //
4092        // Pre-fix, paint() unconditionally called `with_render_frame`,
4093        // which walked every block on every paint — visible as a
4094        // ~17% chunk in `rasterize_glyph` / `render_run_glyphs` on
4095        // the flamegraph because caret blinks and signal updates
4096        // were forcing a full re-render at ~60 Hz.
4097        let block_relayout = st.last_relayout_block_id.take();
4098        let pending_full = std::mem::replace(&mut st.pending_full_render, false);
4099        enum RenderChoice {
4100            Full,
4101            Block(usize),
4102            CursorOnly,
4103        }
4104        // `pending_full` covers the case where `frame_loop::tick`
4105        // already ran `layout_full` this frame (e.g. on FormatChanged
4106        // or FlowElementsInserted events from a list-indent edit or
4107        // Enter key) but cleared `needs_full_layout` before paint ran.
4108        // Without it, paint would fall through to CursorOnly and the
4109        // new layout wouldn't render until something else forced a
4110        // Full pass (resize, scroll out and back into view).
4111        let choice = if did_full_layout || pending_full {
4112            RenderChoice::Full
4113        } else if let Some(bid) = block_relayout {
4114            RenderChoice::Block(bid)
4115        } else {
4116            RenderChoice::CursorOnly
4117        };
4118
4119        // Split-borrow the state fields so the paint walker can hold
4120        // `&engine.with_render_frame(...)`, `&document`, and
4121        // `&mut image_cache` simultaneously.
4122        let state_ref: &mut EditorState = &mut st;
4123        // Read before the split borrow below, which reborrows `state_ref`
4124        // field by field.
4125        let selection_range = {
4126            let (s, e) = (
4127                state_ref.cursor.selection_start(),
4128                state_ref.cursor.selection_end(),
4129            );
4130            (s != e).then_some((s, e))
4131        };
4132        let EditorState {
4133            ref mut engine,
4134            ref document,
4135            ref mut image_cache,
4136            ref image_resolver,
4137            ref selected_image,
4138            ref resize_preview,
4139            ..
4140        } = *state_ref;
4141        let image_resolver = image_resolver.as_ref();
4142        let resize_preview_rect = resize_preview.get();
4143        let paint_closure = |frame: &teksilo_text::RenderFrame| {
4144            paint_frame(
4145                canvas,
4146                PaintParams {
4147                    frame,
4148                    origin: Point::new(bounds.x, bounds.y),
4149                    document,
4150                    image_cache,
4151                    image_resolver,
4152                    selection: selection_range,
4153                    // The same colour the typesetter drew underneath, resolved
4154                    // above for `engine.set_selection_color`.
4155                    selection_color: new_sel,
4156                    // The paint pass is the one place that has both the image
4157                    // rects and the selection, so it is what tells the pointer
4158                    // handler where the grips are.
4159                    selected_image_out: Some(selected_image),
4160                    resize_preview: resize_preview_rect,
4161                    draw_caret: caret_on_now,
4162                },
4163            );
4164        };
4165        match choice {
4166            RenderChoice::Full => engine.with_render_frame(paint_closure),
4167            RenderChoice::Block(bid) => engine.with_render_block_only(bid, paint_closure),
4168            RenderChoice::CursorOnly => engine.with_render_cursor_only(paint_closure),
4169        };
4170
4171        // IME preedit underline. Walk the composing range char-by-char,
4172        // emitting one underline segment per visual line so a wrapped
4173        // composition underlines correctly. Engine coords are content-
4174        // space; screen = bounds + content − scroll (matches the glyphs).
4175        // On a read-only viewer there is never a preedit, so this is inert.
4176        if let Some(range) = ime_preedit_range
4177            && engine.has_full_layout()
4178            && range.start < range.end
4179        {
4180            let color = ctx.theme.colors.text_primary;
4181            let underline = |canvas: &mut Canvas, x0: f32, x1: f32, y: f32, h: f32| {
4182                let uy = y + h - 1.0;
4183                canvas.draw_line(
4184                    Point::new(x0, uy),
4185                    Point::new(x1, uy),
4186                    color,
4187                    teksilo_canvas::StrokeStyle::solid(1.0),
4188                );
4189            };
4190            let mut seg_x0: Option<f32> = None;
4191            let (mut seg_y, mut seg_h, mut last_x) = (0.0_f32, 0.0_f32, 0.0_f32);
4192            for p in range.start..=range.end {
4193                let c = engine.caret_rect(p, ime_affinity);
4194                let x = bounds.x + c[0] - scroll_x_logical;
4195                let y = bounds.y + c[1] - scroll_y_logical;
4196                match seg_x0 {
4197                    None => {
4198                        seg_x0 = Some(x);
4199                        seg_y = y;
4200                        seg_h = c[3];
4201                        last_x = x;
4202                    }
4203                    Some(x0) => {
4204                        if (y - seg_y).abs() > 0.5 {
4205                            underline(canvas, x0, last_x, seg_y, seg_h);
4206                            seg_x0 = Some(x);
4207                            seg_y = y;
4208                            seg_h = c[3];
4209                        }
4210                        last_x = x;
4211                    }
4212                }
4213            }
4214            if let Some(x0) = seg_x0 {
4215                underline(canvas, x0, last_x, seg_y, seg_h);
4216            }
4217        }
4218
4219        canvas.clear_clip();
4220    }
4221
4222    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
4223        use self::policy::AccessibilityRole;
4224        use self::state::SyntheticElementRef;
4225        use teksilo_core::accesskit::{Action, NodeId, Role};
4226        use teksilo_text::text_document::{FlowElementSnapshot, FragmentContent};
4227
4228        let st = self.state.borrow();
4229
4230        let role = match st.policy.access_role {
4231            AccessibilityRole::Editor => Role::MultilineTextInput,
4232            AccessibilityRole::Document => Role::Document,
4233        };
4234        builder.set_role(role);
4235        if st.policy.is_read_only() {
4236            builder.set_read_only();
4237        }
4238
4239        // Walk the cached flow snapshot (or rebuild it if the last
4240        // edit cleared the cache). For each block we emit a
4241        // Role::Paragraph child (or Role::Heading when the block's
4242        // heading_level is set), then for each text fragment we
4243        // emit a Role::TextRun child carrying value,
4244        // character_lengths, word_starts, and per-character
4245        // geometry from text-typeset. Widget-local
4246        // synthetic_to_element map is populated so the on-access
4247        // handler can convert AccessKit TextSelection back into
4248        // document-absolute cursor positions.
4249        let snap = {
4250            let mut cache = st.accessibility_flow_snapshot.borrow_mut();
4251            if cache.is_none() {
4252                // A bare view (show_highlights=false) builds its AT tree from a
4253                // clean snapshot too, so screen readers never hear highlight-
4254                // driven formatting that no sighted user sees. The paint-only
4255                // overlay is skipped: the AT walk reads fragments, never the
4256                // overlay, so computing a paint span per spell/find range here
4257                // would be pure waste (it dominated the a11y rebuild on a large
4258                // spell-checked document).
4259                *cache = Some(st.flow_snapshot_for_a11y());
4260            }
4261            cache.as_ref().cloned()
4262        };
4263
4264        // While composing (IME preedit active), expose the composition as
4265        // the AT selection so screen readers / braille track the tentative
4266        // text — the composing characters are already in the runs / value.
4267        // Falls back to the live cursor/selection otherwise.
4268        let (user_anchor, user_pos) = match st.ime_preedit_range.clone() {
4269            Some(range) => (range.start, range.end),
4270            None => (st.cursor.anchor(), st.cursor.position()),
4271        };
4272        let mut caret_pair: Option<(NodeId, usize)> = None;
4273        let mut anchor_pair: Option<(NodeId, usize)> = None;
4274        let mut syn_map: std::collections::HashMap<NodeId, SyntheticElementRef> =
4275            std::collections::HashMap::new();
4276
4277        if let Some(snap) = snap {
4278            for elem in &snap.elements {
4279                if let FlowElementSnapshot::Block(block) = elem {
4280                    let para_id = builder.push_paragraph_child(block.block_id as u64);
4281                    if let Some(level) = block.block_format.heading_level {
4282                        builder.set_paragraph_as_heading(para_id, level);
4283                    }
4284                    for frag in &block.fragments {
4285                        if let FragmentContent::Text {
4286                            text,
4287                            offset,
4288                            length,
4289                            element_id,
4290                            word_starts,
4291                            format,
4292                            ..
4293                        } = frag
4294                        {
4295                            // Text attributes for AT (WCAG 1.3.1 / EN 301 549
4296                            // 11.5.2.9): bold / italic / underline / strikethrough
4297                            // per formatting run. AccessKit has no bold flag, so
4298                            // an explicit weight wins, else bold folds to 700.
4299                            let attrs = teksilo_core::accessibility::TextRunAttributes {
4300                                font_weight: format.font_weight.map(|w| w as u16),
4301                                bold: format.font_bold.unwrap_or(false),
4302                                italic: format.font_italic.unwrap_or(false),
4303                                underline: format.font_underline.unwrap_or(false),
4304                                strikethrough: format.font_strikeout.unwrap_or(false),
4305                            };
4306                            // character_lengths: UTF-8 byte length of each char.
4307                            // AccessKit indexes by char, each entry is byte count.
4308                            let char_lengths: Vec<u8> =
4309                                text.chars().map(|c| c.len_utf8() as u8).collect();
4310
4311                            // Per-character geometry from text-typeset. char_start
4312                            // / char_end are block-relative character offsets
4313                            // (matches LayoutLine::char_range's coordinate space).
4314                            let char_start = *offset;
4315                            let char_end = char_start + *length;
4316                            let geom =
4317                                st.engine
4318                                    .character_geometry(block.block_id, char_start, char_end);
4319                            let char_positions: Vec<f32> =
4320                                geom.iter().map(|g| g.position).collect();
4321                            let char_widths: Vec<f32> = geom.iter().map(|g| g.width).collect();
4322
4323                            let node_id = builder.push_text_run_child(
4324                                para_id,
4325                                *element_id,
4326                                *offset,
4327                                text.clone(),
4328                                char_lengths,
4329                                Some(word_starts.clone()),
4330                                if char_positions.is_empty() {
4331                                    None
4332                                } else {
4333                                    Some(char_positions)
4334                                },
4335                                if char_widths.is_empty() {
4336                                    None
4337                                } else {
4338                                    Some(char_widths)
4339                                },
4340                                attrs,
4341                            );
4342
4343                            // Annotations covering this run: one Role::Comment
4344                            // node each, linked from the run through `details`.
4345                            // Emitted per run rather than once per span because a
4346                            // span can cross runs (a bold word inside a commented
4347                            // sentence splits it), and every covered run must
4348                            // carry the relation or the announcement drops out
4349                            // halfway through the phrase.
4350                            let run_start = block.position + *offset;
4351                            let run_end = run_start + *length;
4352                            for span in &st.annotation_spans {
4353                                if span.start < run_end && span.end > run_start {
4354                                    let detail = builder
4355                                        .push_annotation_child(span.group_id, span.summary.clone());
4356                                    builder.push_detail_on_child(node_id, detail);
4357                                }
4358                            }
4359
4360                            // Remember where this run lives in the document so
4361                            // the on-access handler can resolve
4362                            // SetTextSelection(TextRun NodeId, char_index).
4363                            let absolute_start = block.position + *offset;
4364                            syn_map.insert(
4365                                node_id,
4366                                SyntheticElementRef {
4367                                    element_id: *element_id,
4368                                    absolute_start,
4369                                    text: text.clone(),
4370                                },
4371                            );
4372
4373                            // Resolve user cursor / anchor to this run if they
4374                            // fall within its absolute character range
4375                            // [absolute_start, absolute_start + length].
4376                            let absolute_end = absolute_start + *length;
4377                            if user_pos >= absolute_start && user_pos <= absolute_end {
4378                                let char_idx = char_index_in_text(text, user_pos - absolute_start);
4379                                caret_pair = Some((node_id, char_idx));
4380                            }
4381                            if user_anchor >= absolute_start && user_anchor <= absolute_end {
4382                                let char_idx =
4383                                    char_index_in_text(text, user_anchor - absolute_start);
4384                                anchor_pair = Some((node_id, char_idx));
4385                            }
4386                        }
4387
4388                        // Inline objects: one document character each, rendered
4389                        // as something a reader sees but cannot read out of the
4390                        // text — an image, or a footnote's marker.
4391                        //
4392                        // Announced as a single-character text run whose value
4393                        // is that description. `character_lengths` is one entry
4394                        // spanning the whole string on purpose: the object *is*
4395                        // one character of the document, however many letters
4396                        // stand in for it, and telling AccessKit otherwise would
4397                        // put every caret offset after it out by the difference.
4398                        //
4399                        // Images were reaching no assistive technology at all
4400                        // until now — their `alt` was carried the whole way
4401                        // through the pipeline and then dropped here, at the
4402                        // last step, because this loop only ever matched `Text`.
4403                        let object_run = match frag {
4404                            FragmentContent::Image {
4405                                alt,
4406                                offset,
4407                                element_id,
4408                                format,
4409                                ..
4410                            } => Some((alt.clone(), *offset, *element_id, format)),
4411                            FragmentContent::FootnoteReference {
4412                                marker,
4413                                offset,
4414                                element_id,
4415                                format,
4416                                ..
4417                            } => Some((marker.clone(), *offset, *element_id, format)),
4418                            FragmentContent::Text { .. } => None,
4419                        };
4420
4421                        if let Some((value, offset, element_id, format)) = object_run {
4422                            let attrs = teksilo_core::accessibility::TextRunAttributes {
4423                                font_weight: format.font_weight.map(|w| w as u16),
4424                                bold: format.font_bold.unwrap_or(false),
4425                                italic: format.font_italic.unwrap_or(false),
4426                                underline: format.font_underline.unwrap_or(false),
4427                                strikethrough: format.font_strikeout.unwrap_or(false),
4428                            };
4429                            // An empty description would announce nothing at
4430                            // all, which is indistinguishable from a rendering
4431                            // fault. A single space is at least a spoken pause.
4432                            let value = if value.is_empty() {
4433                                " ".to_string()
4434                            } else {
4435                                value
4436                            };
4437                            let geom =
4438                                st.engine
4439                                    .character_geometry(block.block_id, offset, offset + 1);
4440                            let node_id = builder.push_text_run_child(
4441                                para_id,
4442                                element_id,
4443                                offset,
4444                                value.clone(),
4445                                vec![value.len().min(u8::MAX as usize) as u8],
4446                                None,
4447                                if geom.is_empty() {
4448                                    None
4449                                } else {
4450                                    Some(geom.iter().map(|g| g.position).collect())
4451                                },
4452                                if geom.is_empty() {
4453                                    None
4454                                } else {
4455                                    Some(geom.iter().map(|g| g.width).collect())
4456                                },
4457                                attrs,
4458                            );
4459
4460                            let absolute_start = block.position + offset;
4461                            syn_map.insert(
4462                                node_id,
4463                                SyntheticElementRef {
4464                                    element_id,
4465                                    absolute_start,
4466                                    text: value,
4467                                },
4468                            );
4469                            if user_pos >= absolute_start && user_pos <= absolute_start + 1 {
4470                                caret_pair = Some((node_id, user_pos - absolute_start));
4471                            }
4472                            if user_anchor >= absolute_start && user_anchor <= absolute_start + 1 {
4473                                anchor_pair = Some((node_id, user_anchor - absolute_start));
4474                            }
4475                        }
4476                    }
4477                }
4478            }
4479        }
4480
4481        // Attach the text selection on the editor itself, referencing
4482        // the appropriate TextRun children. If we couldn't resolve
4483        // either endpoint (empty document, cursor in no fragment),
4484        // fall back to a self-targeted selection so screen readers
4485        // still see *something*.
4486        if let (Some(a), Some(c)) = (anchor_pair, caret_pair) {
4487            builder.set_text_selection_to(a, c);
4488        } else {
4489            builder.set_text_selection_on_self(user_anchor, user_pos);
4490        }
4491
4492        *st.synthetic_to_element.borrow_mut() = syn_map;
4493
4494        builder.add_action(Action::Focus);
4495        builder.add_action(Action::ScrollIntoView);
4496        builder.add_action(Action::SetTextSelection);
4497        if matches!(st.policy.access_role, AccessibilityRole::Editor) {
4498            builder.add_action(Action::SetValue);
4499            builder.add_action(Action::ReplaceSelectedText);
4500        }
4501    }
4502
4503    fn clips_children(&self) -> bool {
4504        true
4505    }
4506}
4507
4508impl Widget for RichTextEditor {
4509    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4510        // Tell the framework this widget edits text.
4511        //
4512        // What it buys: an application may take `Ctrl+Z`, `Ctrl+C` and friends
4513        // for itself — a single Undo command over the whole app has to — and
4514        // registered shortcuts resolve before any widget sees the raw key. This
4515        // is how the host can tell that the caret is *here*, and either drive
4516        // this surface or step aside so it keeps its own keys. Without it, an
4517        // application that routes those chords silently breaks every text
4518        // widget it does not personally know about. See
4519        // `teksilo_core::text_surface`.
4520        ctx.register_text_surface(std::rc::Rc::new(self.handle()));
4521        // Engine swap: replace the private fallback with one sharing
4522        // the application's `SharedTypesetter` so rendered glyphs end
4523        // up in the atlas teksilo-render uploads to the GPU. Headless
4524        // tests without a `SharedTypesetter` keep the private engine
4525        // untouched. Lives on the wrapper because state mutation
4526        // doesn't depend on `ctx.self_id()`.
4527        if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
4528            let mut st = self.state.borrow_mut();
4529            let wrap = st.wrap_mode;
4530            // Carry over builder-set engine config that the swap would otherwise
4531            // drop — `.typography_defaults()`, `.echo_char()` are set on the
4532            // private engine before mount, and this runs on every rebuild.
4533            // (Theme colours / font-scale re-derive themselves in `paint()`.)
4534            let typography = st.engine.typography_defaults().clone();
4535            let echo = st.engine.echo_char();
4536            let mut engine = RichTextEngine::from_shared(shared.clone());
4537            engine.set_wrap_mode(wrap);
4538            engine.set_hyphenate_justified(true);
4539            engine.set_typography_defaults(typography);
4540            engine.set_echo_char(echo);
4541            st.engine = engine;
4542            st.needs_full_layout = true;
4543        }
4544
4545        // Stash the tree's frame-request handle on the state so the
4546        // frame-tick effect can self-chain (caret blink, drag
4547        // auto-scroll) without mutable access to the tree.
4548        {
4549            let mut st = self.state.borrow_mut();
4550            st.frame_request = Some(ctx.frame_request_handle());
4551            st.frame_wake_at = Some(ctx.wake_at_handle());
4552            // Remember this build's wrapper id — the `.focusable(true)` node — so a
4553            // held handle can request focus back onto the editor.
4554            st.self_id = Some(ctx.self_id());
4555        }
4556
4557        // Kick off the first frame so the initial layout/paint runs
4558        // through the tick path and populates max_scroll / content
4559        // metrics. Gated by activation: a tab content pane parked in a
4560        // non-selected `Switcher` branch must not keep the event loop
4561        // awake just because it was built (TabWidget pre-mounts every
4562        // open tab).
4563        let activation = ctx.activation_signal(ctx.self_id());
4564        // Stash it too: `reveal_range` has no other way to tell an on-screen
4565        // editor from one parked dormant, because the engine's layout survives
4566        // the parking. Taken from the signal rather than set by the dormancy
4567        // effect below, which fires only on a *transition* — an editor built
4568        // dormant (a TabWidget pre-mounts every open tab) never transitions.
4569        self.state.borrow_mut().activation = Some(activation.clone());
4570        if activation.get() {
4571            ctx.request_frame();
4572        }
4573
4574        // When this editor is parked dormant (tab switch, collapsed
4575        // pane, …) clear local focus state synchronously. The tree may
4576        // also dispatch FocusLost via revalidate, but a race between
4577        // selection change and pointer focus — or a programmatic
4578        // selection change that never moves focus — used to leave
4579        // `has_focus = true` on every visited tab. Each stuck editor
4580        // kept scheduling caret `wake_at`s, and every open tab's
4581        // frame-tick effect still ran on those wakes (observers are
4582        // not dormancy-gated). Rapid tab switching made CPU climb.
4583        {
4584            let state = self.state.clone();
4585            ctx.effect(&activation, move |&active| {
4586                if active {
4587                    // **Re-activated** — re-arm the frame loop.
4588                    //
4589                    // The dormant branch below deliberately does not re-arm
4590                    // `frame_request`, and the frame-tick effect is skipped
4591                    // entirely while dormant, so nothing restarts the tick on the
4592                    // way back: the editor paints once and then goes quiet. The
4593                    // caret is what makes that visible — `on_focus` restarts the
4594                    // blink, but only the tick pushes the cursor through to the
4595                    // engine, so a re-activated editor that is then focused shows
4596                    // **no caret at all** and reads as a broken surface.
4597                    //
4598                    // The in-tree modal path hits this on every open: it builds
4599                    // the content, marks it dormant, mounts it, activates it and
4600                    // *then* moves focus in (`present_in_tree_modal_request`). A
4601                    // tab switch and a collapsed pane take the same route back.
4602                    //
4603                    // Cheap and self-limiting: one frame request, after which the
4604                    // ordinary tick loop re-arms itself only while it has work.
4605                    let st = state.borrow();
4606                    if let Some(handle) = &st.frame_request {
4607                        handle.set(true);
4608                    }
4609                    return;
4610                }
4611                let mut st = state.borrow_mut();
4612                if st.has_focus {
4613                    st.has_focus = false;
4614                    st.focus_signal.set(false);
4615                }
4616                if st.caret_visible.get() {
4617                    st.caret_visible.set(false);
4618                }
4619                st.blink.reset();
4620                // Retire the caret band here too. Only `frame_loop::tick` pushes the band's
4621                // focus state through to the document, and the tick effect below is skipped
4622                // entirely while dormant — so a parked editor would keep its last band
4623                // registered on a document its siblings are still showing, and a split pane
4624                // over the same document would show two. Clearing `has_focus` above is not
4625                // enough; nothing would ever act on it.
4626                if let Some(band) = &st.caret_highlight {
4627                    band.set_active(false);
4628                }
4629                st.caret_highlight_active = false;
4630                // Do not re-arm frame_request here: a dormant editor has
4631                // nothing to paint, and re-arming is exactly the leak
4632                // this gate exists to stop.
4633            });
4634        }
4635
4636        // Frame-tick effect — drains document events, blinks the
4637        // caret, runs drag auto-scroll. Re-arms the tree's
4638        // frame-request flag while there's still pending work.
4639        // Skipped entirely while dormant so a multi-tab TabWidget does
4640        // not pay O(open tabs) per wake for editors nobody can see.
4641        {
4642            let state = self.state.clone();
4643            let active = activation.clone();
4644            let tick_signal = ctx.frame_tick();
4645            ctx.effect(&tick_signal, move |delta| {
4646                if !active.get() {
4647                    return;
4648                }
4649                let mut st = state.borrow_mut();
4650                let more = frame_loop::tick(&mut st, *delta);
4651                // Signal::set is unconditional (clones+invokes every
4652                // observer even when value unchanged), so only call it
4653                // when the bool actually flipped. Avoids per-tick fanout
4654                // to chrome widgets that watch the selection state.
4655                let new_has_selection = st.cursor.has_selection();
4656                if st.has_selection.get() != new_has_selection {
4657                    st.has_selection.set(new_has_selection);
4658                }
4659                if more && let Some(handle) = &st.frame_request {
4660                    handle.set(true);
4661                }
4662                drop(st);
4663            });
4664        }
4665
4666        // Window-active effect — mirror the tree's window-active state onto the
4667        // editor state so the frame loop (which has no context) can gate the
4668        // caret. The frame loop may not tick while the window is inactive (the
4669        // animation scheduler is parked), so on deactivation we hide the caret
4670        // *synchronously* here rather than waiting for a tick, and request a
4671        // frame so the change reaches a paint pass — but only while this
4672        // editor is itself active. A dormant tab must not re-arm the frame
4673        // loop just because the host window blinked.
4674        {
4675            let state = self.state.clone();
4676            let active = activation.clone();
4677            let wa_signal = ctx.window_active_signal();
4678            ctx.effect(&wa_signal, move |&window_active| {
4679                let mut st = state.borrow_mut();
4680                st.window_active = window_active;
4681                if window_active {
4682                    // Reactivated: if the editor still holds focus, show the
4683                    // caret immediately (restart the blink phase) rather than
4684                    // waiting up to one blink interval. `Hidden` policy stays
4685                    // hidden — the paint gate suppresses it anyway.
4686                    let show =
4687                        st.has_focus && !matches!(st.policy.caret_policy, CaretPolicy::Hidden);
4688                    if show && !st.caret_visible.get() {
4689                        st.caret_visible.set(true);
4690                    }
4691                    st.blink.reset();
4692                } else {
4693                    // Deactivated: hide the caret synchronously (the frame loop
4694                    // may not tick while the window is inactive).
4695                    if st.caret_visible.get() {
4696                        st.caret_visible.set(false);
4697                    }
4698                    st.blink.reset();
4699                }
4700                if active.get()
4701                    && let Some(handle) = &st.frame_request
4702                {
4703                    handle.set(true);
4704                }
4705                drop(st);
4706            });
4707        }
4708
4709        // Attach handlers on the WRAPPER — making the composing
4710        // widget itself the focus + event target. The body is a
4711        // pure leaf so users can wrap it in arbitrary chrome via
4712        // `RichTextEditorStyle::make_body` without losing focus
4713        // semantics.
4714        let mut handlers = HandlerSet::new();
4715        // Editable editors are text-input surfaces — enable the OS IME
4716        // while focused. Read-only viewers stay focusable for selection but
4717        // accept no text input, so they leave the IME descriptor unset.
4718        if !self.state.borrow().policy.is_read_only() {
4719            handlers = handlers.ime_input(teksilo_core::ime::ImeContext::text());
4720        }
4721        handlers = handlers
4722            // Text and files dropped onto the editor land at the caret, and the
4723            // caret follows the drag so the writer can see where that is. An
4724            // editor with no drop handling at all is not merely inert: the drag
4725            // bubbles to whatever ancestor claims it, and the pane's own
4726            // `DropTarget` paints a reject tint across the whole surface, which
4727            // reads as the editor refusing the drop rather than never being
4728            // offered it.
4729            .on_drag_hover({
4730                let state = self.state.clone();
4731                move |payload, pos, ctx| {
4732                    // Read the policy live rather than snapshotting it here: the
4733                    // command filter is swappable on a mounted editor
4734                    // (`set_command_filter`), and a value captured at build time
4735                    // would keep promising a drop the drop handler then refuses.
4736                    let read_only = state.borrow().policy.is_read_only();
4737                    if read_only || !droppable(payload) {
4738                        // `NoFeedback` rather than a reject visual: the drag
4739                        // must keep bubbling so an ancestor that does want this
4740                        // payload — a binder row dropped on the editor pane —
4741                        // still gets it.
4742                        return teksilo_core::DropFeedback::NoFeedback;
4743                    }
4744                    if !self::mouse::move_caret_for_drag(&state, pos) {
4745                        self::mouse::clear_drop_caret(&state);
4746                        return teksilo_core::DropFeedback::NoFeedback;
4747                    }
4748                    ctx.request_frame();
4749                    // The caret IS the feedback — a framework insertion line
4750                    // would draw a second, differently-placed promise about
4751                    // where the drop lands.
4752                    teksilo_core::DropFeedback::Accept
4753                }
4754            })
4755            // The drag moved off this editor (or was cancelled over it): stop
4756            // promising a landing place. Without this the drop caret is left
4757            // burnt into an editor the drag has already left.
4758            .on_drag_leave({
4759                let state = self.state.clone();
4760                move |ctx| {
4761                    self::mouse::clear_drop_caret(&state);
4762                    ctx.request_frame();
4763                }
4764            })
4765            .on_drop({
4766                let state = self.state.clone();
4767                move |payload, pos, ctx| {
4768                    self::mouse::clear_drop_caret(&state);
4769                    // Live, for the same reason as `on_drag_hover` above.
4770                    let read_only = state.borrow().policy.is_read_only();
4771                    if read_only || !droppable(&payload) {
4772                        return false;
4773                    }
4774                    // Place the caret one last time: a drop can arrive without a
4775                    // final hover at the same point (a fast release, or a
4776                    // backend that only fills the payload at drop time).
4777                    self::mouse::move_caret_for_drag(&state, pos);
4778                    // Text dragged out of an editor. Dropped back into the one
4779                    // it came from it is a *move* — the original goes away —
4780                    // and dropped into any other editor it is a copy, which is
4781                    // what a writer means by carrying a phrase to a second
4782                    // document rather than emptying it out of the first.
4783                    if let Some(drag) = payload.get_typed::<EditorTextDrag>() {
4784                        let same_editor = state.borrow().self_id == Some(drag.source);
4785                        let moved = self::mouse::apply_text_drop(&state, drag, same_editor);
4786                        if moved {
4787                            sync_cursor_signals(&state);
4788                            state.borrow_mut().pending_text_changed = true;
4789                            // Take the caret with the text. Focus is still in
4790                            // the editor the drag *started* in, so without this
4791                            // the writer is left looking at text they just
4792                            // placed here while typing into somewhere else.
4793                            let self_id = state.borrow().self_id;
4794                            if let Some(id) = self_id {
4795                                ctx.request_focus(id);
4796                            }
4797                            ctx.request_frame();
4798                        }
4799                        return moved;
4800                    }
4801                    let files: Vec<std::path::PathBuf> = payload.files().to_vec();
4802                    if !files.is_empty() {
4803                        // Files mean nothing to a text editor on their own —
4804                        // whether a path becomes a picture, a link, or an
4805                        // include is the host's policy. Hand them over.
4806                        let cb = state.borrow().on_files_dropped.clone();
4807                        let Some(cb) = cb else { return false };
4808                        cb(&files, ctx);
4809                        ctx.request_frame();
4810                        return true;
4811                    }
4812                    // Advertised but delivered nothing: decline, so the drag
4813                    // bubbles rather than being silently eaten.
4814                    let Some(text) = payload.text().filter(|t| !t.is_empty()) else {
4815                        return false;
4816                    };
4817                    {
4818                        let st = state.borrow();
4819                        let _ = st.cursor.insert_text(text);
4820                    }
4821                    sync_cursor_signals(&state);
4822                    state.borrow_mut().pending_text_changed = true;
4823                    ctx.request_frame();
4824                    true
4825                }
4826            })
4827            .focusable(true)
4828            .cursor(CursorIcon::Text)
4829            .on_focus({
4830                let state = self.state.clone();
4831                move |gained, ctx| {
4832                    let mut st = state.borrow_mut();
4833                    st.has_focus = gained;
4834                    // Mirror onto the reactive signal so chrome
4835                    // installed by `RichTextEditorStyle::make_body`
4836                    // (focus-aware border / ring) re-renders.
4837                    st.focus_signal.set(gained);
4838                    if gained && matches!(st.policy.caret_policy, CaretPolicy::Blinking) {
4839                        st.blink.restart();
4840                        st.caret_visible.set(true);
4841                    }
4842                    drop(st);
4843                    if gained {
4844                        // Seed the OS IME candidate area at the caret.
4845                        self::keyboard::report_ime_cursor_area(&state, ctx);
4846                    } else {
4847                        // Abandon any in-progress composition on blur, and drop
4848                        // the IME-area / caret-chase caches. The OS IME candidate
4849                        // area is a single *per-window* resource a sibling field
4850                        // may have re-pointed while we were unfocused; clearing
4851                        // `last_ime_area` forces the next focus-gain report to
4852                        // re-seed it (the dedup must not swallow that re-seed).
4853                        // Clearing `last_chase_pos` lets a refocus re-reveal the
4854                        // caret even if it has not moved since we lost focus.
4855                        self::keyboard::clear_ime_preedit(&state);
4856                        let mut st = state.borrow_mut();
4857                        st.last_ime_area = None;
4858                        st.last_chase_pos = None;
4859                    }
4860                    ctx.request_frame();
4861                }
4862            })
4863            .on_pointer_event({
4864                let state = self.state.clone();
4865                let v_sb = self.v_scrollbar_bounds.clone();
4866                let h_sb = self.h_scrollbar_bounds.clone();
4867                move |event, ctx| {
4868                    self::mouse::handle_pointer_event(&state, &v_sb, &h_sb, event, ctx)
4869                }
4870            })
4871            .on_scroll({
4872                let state = self.state.clone();
4873                let overscroll = self.overscroll_behavior;
4874                move |event, ctx| self::mouse::handle_scroll(&state, overscroll, event, ctx)
4875            })
4876            .on_key({
4877                let state = self.state.clone();
4878                move |event, ctx| self::keyboard::handle_key(&state, event, ctx)
4879            })
4880            .on_double_tap({
4881                let state = self.state.clone();
4882                move |event, ctx| self::mouse::handle_double_tap(&state, event.position, ctx)
4883            })
4884            .on_triple_tap({
4885                let state = self.state.clone();
4886                move |event, ctx| self::mouse::handle_triple_tap(&state, event.position, ctx)
4887            })
4888            .on_access_action_request({
4889                let state = self.state.clone();
4890                move |action, target_node, data, ctx| {
4891                    handle_access_action_request(&state, action, target_node, data, ctx)
4892                }
4893            });
4894
4895        // Context-menu factory — same shape as before, just hosted on
4896        // the wrapper. The factory reads the policy from the shared state on
4897        // each right-click, so a filter swapped in after mount is honoured.
4898        if let Some(factory) = context_menu::resolve_factory(
4899            self.custom_context_menu.take(),
4900            self.default_context_menu_enabled,
4901            self.state.clone(),
4902        ) {
4903            handlers = handlers.context_menu(move |pos, ctx| factory(pos, ctx));
4904        }
4905
4906        ctx.apply_self_handlers(handlers);
4907
4908        // Build the pure-paint leaf body. The body carries
4909        // layout/paint/accessibility (using its own `self_id()` for
4910        // `caret_visible` + `document_version` bindings); the shared
4911        // `state` propagates handler-driven mutations into it.
4912        let body = RichTextEditorBody {
4913            state: self.state.clone(),
4914            min_lines: self.min_lines,
4915            max_lines: self.max_lines,
4916        };
4917        let viewport_id = ctx.add(body);
4918
4919        // Reactive colour overrides: a signal/role-bound `ColorProp` must
4920        // repaint the body (the leaf that resolves + applies them in `paint`)
4921        // when it changes. Bind to `viewport_id`, not the wrapper — the painter
4922        // owns its prop bindings (the `RectWidget` pattern). Theme-role changes
4923        // already dirty every node via the reactive theme; this covers
4924        // `Signal`-bound props. The background prop is reactive through the
4925        // `RectWidget` the style builds, so it isn't registered here.
4926        {
4927            let props = {
4928                let st = self.state.borrow();
4929                [
4930                    st.text_color_prop.clone(),
4931                    st.caret_color_prop.clone(),
4932                    st.selection_color_prop.clone(),
4933                ]
4934            };
4935            let registry = ctx.binding_registry();
4936            for prop in props.iter().flatten() {
4937                prop.register_if_bound(
4938                    viewport_id,
4939                    registry,
4940                    teksilo_core::binding::BindingLevel::RepaintOnly,
4941                );
4942            }
4943        }
4944
4945        // Snapshot focus + read-only state for the chrome. `is_focused`
4946        // is the reactive mirror updated by `on_focus`; `is_read_only`
4947        // is sampled from the policy bundle.
4948        let (is_focused, is_read_only) = {
4949            let st = self.state.borrow();
4950            (st.focus_signal.clone(), st.policy.is_read_only())
4951        };
4952
4953        let style: SharedRichTextEditorStyle = self
4954            .style_override
4955            .clone()
4956            .or_else(|| ctx.theme().style_slots.rich_text_editor.clone())
4957            .unwrap_or_else(|| Rc::new(RecipeRichTextEditorStyle));
4958        let cfg = RichTextEditorStyleConfig {
4959            viewport: viewport_id,
4960            is_focused,
4961            is_read_only,
4962            content_padding: self.content_padding,
4963            background: self.state.borrow().background_prop.clone(),
4964        };
4965        let root = style.make_body(&cfg, ctx);
4966        self.root_child_id = Some(root);
4967
4968        // Overlay scrollbars — floated on top of the chrome at the
4969        // right / bottom edges. Driven by the same signals the frame
4970        // loop publishes (`scroll_*`, `max_scroll_*`, `viewport_ratio_*`).
4971        // ScrollPolicy::AlwaysOff suppresses the widget entirely so it
4972        // doesn't sit in the children list as a zero-sized stub.
4973        let (scroll_x, scroll_y, max_scroll_x, max_scroll_y, vr_x, vr_y) = {
4974            let st = self.state.borrow();
4975            (
4976                st.scroll_x.clone(),
4977                st.scroll_y.clone(),
4978                st.max_scroll_x.clone(),
4979                st.max_scroll_y.clone(),
4980                st.viewport_ratio_x.clone(),
4981                st.viewport_ratio_y.clone(),
4982            )
4983        };
4984
4985        let mut children = vec![root];
4986        if self.v_scroll_policy != ScrollPolicy::AlwaysOff {
4987            let v_sb = ScrollBar::new(
4988                ScrollBarOrientation::Vertical,
4989                scroll_y,
4990                max_scroll_y.clone(),
4991                vr_y,
4992            )
4993            .visual(ScrollBarVariant::Overlay);
4994            let v_id = ctx.add(v_sb);
4995            self.v_scrollbar_id = Some(v_id);
4996            children.push(v_id);
4997        }
4998        if self.h_scroll_policy != ScrollPolicy::AlwaysOff {
4999            let h_sb = ScrollBar::new(
5000                ScrollBarOrientation::Horizontal,
5001                scroll_x,
5002                max_scroll_x.clone(),
5003                vr_x,
5004            )
5005            .visual(ScrollBarVariant::Overlay);
5006            let h_id = ctx.add(h_sb);
5007            self.h_scrollbar_id = Some(h_id);
5008            children.push(h_id);
5009        }
5010
5011        // `place_children` reads `max_scroll_y` / `max_scroll_x`
5012        // synchronously to decide whether to give the overlay
5013        // scrollbars a non-zero rect under `ScrollPolicy::Auto`. The
5014        // frame loop publishes those values from `Step 7` on every
5015        // tick — without a Relayout binding the wrapper wouldn't
5016        // re-place its children when the values cross zero, so the
5017        // bars would stay sized 0×0 until something else (scroll
5018        // wheel, resize) forced a layout pass.
5019        let self_id = ctx.self_id();
5020        let registry = ctx.binding_registry();
5021        max_scroll_y.bind_to(
5022            self_id,
5023            registry,
5024            teksilo_core::binding::BindingLevel::Relayout,
5025        );
5026        max_scroll_x.bind_to(
5027            self_id,
5028            registry,
5029            teksilo_core::binding::BindingLevel::Relayout,
5030        );
5031
5032        children
5033    }
5034
5035    fn layout_response(
5036        &self,
5037        proposal: SizeProposal,
5038        ctx: &LayoutContext,
5039    ) -> teksilo_core::widget::LayoutResponse {
5040        self.root_child_id
5041            .and_then(|id| ctx.child_size(id, proposal))
5042            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
5043            .into()
5044    }
5045
5046    fn place_children(
5047        &self,
5048        bounds: Rect,
5049        _proposal: SizeProposal,
5050        children: &mut [WidgetPlacement],
5051        _ctx: &LayoutContext,
5052    ) {
5053        // Chrome (first child) fills the entire bounds. Overlay
5054        // scrollbars float on top at the right (vertical) and
5055        // bottom (horizontal) edges — collapsed to zero when the
5056        // axis policy is `Auto` and there's nothing to scroll.
5057        let sb_thickness = self::frame_loop::SCROLLBAR_THICKNESS;
5058        {
5059            // Record the wrapper node's window-space origin so the pointer
5060            // handlers can reconstruct window coords from the now
5061            // wrapper-node-local positions (see `State::node_origin`).
5062            let mut st = self.state.borrow_mut();
5063            st.node_origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5064        }
5065        let st = self.state.borrow();
5066        let max_y = st.max_scroll_y.get();
5067        let max_x = st.max_scroll_x.get();
5068        drop(st);
5069        let show_v = match self.v_scroll_policy {
5070            ScrollPolicy::AlwaysOn => true,
5071            ScrollPolicy::Auto => max_y > 0.0,
5072            ScrollPolicy::AlwaysOff => false,
5073        };
5074        let show_h = match self.h_scroll_policy {
5075            ScrollPolicy::AlwaysOn => true,
5076            ScrollPolicy::Auto => max_x > 0.0,
5077            ScrollPolicy::AlwaysOff => false,
5078        };
5079        let mut v_rect = Rect::ZERO;
5080        let mut h_rect = Rect::ZERO;
5081        for (idx, child) in children.iter_mut().enumerate() {
5082            if idx == 0 {
5083                child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5084                child.size = Size::new(bounds.width, bounds.height);
5085            } else if Some(child.id) == self.v_scrollbar_id {
5086                if show_v {
5087                    let h = if show_h {
5088                        (bounds.height - sb_thickness).max(0.0)
5089                    } else {
5090                        bounds.height
5091                    };
5092                    child.origin = teksilo_canvas::Point::new(
5093                        bounds.x + bounds.width - sb_thickness,
5094                        bounds.y,
5095                    );
5096                    child.size = Size::new(sb_thickness, h);
5097                    // Widget-local: pointer events arrive widget-local, so
5098                    // the published bounds the press-bypass test compares
5099                    // against must be local too (subtract the widget origin).
5100                    v_rect = Rect::new(
5101                        child.origin.x - bounds.x,
5102                        child.origin.y - bounds.y,
5103                        sb_thickness,
5104                        h,
5105                    );
5106                } else {
5107                    child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5108                    child.size = Size::ZERO;
5109                }
5110            } else if Some(child.id) == self.h_scrollbar_id {
5111                if show_h {
5112                    let w = if show_v {
5113                        (bounds.width - sb_thickness).max(0.0)
5114                    } else {
5115                        bounds.width
5116                    };
5117                    child.origin = teksilo_canvas::Point::new(
5118                        bounds.x,
5119                        bounds.y + bounds.height - sb_thickness,
5120                    );
5121                    child.size = Size::new(w, sb_thickness);
5122                    // Widget-local (see the v_scrollbar branch).
5123                    h_rect = Rect::new(
5124                        child.origin.x - bounds.x,
5125                        child.origin.y - bounds.y,
5126                        w,
5127                        sb_thickness,
5128                    );
5129                } else {
5130                    child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
5131                    child.size = Size::ZERO;
5132                }
5133            }
5134        }
5135        // Published to the wrapper's `on_pointer_event` so a press over
5136        // an overlay scrollbar bypasses the drag-select latch — see
5137        // [`v_scrollbar_bounds`](Self::v_scrollbar_bounds).
5138        self.v_scrollbar_bounds.set(v_rect);
5139        self.h_scrollbar_bounds.set(h_rect);
5140    }
5141
5142    fn children(&self) -> Vec<WidgetId> {
5143        let mut ids = Vec::with_capacity(3);
5144        if let Some(id) = self.root_child_id {
5145            ids.push(id);
5146        }
5147        if let Some(id) = self.v_scrollbar_id {
5148            ids.push(id);
5149        }
5150        if let Some(id) = self.h_scrollbar_id {
5151            ids.push(id);
5152        }
5153        ids
5154    }
5155
5156    fn clips_children(&self) -> bool {
5157        // Mirror the body's clipping so chrome around the editor
5158        // doesn't leak the body's overflow.
5159        true
5160    }
5161
5162    fn focus_reveal_rect(&self, _bounds: Rect) -> Option<Rect> {
5163        // On focus gain the framework reveals the focused widget into any
5164        // enclosing ScrollArea. Reveal the caret *line*, not the (potentially
5165        // page-tall, own-scroll-suppressed) whole editor: a click that only
5166        // placed the caret near the top must not jump the page to the editor's
5167        // bottom. Returns the exact absolute caret rect the in-page caret-follow
5168        // uses (viewport_origin + caret − scroll); `scroll_rect_into_view`
5169        // excludes the editor itself, so this targets the enclosing ScrollArea
5170        // with no double-scroll. `None` (→ reveal whole bounds) before the first
5171        // layout or while unfocused.
5172        self::keyboard::caret_window_rect(&self.state.borrow())
5173    }
5174
5175    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
5176        // Transparent container in the AT tree — the inner
5177        // `RichTextEditorBody` carries the real role
5178        // (`MultilineTextInput` / `Document`) plus the paragraph and
5179        // text-run children. Without this method the wrapper would
5180        // emit a `Role::Unknown` node (the `AccessNodeBuilder`
5181        // default), which screen readers can't classify. Same
5182        // pattern as [`TextInput`](crate::TextInput), which also
5183        // wraps a focusable inner field.
5184        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
5185    }
5186}
5187
5188// ---------------------------------------------------------------------------
5189// Event handlers — take `&SharedState` so they can be boxed into handler
5190// closures without borrowing `self`.
5191// ---------------------------------------------------------------------------
5192
5193/// Shared body of [`RichTextEditor::reveal_range`] and its
5194/// [`EditorHandle`] twin — one implementation so the two can never drift on the
5195/// typewriter-pin rule.
5196///
5197/// The pin applies here even though the range is not the caret: with typewriter
5198/// scrolling on, walking search hits should bring each one to the same height
5199/// the writer works at. Unlike the caret chase, a pointer anchor does *not*
5200/// suppress it — the user asked for this jump explicitly by pressing Find Next,
5201/// so there is no gesture to fight.
5202fn reveal_range_impl(
5203    state: &SharedState,
5204    ctx: &mut teksilo_core::widget::EventContext,
5205    start: usize,
5206    end: usize,
5207) -> bool {
5208    // **Named as the rect's owner**, so the scroll walk climbs the *editor's*
5209    // ancestors and not the handler's. The two are the same widget when the editor
5210    // reveals its own caret, and different every time something built beside it asks
5211    // — a find banner's Next button, a mention counter's chevron. Those sit outside
5212    // the scrolling page, so a walk from them leaves through the strip and never
5213    // meets the scroll container: the match was selected, the counter moved, and the
5214    // viewport stayed exactly where it was. `self_id` is `None` only before the
5215    // editor's first build, and there is nothing laid out to reveal then anyway.
5216    let (area, pin, owner) = {
5217        let st = state.borrow();
5218        // **Dormant is "no layout to do it in"**, even though the engine still holds
5219        // one: `has_full_layout` is set at the first full layout and never cleared, and
5220        // parking an editor clears its focus, caret and band but not its layout. So the
5221        // rect below resolves perfectly for a tab nobody can see, the ancestor walk
5222        // finds a scroll container that is not on screen, and the answer `true` tells a
5223        // caller holding several editors over one document to stop looking — while the
5224        // visible one, never asked, stays exactly where it was.
5225        if st.activation.as_ref().is_some_and(|a| !a.get()) {
5226            return false;
5227        }
5228        match self::keyboard::range_window_rect(&st, start, end) {
5229            Some(a) => (a, st.typewriter, st.self_id),
5230            None => return false,
5231        }
5232    };
5233    match (pin, owner) {
5234        (Some(fraction), Some(owner)) => ctx.ensure_visible_aligned_from(
5235            owner,
5236            area,
5237            fraction,
5238            teksilo_core::event::ScrollMotion::Smooth,
5239        ),
5240        (Some(fraction), None) => {
5241            ctx.ensure_visible_aligned(area, fraction, teksilo_core::event::ScrollMotion::Smooth)
5242        }
5243        (None, Some(owner)) => ctx.ensure_visible_from(owner, area),
5244        (None, None) => ctx.ensure_visible(area),
5245    }
5246    true
5247}
5248
5249/// Set (or clear) an editor's ambient caret band. Shared by
5250/// [`RichTextEditor::set_caret_highlight`] and its [`EditorHandle`] mirror.
5251///
5252/// The session is created on first use and torn down when the band is cleared, so an editor
5253/// that never asks for one registers nothing on the document at all — which matters, since
5254/// every read-only preview pane shares the documents the writing panes are editing.
5255fn set_caret_highlight(state: &SharedState, highlight: Option<caret_highlight::CaretHighlight>) {
5256    let mut st = state.borrow_mut();
5257    match (&st.caret_highlight, &highlight) {
5258        (None, None) => return,
5259        (None, Some(_)) => {
5260            let session = caret_highlight::CaretHighlightSession::new(&st.document);
5261            session.set_config(highlight);
5262            // The frame loop hands it the focus state and the caret on the next tick, so a band
5263            // switched on mid-session appears without the editor having to be touched.
5264            let active = st.has_focus && !st.cursor.has_selection();
5265            session.set_active(active);
5266            st.caret_highlight_active = active;
5267            st.caret_highlight = Some(session);
5268        }
5269        (Some(_), None) => {
5270            // Dropping the session retires its highlight layer.
5271            st.caret_highlight = None;
5272            st.caret_highlight_active = false;
5273        }
5274        (Some(session), Some(_)) => {
5275            session.set_config(highlight);
5276        }
5277    }
5278    // A band that appeared, vanished or changed colour needs a frame to draw it — and the
5279    // resolve-and-push itself only happens in `frame_loop::tick`, so without waking the tree an
5280    // idle editor stays configured-but-unbanded until some unrelated interaction pumps a frame.
5281    // Same poke `set_typography_defaults` / `set_font_size_scale` make, for the same reason:
5282    // these are the ctx-less setters a host calls from a settings or theme effect.
5283    st.content_dirty = true;
5284    if let Some(handle) = &st.frame_request {
5285        handle.set(true);
5286    }
5287}
5288
5289/// Push the current cursor position / anchor / selection flag into
5290/// the state's reactive signals. Called after every cursor mutation
5291/// so external observers (status bars, tests) see the change on the
5292/// next signal propagation. Exported to `keyboard` and `mouse`
5293/// because every event handler ends with a signal publish.
5294pub(super) fn sync_cursor_signals(state: &SharedState) {
5295    let mut st = state.borrow_mut();
5296    let pos = st.cursor.position();
5297    let anc = st.cursor.anchor();
5298    let has_sel = st.cursor.has_selection();
5299    let pos_sig = st.cursor_position.clone();
5300    let anc_sig = st.cursor_anchor.clone();
5301    let sel_sig = st.has_selection.clone();
5302    let caret_vis_sig = st.caret_visible.clone();
5303    // Restart the blink phase on every cursor mutation: a steady-visible
5304    // caret while typing or holding an arrow key, blinking only
5305    // resumes after the user stops moving. Mirrors focus-gain behavior
5306    // (see the FocusChanged handler around rich_text.rs:2041). The frame
5307    // loop only toggles once a full interval has elapsed since the phase
5308    // start, so restarting here delays the next toggle by a full interval.
5309    let blink_reset = st.has_focus && matches!(st.policy.caret_policy, CaretPolicy::Blinking);
5310    if blink_reset {
5311        st.blink.restart();
5312    }
5313    drop(st);
5314    pos_sig.set(pos);
5315    anc_sig.set(anc);
5316    sel_sig.set(has_sel);
5317    if blink_reset && !caret_vis_sig.get() {
5318        caret_vis_sig.set(true);
5319    }
5320}
5321
5322/// Dispatch an AccessKit `ActionRequest` payload for the rich text
5323/// editor. Handles `SetTextSelection` (screen-reader-initiated
5324/// caret moves), `SetValue` (programmatic text replacement), and
5325/// `ScrollIntoView` (scroll so the caret is visible).
5326fn handle_access_action_request(
5327    state: &SharedState,
5328    action: teksilo_core::accesskit::Action,
5329    _target_node: teksilo_core::accesskit::NodeId,
5330    data: Option<teksilo_core::accesskit::ActionData>,
5331    ctx: &mut teksilo_core::widget::EventContext,
5332) -> teksilo_core::event::EventResponse {
5333    use self::policy::EditCommandKind;
5334    use teksilo_core::accesskit::{Action, ActionData};
5335    use teksilo_core::event::EventResponse;
5336    use teksilo_text::text_document::{MoveMode, SelectionType};
5337
5338    match (action, data) {
5339        (Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
5340            let filter = state.borrow().policy.command_filter;
5341            // Screen-reader-initiated caret moves are "navigation",
5342            // filtered under the same rule as arrow keys.
5343            if !filter.accepts(EditCommandKind::MoveLeft) {
5344                return EventResponse::Ignored;
5345            }
5346            let resolve = |pos: teksilo_core::accesskit::TextPosition| -> Option<usize> {
5347                let st = state.borrow();
5348                let map = st.synthetic_to_element.borrow();
5349                let er = map.get(&pos.node)?.clone();
5350                // Convert character_index (char units within the run)
5351                // to a byte offset within the run's text, then add
5352                // absolute_start to get the document position.
5353                let byte_off = er
5354                    .text
5355                    .char_indices()
5356                    .nth(pos.character_index)
5357                    .map(|(i, _)| i)
5358                    .unwrap_or(er.text.len());
5359                Some(er.absolute_start + byte_off)
5360            };
5361            if let (Some(a), Some(f)) = (resolve(sel.anchor), resolve(sel.focus)) {
5362                let st = state.borrow();
5363                st.cursor.set_position(a, MoveMode::MoveAnchor);
5364                st.cursor.set_position(f, MoveMode::KeepAnchor);
5365                drop(st);
5366                sync_cursor_signals(state);
5367                ctx.request_frame();
5368                EventResponse::Handled
5369            } else {
5370                EventResponse::Ignored
5371            }
5372        }
5373        (Action::SetValue, Some(ActionData::Value(value))) => {
5374            let filter = state.borrow().policy.command_filter;
5375            // `SetValue` swaps the *whole document* for the supplied string, so
5376            // accepting `InsertChar` is not enough on its own: under a
5377            // forward-only filter this is the single most destructive edit
5378            // available, however additive the incoming text looks. Dictation
5379            // that wants to add rather than replace arrives as
5380            // `ReplaceSelectedText` below.
5381            if !filter.accepts(EditCommandKind::InsertChar)
5382                || !filter.allows_wholesale_replacement()
5383            {
5384                return EventResponse::Ignored;
5385            }
5386            let st = state.borrow();
5387            st.cursor.select(SelectionType::Document);
5388            let _ = st.cursor.insert_text(value.as_ref());
5389            // For some people this **is** typing — dictation, a braille display —
5390            // and it is reported as itself rather than as `Keyboard` or as
5391            // nothing at all. A toolkit that folded it into typing would erase
5392            // how they work; one that reported nothing would leave anything
5393            // counting arrivals silently short for exactly those writers.
5394            st.report_inserted(EditSource::Accessibility, value.as_ref());
5395            drop(st);
5396            sync_cursor_signals(state);
5397            ctx.request_frame();
5398            EventResponse::Handled
5399        }
5400        (Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
5401            // Insert at the caret, replacing the active selection (if
5402            // any) — NOT the whole document like `SetValue`. The AT-SPI
5403            // (Linux) / UIA (Windows) braille-keyboard & dictation
5404            // insertion path; macOS routes insertion through `SetValue`.
5405            // We advertise the action in `accessibility()`, so service it.
5406            let filter = state.borrow().policy.command_filter;
5407            if !filter.accepts(EditCommandKind::InsertChar) {
5408                return EventResponse::Ignored;
5409            }
5410            let st = state.borrow();
5411            self::keyboard::collapse_selection_before_insert(&st);
5412            let _ = st.cursor.insert_text(value.as_ref());
5413            // The AT-SPI / UIA insertion path, which is how a braille keyboard
5414            // and most dictation write. Same reason as `SetValue` above.
5415            st.report_inserted(EditSource::Accessibility, value.as_ref());
5416            drop(st);
5417            sync_cursor_signals(state);
5418            ctx.request_frame();
5419            EventResponse::Handled
5420        }
5421        (Action::ScrollIntoView, _) => {
5422            let mut st = state.borrow_mut();
5423            if let Some(new_y) = st.engine.ensure_caret_visible() {
5424                st.scroll_y.set(new_y);
5425            }
5426            drop(st);
5427            ctx.request_frame();
5428            EventResponse::Handled
5429        }
5430        _ => EventResponse::Ignored,
5431    }
5432}
5433
5434/// Convert an intra-fragment byte offset into a character index.
5435/// Used by `accessibility()` to map the user's document-absolute
5436/// cursor position into AccessKit's `TextPosition.character_index`
5437/// (which indexes into the target TextRun's `character_lengths`,
5438/// i.e., one entry per Rust `char`).
5439fn char_index_in_text(text: &str, byte_offset: usize) -> usize {
5440    // Walk char_indices until we pass byte_offset; the count at
5441    // that point is the character index. Fall back to the char
5442    // count when byte_offset >= text.len().
5443    if byte_offset >= text.len() {
5444        return text.chars().count();
5445    }
5446    let mut count = 0usize;
5447    for (i, _) in text.char_indices() {
5448        if i >= byte_offset {
5449            return count;
5450        }
5451        count += 1;
5452    }
5453    count
5454}
5455
5456// ── The framework's uniform view of a text-editing widget ────────────────────
5457
5458impl teksilo_core::text_surface::TextSurface for EditorHandle {
5459    fn can_undo(&self) -> bool {
5460        EditorHandle::can_undo(self).get()
5461    }
5462
5463    fn can_redo(&self) -> bool {
5464        EditorHandle::can_redo(self).get()
5465    }
5466
5467    fn undo(&self) {
5468        EditorHandle::undo(self);
5469    }
5470
5471    fn redo(&self) {
5472        EditorHandle::redo(self);
5473    }
5474
5475    /// The editor's own [`CommandFilter`]
5476    /// is the authority: a host that has imposed `ForwardOnly` or `ReadOnly` on
5477    /// this editor must not be able to route around it from a menu.
5478    fn history_frozen(&self) -> bool {
5479        !self.command_filter().accepts(EditCommandKind::Undo)
5480    }
5481
5482    fn has_selection(&self) -> bool {
5483        EditorHandle::has_selection(self).get()
5484    }
5485
5486    fn is_read_only(&self) -> bool {
5487        !self.command_filter().accepts(EditCommandKind::InsertChar)
5488    }
5489
5490    fn allows_copy(&self) -> bool {
5491        self.command_filter().accepts(EditCommandKind::Copy)
5492    }
5493
5494    fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5495        EditorHandle::cut(self, ctx);
5496    }
5497
5498    fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5499        EditorHandle::copy(self, ctx);
5500    }
5501
5502    fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5503        EditorHandle::paste(self, ctx);
5504    }
5505
5506    fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
5507        EditorHandle::paste_unformatted(self, ctx);
5508    }
5509
5510    fn select_all(&self) {
5511        EditorHandle::select_all(self);
5512    }
5513}