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