agg-gui 0.4.0

Immediate-mode Rust GUI library with AGG rendering, Y-up layout, widgets, text, SVG, and native/WASM adapters
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
//! `RichEditCore` — the **logical** heart of the interactive rich-text editor,
//! shared (behind `Rc<RefCell<_>>`) between the [`RichTextEdit`](super::super::RichTextEdit)
//! widget and the formatting toolbar via a [`RichEditHandle`].
//!
//! Everything here is geometry-free: it owns the document, the caret + anchor
//! (a [`DocRange`] selection), the *pending* caret style, and a time-coalescing
//! [`Undoer`] snapshotting `{doc, caret, anchor}`.  All structural editing goes
//! through [`super::super::model`]'s primitives and the command engine in
//! [`super::super::commands`]; the widget layer adds only pixel geometry,
//! scrolling and painting on top.
//!
//! # Pending caret style (Word behaviour)
//!
//! Toggling an inline format (bold, colour, …) with a *collapsed* selection does
//! not mutate any run — there is nothing selected.  Instead we remember the
//! toggled style as [`pending_style`](RichEditCore::pending_style); the next
//! inserted text is stamped with it.  Moving the caret or applying the format to
//! a real selection clears it.

use std::cell::RefCell;
use std::rc::Rc;

use crate::undo::Undoer;
use crate::widgets::text_field_core::{next_char_boundary, prev_char_boundary};

use super::super::commands::{apply_command, range_common_style, style_at, CommonStyle, RichCommand};
use super::super::model::{
    extract_range, insert_text, merge_block_with_prev, remove_range, splice_fragment, split_block,
    Block, DocPos, DocRange, InlineStyle, ListKind, RichDoc,
};

/// One undo/redo snapshot: the whole document plus the caret and anchor, so an
/// undo restores the selection exactly as it was.
#[derive(Clone, PartialEq)]
pub(crate) struct EditSnapshot {
    doc: RichDoc,
    caret: DocPos,
    anchor: DocPos,
}

/// The shared, geometry-free editor state.
pub struct RichEditCore {
    doc: RichDoc,
    caret: DocPos,
    anchor: DocPos,
    pending_style: Option<InlineStyle>,
    default_font_size: f64,
    undoer: Undoer<EditSnapshot>,
    /// While `true`, [`feed_undo`](Self::feed_undo) is a no-op. Used during a
    /// live colour-preview drag so the rapid per-frame document mutations don't
    /// each seed an undo snapshot; a single step is recorded once the preview
    /// commits and feeding resumes.
    undo_suspended: bool,
    /// The committed `{doc, caret, anchor}` captured at
    /// [`begin_preview`](Self::begin_preview), restored verbatim on
    /// [`cancel_preview`](Self::cancel_preview). A full snapshot (not just the
    /// colour) so a mixed-colour selection — or a highlight that was `None` —
    /// is restored exactly.
    preview_snapshot: Option<EditSnapshot>,
    /// Set the moment a preview session actually mutates the document (a live
    /// colour drag exec'd `SetTextColor` / `SetHighlight`), so a host can tell
    /// a *changed* session apart from one where the user opened the dialog and
    /// dismissed it without touching anything. The click-away dismissal uses
    /// this to choose commit (banked as one undo step) vs. a silent cancel.
    /// Cleared when a session starts, commits, or cancels.
    preview_dirty: bool,
    /// Bumped whenever `doc` changes — the view invalidates its layout cache.
    doc_rev: u64,
    /// Bumped on any caret / anchor / doc change — the view repaints.
    rev: u64,
}

impl RichEditCore {
    /// Create a core over `doc` with the given default font size (points).
    pub fn new(doc: RichDoc, default_font_size: f64) -> Self {
        Self {
            doc,
            caret: DocPos::default(),
            anchor: DocPos::default(),
            pending_style: None,
            default_font_size,
            undoer: Undoer::default(),
            undo_suspended: false,
            preview_snapshot: None,
            preview_dirty: false,
            doc_rev: 0,
            rev: 0,
        }
    }

