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