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