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