    /// Replace the entire document, resetting the caret/selection to the start
    /// and **discarding the undo/redo history** — the freshly-loaded document is
    /// the new baseline, so there is nothing to undo back to.  Any armed pending
    /// caret style is cleared.
    pub fn load(&mut self, doc: RichDoc) {
        self.doc = doc;
        self.caret = DocPos::default();
        self.anchor = DocPos::default();
        self.pending_style = None;
        // A new document is a new history: drop every prior undo/redo snapshot.
        self.undoer = Undoer::default();
        // Abandon any in-flight live preview: the captured snapshot belongs to
        // the old document, so keeping it would let a later `cancel_preview`
        // clobber the freshly-loaded doc — and leaving undo suspended would keep
        // `feed_undo` a no-op forever (dead undo).
        self.preview_snapshot = None;
        self.preview_dirty = false;
        self.undo_suspended = false;
        self.bump_doc();
    }

    // ── Accessors ─────────────────────────────────────────────────────────

    /// The document being edited.
    pub fn doc(&self) -> &RichDoc {
        &self.doc
    }
    /// The document's plain text (blocks joined by `\n`).
    pub fn plain_text(&self) -> String {
        self.doc.plain_text()
    }
    /// The moving end of the selection (the blinking caret position).
    pub fn caret(&self) -> DocPos {
        self.caret
    }
    /// The fixed end of the selection (coincides with the caret when collapsed).
    pub fn anchor(&self) -> DocPos {
        self.anchor
    }
    /// Default font size (points) runs inherit when their style leaves it unset.
    pub fn default_font_size(&self) -> f64 {
        self.default_font_size
    }
    /// Change the inherited default font size and mark the document dirty.
    pub fn set_default_font_size(&mut self, size: f64) {
        self.default_font_size = size;
        self.bump_doc();
    }
    /// Revision counter bumped on every document change (drives layout caching).
    pub fn doc_rev(&self) -> u64 {
        self.doc_rev
    }
    /// Revision counter bumped on any document, caret, or selection change
    /// (drives repaint / scroll-into-view).
    pub fn rev(&self) -> u64 {
        self.rev
    }
    /// The armed pending caret style (a format toggled at a collapsed caret,
    /// awaiting the next keystroke), if any.
    pub fn pending_style(&self) -> Option<&InlineStyle> {
        self.pending_style.as_ref()
    }

    /// The current selection (anchor → caret; collapsed when they coincide).
    pub fn selection(&self) -> DocRange {
        DocRange::new(self.anchor, self.caret)
    }

    fn bump_rev(&mut self) {
        self.rev = self.rev.wrapping_add(1);
    }
    fn bump_doc(&mut self) {
        self.doc_rev = self.doc_rev.wrapping_add(1);
        self.bump_rev();
    }

    /// Clamp a raw `(block, byte)` onto a valid caret position: block in range,
    /// byte within the block and on a char boundary.
    pub fn clamp_pos(&self, pos: DocPos) -> DocPos {
        let block = pos.block.min(self.doc.blocks.len().saturating_sub(1));
        let Some(b) = self.doc.blocks.get(block) else {
            return DocPos::new(0, 0);
        };
        let text = b.text();
        let mut byte = pos.byte.min(text.len());
        while byte > 0 && !text.is_char_boundary(byte) {
            byte -= 1;
        }
        DocPos::new(block, byte)
    }

    // ── Caret / selection movement ────────────────────────────────────────

    /// Move the caret to `pos`.  `extend` keeps the anchor (extending a
    /// selection); otherwise the selection collapses.  Any pending caret style
    /// is discarded (moving the caret abandons an un-typed format).
    pub fn set_caret(&mut self, pos: DocPos, extend: bool) {
        let pos = self.clamp_pos(pos);
        self.caret = pos;
        if !extend {
            self.anchor = pos;
        }
        self.pending_style = None;
        self.bump_rev();
    }

    /// Select the whole document.
    pub fn select_all(&mut self) {
        self.anchor = DocPos::new(0, 0);
        self.caret = self.doc.end_pos();
        self.pending_style = None;
        self.bump_rev();
    }

    /// Set both endpoints of the selection explicitly — `anchor` is the fixed
    /// end, `caret` the moving end. Used by double/triple-click word/block
    /// selection and the drag that extends it.
    pub fn set_selection(&mut self, anchor: DocPos, caret: DocPos) {
        self.anchor = self.clamp_pos(anchor);
        self.caret = self.clamp_pos(caret);
        self.pending_style = None;
        self.bump_rev();
    }

    // ── Style introspection ───────────────────────────────────────────────

    /// Style a freshly-typed character would take: the pending caret style if
    /// one is armed, else the inherited [`style_at`] the caret.
    pub fn style_for_insert(&self) -> InlineStyle {
        self.pending_style
            .clone()
            .unwrap_or_else(|| style_at(&self.doc, self.caret))
    }

