Skip to main content

agg_gui/widgets/rich_text/toolbar/
mod.rs

1//! `RichTextToolbar` — a configurable, batteries-included formatting toolbar
2//! driven by a [`RichEditHandle`].
3//!
4//! This is the library counterpart of the demo's toolbar
5//! (`demo-ui/src/windows/rich_text_demo/toolbar.rs`): a self-contained widget an
6//! embedding app can drop above a [`RichTextEdit`](super::RichTextEdit) to get
7//! bold/italic/underline/strike, alignment, lists, indent/outdent, undo/redo, a
8//! font-size dropdown, and text/highlight colours — all wired to the same
9//! shared editor core the widget renders.  Every control group can be toggled
10//! off through the builder; all are on by default.  Every control also carries a
11//! hover tooltip out of the box (gate them with
12//! [`with_tooltips`](RichTextToolbar::with_tooltips)).
13//!
14//! # Layout
15//!
16//! Controls flow across **two rows** (a [`FlexColumn`] of two
17//! [`FlexRow`](crate::widgets::flex_row::FlexRow)s),
18//! matching the demo: row 1 is inline character formatting + font family/size +
19//! colours; row 2 is block formatting (alignment, lists, indent) + history.  An
20//! empty row (its whole group disabled) is omitted.
21//!
22//! # Font family dropdown
23//!
24//! The library cannot depend on any font catalog, so the family dropdown is
25//! **opt-in**: supply names (and optional per-item preview fonts) via
26//! [`with_families`](RichTextToolbar::with_families).  With no families the
27//! dropdown is omitted entirely.
28//!
29//! # Colours (self-contained)
30//!
31//! The text/highlight swatches open a floating
32//! [`color_wheel_picker_dialog_with_on_close`](crate::widgets::color_wheel_picker::color_wheel_picker_dialog_with_on_close).
33//! That dialog is a **modal** window, so it paints through the framework's
34//! clip-free global-overlay pass and clamps itself into the viewport — meaning
35//! the picker can live *inside* the (thin) toolbar widget and still float
36//! un-truncated over the editor.  The toolbar is therefore **fully
37//! self-contained**: no companion overlay to place, no top-level `Stack`
38//! required.  Colours drive a live preview through the handle's preview session
39//! ([`begin_preview`](RichEditHandle::begin_preview) /
40//! [`commit_preview`](RichEditHandle::commit_preview) /
41//! [`cancel_preview`](RichEditHandle::cancel_preview)) — the selection recolours
42//! as the wheel is dragged, commits on *Select*, and restores on
43//! *Cancel* / × / *Escape*.
44//!
45//! # Example
46//!
47//! Extends the module-level embedding example: just drop the toolbar above the
48//! editor in a [`FlexColumn`] — the colour picker is handled internally.
49//!
50//! ```
51//! use std::sync::Arc;
52//! use agg_gui::Font;
53//! use agg_gui::widgets::rich_text::{single_font_resolver, RichDoc, RichTextEdit};
54//! use agg_gui::widgets::rich_text::toolbar::{RichTextToolbar, Variant};
55//! use agg_gui::FlexColumn;
56//!
57//! let bytes = std::fs::read(concat!(
58//!     env!("CARGO_MANIFEST_DIR"),
59//!     "/assets/fonts/NotoSans-Regular.ttf",
60//! )).expect("bundled font readable");
61//! let font = Arc::new(Font::from_slice(&bytes).expect("valid font"));
62//!
63//! let editor = RichTextEdit::new(RichDoc::new(), single_font_resolver(Arc::clone(&font)));
64//! let handle = editor.handle();
65//!
66//! // Configure a toolbar. Bold/Italic gate on a variant check; colours are on.
67//! let toolbar = RichTextToolbar::new(handle, Arc::clone(&font))
68//!     .with_families(vec!["Sans".to_string(), "Serif".to_string()], None)
69//!     .with_variant_check(|_family, v| matches!(v, Variant::Italic));
70//!
71//! // The toolbar strip sits above the editor; the colour dialog floats itself.
72//! let _body = FlexColumn::new()
73//!     .add(Box::new(toolbar))
74//!     .add_flex(Box::new(editor), 1.0);
75//! ```
76
77use std::cell::Cell;
78use std::rc::Rc;
79use std::sync::Arc;
80
81use crate::draw_ctx::DrawCtx;
82use crate::event::{Event, EventResult};
83use crate::geometry::{Rect, Size};
84use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
85use crate::platform::primary_modifier_label;
86use crate::text::Font;
87use crate::widget::Widget;
88use crate::widgets::flex::FlexColumn;
89
90use super::commands::CommonStyle;
91use super::editor::RichEditHandle;
92use super::model::ListKind;
93
94mod color;
95mod controls;
96
97use crate::widgets::text_area::TextHAlign;
98
99/// Which colour the toolbar's floating picker is currently editing, shared
100/// between the swatch buttons and the internal colour overlay.
101#[derive(Clone, Copy, PartialEq, Eq)]
102pub(crate) enum PickerKind {
103    None,
104    TextColor,
105    Highlight,
106}
107
108/// A synthetic face variant a family may or may not ship, passed to a
109/// [`variant check`](RichTextToolbar::with_variant_check) that gates the
110/// Bold / Italic toggles.
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum Variant {
113    Bold,
114    Italic,
115}
116
117/// Injected predicate: does `family` ship the given [`Variant`]?  Used to grey
118/// out the Bold / Italic toggles for families without a real bold/italic face.
119pub(crate) type VariantCheck = Rc<dyn Fn(&str, Variant) -> bool>;
120
121/// The injected font-family list (and optional per-item preview fonts) that
122/// backs the family dropdown.
123struct Families {
124    names: Vec<String>,
125    item_fonts: Option<Vec<Arc<Font>>>,
126}
127
128/// Which control groups are shown.  Every field defaults to `true`.
129#[derive(Clone, Copy)]
130struct ToolbarConfig {
131    bold: bool,
132    italic: bool,
133    underline: bool,
134    strikethrough: bool,
135    alignment: bool,
136    lists: bool,
137    indent: bool,
138    history: bool,
139    font_size: bool,
140    text_color: bool,
141    highlight: bool,
142    /// Attach a hover tooltip to every control. On by default.
143    tooltips: bool,
144}
145
146impl Default for ToolbarConfig {
147    fn default() -> Self {
148        Self {
149            bold: true,
150            italic: true,
151            underline: true,
152            strikethrough: true,
153            alignment: true,
154            lists: true,
155            indent: true,
156            history: true,
157            font_size: true,
158            text_color: true,
159            highlight: true,
160            tooltips: true,
161        }
162    }
163}
164
165/// Default font-size steps (points) offered by the size dropdown, 8–32.
166const DEFAULT_FONT_SIZES: &[f64] = &[
167    8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0,
168];
169
170/// Generous slot used to lay out the colour-overlay's modal dialog so it can
171/// size itself regardless of how thin the toolbar strip is. The dialog's final
172/// on-screen position comes from the paint-time viewport clamp, not this size.
173const OVERLAY_LAYOUT_ROOM: Size = Size::new(4096.0, 4096.0);
174
175/// A configurable formatting toolbar bound to a [`RichEditHandle`].
176///
177/// Build it with [`new`](Self::new), tweak the control roster and font
178/// family/size options through the `with_*` builders, then place it in a layout
179/// like any other widget.  The colour picker is hosted internally (a modal
180/// dialog that floats via the global-overlay pass), so the toolbar is fully
181/// self-contained — nothing else to place (see the [module example](self)).
182pub struct RichTextToolbar {
183    bounds: Rect,
184    base: WidgetBase,
185    /// Exactly one built child (the two-row [`FlexColumn`]); rebuilt whenever a
186    /// builder changes the config so `children()` / `measure_min_height` are
187    /// always valid without a prior layout pass.
188    root: Vec<Box<dyn Widget>>,
189
190    handle: RichEditHandle,
191    font: Arc<Font>,
192    cfg: ToolbarConfig,
193    families: Option<Families>,
194    variant_check: Option<VariantCheck>,
195    font_sizes: Vec<f64>,
196    picker: Rc<Cell<PickerKind>>,
197}
198
199impl RichTextToolbar {
200    /// Create a toolbar driving `handle`, labelling controls with `font` (the
201    /// same base font whose Font Awesome fallback renders the icon glyphs).  All
202    /// control groups are enabled; the font-family dropdown is omitted until you
203    /// call [`with_families`](Self::with_families).
204    pub fn new(handle: RichEditHandle, font: Arc<Font>) -> Self {
205        let mut this = Self {
206            bounds: Rect::default(),
207            base: WidgetBase::new(),
208            root: Vec::new(),
209            handle,
210            font,
211            cfg: ToolbarConfig::default(),
212            families: None,
213            variant_check: None,
214            font_sizes: DEFAULT_FONT_SIZES.to_vec(),
215            picker: Rc::new(Cell::new(PickerKind::None)),
216        };
217        this.rebuild();
218        this
219    }
220
221    // ── Control-roster builders (all groups default on) ────────────────────
222
223    /// Show/hide the Bold toggle.
224    pub fn with_bold(mut self, on: bool) -> Self {
225        self.cfg.bold = on;
226        self.rebuild();
227        self
228    }
229    /// Show/hide the Italic toggle.
230    pub fn with_italic(mut self, on: bool) -> Self {
231        self.cfg.italic = on;
232        self.rebuild();
233        self
234    }
235    /// Show/hide the Underline toggle.
236    pub fn with_underline(mut self, on: bool) -> Self {
237        self.cfg.underline = on;
238        self.rebuild();
239        self
240    }
241    /// Show/hide the Strikethrough toggle.
242    pub fn with_strikethrough(mut self, on: bool) -> Self {
243        self.cfg.strikethrough = on;
244        self.rebuild();
245        self
246    }
247    /// Show/hide the alignment trio (left / center / right).
248    pub fn with_alignment(mut self, on: bool) -> Self {
249        self.cfg.alignment = on;
250        self.rebuild();
251        self
252    }
253    /// Show/hide the ordered/bullet list toggles.
254    pub fn with_lists(mut self, on: bool) -> Self {
255        self.cfg.lists = on;
256        self.rebuild();
257        self
258    }
259    /// Show/hide the outdent/indent buttons.
260    pub fn with_indent(mut self, on: bool) -> Self {
261        self.cfg.indent = on;
262        self.rebuild();
263        self
264    }
265    /// Show/hide the undo/redo buttons.
266    pub fn with_history(mut self, on: bool) -> Self {
267        self.cfg.history = on;
268        self.rebuild();
269        self
270    }
271    /// Show/hide the font-size dropdown.
272    pub fn with_font_size_combo(mut self, on: bool) -> Self {
273        self.cfg.font_size = on;
274        self.rebuild();
275        self
276    }
277    /// Show/hide **both** colour swatches (text colour + highlight).
278    pub fn with_colors(mut self, on: bool) -> Self {
279        self.cfg.text_color = on;
280        self.cfg.highlight = on;
281        self.rebuild();
282        self
283    }
284
285    /// Enable/disable the hover tooltips carried by **every** control (Bold,
286    /// alignment, colours, undo/redo, …).  On by default — the toolbar ships
287    /// self-documenting.  Pass `false` for a bare strip when the embedding app
288    /// provides its own help affordance.
289    pub fn with_tooltips(mut self, on: bool) -> Self {
290        self.cfg.tooltips = on;
291        self.rebuild();
292        self
293    }
294
295    /// Replace the font-size steps (points) offered by the size dropdown.
296    pub fn with_font_sizes(mut self, sizes: Vec<f64>) -> Self {
297        self.font_sizes = sizes;
298        self.rebuild();
299        self
300    }
301
302    /// Enable the font-family dropdown over `names`.  `item_fonts`, when
303    /// supplied (one [`Font`] per name), renders each dropdown row in its own
304    /// face; pass `None` for a plain-text list.  Selecting a family issues a
305    /// [`SetFontFamily`](super::commands::RichCommand::SetFontFamily) — the
306    /// app's resolver maps family + bold/italic to a concrete face.
307    ///
308    /// `item_fonts` need not match `names` in length: a row with no matching
309    /// entry falls back to the toolbar's base font, and any extra fonts beyond
310    /// `names.len()` are ignored.
311    pub fn with_families(mut self, names: Vec<String>, item_fonts: Option<Vec<Arc<Font>>>) -> Self {
312        self.families = if names.is_empty() {
313            None
314        } else {
315            Some(Families { names, item_fonts })
316        };
317        self.rebuild();
318        self
319    }
320
321    /// Gate the Bold / Italic toggles on the selection's family actually
322    /// shipping that [`Variant`] — a family with no bold face greys out the Bold
323    /// toggle.  Without a check both stay enabled.  The check only fires for a
324    /// consistent, explicit family; an inherited-default or mixed selection
325    /// keeps the toggles enabled.
326    pub fn with_variant_check(mut self, check: impl Fn(&str, Variant) -> bool + 'static) -> Self {
327        self.variant_check = Some(Rc::new(check));
328        self.rebuild();
329        self
330    }
331
332    // ── Layout-props builders ──────────────────────────────────────────────
333
334    /// Set the toolbar's outer margin.
335    pub fn with_margin(mut self, m: Insets) -> Self {
336        self.base.margin = m;
337        self
338    }
339    /// Set the horizontal anchor used when placed in a layout.
340    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
341        self.base.h_anchor = h;
342        self
343    }
344    /// Set the vertical anchor used when placed in a layout.
345    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
346        self.base.v_anchor = v;
347        self
348    }
349
350    /// Reconstruct the control tree from the current config.
351    ///
352    /// `root[0]` is the two-row [`FlexColumn`] that determines the toolbar's
353    /// size.  When either colour swatch is enabled, `root[1]` is the
354    /// self-contained colour-picker overlay — a modal dialog that paints through
355    /// the global-overlay pass, so it floats over the editor without enlarging
356    /// the strip (see [`layout`](Widget::layout), which excludes it from the
357    /// reported size).
358    fn rebuild(&mut self) {
359        let mut col = FlexColumn::new().with_gap(6.0);
360        let row1 = self.build_row1();
361        if !row1.children().is_empty() {
362            col.push(row1, 0.0);
363        }
364        let row2 = self.build_row2();
365        if !row2.children().is_empty() {
366            col.push(row2, 0.0);
367        }
368        let mut root: Vec<Box<dyn Widget>> = vec![Box::new(col)];
369        if self.cfg.text_color || self.cfg.highlight {
370            root.push(color::color_overlay(&self.font, &self.handle, &self.picker));
371        }
372        self.root = root;
373    }
374
375    /// Attach hover-help `text` to `w` via its [`WidgetBase`] when tooltips are
376    /// enabled (the default); otherwise return it untouched.  The central
377    /// tooltip controller (see [`crate::widgets::tooltip::controller`]) shows the
378    /// tip on hover — no wrapper widget.  Centralises the toolbar's default-on
379    /// policy so every control participates uniformly and a single
380    /// [`with_tooltips`](Self::with_tooltips) toggle gates them all.
381    fn tip(&self, mut w: Box<dyn Widget>, text: impl Into<String>) -> Box<dyn Widget> {
382        if self.cfg.tooltips {
383            w.set_tooltip_text(Some(text.into()));
384        }
385        w
386    }
387
388    /// Row 1: inline character formatting, font family + size, colours.
389    fn build_row1(&self) -> Box<dyn Widget> {
390        let mut row = controls::new_row();
391        let check = self.variant_check.as_ref();
392        if self.cfg.bold {
393            row = row.add(self.tip(
394                controls::style_toggle(
395                    &self.font,
396                    &self.handle,
397                    controls::ICON_BOLD,
398                    |c| c.bold,
399                    super::commands::RichCommand::ToggleBold,
400                    Some(Variant::Bold),
401                    check,
402                ),
403                "Bold",
404            ));
405        }
406        if self.cfg.italic {
407            row = row.add(self.tip(
408                controls::style_toggle(
409                    &self.font,
410                    &self.handle,
411                    controls::ICON_ITALIC,
412                    |c| c.italic,
413                    super::commands::RichCommand::ToggleItalic,
414                    Some(Variant::Italic),
415                    check,
416                ),
417                "Italic",
418            ));
419        }
420        if self.cfg.underline {
421            row = row.add(self.tip(
422                controls::style_toggle(
423                    &self.font,
424                    &self.handle,
425                    controls::ICON_UNDERLINE,
426                    |c| c.underline,
427                    super::commands::RichCommand::ToggleUnderline,
428                    None,
429                    None,
430                ),
431                "Underline",
432            ));
433        }
434        if self.cfg.strikethrough {
435            row = row.add(self.tip(
436                controls::style_toggle(
437                    &self.font,
438                    &self.handle,
439                    controls::ICON_STRIKE,
440                    |c| c.strikethrough,
441                    super::commands::RichCommand::ToggleStrikethrough,
442                    None,
443                    None,
444                ),
445                "Strikethrough",
446            ));
447        }
448        if let Some(families) = &self.families {
449            row = row.add(self.tip(
450                controls::family_combo(&self.font, &self.handle, families),
451                "Font family",
452            ));
453        }
454        if self.cfg.font_size {
455            row = row.add(self.tip(
456                controls::size_combo(&self.font, &self.handle, &self.font_sizes),
457                "Font size",
458            ));
459        }
460        if self.cfg.text_color {
461            row = row.add(self.tip(
462                color::text_color_button(&self.font, &self.picker),
463                "Text color",
464            ));
465        }
466        if self.cfg.highlight {
467            row = row.add(self.tip(
468                color::highlight_button(&self.font, &self.picker),
469                "Highlight color",
470            ));
471            row = row.add(self.tip(
472                color::remove_highlight_button(&self.font, &self.handle),
473                "Remove highlight",
474            ));
475        }
476        Box::new(row)
477    }
478
479    /// Row 2: alignment, lists, indent, history.
480    fn build_row2(&self) -> Box<dyn Widget> {
481        let modifier = primary_modifier_label();
482        let mut row = controls::new_row();
483        if self.cfg.alignment {
484            row = row.add(self.tip(
485                controls::align_toggle(&self.font, &self.handle, controls::ICON_ALIGN_LEFT, TextHAlign::Left),
486                "Align left",
487            ));
488            row = row.add(self.tip(
489                controls::align_toggle(&self.font, &self.handle, controls::ICON_ALIGN_CENTER, TextHAlign::Center),
490                "Align center",
491            ));
492            row = row.add(self.tip(
493                controls::align_toggle(&self.font, &self.handle, controls::ICON_ALIGN_RIGHT, TextHAlign::Right),
494                "Align right",
495            ));
496        }
497        if self.cfg.lists {
498            row = row.add(self.tip(
499                controls::list_toggle(&self.font, &self.handle, controls::ICON_LIST_OL, ListKind::Ordered),
500                "Numbered list",
501            ));
502            row = row.add(self.tip(
503                controls::list_toggle(&self.font, &self.handle, controls::ICON_LIST_UL, ListKind::Bullet),
504                "Bulleted list",
505            ));
506        }
507        if self.cfg.indent {
508            row = row.add(self.tip(
509                controls::command_button(&self.font, &self.handle, controls::ICON_OUTDENT, super::commands::RichCommand::Outdent),
510                "Decrease indent",
511            ));
512            row = row.add(self.tip(
513                controls::command_button(&self.font, &self.handle, controls::ICON_INDENT, super::commands::RichCommand::Indent),
514                "Increase indent",
515            ));
516        }
517        if self.cfg.history {
518            // Undo/redo are the only toolbar actions with a real key binding
519            // (the editor binds `{mod}+Z` / `{mod}+Y`), so they get a shortcut hint.
520            row = row.add(self.tip(
521                controls::undo_button(&self.font, &self.handle),
522                format!("Undo ({modifier}+Z)"),
523            ));
524            row = row.add(self.tip(
525                controls::redo_button(&self.font, &self.handle),
526                format!("Redo ({modifier}+Y)"),
527            ));
528        }
529        Box::new(row)
530    }
531
532    /// Read-only view of the common style under the current selection (the data
533    /// the toggles reflect); handy for tests and custom active-state logic.
534    pub fn common_style_of_selection(&self) -> CommonStyle {
535        self.handle.common_style_of_selection()
536    }
537}
538
539impl Widget for RichTextToolbar {
540    fn type_name(&self) -> &'static str {
541        "RichTextToolbar"
542    }
543    fn bounds(&self) -> Rect {
544        self.bounds
545    }
546    fn set_bounds(&mut self, b: Rect) {
547        self.bounds = b;
548    }
549    fn children(&self) -> &[Box<dyn Widget>] {
550        &self.root
551    }
552    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
553        &mut self.root
554    }
555
556    fn margin(&self) -> Insets {
557        self.base.margin
558    }
559    fn widget_base(&self) -> Option<&WidgetBase> {
560        Some(&self.base)
561    }
562    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
563        Some(&mut self.base)
564    }
565    fn h_anchor(&self) -> HAnchor {
566        self.base.h_anchor
567    }
568    fn v_anchor(&self) -> VAnchor {
569        self.base.v_anchor
570    }
571    fn min_size(&self) -> Size {
572        self.base.min_size
573    }
574    fn max_size(&self) -> Size {
575        self.base.max_size
576    }
577
578    fn measure_min_height(&self, available_w: f64) -> f64 {
579        self.root
580            .first()
581            .map(|c| c.measure_min_height(available_w))
582            .unwrap_or(0.0)
583    }
584
585    fn layout(&mut self, available: Size) -> Size {
586        // root[0] (the rows) sizes the toolbar; root[1..] (the colour overlay)
587        // is laid out so its modal dialog can size/position itself, but excluded
588        // from the reported size so opening the picker never grows the strip —
589        // the modal paints via the global-overlay pass regardless of bounds.
590        let mut reported = Size::new(0.0, 0.0);
591        for (i, child) in self.root.iter_mut().enumerate() {
592            if i == 0 {
593                let desired = child.layout(available);
594                child.set_bounds(Rect::new(0.0, 0.0, desired.width, desired.height));
595                reported = desired;
596            } else {
597                // The overlay's modal dialog must size itself against ample room
598                // — a thin toolbar strip's `available` (a few dozen px tall)
599                // would clamp the dialog's min-size against too small a slot and
600                // panic. It positions itself via the paint-time viewport clamp,
601                // so the generous layout size here has no effect on where it
602                // lands. Zero bounds keep it out of normal hit-testing while
603                // closed; its window paints via the global-overlay pass.
604                child.layout(OVERLAY_LAYOUT_ROOM);
605                child.set_bounds(Rect::new(0.0, 0.0, 0.0, 0.0));
606            }
607        }
608        self.bounds = Rect::new(0.0, 0.0, reported.width, reported.height);
609        reported
610    }
611
612    fn paint(&mut self, _ctx: &mut dyn DrawCtx) {}
613
614    fn on_event(&mut self, _event: &Event) -> EventResult {
615        EventResult::Ignored
616    }
617}
618
619impl Drop for RichTextToolbar {
620    /// If the toolbar is torn down while its colour dialog is still open
621    /// mid-preview, the shared editor core would be left with undo suspended and
622    /// a dangling preview snapshot (dead undo; a later `cancel_preview` could
623    /// clobber the doc). Unwind the session so the editor stays usable.
624    ///
625    /// The guard fires only when *this* toolbar's own picker is open, and
626    /// `cancel_preview` is a no-op when no session is active, so it never
627    /// disturbs an unrelated editor. The held [`RichEditHandle`] keeps the core
628    /// alive for the call, so this is safe regardless of drop order relative to
629    /// the editor widget.
630    fn drop(&mut self) {
631        if self.picker.get() != PickerKind::None {
632            self.handle.cancel_preview();
633            self.picker.set(PickerKind::None);
634        }
635    }
636}
637
638#[cfg(test)]
639#[path = "tests.rs"]
640mod tests;