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;