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
//! Unit tests for [`RichTextToolbar`](super::RichTextToolbar): the control
//! roster produced per config, command dispatch through a real handle (clicking
//! Bold bolds the selection), the tri-state active-display data source, and the
//! family-omitted configuration.

use std::sync::Arc;

use crate::color::Color;
use crate::event::{Event, Modifiers, MouseButton};
use crate::geometry::{Point, Size};
use crate::text::Font;
use crate::widget::Widget;
use crate::widgets::rich_text::commands::RichCommand;
use crate::widgets::rich_text::model::{Block, InlineStyle, RichDoc, TextRun};
use crate::widgets::rich_text::view::SharedResolver;
use crate::widgets::rich_text::{RichEditHandle, RichTextEdit};

use super::RichTextToolbar;

/// Font Awesome "eraser" glyph the Remove-highlight button renders.
const ICON_ERASER: &str = "\u{F12D}";

const FONT_BYTES: &[u8] = include_bytes!("../../../../../demo/assets/CascadiaCode.ttf");

fn font() -> Arc<Font> {
    Arc::new(Font::from_slice(FONT_BYTES).expect("test font loads"))
}

fn resolver(font: &Arc<Font>) -> SharedResolver {
    let f = Arc::clone(font);
    std::rc::Rc::new(move |_: &InlineStyle| Arc::clone(&f))
}

/// Build a toolbar over a fresh editor seeded with `doc`, returning both so the
/// test can drive the shared core through the returned handle.
fn toolbar_over(doc: RichDoc) -> (RichTextToolbar, RichEditHandle) {
    let font = font();
    let editor = RichTextEdit::new(doc, resolver(&font));
    let handle = editor.handle();
    (RichTextToolbar::new(handle.clone(), font), handle)
}

/// Controls are no longer wrapped in a `Tooltip`: each carries its tip directly
/// on its `WidgetBase`, so a control's `type_name` is read straight off the
/// child. (Kept as a named helper for the roster assertions below.)
fn child_type_names(w: &dyn Widget) -> Vec<&'static str> {
    w.children().iter().map(|c| c.type_name()).collect()
}

/// The tip text a control carries via the first-class tooltip system
/// (`WidgetBase::tooltip`, read through [`Widget::tooltip_text`]), or `None`.
fn tip_text(w: &dyn Widget) -> Option<String> {
    w.tooltip_text().map(str::to_string)
}

/// The two-row `FlexColumn` root and its rows.
fn rows(toolbar: &RichTextToolbar) -> Vec<&dyn Widget> {
    let col = toolbar.children()[0].as_ref();
    col.children().iter().map(|c| c.as_ref()).collect()
}

// ── Control roster per config ──────────────────────────────────────────────

/// A fully-enabled toolbar with an injected family list lays out the complete
/// two-row roster: row 1 = B/I/U/S + family combo + size combo + text/highlight
/// swatches; row 2 = 3 alignments + 2 lists + outdent/indent + undo/redo.
#[test]
fn full_roster_with_families() {
    let (toolbar, _h) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    let toolbar = toolbar.with_families(vec!["Sans".into(), "Serif".into()], None);

    let rows = rows(&toolbar);
    assert_eq!(rows.len(), 2, "two rows");
    assert_eq!(
        child_type_names(rows[0]),
        ["Button", "Button", "Button", "Button", "ComboBox", "ComboBox", "Button", "Button", "Button"],
        "row 1: B/I/U/S, family combo, size combo, text-colour, highlight, remove-highlight"
    );
    assert_eq!(child_type_names(rows[1]), vec!["Button"; 9], "row 2 roster");
}

/// Disabling groups drops exactly those controls and omits a fully-empty row.
#[test]
fn config_disables_controls_and_empty_rows() {
    let (toolbar, _h) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    let toolbar = toolbar
        .with_bold(false)
        .with_colors(false)
        .with_history(false)
        .with_alignment(false)
        .with_lists(false)
        .with_indent(false);

    let rows = rows(&toolbar);
    // Row 2's whole roster (align/lists/indent/history) is disabled, so the row
    // is omitted entirely — only row 1 survives.
    assert_eq!(rows.len(), 1, "empty second row omitted");
    // Row 1: italic, underline, strike (bold off), then size combo (colours off,
    // no families).
    assert_eq!(
        child_type_names(rows[0]),
        ["Button", "Button", "Button", "ComboBox"],
        "row 1 lost bold + colours, kept I/U/S + size combo"
    );
}

