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