    /// Summary of the styles under the current selection, for toolbar state.
    /// With a collapsed selection and an armed pending style, reports that
    /// pending style so the toolbar reflects the format about to be typed.
    pub fn common_style_of_selection(&self) -> CommonStyle {
        let sel = self.selection();
        if sel.is_empty() {
            if let Some(p) = &self.pending_style {
                // Inline attributes come from the armed pending style, but
                // block-level align/list are independent of it — fold the
                // caret block's values in so the alignment/list toggles keep
                // reflecting the caret's block while a format is pending.
                let mut cs = CommonStyle::of_style(p);
                cs.merge_blocks(&self.doc, sel);
                return cs;
            }
        }
        range_common_style(&self.doc, sel)
    }

    /// The selected text as plain text, blocks joined by `\n` (empty when the
    /// selection is collapsed).  Drives Copy / Cut.
    pub fn selected_plain_text(&self) -> String {
        let sel = self.selection();
        if sel.is_empty() {
            return String::new();
        }
        let (a, b) = sel.ordered();
        let mut out = String::new();
        for bi in a.block..=b.block {
            let Some(block) = self.doc.blocks.get(bi) else {
                continue;
            };
            let text = block.text();
            let lo = if bi == a.block { a.byte } else { 0 };
            let hi = if bi == b.block { b.byte.min(text.len()) } else { text.len() };
            if bi > a.block {
                out.push('\n');
            }
            if lo <= hi {
                out.push_str(&text[lo..hi]);
            }
        }
        out
    }

    /// The selected content as a styled fragment (blocks + runs), for the rich
    /// clipboard. Empty when the selection is collapsed. The plain-text
    /// flattening of this fragment equals [`selected_plain_text`](Self::selected_plain_text),
    /// which is the fingerprint the paste path matches against.
    pub fn selected_fragment(&self) -> Vec<Block> {
        extract_range(&self.doc, self.selection())
    }

    // ── Text mutation ─────────────────────────────────────────────────────

    /// Remove the active selection (if any), returning the collapse position.
    fn take_selection(&mut self) -> DocPos {
        let sel = self.selection();
        if sel.is_empty() {
            self.caret
        } else {
            let pos = remove_range(&mut self.doc, sel);
            self.caret = pos;
            self.anchor = pos;
            pos
        }
    }

    /// Insert `text` at the caret, replacing any selection.  Embedded `\n`
    /// characters split the paragraph (so pasted multi-line text lands as
    /// several blocks).  The inserted text takes [`Self::style_for_insert`].
    pub fn insert(&mut self, text: &str) {
        if text.is_empty() {
            return;
        }
        let style = self.style_for_insert();
        self.take_selection();
        let mut first = true;
        for segment in text.split('\n') {
            if !first {
                self.caret = split_block(&mut self.doc, self.caret);
            }
            if !segment.is_empty() {
                insert_text(&mut self.doc, self.caret, segment, style.clone());
                self.caret = DocPos::new(self.caret.block, self.caret.byte + segment.len());
            }
            first = false;
        }
        self.anchor = self.caret;
        self.pending_style = None;
        self.bump_doc();
    }

    /// Insert a styled `fragment` (from the rich clipboard) at the caret,
    /// replacing any selection. Preserves each run's style and the fragment's
    /// block structure, unlike [`insert`](Self::insert) which only carries plain
    /// text in the caret's style. One `bump_doc` — so it coalesces into a single
    /// undo step per paste, matching plain paste.
    pub fn insert_fragment(&mut self, fragment: &[Block]) {
        if fragment.is_empty() {
            return;
        }
        self.take_selection();
        let end = splice_fragment(&mut self.doc, self.caret, fragment);
        self.caret = end;
        self.anchor = end;
        self.pending_style = None;
        self.bump_doc();
    }

    /// Enter: split the paragraph at the caret (after clearing any selection).
    ///
    /// Standard editor behaviour for an **empty list item**: instead of adding
    /// yet another empty bullet, Enter first outdents (indent > 0 → indent − 1),
    /// and at indent 0 exits the list ([`ListKind::None`]) — no split in either
    /// case.  A non-empty (or non-list) block splits normally.
    pub fn split(&mut self) {
        self.take_selection();
        if let Some(block) = self.doc.blocks.get_mut(self.caret.block) {
            if block.list != ListKind::None && block.text_len() == 0 {
                if block.indent > 0 {
                    block.indent -= 1;
                } else {
                    block.list = ListKind::None;
                }
                self.anchor = self.caret;
                self.pending_style = None;
                self.bump_doc();
                return;
            }
        }
        self.caret = split_block(&mut self.doc, self.caret);
        self.anchor = self.caret;
        self.pending_style = None;
        self.bump_doc();
    }