/// With no injected family list the family dropdown is omitted: row 1 carries a
/// single `ComboBox` (the size dropdown), not two.
#[test]
fn family_omitted_when_no_families() {
    let (toolbar, _h) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    let rows = rows(&toolbar);
    assert_eq!(
        child_type_names(rows[0]),
        ["Button", "Button", "Button", "Button", "ComboBox", "Button", "Button", "Button"],
        "row 1 without a family combo: B/I/U/S, size combo, two colour swatches, remove-highlight"
    );
    let combo_count = child_type_names(rows[0]).iter().filter(|t| **t == "ComboBox").count();
    assert_eq!(combo_count, 1, "only the size ComboBox, no family ComboBox");
}

// ── Tooltips: on by default, opt out with `with_tooltips(false)` ────────────

/// Every control is wrapped in a hover `Tooltip` by default, carrying a sensible
/// short label. A representative sweep across both rows pins the contract: row 1
/// character/colour controls and row 2 block/history controls each read the
/// expected tip, and Undo/Redo include the editor's real key binding.
#[test]
fn default_controls_carry_expected_tooltips() {
    let (toolbar, _h) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    let toolbar = toolbar.with_families(vec!["Sans".into(), "Serif".into()], None);
    let rows = rows(&toolbar);

    // Row 1 (with families): B/I/U/S, family combo, size combo, text-colour,
    // highlight, remove-highlight.
    let row1: Vec<Option<String>> = rows[0].children().iter().map(|c| tip_text(c.as_ref())).collect();
    assert_eq!(row1[0].as_deref(), Some("Bold"));
    assert_eq!(row1[1].as_deref(), Some("Italic"));
    assert_eq!(row1[2].as_deref(), Some("Underline"));
    assert_eq!(row1[3].as_deref(), Some("Strikethrough"));
    assert_eq!(row1[4].as_deref(), Some("Font family"));
    assert_eq!(row1[5].as_deref(), Some("Font size"));
    assert_eq!(row1[6].as_deref(), Some("Text color"));
    assert_eq!(row1[7].as_deref(), Some("Highlight color"));
    assert_eq!(row1[8].as_deref(), Some("Remove highlight"));

    // Row 2: align L/C/R, ordered/bullet lists, outdent/indent, undo/redo.
    let row2: Vec<Option<String>> = rows[1].children().iter().map(|c| tip_text(c.as_ref())).collect();
    assert_eq!(row2[0].as_deref(), Some("Align left"));
    assert_eq!(row2[1].as_deref(), Some("Align center"));
    assert_eq!(row2[2].as_deref(), Some("Align right"));
    assert_eq!(row2[3].as_deref(), Some("Numbered list"));
    assert_eq!(row2[4].as_deref(), Some("Bulleted list"));
    assert_eq!(row2[5].as_deref(), Some("Decrease indent"));
    assert_eq!(row2[6].as_deref(), Some("Increase indent"));
    // Undo/Redo carry the platform primary-modifier + the bound key.
    let m = crate::platform::primary_modifier_label();
    assert_eq!(row2[7].as_deref(), Some(format!("Undo ({m}+Z)").as_str()));
    assert_eq!(row2[8].as_deref(), Some(format!("Redo ({m}+Y)").as_str()));
}

/// `with_tooltips(false)` ships a bare strip: no control anywhere in the toolbar
/// tree carries a tip (and, as before, nothing is wrapped in a `Tooltip`).
#[test]
fn with_tooltips_false_wraps_nothing() {
    let (toolbar, _h) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    let toolbar = toolbar
        .with_families(vec!["Sans".into(), "Serif".into()], None)
        .with_tooltips(false);

    let mut names = Vec::new();
    descendant_type_names(toolbar.children()[0].as_ref(), &mut names);
    assert!(
        !names.contains(&"Tooltip"),
        "with_tooltips(false) must not wrap any control; tree was {names:?}"
    );
    assert!(
        !any_descendant_has_tip(toolbar.children()[0].as_ref()),
        "with_tooltips(false) must not set a tip on any control"
    );
}

/// Whether any widget in the subtree carries a first-class tooltip.
fn any_descendant_has_tip(w: &dyn Widget) -> bool {
    w.tooltip_text().is_some() || w.children().iter().any(|c| any_descendant_has_tip(c.as_ref()))
}

// ── Command dispatch through a real handle ─────────────────────────────────

/// Reach the Bold toggle: root FlexColumn → row 1 → first button.
fn bold_button(toolbar: &mut RichTextToolbar) -> &mut Box<dyn Widget> {
    let col = &mut toolbar.children_mut()[0];
    let row1 = &mut col.children_mut()[0];
    &mut row1.children_mut()[0]
}

