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;