    /// Backspace: delete the selection, else the char before the caret, else
    /// merge with the previous paragraph.
    pub fn backspace(&mut self) {
        if !self.selection().is_empty() {
            self.take_selection();
            self.pending_style = None;
            self.bump_doc();
            return;
        }
        if self.caret.byte > 0 {
            let text = self.doc.blocks[self.caret.block].text();
            let prev = prev_char_boundary(&text, self.caret.byte);
            let pos = remove_range(
                &mut self.doc,
                DocRange::new(DocPos::new(self.caret.block, prev), self.caret),
            );
            self.caret = pos;
            self.anchor = pos;
        } else if self.caret.block > 0 {
            let pos = merge_block_with_prev(&mut self.doc, self.caret.block);
            self.caret = pos;
            self.anchor = pos;
        } else {
            return;
        }
        self.pending_style = None;
        self.bump_doc();
    }

    /// Delete forward: the selection, else the char after the caret, else pull
    /// the next paragraph up into this one.
    pub fn delete_forward(&mut self) {
        if !self.selection().is_empty() {
            self.take_selection();
            self.pending_style = None;
            self.bump_doc();
            return;
        }
        let block_len = self.doc.blocks[self.caret.block].text_len();
        if self.caret.byte < block_len {
            let text = self.doc.blocks[self.caret.block].text();
            let next = next_char_boundary(&text, self.caret.byte);
            remove_range(
                &mut self.doc,
                DocRange::new(self.caret, DocPos::new(self.caret.block, next)),
            );
        } else if self.caret.block + 1 < self.doc.blocks.len() {
            // Merge the following block into this one; the join point is the
            // current caret (end of this block).
            merge_block_with_prev(&mut self.doc, self.caret.block + 1);
        } else {
            return;
        }
        self.pending_style = None;
        self.bump_doc();
    }

    // ── Command dispatch ──────────────────────────────────────────────────

    /// Apply a formatting command.  An inline command over a *collapsed*
    /// selection arms the pending caret style instead of mutating a run; block
    /// commands and inline commands over a real selection mutate the document.
    pub fn exec(&mut self, cmd: &RichCommand) {
        let sel = self.selection();
        if sel.is_empty() && is_inline(cmd) {
            let mut base = self.style_for_insert();
            apply_inline_to_style(&mut base, cmd);
            self.pending_style = Some(base);
            self.bump_rev();
        } else {
            apply_command(&mut self.doc, sel, cmd);
            self.pending_style = None;
            // A mutation while a preview session is live means the user actually
            // changed something (a colour drag) — record that so a click-away
            // dismissal knows to commit rather than silently cancel.
            if self.preview_snapshot.is_some() {
                self.preview_dirty = true;
            }
            self.bump_doc();
        }
    }

    // ── Undo / redo (time-coalescing snapshots) ───────────────────────────
    //
    // Note: `feed_undo` (once per frame) and `can_undo` / `can_redo` each build
    // a `snapshot`, which clones the whole `RichDoc`. That is fine at demo
    // scale — per the measure-first rule we don't optimise on speculation — but
    // for very large documents this per-frame clone is the first thing to
    // revisit (e.g. a dirty flag or structural sharing).

    fn snapshot(&self) -> EditSnapshot {
        EditSnapshot {
            doc: self.doc.clone(),
            caret: self.caret,
            anchor: self.anchor,
        }
    }

    fn apply_snapshot(&mut self, snap: EditSnapshot) {
        self.doc = snap.doc;
        self.caret = snap.caret;
        self.anchor = snap.anchor;
        self.pending_style = None;
        self.bump_doc();
    }

    /// Feed the undoer the current state at `time` (seconds).  Returns `true`
    /// while a change is still coalescing, so the caller keeps frames coming.
    ///
    /// Suspended during a live preview ([`begin_preview`](Self::begin_preview))
    /// so the drag's rapid mutations don't each snapshot; the committed change
    /// is recorded in the single frame after feeding resumes.
    pub fn feed_undo(&mut self, time: f64) -> bool {
        if self.undo_suspended {
            return false;
        }
        let snap = self.snapshot();
        self.undoer.feed_state(time, &snap);
        self.undoer.is_in_flux()
    }

