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::layout::{layout_doc, DocLayout, FontResolver};
38use super::model::RichDoc;
39use super::model::{DocPos, DocRange};
40use super::view::SharedResolver;
41
42mod context_menu;
43pub mod core;
44mod geometry;
45mod input;
46mod paint;
47mod scroll;
48
49pub use core::{RichEditCore, RichEditHandle};
50
51#[cfg(test)]
52mod handle_api_tests;
53#[cfg(test)]
54mod preview_dirty_tests;
55#[cfg(test)]
56mod styled_clipboard_tests;
57#[cfg(test)]
58mod tab_indent_tests;
59#[cfg(test)]
60mod tests;
61#[cfg(test)]
62mod trailing_space_tests;
63#[cfg(test)]
64mod word_delete_tests;
65
66/// An interactive, styled rich-text editor.
67pub struct RichTextEdit {
68    bounds: Rect,
69    children: Vec<Box<dyn Widget>>, // always empty
70    base: WidgetBase,
71
72    /// Shared logical state — also reachable through a [`RichEditHandle`].
73    core: Rc<RefCell<RichEditCore>>,
74    resolver: SharedResolver,
75    padding: f64,
76
77    /// Cached layout + the width and doc revision it was computed for.
78    layout: Option<DocLayout>,
79    layout_width: f64,
80    layout_doc_rev: u64,
81
82    /// Internal vertical scroll (composes the shared [`ScrollbarAxis`], as
83    /// `TextArea` does — see `scroll.rs`).
84    vbar: ScrollbarAxis,
85
86    /// Stable id for the programmatic focus channel; `None` opts out.
87    focus_request_id: Option<FocusId>,
88
89    /// Ephemeral input state.
90    focused: bool,
91    hovered: bool,
92    selecting_drag: bool,
93    focus_time: Option<Instant>,
94    blink_last_phase: Cell<u64>,
95
96    /// Multi-click (single / double / triple) detection and the granularity of
97    /// the active selection drag. A double-click selects the word, a
98    /// triple-click the block; dragging afterwards extends by whole words /
99    /// blocks. `select_pivot` is the `(anchor, caret)` range (in document
100    /// order) the initiating click selected.
101    multi_click: MultiClickTracker,
102    select_granularity: SelectGranularity,
103    select_pivot: (DocPos, DocPos),
104    /// Start instant for the undoer's monotonic clock.
105    start: Instant,
106    /// Caret revision observed at the previous layout, to scroll external
107    /// (toolbar-driven) caret moves into view.
108    last_layout_rev: Option<u64>,
109
110    /// Per-widget CPU bitmap cache. Routes the whole editor (bg + selection +
111    /// styled runs) through the same LCD-subpixel / grayscale pipeline as
112    /// `Label` and `TextField` via `paint_subtree_backbuffered`, so styled text
113    /// gets the app's best rendering by default and follows the System settings.
114    cache: BackbufferCache,
115    /// Last painted signature; a change invalidates [`Self::cache`] in `layout`.
116    last_sig: Option<RichEditSig>,
117    /// Monotonic counter bumped by [`Self::invalidate_layout`]. The cached
118    /// layout is keyed on `(width, doc_rev)` and does NOT observe the demo's
119    /// asynchronous font catalog, so an async face arrival reshapes the doc
120    /// *without* advancing `core.rev()`. Folding this counter into
121    /// [`RichEditSig`] makes such a reshape invalidate the backbuffer too,
122    /// otherwise the cache would keep blitting the stale fallback-font bitmap.
123    layout_gen: u64,
124
125    /// Default-on right-click Cut/Copy/Paste/Select-All menu. Opt out with
126    /// [`with_context_menu(false)`](Self::with_context_menu).
127    context_menu: crate::widgets::text_context_menu::TextContextMenu,
128    context_menu_enabled: bool,
129}
130
131/// Snapshot of every input that affects the cached backbuffer bitmap
132/// (background + selection band + styled runs + border). `layout` compares the
133/// current sig against the last one and drops the cache on any difference.
134///
135/// Blink phase and the floating scrollbar paint in `paint_overlay` (after the
136/// blit) and are excluded; typography / theme invalidation is handled by the
137/// framework's epoch checks. `core_rev` folds in every document, caret and
138/// selection change (see [`RichEditCore::rev`]).
139#[derive(Clone, Debug, PartialEq)]
140struct RichEditSig {
141    core_rev: u64,
142    focused: bool,
143    hovered: bool,
144    offset_bits: u64,
145    w_bits: u64,
146    h_bits: u64,
147    layout_gen: u64,
148}
149
150impl RichTextEdit {
151    /// Create an editor over `initial`, resolving run fonts via `resolver`.
152    pub fn new(initial: RichDoc, resolver: SharedResolver) -> Self {
153        Self {
154            bounds: Rect::default(),
155            children: Vec::new(),
156            base: WidgetBase::new(),
157            core: Rc::new(RefCell::new(RichEditCore::new(initial, 16.0))),
158            resolver,
159            padding: 8.0,
160            layout: None,
161            layout_width: -1.0,
162            layout_doc_rev: u64::MAX,
163            vbar: ScrollbarAxis {
164                enabled: true,
165                ..ScrollbarAxis::default()
166            },
167            focus_request_id: None,
168            focused: false,
169            hovered: false,
170            selecting_drag: false,
171            focus_time: None,
172            blink_last_phase: Cell::new(0),
173            multi_click: MultiClickTracker::default(),
174            select_granularity: SelectGranularity::default(),
175            select_pivot: (DocPos::new(0, 0), DocPos::new(0, 0)),
176            start: Instant::now(),
177            last_layout_rev: None,
178            cache: BackbufferCache::default(),
179            last_sig: None,
180            layout_gen: 0,
181            context_menu: crate::widgets::text_context_menu::TextContextMenu::new(),
182            context_menu_enabled: true,
183        }
184    }
185
186    /// Enable or disable the default right-click Cut/Copy/Paste/Select-All
187    /// context menu. On by default.
188    pub fn with_context_menu(mut self, v: bool) -> Self {
189        self.context_menu_enabled = v;
190        self
191    }
192
193    /// Build the backbuffer-cache invalidation signature from current state.
194    fn cache_sig(&self) -> RichEditSig {
195        RichEditSig {
196            core_rev: self.core.borrow().rev(),
197            focused: self.focused,
198            hovered: self.hovered,
199            offset_bits: self.vbar.offset.to_bits(),
200            w_bits: self.bounds.width.to_bits(),
201            h_bits: self.bounds.height.to_bits(),
202            layout_gen: self.layout_gen,
203        }
204    }
205
206    /// Set the default font size (points) runs inherit when their style leaves
207    /// [`InlineStyle::font_size`](super::model::InlineStyle::font_size) unset.
208    pub fn with_font_size(self, size: f64) -> Self {
209        self.core.borrow_mut().set_default_font_size(size);
210        self
211    }
212    /// Set the inner padding (logical px) between the border and the text.
213    pub fn with_padding(mut self, p: f64) -> Self {
214        self.padding = p;
215        self
216    }
217    /// Set the widget's outer margin.
218    pub fn with_margin(mut self, m: Insets) -> Self {
219        self.base.margin = m;
220        self
221    }
222    /// Assign a stable [`FocusId`] so the app can focus this editor
223    /// programmatically.
224    pub fn with_focus_id(mut self, id: FocusId) -> Self {
225        self.focus_request_id = Some(id);
226        self
227    }
228    /// Set the horizontal anchor used when the editor is placed in a layout.
229    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
230        self.base.h_anchor = h;
231        self
232    }
233    /// Set the vertical anchor used when the editor is placed in a layout.
234    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
235        self.base.v_anchor = v;
236        self
237    }
238
239    /// A cheap, cloneable handle onto this editor's shared core, for a toolbar.
240    pub fn handle(&self) -> RichEditHandle {
241        RichEditHandle::new(Rc::clone(&self.core))
242    }
243
244    // ── Public API (delegating to the shared core) ────────────────────────
245
246    /// Apply a formatting command over the current selection/caret.
247    pub fn exec(&mut self, cmd: &RichCommand) {
248        self.core.borrow_mut().exec(cmd);
249    }
250    /// Summary of the styles under the current selection (drives toolbar state).
251    pub fn common_style_of_selection(&self) -> CommonStyle {
252        self.core.borrow().common_style_of_selection()
253    }
254
255    /// Select the whole document.
256    pub fn select_all(&mut self) {
257        self.core.borrow_mut().select_all();
258    }
259    /// Move the caret to `pos` (clamped), collapsing any selection.
260    pub fn set_caret(&mut self, pos: DocPos) {
261        self.core.borrow_mut().set_caret(pos, false);
262    }
263    /// Set the selection to `range` — `range.start` is the anchor, `range.end`
264    /// the caret, each clamped onto a valid position.
265    pub fn set_selection(&mut self, range: DocRange) {
266        self.core.borrow_mut().set_selection(range.start, range.end);
267    }
268    /// The current selection (anchor → caret; collapsed when they coincide).
269    pub fn selection(&self) -> DocRange {
270        self.core.borrow().selection()
271    }
272    /// Replace the document with `doc`, resetting the caret to the start and
273    /// **discarding the undo/redo history** (the loaded document is the new
274    /// baseline).
275    pub fn load(&mut self, doc: RichDoc) {
276        self.core.borrow_mut().load(doc);
277    }
278    /// Undo the last coalesced edit; returns `true` if the document changed.
279    pub fn undo(&mut self) -> bool {
280        self.core.borrow_mut().undo()
281    }
282    /// Redo the last undone edit; returns `true` if the document changed.
283    pub fn redo(&mut self) -> bool {
284        self.core.borrow_mut().redo()
285    }
286    /// Whether an undo step is available.
287    pub fn can_undo(&self) -> bool {
288        self.core.borrow().can_undo()
289    }
290    /// Whether a redo step is available.
291    pub fn can_redo(&self) -> bool {
292        self.core.borrow().can_redo()
293    }
294
295    /// The document's plain text (blocks joined by `\n`).
296    pub fn plain_text(&self) -> String {
297        self.core.borrow().doc().plain_text()
298    }
299
300    /// Force the cached layout to be rebuilt on the next frame.  The layout is
301    /// otherwise cached against `(width, doc_revision)` and does **not** observe
302    /// the demo's asynchronous font catalog; call this when a newly-loaded font
303    /// should be picked up (e.g. after the font-cache epoch advances).
304    pub fn invalidate_layout(&mut self) {
305        self.layout = None;
306        self.layout_width = -1.0;
307        self.layout_doc_rev = u64::MAX;
308        // Advance the generation so the backbuffer cache re-rasters too: an
309        // async font arrival reshapes without bumping `core.rev()`, which the
310        // paint signature otherwise keys on.
311        self.layout_gen = self.layout_gen.wrapping_add(1);
312    }
313
314    // ── Layout cache ──────────────────────────────────────────────────────
315
316    /// Rebuild the cached layout when the width or document revision changed.
317    fn ensure_layout(&mut self, width: f64) {
318        let doc_rev = self.core.borrow().doc_rev();
319        if self.layout.is_some()
320            && (self.layout_width - width).abs() < 0.5
321            && self.layout_doc_rev == doc_rev
322        {
323            return;
324        }
325        let resolver: &FontResolver = &*self.resolver;
326        let core = self.core.borrow();
327        self.layout = Some(layout_doc(
328            core.doc(),
329            width,
330            core.default_font_size(),
331            resolver,
332        ));
333        self.layout_width = width;
334        self.layout_doc_rev = doc_rev;
335    }
336
337    /// Content height from the cached layout (0 before the first layout).
338    pub(crate) fn content_height(&self) -> f64 {
339        self.layout.as_ref().map(|l| l.height).unwrap_or(0.0)
340    }
341}
342
343impl Widget for RichTextEdit {
344    fn type_name(&self) -> &'static str {
345        "RichTextEdit"
346    }
347    fn bounds(&self) -> Rect {
348        self.bounds
349    }
350    fn set_bounds(&mut self, b: Rect) {
351        self.bounds = b;
352    }
353    fn children(&self) -> &[Box<dyn Widget>] {
354        &self.children
355    }
356    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
357        &mut self.children
358    }
359
360    fn is_focusable(&self) -> bool {
361        true
362    }
363    fn focus_id(&self) -> Option<FocusId> {
364        self.focus_request_id
365    }
366    fn accepts_text_input(&self) -> bool {
367        true
368    }
369    fn text_input_value(&self) -> Option<String> {
370        Some(self.plain_text())
371    }
372
373    fn margin(&self) -> Insets {
374        self.base.margin
375    }
376    fn widget_base(&self) -> Option<&WidgetBase> {
377        Some(&self.base)
378    }
379    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
380        Some(&mut self.base)
381    }
382    fn h_anchor(&self) -> HAnchor {
383        self.base.h_anchor
384    }
385    fn v_anchor(&self) -> VAnchor {
386        self.base.v_anchor
387    }
388    fn min_size(&self) -> Size {
389        self.base.min_size
390    }
391    fn max_size(&self) -> Size {
392        self.base.max_size
393    }
394
395    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
396        Some(&mut self.cache)
397    }
398
399    fn backbuffer_mode(&self) -> BackbufferMode {
400        // Same decision as `Label` / `TextField` / `TextArea`: LCD coverage
401        // buffer when the global toggle is on, grayscale RGBA otherwise. The
402        // mode-flip detection in `paint_subtree_backbuffered` re-rasters when
403        // the toggle changes.
404        if crate::font_settings::lcd_enabled() {
405            BackbufferMode::LcdCoverage
406        } else {
407            BackbufferMode::Rgba
408        }
409    }
410
411    fn measure_min_height(&self, available_w: f64) -> f64 {
412        let resolver: &FontResolver = &*self.resolver;
413        let core = self.core.borrow();
414        let inner_w = (available_w - self.padding * 2.0).max(1.0);
415        layout_doc(core.doc(), inner_w, core.default_font_size(), resolver).height
416            + self.padding * 2.0
417    }
418
419    fn layout(&mut self, available: Size) -> Size {
420        let w = available.width.max(self.padding * 2.0 + 20.0);
421        let h = available.height.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}