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