    // ── Live preview (colour dialog) ──────────────────────────────────────
    //
    // A colour dialog previews its result by exec-ing `SetTextColor` /
    // `SetHighlight` on every drag frame so the document updates in context.
    // Those rapid mutations must NOT each become an undo step, and a Cancel
    // must leave no residue. `begin_preview` captures the committed state and
    // suspends undo feeding; `commit_preview` keeps the result and resumes
    // (one step is recorded on the next feed); `cancel_preview` restores the
    // captured state and resumes (restored == committed, so nothing is added).

    /// Start a live-preview session: snapshot the committed state and suspend
    /// undo feeding. Idempotent — a redundant call keeps the original snapshot.
    pub fn begin_preview(&mut self) {
        if self.preview_snapshot.is_none() {
            self.preview_snapshot = Some(self.snapshot());
            // Fresh session starts clean; the first previewing `exec` sets this.
            self.preview_dirty = false;
        }
        self.undo_suspended = true;
    }

    /// Commit the preview: keep the current document and resume undo feeding so
    /// the whole drag collapses into a single undo step.
    pub fn commit_preview(&mut self) {
        self.preview_snapshot = None;
        self.preview_dirty = false;
        self.undo_suspended = false;
    }

    /// Cancel the preview: restore the `{doc, caret, anchor}` captured at
    /// [`begin_preview`](Self::begin_preview) and resume undo feeding. The
    /// restored state equals the last committed one, so no stray undo entry is
    /// recorded.
    pub fn cancel_preview(&mut self) {
        if let Some(snap) = self.preview_snapshot.take() {
            self.apply_snapshot(snap);
        }
        self.preview_dirty = false;
        self.undo_suspended = false;
    }

    /// Whether a live preview is currently active (test/introspection hook).
    pub fn is_previewing(&self) -> bool {
        self.preview_snapshot.is_some()
    }

    /// Whether the active preview session has mutated the document since it
    /// began. Drives the colour dialog's click-away dismissal: a dirty session
    /// commits (one undo step); a clean one cancels silently (no undo residue).
    pub fn is_preview_dirty(&self) -> bool {
        self.preview_dirty
    }

    pub fn can_undo(&self) -> bool {
        self.undoer.has_undo(&self.snapshot())
    }
    pub fn can_redo(&self) -> bool {
        self.undoer.has_redo(&self.snapshot())
    }

    /// Undo one step.  Returns `true` if the document changed.
    pub fn undo(&mut self) -> bool {
        let cur = self.snapshot();
        if let Some(prev) = self.undoer.undo(&cur).cloned() {
            self.apply_snapshot(prev);
            true
        } else {
            false
        }
    }

    /// Redo one step.  Returns `true` if the document changed.
    pub fn redo(&mut self) -> bool {
        let cur = self.snapshot();
        if let Some(next) = self.undoer.redo(&cur).cloned() {
            self.apply_snapshot(next);
            true
        } else {
            false
        }
    }
}

/// Whether a command formats inline characters (vs. block-level layout).
fn is_inline(cmd: &RichCommand) -> bool {
    matches!(
        cmd,
        RichCommand::ToggleBold
            | RichCommand::ToggleItalic
            | RichCommand::ToggleUnderline
            | RichCommand::ToggleStrikethrough
            | RichCommand::SetFontFamily(_)
            | RichCommand::SetFontSize(_)
            | RichCommand::SetTextColor(_)
            | RichCommand::SetHighlight(_)
    )
}

/// Fold an inline command into a single style (for the pending caret style).
/// Toggles flip relative to `style`; setters overwrite.
fn apply_inline_to_style(style: &mut InlineStyle, cmd: &RichCommand) {
    match cmd {
        RichCommand::ToggleBold => style.bold = !style.bold,
        RichCommand::ToggleItalic => style.italic = !style.italic,
        RichCommand::ToggleUnderline => style.underline = !style.underline,
        RichCommand::ToggleStrikethrough => style.strikethrough = !style.strikethrough,
        RichCommand::SetFontFamily(f) => style.font_family = Some(f.clone()),
        RichCommand::SetFontSize(s) => style.font_size = Some(*s),
        RichCommand::SetTextColor(c) => style.text_color = Some(*c),
        RichCommand::SetHighlight(c) => style.highlight = *c,
        _ => {}
    }
}

