Skip to main content

agg_gui/widgets/rich_text/editor/
core.rs

1//! `RichEditCore` — the **logical** heart of the interactive rich-text editor,
2//! shared (behind `Rc<RefCell<_>>`) between the [`RichTextEdit`](super::super::RichTextEdit)
3//! widget and the formatting toolbar via a [`RichEditHandle`].
4//!
5//! Everything here is geometry-free: it owns the document, the caret + anchor
6//! (a [`DocRange`] selection), the *pending* caret style, and a time-coalescing
7//! [`Undoer`] snapshotting `{doc, caret, anchor}`.  All structural editing goes
8//! through [`super::super::model`]'s primitives and the command engine in
9//! [`super::super::commands`]; the widget layer adds only pixel geometry,
10//! scrolling and painting on top.
11//!
12//! # Pending caret style (Word behaviour)
13//!
14//! Toggling an inline format (bold, colour, …) with a *collapsed* selection does
15//! not mutate any run — there is nothing selected.  Instead we remember the
16//! toggled style as [`pending_style`](RichEditCore::pending_style); the next
17//! inserted text is stamped with it.  Moving the caret or applying the format to
18//! a real selection clears it.
19
20use std::cell::RefCell;
21use std::rc::Rc;
22
23use crate::undo::Undoer;
24use crate::widgets::text_field_core::{next_char_boundary, prev_char_boundary};
25
26use super::super::commands::{apply_command, range_common_style, style_at, CommonStyle, RichCommand};
27use super::super::model::{
28    extract_range, insert_text, merge_block_with_prev, remove_range, splice_fragment, split_block,
29    Block, DocPos, DocRange, InlineStyle, ListKind, RichDoc,
30};
31
32/// One undo/redo snapshot: the whole document plus the caret and anchor, so an
33/// undo restores the selection exactly as it was.
34#[derive(Clone, PartialEq)]
35pub(crate) struct EditSnapshot {
36    doc: RichDoc,
37    caret: DocPos,
38    anchor: DocPos,
39}
40
41/// The shared, geometry-free editor state.
42pub struct RichEditCore {
43    doc: RichDoc,
44    caret: DocPos,
45    anchor: DocPos,
46    pending_style: Option<InlineStyle>,
47    default_font_size: f64,
48    undoer: Undoer<EditSnapshot>,
49    /// While `true`, [`feed_undo`](Self::feed_undo) is a no-op. Used during a
50    /// live colour-preview drag so the rapid per-frame document mutations don't
51    /// each seed an undo snapshot; a single step is recorded once the preview
52    /// commits and feeding resumes.
53    undo_suspended: bool,
54    /// The committed `{doc, caret, anchor}` captured at
55    /// [`begin_preview`](Self::begin_preview), restored verbatim on
56    /// [`cancel_preview`](Self::cancel_preview). A full snapshot (not just the
57    /// colour) so a mixed-colour selection — or a highlight that was `None` —
58    /// is restored exactly.
59    preview_snapshot: Option<EditSnapshot>,
60    /// Set the moment a preview session actually mutates the document (a live
61    /// colour drag exec'd `SetTextColor` / `SetHighlight`), so a host can tell
62    /// a *changed* session apart from one where the user opened the dialog and
63    /// dismissed it without touching anything. The click-away dismissal uses
64    /// this to choose commit (banked as one undo step) vs. a silent cancel.
65    /// Cleared when a session starts, commits, or cancels.
66    preview_dirty: bool,
67    /// Bumped whenever `doc` changes — the view invalidates its layout cache.
68    doc_rev: u64,
69    /// Bumped on any caret / anchor / doc change — the view repaints.
70    rev: u64,
71}
72
73impl RichEditCore {
74    /// Create a core over `doc` with the given default font size (points).
75    pub fn new(doc: RichDoc, default_font_size: f64) -> Self {
76        Self {
77            doc,
78            caret: DocPos::default(),
79            anchor: DocPos::default(),
80            pending_style: None,
81            default_font_size,
82            undoer: Undoer::default(),
83            undo_suspended: false,
84            preview_snapshot: None,
85            preview_dirty: false,
86            doc_rev: 0,
87            rev: 0,
88        }
89    }
90
91    /// Replace the entire document, resetting the caret/selection to the start
92    /// and **discarding the undo/redo history** — the freshly-loaded document is
93    /// the new baseline, so there is nothing to undo back to.  Any armed pending
94    /// caret style is cleared.
95    pub fn load(&mut self, doc: RichDoc) {
96        self.doc = doc;
97        self.caret = DocPos::default();
98        self.anchor = DocPos::default();
99        self.pending_style = None;
100        // A new document is a new history: drop every prior undo/redo snapshot.
101        self.undoer = Undoer::default();
102        // Abandon any in-flight live preview: the captured snapshot belongs to
103        // the old document, so keeping it would let a later `cancel_preview`
104        // clobber the freshly-loaded doc — and leaving undo suspended would keep
105        // `feed_undo` a no-op forever (dead undo).
106        self.preview_snapshot = None;
107        self.preview_dirty = false;
108        self.undo_suspended = false;
109        self.bump_doc();
110    }
111
112    // ── Accessors ─────────────────────────────────────────────────────────
113
114    /// The document being edited.
115    pub fn doc(&self) -> &RichDoc {
116        &self.doc
117    }
118    /// The document's plain text (blocks joined by `\n`).
119    pub fn plain_text(&self) -> String {
120        self.doc.plain_text()
121    }
122    /// The moving end of the selection (the blinking caret position).
123    pub fn caret(&self) -> DocPos {
124        self.caret
125    }
126    /// The fixed end of the selection (coincides with the caret when collapsed).
127    pub fn anchor(&self) -> DocPos {
128        self.anchor
129    }
130    /// Default font size (points) runs inherit when their style leaves it unset.
131    pub fn default_font_size(&self) -> f64 {
132        self.default_font_size
133    }
134    /// Change the inherited default font size and mark the document dirty.
135    pub fn set_default_font_size(&mut self, size: f64) {
136        self.default_font_size = size;
137        self.bump_doc();
138    }
139    /// Revision counter bumped on every document change (drives layout caching).
140    pub fn doc_rev(&self) -> u64 {
141        self.doc_rev
142    }
143    /// Revision counter bumped on any document, caret, or selection change
144    /// (drives repaint / scroll-into-view).
145    pub fn rev(&self) -> u64 {
146        self.rev
147    }
148    /// The armed pending caret style (a format toggled at a collapsed caret,
149    /// awaiting the next keystroke), if any.
150    pub fn pending_style(&self) -> Option<&InlineStyle> {
151        self.pending_style.as_ref()
152    }
153
154    /// The current selection (anchor → caret; collapsed when they coincide).
155    pub fn selection(&self) -> DocRange {
156        DocRange::new(self.anchor, self.caret)
157    }
158
159    fn bump_rev(&mut self) {
160        self.rev = self.rev.wrapping_add(1);
161    }
162    fn bump_doc(&mut self) {
163        self.doc_rev = self.doc_rev.wrapping_add(1);
164        self.bump_rev();
165    }
166
167    /// Clamp a raw `(block, byte)` onto a valid caret position: block in range,
168    /// byte within the block and on a char boundary.
169    pub fn clamp_pos(&self, pos: DocPos) -> DocPos {
170        let block = pos.block.min(self.doc.blocks.len().saturating_sub(1));
171        let Some(b) = self.doc.blocks.get(block) else {
172            return DocPos::new(0, 0);
173        };
174        let text = b.text();
175        let mut byte = pos.byte.min(text.len());
176        while byte > 0 && !text.is_char_boundary(byte) {
177            byte -= 1;
178        }
179        DocPos::new(block, byte)
180    }
181
182    // ── Caret / selection movement ────────────────────────────────────────
183
184    /// Move the caret to `pos`.  `extend` keeps the anchor (extending a
185    /// selection); otherwise the selection collapses.  Any pending caret style
186    /// is discarded (moving the caret abandons an un-typed format).
187    pub fn set_caret(&mut self, pos: DocPos, extend: bool) {
188        let pos = self.clamp_pos(pos);
189        self.caret = pos;
190        if !extend {
191            self.anchor = pos;
192        }
193        self.pending_style = None;
194        self.bump_rev();
195    }
196
197    /// Select the whole document.
198    pub fn select_all(&mut self) {
199        self.anchor = DocPos::new(0, 0);
200        self.caret = self.doc.end_pos();
201        self.pending_style = None;
202        self.bump_rev();
203    }
204
205    /// Set both endpoints of the selection explicitly — `anchor` is the fixed
206    /// end, `caret` the moving end. Used by double/triple-click word/block
207    /// selection and the drag that extends it.
208    pub fn set_selection(&mut self, anchor: DocPos, caret: DocPos) {
209        self.anchor = self.clamp_pos(anchor);
210        self.caret = self.clamp_pos(caret);
211        self.pending_style = None;
212        self.bump_rev();
213    }
214
215    // ── Style introspection ───────────────────────────────────────────────
216
217    /// Style a freshly-typed character would take: the pending caret style if
218    /// one is armed, else the inherited [`style_at`] the caret.
219    pub fn style_for_insert(&self) -> InlineStyle {
220        self.pending_style
221            .clone()
222            .unwrap_or_else(|| style_at(&self.doc, self.caret))
223    }
224
225    /// Summary of the styles under the current selection, for toolbar state.
226    /// With a collapsed selection and an armed pending style, reports that
227    /// pending style so the toolbar reflects the format about to be typed.
228    pub fn common_style_of_selection(&self) -> CommonStyle {
229        let sel = self.selection();
230        if sel.is_empty() {
231            if let Some(p) = &self.pending_style {
232                // Inline attributes come from the armed pending style, but
233                // block-level align/list are independent of it — fold the
234                // caret block's values in so the alignment/list toggles keep
235                // reflecting the caret's block while a format is pending.
236                let mut cs = CommonStyle::of_style(p);
237                cs.merge_blocks(&self.doc, sel);
238                return cs;
239            }
240        }
241        range_common_style(&self.doc, sel)
242    }
243
244    /// The selected text as plain text, blocks joined by `\n` (empty when the
245    /// selection is collapsed).  Drives Copy / Cut.
246    pub fn selected_plain_text(&self) -> String {
247        let sel = self.selection();
248        if sel.is_empty() {
249            return String::new();
250        }
251        let (a, b) = sel.ordered();
252        let mut out = String::new();
253        for bi in a.block..=b.block {
254            let Some(block) = self.doc.blocks.get(bi) else {
255                continue;
256            };
257            let text = block.text();
258            let lo = if bi == a.block { a.byte } else { 0 };
259            let hi = if bi == b.block { b.byte.min(text.len()) } else { text.len() };
260            if bi > a.block {
261                out.push('\n');
262            }
263            if lo <= hi {
264                out.push_str(&text[lo..hi]);
265            }
266        }
267        out
268    }
269
270    /// The selected content as a styled fragment (blocks + runs), for the rich
271    /// clipboard. Empty when the selection is collapsed. The plain-text
272    /// flattening of this fragment equals [`selected_plain_text`](Self::selected_plain_text),
273    /// which is the fingerprint the paste path matches against.
274    pub fn selected_fragment(&self) -> Vec<Block> {
275        extract_range(&self.doc, self.selection())
276    }
277
278    // ── Text mutation ─────────────────────────────────────────────────────
279
280    /// Remove the active selection (if any), returning the collapse position.
281    fn take_selection(&mut self) -> DocPos {
282        let sel = self.selection();
283        if sel.is_empty() {
284            self.caret
285        } else {
286            let pos = remove_range(&mut self.doc, sel);
287            self.caret = pos;
288            self.anchor = pos;
289            pos
290        }
291    }
292
293    /// Insert `text` at the caret, replacing any selection.  Embedded `\n`
294    /// characters split the paragraph (so pasted multi-line text lands as
295    /// several blocks).  The inserted text takes [`Self::style_for_insert`].
296    pub fn insert(&mut self, text: &str) {
297        if text.is_empty() {
298            return;
299        }
300        let style = self.style_for_insert();
301        self.take_selection();
302        let mut first = true;
303        for segment in text.split('\n') {
304            if !first {
305                self.caret = split_block(&mut self.doc, self.caret);
306            }
307            if !segment.is_empty() {
308                insert_text(&mut self.doc, self.caret, segment, style.clone());
309                self.caret = DocPos::new(self.caret.block, self.caret.byte + segment.len());
310            }
311            first = false;
312        }
313        self.anchor = self.caret;
314        self.pending_style = None;
315        self.bump_doc();
316    }
317
318    /// Insert a styled `fragment` (from the rich clipboard) at the caret,
319    /// replacing any selection. Preserves each run's style and the fragment's
320    /// block structure, unlike [`insert`](Self::insert) which only carries plain
321    /// text in the caret's style. One `bump_doc` — so it coalesces into a single
322    /// undo step per paste, matching plain paste.
323    pub fn insert_fragment(&mut self, fragment: &[Block]) {
324        if fragment.is_empty() {
325            return;
326        }
327        self.take_selection();
328        let end = splice_fragment(&mut self.doc, self.caret, fragment);
329        self.caret = end;
330        self.anchor = end;
331        self.pending_style = None;
332        self.bump_doc();
333    }
334
335    /// Enter: split the paragraph at the caret (after clearing any selection).
336    ///
337    /// Standard editor behaviour for an **empty list item**: instead of adding
338    /// yet another empty bullet, Enter first outdents (indent > 0 → indent − 1),
339    /// and at indent 0 exits the list ([`ListKind::None`]) — no split in either
340    /// case.  A non-empty (or non-list) block splits normally.
341    pub fn split(&mut self) {
342        self.take_selection();
343        if let Some(block) = self.doc.blocks.get_mut(self.caret.block) {
344            if block.list != ListKind::None && block.text_len() == 0 {
345                if block.indent > 0 {
346                    block.indent -= 1;
347                } else {
348                    block.list = ListKind::None;
349                }
350                self.anchor = self.caret;
351                self.pending_style = None;
352                self.bump_doc();
353                return;
354            }
355        }
356        self.caret = split_block(&mut self.doc, self.caret);
357        self.anchor = self.caret;
358        self.pending_style = None;
359        self.bump_doc();
360    }
361
362    /// Backspace: delete the selection, else the char before the caret, else
363    /// merge with the previous paragraph.
364    pub fn backspace(&mut self) {
365        if !self.selection().is_empty() {
366            self.take_selection();
367            self.pending_style = None;
368            self.bump_doc();
369            return;
370        }
371        if self.caret.byte > 0 {
372            let text = self.doc.blocks[self.caret.block].text();
373            let prev = prev_char_boundary(&text, self.caret.byte);
374            let pos = remove_range(
375                &mut self.doc,
376                DocRange::new(DocPos::new(self.caret.block, prev), self.caret),
377            );
378            self.caret = pos;
379            self.anchor = pos;
380        } else if self.caret.block > 0 {
381            let pos = merge_block_with_prev(&mut self.doc, self.caret.block);
382            self.caret = pos;
383            self.anchor = pos;
384        } else {
385            return;
386        }
387        self.pending_style = None;
388        self.bump_doc();
389    }
390
391    /// Delete forward: the selection, else the char after the caret, else pull
392    /// the next paragraph up into this one.
393    pub fn delete_forward(&mut self) {
394        if !self.selection().is_empty() {
395            self.take_selection();
396            self.pending_style = None;
397            self.bump_doc();
398            return;
399        }
400        let block_len = self.doc.blocks[self.caret.block].text_len();
401        if self.caret.byte < block_len {
402            let text = self.doc.blocks[self.caret.block].text();
403            let next = next_char_boundary(&text, self.caret.byte);
404            remove_range(
405                &mut self.doc,
406                DocRange::new(self.caret, DocPos::new(self.caret.block, next)),
407            );
408        } else if self.caret.block + 1 < self.doc.blocks.len() {
409            // Merge the following block into this one; the join point is the
410            // current caret (end of this block).
411            merge_block_with_prev(&mut self.doc, self.caret.block + 1);
412        } else {
413            return;
414        }
415        self.pending_style = None;
416        self.bump_doc();
417    }
418
419    // ── Command dispatch ──────────────────────────────────────────────────
420
421    /// Apply a formatting command.  An inline command over a *collapsed*
422    /// selection arms the pending caret style instead of mutating a run; block
423    /// commands and inline commands over a real selection mutate the document.
424    pub fn exec(&mut self, cmd: &RichCommand) {
425        let sel = self.selection();
426        if sel.is_empty() && is_inline(cmd) {
427            let mut base = self.style_for_insert();
428            apply_inline_to_style(&mut base, cmd);
429            self.pending_style = Some(base);
430            self.bump_rev();
431        } else {
432            apply_command(&mut self.doc, sel, cmd);
433            self.pending_style = None;
434            // A mutation while a preview session is live means the user actually
435            // changed something (a colour drag) — record that so a click-away
436            // dismissal knows to commit rather than silently cancel.
437            if self.preview_snapshot.is_some() {
438                self.preview_dirty = true;
439            }
440            self.bump_doc();
441        }
442    }
443
444    // ── Undo / redo (time-coalescing snapshots) ───────────────────────────
445    //
446    // Note: `feed_undo` (once per frame) and `can_undo` / `can_redo` each build
447    // a `snapshot`, which clones the whole `RichDoc`. That is fine at demo
448    // scale — per the measure-first rule we don't optimise on speculation — but
449    // for very large documents this per-frame clone is the first thing to
450    // revisit (e.g. a dirty flag or structural sharing).
451
452    fn snapshot(&self) -> EditSnapshot {
453        EditSnapshot {
454            doc: self.doc.clone(),
455            caret: self.caret,
456            anchor: self.anchor,
457        }
458    }
459
460    fn apply_snapshot(&mut self, snap: EditSnapshot) {
461        self.doc = snap.doc;
462        self.caret = snap.caret;
463        self.anchor = snap.anchor;
464        self.pending_style = None;
465        self.bump_doc();
466    }
467
468    /// Feed the undoer the current state at `time` (seconds).  Returns `true`
469    /// while a change is still coalescing, so the caller keeps frames coming.
470    ///
471    /// Suspended during a live preview ([`begin_preview`](Self::begin_preview))
472    /// so the drag's rapid mutations don't each snapshot; the committed change
473    /// is recorded in the single frame after feeding resumes.
474    pub fn feed_undo(&mut self, time: f64) -> bool {
475        if self.undo_suspended {
476            return false;
477        }
478        let snap = self.snapshot();
479        self.undoer.feed_state(time, &snap);
480        self.undoer.is_in_flux()
481    }
482
483    // ── Live preview (colour dialog) ──────────────────────────────────────
484    //
485    // A colour dialog previews its result by exec-ing `SetTextColor` /
486    // `SetHighlight` on every drag frame so the document updates in context.
487    // Those rapid mutations must NOT each become an undo step, and a Cancel
488    // must leave no residue. `begin_preview` captures the committed state and
489    // suspends undo feeding; `commit_preview` keeps the result and resumes
490    // (one step is recorded on the next feed); `cancel_preview` restores the
491    // captured state and resumes (restored == committed, so nothing is added).
492
493    /// Start a live-preview session: snapshot the committed state and suspend
494    /// undo feeding. Idempotent — a redundant call keeps the original snapshot.
495    pub fn begin_preview(&mut self) {
496        if self.preview_snapshot.is_none() {
497            self.preview_snapshot = Some(self.snapshot());
498            // Fresh session starts clean; the first previewing `exec` sets this.
499            self.preview_dirty = false;
500        }
501        self.undo_suspended = true;
502    }
503
504    /// Commit the preview: keep the current document and resume undo feeding so
505    /// the whole drag collapses into a single undo step.
506    pub fn commit_preview(&mut self) {
507        self.preview_snapshot = None;
508        self.preview_dirty = false;
509        self.undo_suspended = false;
510    }
511
512    /// Cancel the preview: restore the `{doc, caret, anchor}` captured at
513    /// [`begin_preview`](Self::begin_preview) and resume undo feeding. The
514    /// restored state equals the last committed one, so no stray undo entry is
515    /// recorded.
516    pub fn cancel_preview(&mut self) {
517        if let Some(snap) = self.preview_snapshot.take() {
518            self.apply_snapshot(snap);
519        }
520        self.preview_dirty = false;
521        self.undo_suspended = false;
522    }
523
524    /// Whether a live preview is currently active (test/introspection hook).
525    pub fn is_previewing(&self) -> bool {
526        self.preview_snapshot.is_some()
527    }
528
529    /// Whether the active preview session has mutated the document since it
530    /// began. Drives the colour dialog's click-away dismissal: a dirty session
531    /// commits (one undo step); a clean one cancels silently (no undo residue).
532    pub fn is_preview_dirty(&self) -> bool {
533        self.preview_dirty
534    }
535
536    pub fn can_undo(&self) -> bool {
537        self.undoer.has_undo(&self.snapshot())
538    }
539    pub fn can_redo(&self) -> bool {
540        self.undoer.has_redo(&self.snapshot())
541    }
542
543    /// Undo one step.  Returns `true` if the document changed.
544    pub fn undo(&mut self) -> bool {
545        let cur = self.snapshot();
546        if let Some(prev) = self.undoer.undo(&cur).cloned() {
547            self.apply_snapshot(prev);
548            true
549        } else {
550            false
551        }
552    }
553
554    /// Redo one step.  Returns `true` if the document changed.
555    pub fn redo(&mut self) -> bool {
556        let cur = self.snapshot();
557        if let Some(next) = self.undoer.redo(&cur).cloned() {
558            self.apply_snapshot(next);
559            true
560        } else {
561            false
562        }
563    }
564}
565
566/// Whether a command formats inline characters (vs. block-level layout).
567fn is_inline(cmd: &RichCommand) -> bool {
568    matches!(
569        cmd,
570        RichCommand::ToggleBold
571            | RichCommand::ToggleItalic
572            | RichCommand::ToggleUnderline
573            | RichCommand::ToggleStrikethrough
574            | RichCommand::SetFontFamily(_)
575            | RichCommand::SetFontSize(_)
576            | RichCommand::SetTextColor(_)
577            | RichCommand::SetHighlight(_)
578    )
579}
580
581/// Fold an inline command into a single style (for the pending caret style).
582/// Toggles flip relative to `style`; setters overwrite.
583fn apply_inline_to_style(style: &mut InlineStyle, cmd: &RichCommand) {
584    match cmd {
585        RichCommand::ToggleBold => style.bold = !style.bold,
586        RichCommand::ToggleItalic => style.italic = !style.italic,
587        RichCommand::ToggleUnderline => style.underline = !style.underline,
588        RichCommand::ToggleStrikethrough => style.strikethrough = !style.strikethrough,
589        RichCommand::SetFontFamily(f) => style.font_family = Some(f.clone()),
590        RichCommand::SetFontSize(s) => style.font_size = Some(*s),
591        RichCommand::SetTextColor(c) => style.text_color = Some(*c),
592        RichCommand::SetHighlight(c) => style.highlight = *c,
593        _ => {}
594    }
595}
596
597/// A cheap, cloneable handle to a shared [`RichEditCore`], so a toolbar can
598/// drive the same editor the widget renders.  All mutating calls request a
599/// redraw.
600#[derive(Clone)]
601pub struct RichEditHandle {
602    core: Rc<RefCell<RichEditCore>>,
603}
604
605impl RichEditHandle {
606    pub(crate) fn new(core: Rc<RefCell<RichEditCore>>) -> Self {
607        Self { core }
608    }
609
610    /// Apply a formatting command through the shared core.
611    pub fn exec(&self, cmd: &RichCommand) {
612        self.core.borrow_mut().exec(cmd);
613        crate::animation::request_draw();
614    }
615
616    /// Summary of the styles under the current selection (drives toolbar state).
617    pub fn common_style_of_selection(&self) -> CommonStyle {
618        self.core.borrow().common_style_of_selection()
619    }
620
621    /// Select the whole document, then request a redraw.
622    pub fn select_all(&self) {
623        self.core.borrow_mut().select_all();
624        crate::animation::request_draw();
625    }
626
627    /// Move the caret to `pos` (clamped onto a valid position), collapsing any
628    /// selection, and request a redraw.
629    pub fn set_caret(&self, pos: DocPos) {
630        self.core.borrow_mut().set_caret(pos, false);
631        crate::animation::request_draw();
632    }
633
634    /// Set the selection to `range` — `range.start` becomes the fixed anchor and
635    /// `range.end` the moving caret, each clamped onto a valid position — and
636    /// request a redraw.
637    pub fn set_selection(&self, range: DocRange) {
638        self.core
639            .borrow_mut()
640            .set_selection(range.start, range.end);
641        crate::animation::request_draw();
642    }
643
644    /// The current selection (anchor → caret; collapsed when they coincide).
645    pub fn selection(&self) -> DocRange {
646        self.core.borrow().selection()
647    }
648
649    /// The document's plain text (blocks joined by `\n`).
650    pub fn plain_text(&self) -> String {
651        self.core.borrow().plain_text()
652    }
653
654    /// Replace the editor's document with `doc`, resetting the caret to the
655    /// start and **discarding the undo/redo history** (the loaded document is
656    /// the new baseline).  Requests a redraw.
657    pub fn load(&self, doc: RichDoc) {
658        self.core.borrow_mut().load(doc);
659        crate::animation::request_draw();
660    }
661
662    /// Undo one step through the shared core, requesting a redraw on change.
663    pub fn undo(&self) {
664        if self.core.borrow_mut().undo() {
665            crate::animation::request_draw();
666        }
667    }
668    /// Redo one step through the shared core, requesting a redraw on change.
669    pub fn redo(&self) {
670        if self.core.borrow_mut().redo() {
671            crate::animation::request_draw();
672        }
673    }
674    /// Whether an undo step is available.
675    pub fn can_undo(&self) -> bool {
676        self.core.borrow().can_undo()
677    }
678    /// Whether a redo step is available.
679    pub fn can_redo(&self) -> bool {
680        self.core.borrow().can_redo()
681    }
682
683    // ── Live preview (colour dialog) ──────────────────────────────────────
684
685    /// Begin a live-preview session — see [`RichEditCore::begin_preview`].
686    /// Call when a colour dialog opens, before any preview `exec`.
687    pub fn begin_preview(&self) {
688        self.core.borrow_mut().begin_preview();
689    }
690
691    /// Commit the live preview (dialog's Select) — see
692    /// [`RichEditCore::commit_preview`].
693    pub fn commit_preview(&self) {
694        self.core.borrow_mut().commit_preview();
695        crate::animation::request_draw();
696    }
697
698    /// Cancel the live preview (dialog's Cancel / Escape / close) — restores
699    /// the state captured when the preview began. See
700    /// [`RichEditCore::cancel_preview`].
701    pub fn cancel_preview(&self) {
702        self.core.borrow_mut().cancel_preview();
703        crate::animation::request_draw();
704    }
705
706    /// Whether a live preview is currently active. Exposed so a host can assert
707    /// (in tests) that every dialog-dismissal route unwinds the session.
708    pub fn is_previewing(&self) -> bool {
709        self.core.borrow().is_previewing()
710    }
711
712    /// Whether the active preview session has changed the document. The colour
713    /// dialog's click-away handler reads this to decide whether to commit the
714    /// live change (one undo step) or cancel it silently. See
715    /// [`RichEditCore::is_preview_dirty`].
716    pub fn is_preview_dirty(&self) -> bool {
717        self.core.borrow().is_preview_dirty()
718    }
719}