Skip to main content

agg_gui/widgets/rich_text/
editor.rs

1//! `RichTextEdit` — the **interactive** rich-text editor widget.
2//!
3//! Builds on the phase-1 pieces: the [`RichDoc`] model, the command engine, and
4//! the width-constrained [`layout`](super::layout) that the read-only
5//! [`RichTextView`](super::view::RichTextView) also uses. It adds a caret +
6//! selection, keyboard editing, mouse click/drag positioning, an internal
7//! vertical scrollbar, and time-coalescing undo/redo.
8//!
9//! # Architecture
10//!
11//! The *logical* editor state lives in [`RichEditCore`] (document, caret,
12//! anchor, pending caret style, undoer) behind an `Rc<RefCell<_>>`. This widget
13//! owns only the *view* state — bounds, the cached [`DocLayout`], the scrollbar,
14//! and focus/hover flags — and drives the core in response to input. A cheap
15//! [`RichEditHandle`] shares the same core so a formatting toolbar can `exec`
16//! commands, read [`common_style_of_selection`](RichEditHandle::common_style_of_selection),
17//! and undo/redo the very editor being rendered.
18//!
19//! Submodules keep each concern (and this file) under the 800-line cap:
20//! [`core`] (shared logic), `geometry` (caret ↔ pixel mapping), `input`
21//! (key/mouse handling), `paint`, and `scroll` (internal vertical scrollbar).
22
23use std::cell::{Cell, RefCell};
24use std::rc::Rc;
25
26use web_time::Instant;
27
28use crate::event::{Event, EventResult};
29use crate::focus::FocusId;
30use crate::geometry::{Rect, Size};
31use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
32use crate::widget::{BackbufferCache, BackbufferMode, Widget};
33use crate::widgets::multi_click::{MultiClickTracker, SelectGranularity};
34use crate::widgets::scrollbar::ScrollbarAxis;
35
36use super::commands::{CommonStyle, RichCommand};
37use super::model::{DocPos, DocRange};
38use super::layout::{layout_doc, DocLayout, FontResolver};
39use super::model::RichDoc;
40use super::view::SharedResolver;
41
42pub mod core;
43mod context_menu;
44mod geometry;
45mod input;
46mod paint;
47mod scroll;
48
49pub use core::{RichEditCore, RichEditHandle};
50
51#[cfg(test)]
52mod tests;
53#[cfg(test)]
54mod handle_api_tests;
55#[cfg(test)]
56mod preview_dirty_tests;
57#[cfg(test)]
58mod tab_indent_tests;
59#[cfg(test)]
60mod trailing_space_tests;
61#[cfg(test)]
62mod word_delete_tests;
63
64/// An interactive, styled rich-text editor.
65pub struct RichTextEdit {
66    bounds: Rect,
67    children: Vec<Box<dyn Widget>>, // always empty
68    base: WidgetBase,
69
70    /// Shared logical state — also reachable through a [`RichEditHandle`].
71    core: Rc<RefCell<RichEditCore>>,
72    resolver: SharedResolver,
73    padding: f64,
74
75    /// Cached layout + the width and doc revision it was computed for.
76    layout: Option<DocLayout>,
77    layout_width: f64,
78    layout_doc_rev: u64,
79
80    /// Internal vertical scroll (composes the shared [`ScrollbarAxis`], as
81    /// `TextArea` does — see `scroll.rs`).
82    vbar: ScrollbarAxis,
83
84    /// Stable id for the programmatic focus channel; `None` opts out.
85    focus_request_id: Option<FocusId>,
86
87    /// Ephemeral input state.
88    focused: bool,
89    hovered: bool,
90    selecting_drag: bool,
91    focus_time: Option<Instant>,
92    blink_last_phase: Cell<u64>,
93
94    /// Multi-click (single / double / triple) detection and the granularity of
95    /// the active selection drag. A double-click selects the word, a
96    /// triple-click the block; dragging afterwards extends by whole words /
97    /// blocks. `select_pivot` is the `(anchor, caret)` range (in document
98    /// order) the initiating click selected.
99    multi_click: MultiClickTracker,
100    select_granularity: SelectGranularity,
101    select_pivot: (DocPos, DocPos),
102    /// Start instant for the undoer's monotonic clock.
103    start: Instant,
104    /// Caret revision observed at the previous layout, to scroll external
105    /// (toolbar-driven) caret moves into view.
106    last_layout_rev: Option<u64>,
107
108    /// Per-widget CPU bitmap cache. Routes the whole editor (bg + selection +
109    /// styled runs) through the same LCD-subpixel / grayscale pipeline as
110    /// `Label` and `TextField` via `paint_subtree_backbuffered`, so styled text
111    /// gets the app's best rendering by default and follows the System settings.
112    cache: BackbufferCache,
113    /// Last painted signature; a change invalidates [`Self::cache`] in `layout`.
114    last_sig: Option<RichEditSig>,
115    /// Monotonic counter bumped by [`Self::invalidate_layout`]. The cached
116    /// layout is keyed on `(width, doc_rev)` and does NOT observe the demo's
117    /// asynchronous font catalog, so an async face arrival reshapes the doc
118    /// *without* advancing `core.rev()`. Folding this counter into
119    /// [`RichEditSig`] makes such a reshape invalidate the backbuffer too,
120    /// otherwise the cache would keep blitting the stale fallback-font bitmap.
121    layout_gen: u64,
122
123    /// Default-on right-click Cut/Copy/Paste/Select-All menu. Opt out with
124    /// [`with_context_menu(false)`](Self::with_context_menu).
125    context_menu: crate::widgets::text_context_menu::TextContextMenu,
126    context_menu_enabled: bool,
127}
128
129/// Snapshot of every input that affects the cached backbuffer bitmap
130/// (background + selection band + styled runs + border). `layout` compares the
131/// current sig against the last one and drops the cache on any difference.
132///
133/// Blink phase and the floating scrollbar paint in `paint_overlay` (after the
134/// blit) and are excluded; typography / theme invalidation is handled by the
135/// framework's epoch checks. `core_rev` folds in every document, caret and
136/// selection change (see [`RichEditCore::rev`]).
137#[derive(Clone, Debug, PartialEq)]
138struct RichEditSig {
139    core_rev: u64,
140    focused: bool,
141    hovered: bool,
142    offset_bits: u64,
143    w_bits: u64,
144    h_bits: u64,
145    layout_gen: u64,
146}
147
148impl RichTextEdit {
149    /// Create an editor over `initial`, resolving run fonts via `resolver`.
150    pub fn new(initial: RichDoc, resolver: SharedResolver) -> Self {
151        Self {
152            bounds: Rect::default(),
153            children: Vec::new(),
154            base: WidgetBase::new(),
155            core: Rc::new(RefCell::new(RichEditCore::new(initial, 16.0))),
156            resolver,
157            padding: 8.0,
158            layout: None,
159            layout_width: -1.0,
160            layout_doc_rev: u64::MAX,
161            vbar: ScrollbarAxis {
162                enabled: true,
163                ..ScrollbarAxis::default()
164            },
165            focus_request_id: None,
166            focused: false,
167            hovered: false,
168            selecting_drag: false,
169            focus_time: None,
170            blink_last_phase: Cell::new(0),
171            multi_click: MultiClickTracker::default(),
172            select_granularity: SelectGranularity::default(),
173            select_pivot: (DocPos::new(0, 0), DocPos::new(0, 0)),
174            start: Instant::now(),
175            last_layout_rev: None,
176            cache: BackbufferCache::default(),
177            last_sig: None,
178            layout_gen: 0,
179            context_menu: crate::widgets::text_context_menu::TextContextMenu::new(),
180            context_menu_enabled: true,
181        }
182    }
183
184    /// Enable or disable the default right-click Cut/Copy/Paste/Select-All
185    /// context menu. On by default.
186    pub fn with_context_menu(mut self, v: bool) -> Self {
187        self.context_menu_enabled = v;
188        self
189    }
190
191    /// Build the backbuffer-cache invalidation signature from current state.
192    fn cache_sig(&self) -> RichEditSig {
193        RichEditSig {
194            core_rev: self.core.borrow().rev(),
195            focused: self.focused,
196            hovered: self.hovered,
197            offset_bits: self.vbar.offset.to_bits(),
198            w_bits: self.bounds.width.to_bits(),
199            h_bits: self.bounds.height.to_bits(),
200            layout_gen: self.layout_gen,
201        }
202    }
203
204    /// Set the default font size (points) runs inherit when their style leaves
205    /// [`InlineStyle::font_size`](super::model::InlineStyle::font_size) unset.
206    pub fn with_font_size(self, size: f64) -> Self {
207        self.core.borrow_mut().set_default_font_size(size);
208        self
209    }
210    /// Set the inner padding (logical px) between the border and the text.
211    pub fn with_padding(mut self, p: f64) -> Self {
212        self.padding = p;
213        self
214    }
215    /// Set the widget's outer margin.
216    pub fn with_margin(mut self, m: Insets) -> Self {
217        self.base.margin = m;
218        self
219    }
220    /// Assign a stable [`FocusId`] so the app can focus this editor
221    /// programmatically.
222    pub fn with_focus_id(mut self, id: FocusId) -> Self {
223        self.focus_request_id = Some(id);
224        self
225    }
226    /// Set the horizontal anchor used when the editor is placed in a layout.
227    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
228        self.base.h_anchor = h;
229        self
230    }
231    /// Set the vertical anchor used when the editor is placed in a layout.
232    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
233        self.base.v_anchor = v;
234        self
235    }
236
237    /// A cheap, cloneable handle onto this editor's shared core, for a toolbar.
238    pub fn handle(&self) -> RichEditHandle {
239        RichEditHandle::new(Rc::clone(&self.core))
240    }
241
242    // ── Public API (delegating to the shared core) ────────────────────────
243
244    /// Apply a formatting command over the current selection/caret.
245    pub fn exec(&mut self, cmd: &RichCommand) {
246        self.core.borrow_mut().exec(cmd);
247    }
248    /// Summary of the styles under the current selection (drives toolbar state).
249    pub fn common_style_of_selection(&self) -> CommonStyle {
250        self.core.borrow().common_style_of_selection()
251    }
252
253    /// Select the whole document.
254    pub fn select_all(&mut self) {
255        self.core.borrow_mut().select_all();
256    }
257    /// Move the caret to `pos` (clamped), collapsing any selection.
258    pub fn set_caret(&mut self, pos: DocPos) {
259        self.core.borrow_mut().set_caret(pos, false);
260    }
261    /// Set the selection to `range` — `range.start` is the anchor, `range.end`
262    /// the caret, each clamped onto a valid position.
263    pub fn set_selection(&mut self, range: DocRange) {
264        self.core.borrow_mut().set_selection(range.start, range.end);
265    }
266    /// The current selection (anchor → caret; collapsed when they coincide).
267    pub fn selection(&self) -> DocRange {
268        self.core.borrow().selection()
269    }
270    /// Replace the document with `doc`, resetting the caret to the start and
271    /// **discarding the undo/redo history** (the loaded document is the new
272    /// baseline).
273    pub fn load(&mut self, doc: RichDoc) {
274        self.core.borrow_mut().load(doc);
275    }
276    /// Undo the last coalesced edit; returns `true` if the document changed.
277    pub fn undo(&mut self) -> bool {
278        self.core.borrow_mut().undo()
279    }
280    /// Redo the last undone edit; returns `true` if the document changed.
281    pub fn redo(&mut self) -> bool {
282        self.core.borrow_mut().redo()
283    }
284    /// Whether an undo step is available.
285    pub fn can_undo(&self) -> bool {
286        self.core.borrow().can_undo()
287    }
288    /// Whether a redo step is available.
289    pub fn can_redo(&self) -> bool {
290        self.core.borrow().can_redo()
291    }
292
293    /// The document's plain text (blocks joined by `\n`).
294    pub fn plain_text(&self) -> String {
295        self.core.borrow().doc().plain_text()
296    }
297
298    /// Force the cached layout to be rebuilt on the next frame.  The layout is
299    /// otherwise cached against `(width, doc_revision)` and does **not** observe
300    /// the demo's asynchronous font catalog; call this when a newly-loaded font
301    /// should be picked up (e.g. after the font-cache epoch advances).
302    pub fn invalidate_layout(&mut self) {
303        self.layout = None;
304        self.layout_width = -1.0;
305        self.layout_doc_rev = u64::MAX;
306        // Advance the generation so the backbuffer cache re-rasters too: an
307        // async font arrival reshapes without bumping `core.rev()`, which the
308        // paint signature otherwise keys on.
309        self.layout_gen = self.layout_gen.wrapping_add(1);
310    }
311
312    // ── Layout cache ──────────────────────────────────────────────────────
313
314    /// Rebuild the cached layout when the width or document revision changed.
315    fn ensure_layout(&mut self, width: f64) {
316        let doc_rev = self.core.borrow().doc_rev();
317        if self.layout.is_some()
318            && (self.layout_width - width).abs() < 0.5
319            && self.layout_doc_rev == doc_rev
320        {
321            return;
322        }
323        let resolver: &FontResolver = &*self.resolver;
324        let core = self.core.borrow();
325        self.layout = Some(layout_doc(
326            core.doc(),
327            width,
328            core.default_font_size(),
329            resolver,
330        ));
331        self.layout_width = width;
332        self.layout_doc_rev = doc_rev;
333    }
334
335    /// Content height from the cached layout (0 before the first layout).
336    pub(crate) fn content_height(&self) -> f64 {
337        self.layout.as_ref().map(|l| l.height).unwrap_or(0.0)
338    }
339}
340
341impl Widget for RichTextEdit {
342    fn type_name(&self) -> &'static str {
343        "RichTextEdit"
344    }
345    fn bounds(&self) -> Rect {
346        self.bounds
347    }
348    fn set_bounds(&mut self, b: Rect) {
349        self.bounds = b;
350    }
351    fn children(&self) -> &[Box<dyn Widget>] {
352        &self.children
353    }
354    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
355        &mut self.children
356    }
357
358    fn is_focusable(&self) -> bool {
359        true
360    }
361    fn focus_id(&self) -> Option<FocusId> {
362        self.focus_request_id
363    }
364    fn accepts_text_input(&self) -> bool {
365        true
366    }
367    fn text_input_value(&self) -> Option<String> {
368        Some(self.plain_text())
369    }
370
371    fn margin(&self) -> Insets {
372        self.base.margin
373    }
374    fn widget_base(&self) -> Option<&WidgetBase> {
375        Some(&self.base)
376    }
377    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
378        Some(&mut self.base)
379    }
380    fn h_anchor(&self) -> HAnchor {
381        self.base.h_anchor
382    }
383    fn v_anchor(&self) -> VAnchor {
384        self.base.v_anchor
385    }
386    fn min_size(&self) -> Size {
387        self.base.min_size
388    }
389    fn max_size(&self) -> Size {
390        self.base.max_size
391    }
392
393    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
394        Some(&mut self.cache)
395    }
396
397    fn backbuffer_mode(&self) -> BackbufferMode {
398        // Same decision as `Label` / `TextField` / `TextArea`: LCD coverage
399        // buffer when the global toggle is on, grayscale RGBA otherwise. The
400        // mode-flip detection in `paint_subtree_backbuffered` re-rasters when
401        // the toggle changes.
402        if crate::font_settings::lcd_enabled() {
403            BackbufferMode::LcdCoverage
404        } else {
405            BackbufferMode::Rgba
406        }
407    }
408
409    fn measure_min_height(&self, available_w: f64) -> f64 {
410        let resolver: &FontResolver = &*self.resolver;
411        let core = self.core.borrow();
412        let inner_w = (available_w - self.padding * 2.0).max(1.0);
413        layout_doc(core.doc(), inner_w, core.default_font_size(), resolver).height
414            + self.padding * 2.0
415    }
416
417    fn layout(&mut self, available: Size) -> Size {
418        let w = available.width.max(self.padding * 2.0 + 20.0);
419        let h = available
420            .height
421            .max(self.padding * 2.0 + 20.0);
422        self.bounds = Rect::new(0.0, 0.0, w, h);
423        let inner_w = (w - self.padding * 2.0).max(1.0);
424        self.ensure_layout(inner_w);
425        self.sync_scroll();
426
427        // Feed the undoer once per frame (time-coalescing snapshots); keep
428        // frames coming while a change is still settling.
429        let time = self.start.elapsed().as_secs_f64();
430        let in_flux = self.core.borrow_mut().feed_undo(time);
431        if in_flux {
432            crate::animation::request_draw_after_tagged(
433                std::time::Duration::from_millis(120),
434                "rich_text::editor undo-settle",
435            );
436        }
437
438        // Scroll external (toolbar/undo-driven) caret moves into view.
439        let rev = self.core.borrow().rev();
440        if matches!(self.last_layout_rev, Some(prev) if prev != rev) {
441            self.ensure_caret_visible();
442        }
443        self.last_layout_rev = Some(rev);
444
445        // Drop the cached bitmap when any painted input changed (document,
446        // caret/selection, scroll, focus/hover, size). Blink phase and the
447        // floating scrollbar are excluded — they paint in `paint_overlay`.
448        let sig = self.cache_sig();
449        if self.last_sig.as_ref() != Some(&sig) {
450            self.last_sig = Some(sig);
451            self.cache.invalidate();
452        }
453        Size::new(w, h)
454    }
455
456    fn paint(&mut self, ctx: &mut dyn crate::draw_ctx::DrawCtx) {
457        self.paint_content(ctx);
458    }
459
460    fn paint_overlay(&mut self, ctx: &mut dyn crate::draw_ctx::DrawCtx) {
461        // Floating scrollbar first (after the cache blit) so its hover/fade
462        // animation stays live without re-rastering the text bitmap, then the
463        // blinking caret.
464        self.paint_scrollbar(ctx);
465        self.paint_caret(ctx);
466    }
467
468    fn hit_test(&self, local: crate::geometry::Point) -> bool {
469        local.x >= 0.0
470            && local.x <= self.bounds.width
471            && local.y >= 0.0
472            && local.y <= self.bounds.height
473    }
474
475    fn needs_draw(&self) -> bool {
476        // Scrollbar fade/expand tween genuinely animates every frame while it
477        // runs.  The caret blink is transition-scheduled instead: report dirty
478        // only when wall-clock time crosses a 500 ms flip boundary since the
479        // last painted phase, so an idle focused editor wakes twice a second
480        // (see `next_draw_deadline`) rather than every frame.
481        if self.scrollbar_animating() {
482            return true;
483        }
484        if !self.focused {
485            return false;
486        }
487        let Some(t) = self.focus_time else {
488            return false;
489        };
490        let current_phase = (t.elapsed().as_millis() / 500) as u64;
491        current_phase != self.blink_last_phase.get()
492    }
493
494    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
495        if !self.focused {
496            return None;
497        }
498        let t = self.focus_time?;
499        let ms = t.elapsed().as_millis() as u64;
500        let next_phase = (ms / 500) + 1;
501        Some(t + std::time::Duration::from_millis(next_phase * 500))
502    }
503
504    fn on_event(&mut self, event: &Event) -> EventResult {
505        self.handle_event(event)
506    }
507
508    /// While the right-click menu is open it must capture every event (clicks
509    /// outside dismiss it, Escape closes it).
510    fn has_active_modal(&self) -> bool {
511        self.context_menu.is_open()
512    }
513
514    /// The context menu paints at app level so it can overflow the editor
515    /// bounds and clamp to the viewport.
516    fn paint_global_overlay(&mut self, ctx: &mut dyn crate::draw_ctx::DrawCtx) {
517        let font = self.context_menu_font();
518        let size = self.core.borrow().default_font_size();
519        self.context_menu.paint(ctx, font, size);
520    }
521}