/// A cheap, cloneable handle to a shared [`RichEditCore`], so a toolbar can
/// drive the same editor the widget renders.  All mutating calls request a
/// redraw.
#[derive(Clone)]
pub struct RichEditHandle {
    core: Rc<RefCell<RichEditCore>>,
}

impl RichEditHandle {
    pub(crate) fn new(core: Rc<RefCell<RichEditCore>>) -> Self {
        Self { core }
    }

    /// Apply a formatting command through the shared core.
    pub fn exec(&self, cmd: &RichCommand) {
        self.core.borrow_mut().exec(cmd);
        crate::animation::request_draw();
    }

    /// Summary of the styles under the current selection (drives toolbar state).
    pub fn common_style_of_selection(&self) -> CommonStyle {
        self.core.borrow().common_style_of_selection()
    }

    /// Select the whole document, then request a redraw.
    pub fn select_all(&self) {
        self.core.borrow_mut().select_all();
        crate::animation::request_draw();
    }

    /// Move the caret to `pos` (clamped onto a valid position), collapsing any
    /// selection, and request a redraw.
    pub fn set_caret(&self, pos: DocPos) {
        self.core.borrow_mut().set_caret(pos, false);
        crate::animation::request_draw();
    }

    /// Set the selection to `range` — `range.start` becomes the fixed anchor and
    /// `range.end` the moving caret, each clamped onto a valid position — and
    /// request a redraw.
    pub fn set_selection(&self, range: DocRange) {
        self.core
            .borrow_mut()
            .set_selection(range.start, range.end);
        crate::animation::request_draw();
    }

    /// The current selection (anchor → caret; collapsed when they coincide).
    pub fn selection(&self) -> DocRange {
        self.core.borrow().selection()
    }

    /// The document's plain text (blocks joined by `\n`).
    pub fn plain_text(&self) -> String {
        self.core.borrow().plain_text()
    }

    /// Replace the editor's document with `doc`, resetting the caret to the
    /// start and **discarding the undo/redo history** (the loaded document is
    /// the new baseline).  Requests a redraw.
    pub fn load(&self, doc: RichDoc) {
        self.core.borrow_mut().load(doc);
        crate::animation::request_draw();
    }

    /// Undo one step through the shared core, requesting a redraw on change.
    pub fn undo(&self) {
        if self.core.borrow_mut().undo() {
            crate::animation::request_draw();
        }
    }
    /// Redo one step through the shared core, requesting a redraw on change.
    pub fn redo(&self) {
        if self.core.borrow_mut().redo() {
            crate::animation::request_draw();
        }
    }
    /// Whether an undo step is available.
    pub fn can_undo(&self) -> bool {
        self.core.borrow().can_undo()
    }
    /// Whether a redo step is available.
    pub fn can_redo(&self) -> bool {
        self.core.borrow().can_redo()
    }

    // ── Live preview (colour dialog) ──────────────────────────────────────

    /// Begin a live-preview session — see [`RichEditCore::begin_preview`].
    /// Call when a colour dialog opens, before any preview `exec`.
    pub fn begin_preview(&self) {
        self.core.borrow_mut().begin_preview();
    }

    /// Commit the live preview (dialog's Select) — see
    /// [`RichEditCore::commit_preview`].
    pub fn commit_preview(&self) {
        self.core.borrow_mut().commit_preview();
        crate::animation::request_draw();
    }

    /// Cancel the live preview (dialog's Cancel / Escape / close) — restores
    /// the state captured when the preview began. See
    /// [`RichEditCore::cancel_preview`].
    pub fn cancel_preview(&self) {
        self.core.borrow_mut().cancel_preview();
        crate::animation::request_draw();
    }

    /// Whether a live preview is currently active. Exposed so a host can assert
    /// (in tests) that every dialog-dismissal route unwinds the session.
    pub fn is_previewing(&self) -> bool {
        self.core.borrow().is_previewing()
    }

    /// Whether the active preview session has changed the document. The colour
    /// dialog's click-away handler reads this to decide whether to commit the
    /// live change (one undo step) or cancel it silently. See
    /// [`RichEditCore::is_preview_dirty`].
    pub fn is_preview_dirty(&self) -> bool {
        self.core.borrow().is_preview_dirty()
    }
}