/// A real click on the Bold toggle must dispatch `ToggleBold` through the shared
/// handle and bold the selected run — proving the toolbar drives the very editor
/// it was built over.
#[test]
fn click_bold_bolds_selection_through_handle() {
    let (mut toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hello")]));
    // A real selection so the toggle mutates the document (not just pending).
    handle.select_all();
    assert_ne!(handle.common_style_of_selection().bold, Some(true), "not bold yet");

    // Lay out so the button has non-zero bounds for its local hit-test.
    toolbar.layout(Size::new(600.0, 100.0));

    let button = bold_button(&mut toolbar);
    let b = button.bounds();
    let pos = Point::new(b.width * 0.5, b.height * 0.5);
    let down = Event::MouseDown { pos, button: MouseButton::Left, modifiers: Modifiers::default() };
    let up = Event::MouseUp { pos, button: MouseButton::Left, modifiers: Modifiers::default() };
    button.on_event(&down);
    button.on_event(&up);

    assert_eq!(
        handle.common_style_of_selection().bold,
        Some(true),
        "clicking Bold bolded the whole selection through the handle"
    );
    assert_eq!(handle.plain_text(), "hello", "text unchanged by formatting");
}

// ── Tri-state active display ───────────────────────────────────────────────

/// The toggle's active state is `common_style_of_selection().<attr> == Some(true)`.
/// This exercises the production data source the toolbar reads each frame: a
/// uniform-bold selection reports `Some(true)` (active), a mixed one `None`
/// (inactive), and an all-plain one `Some(false)` (inactive).
#[test]
fn bold_toggle_reflects_tri_state() {
    let bold = InlineStyle { bold: true, ..Default::default() };

    // Uniform bold selection → active.
    let (toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block::from_run(
        TextRun::new("bold", bold.clone()),
    )]));
    handle.select_all();
    let cs = toolbar.common_style_of_selection();
    assert_eq!(cs.bold, Some(true));
    assert!(cs.bold == Some(true), "uniform bold ⇒ toggle active");

    // Mixed selection (bold + plain) → inactive (tri-state "mixed").
    let (toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block {
        runs: vec![TextRun::new("B", bold), TextRun::plain("p")],
        ..Block::new()
    }]));
    handle.select_all();
    let cs = toolbar.common_style_of_selection();
    assert_eq!(cs.bold, None, "mixed selection reports None");
    assert!(cs.bold != Some(true), "mixed ⇒ toggle inactive");

    // All-plain selection → inactive.
    let (toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("plain")]));
    handle.select_all();
    assert_eq!(toolbar.common_style_of_selection().bold, Some(false));
}

// ── Colour picker: "No Color" checkbox is OFF in the rich-text host ────────

/// Recursively collect every descendant `type_name` under `w` (inclusive of
/// the built dialog subtree), so a test can assert on the presence/absence of a
/// particular control anywhere in the overlay's widget tree.
fn descendant_type_names(w: &dyn Widget, out: &mut Vec<&'static str>) {
    out.push(w.type_name());
    for c in w.children() {
        descendant_type_names(c.as_ref(), out);
    }
}

/// Open the toolbar's colour picker for the swatch at `row1_index` and return
/// the flattened `type_name`s of the whole overlay subtree. The picker cell is
/// flipped by clicking the swatch; a re-layout drives the overlay `Rebuilder`
/// to build the dialog before we introspect it.
fn open_picker_type_names(row1_index: usize) -> Vec<&'static str> {
    let (mut toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    handle.select_all();
    toolbar.layout(Size::new(600.0, 100.0));

    {
        let col = &mut toolbar.children_mut()[0];
        let row1 = &mut col.children_mut()[0];
        let swatch = &mut row1.children_mut()[row1_index];
        let b = swatch.bounds();
        let pos = Point::new(b.width * 0.5, b.height * 0.5);
        swatch.on_event(&Event::MouseDown { pos, button: MouseButton::Left, modifiers: Modifiers::default() });
        swatch.on_event(&Event::MouseUp { pos, button: MouseButton::Left, modifiers: Modifiers::default() });
    }
    toolbar.layout(Size::new(600.0, 100.0));
    assert!(handle.is_previewing(), "opening the swatch begins a preview session");

    let mut names = Vec::new();
    descendant_type_names(toolbar.children()[1].as_ref(), &mut names);
    names
}

/// The Highlight picker must NOT expose the "No Color (Pass Through)" checkbox
/// in the rich-text toolbar: the user reported it as confusing here. (The core
/// `ColorWheelPicker` still supports it via `with_allow_none` for other hosts.)
///
/// The checkbox is the picker's only `Checkbox` child, so its absence anywhere
/// in the built overlay subtree proves `allow_none` is off.
#[test]
fn highlight_picker_has_no_no_color_checkbox() {
    // Row 1 without families: B/I/U/S(0-3), size combo(4), text-colour(5),
    // highlight(6).
    let names = open_picker_type_names(6);
    assert!(
        !names.contains(&"Checkbox"),
        "Highlight picker must not build a \"No Color (Pass Through)\" checkbox; \
         tree was {names:?}"
    );
}

