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