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}