/// The Text-colour picker must likewise omit the checkbox (it never applied a
/// `None` colour anyway, and the toggle is confusing).
#[test]
fn text_color_picker_has_no_no_color_checkbox() {
    let names = open_picker_type_names(5);
    assert!(
        !names.contains(&"Checkbox"),
        "Text-colour picker must not build a \"No Color (Pass Through)\" checkbox; \
         tree was {names:?}"
    );
}

// ── Remove-highlight button clears the selection's highlight ───────────────

/// Recursively test whether any descendant of `w` is a widget carrying a
/// `("text", glyph)` property — i.e. a `Label` (or a `Button`'s label child)
/// rendering `glyph`. Used to prove the eraser button exists in the tree.
fn descendant_renders_glyph(w: &dyn Widget, glyph: &str) -> bool {
    if w.properties().iter().any(|(k, v)| *k == "text" && v == glyph) {
        return true;
    }
    w.children().iter().any(|c| descendant_renders_glyph(c.as_ref(), glyph))
}

/// The Remove-highlight button is the sole UI route to `SetHighlight(None)` now
/// that the colour picker dropped its "No Color" checkbox. It must (a) exist in
/// the toolbar tree (its eraser glyph) and (b) clear a highlighted selection to
/// a uniform "no highlight" when clicked.
#[test]
fn remove_highlight_button_clears_selection_highlight() {
    let (mut toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    handle.select_all();

    // Apply a highlight so the selection reports a concrete, uniform colour.
    let yellow = Color::from_rgb8(255, 240, 60);
    handle.exec(&RichCommand::SetHighlight(Some(yellow)));
    assert_eq!(
        handle.common_style_of_selection().highlight,
        Some(Some(yellow)),
        "selection is uniformly highlighted before the click"
    );

    toolbar.layout(Size::new(600.0, 100.0));

    // The eraser button must be present in the built toolbar tree.
    assert!(
        descendant_renders_glyph(toolbar.children()[0].as_ref(), ICON_ERASER),
        "toolbar row must contain the Remove-highlight (eraser) button"
    );

    // Row 1 (no families): B/I/U/S(0-3), size(4), text-colour(5), highlight(6),
    // remove-highlight(7).
    let button = {
        let col = &mut toolbar.children_mut()[0];
        let row1 = &mut col.children_mut()[0];
        &mut row1.children_mut()[7]
    };
    let b = button.bounds();
    let pos = Point::new(b.width * 0.5, b.height * 0.5);
    button.on_event(&Event::MouseDown { pos, button: MouseButton::Left, modifiers: Modifiers::default() });
    button.on_event(&Event::MouseUp { pos, button: MouseButton::Left, modifiers: Modifiers::default() });

    assert_eq!(
        handle.common_style_of_selection().highlight,
        Some(None),
        "clicking Remove highlight cleared the selection's highlight to None"
    );
}

// ── Drop guard: dropping mid-preview unwinds the session ───────────────────

/// Dropping the toolbar while its colour dialog is open mid-preview must cancel
/// the live-preview session, so the shared editor core isn't left with undo
/// suspended and a dangling snapshot.
#[test]
fn drop_mid_preview_cancels_session() {
    let (mut toolbar, handle) = toolbar_over(RichDoc::from_blocks(vec![Block::plain("hi")]));
    handle.select_all();
    toolbar.layout(Size::new(600.0, 100.0));

    // Click the text-colour swatch (row 1, index 5: B/I/U/S + size combo, then
    // text colour) to open the picker, then re-layout so the overlay's
    // Rebuilder builds the dialog and begins the preview session.
    {
        let col = &mut toolbar.children_mut()[0];
        let row1 = &mut col.children_mut()[0];
        let swatch = &mut row1.children_mut()[5];
        let b = swatch.bounds();
        let pos = Point::new(b.width * 0.5, b.height * 0.5);
        swatch.on_event(&Event::MouseDown { pos, button: MouseButton::Left, modifiers: Modifiers::default() });
        swatch.on_event(&Event::MouseUp { pos, button: MouseButton::Left, modifiers: Modifiers::default() });
    }
    toolbar.layout(Size::new(600.0, 100.0));
    assert!(handle.is_previewing(), "opening the swatch begins a preview session");

    drop(toolbar);
    assert!(
        !handle.is_previewing(),
        "dropping the toolbar mid-preview must cancel the session"
    );
}