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