agg-gui 0.2.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
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
//! MenuBar interaction tests — desktop drag/release and mobile backdrop dismiss.

use std::sync::Arc;

use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
use crate::geometry::{Point, Size};
use crate::text::Font;
use crate::widget::Widget;

use super::super::geometry::BAR_H;
use super::super::model::MenuItem;
use super::{MenuBar, TopMenu};

fn test_font() -> Arc<Font> {
    const FONT_BYTES: &[u8] = include_bytes!("../../../../../demo/assets/CascadiaCode.ttf");
    Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
}

/// Desktop drag-and-release in neutral space cancels the popup —
/// the user opened a menu, dragged off the menu bar / popup body,
/// and released somewhere unrelated.  Without this, dragging out
/// of a menu would leave it open with no obvious way to close it
/// from the same gesture.
#[test]
fn desktop_drag_release_in_neutral_space_closes_popup() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![TopMenu::new(
            "File",
            vec![MenuItem::action("New", "file.new").into()],
        )],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    // Press File — popup opens, drag-release armed.
    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(bar.popup.is_open());

    // Drag through neutral space (off the bar, off the popup
    // body) and release there — popup must close.
    let neutral = Point::new(280.0, 170.0);
    bar.on_event(&Event::MouseMove { pos: neutral });
    bar.on_event(&Event::MouseUp {
        pos: neutral,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(
        !bar.popup.is_open(),
        "drag-release in neutral space must close the popup",
    );
}

/// Mobile backdrop dismiss: with a popup open, tapping outside the
/// menu bar AND outside the popup body closes it.  The touch shell
/// fires MouseMove + MouseDown + MouseUp at the tap position, all
/// within the touch-synthesis window.  The MouseDown lands outside
/// any top-menu rect so the bar's "tap on top menu" path doesn't
/// run; popup.handle_event sees an outside-click and closes.
#[test]
fn mobile_backdrop_tap_dismisses_popup() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![TopMenu::new(
            "File",
            vec![MenuItem::action("New", "file.new").into()],
        )],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    // Open File via a tap.
    let file_pos = Point::new(8.0, 8.0);
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseMove { pos: file_pos });
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseDown {
        pos: file_pos,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: file_pos,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(bar.popup.is_open());

    // Tap outside both the menu bar and the popup body.
    let backdrop = Point::new(280.0, 170.0);
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseMove { pos: backdrop });
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseDown {
        pos: backdrop,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: backdrop,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(
        !bar.popup.is_open(),
        "tapping outside the menu bar and popup body must dismiss the popup on mobile",
    );
}

/// Desktop hover-switch: with a popup open, moving the cursor over
/// a different top menu's bar (button NOT held — i.e. AFTER the
/// click that opened the first menu has already released) opens
/// that other menu.  Standard desktop menubar behaviour.
#[test]
fn hover_after_release_switches_open_top_menu_on_desktop() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![
            TopMenu::new(
                "File",
                vec![MenuItem::action("New", "file.new").into()],
            ),
            TopMenu::new(
                "Edit",
                vec![MenuItem::action("Copy", "edit.copy").into()],
            ),
        ],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    // Click File and release.
    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert_eq!(bar.open_index, Some(0));

    // Hover over Edit (button NOT held) — should switch open menu.
    bar.on_event(&Event::MouseMove {
        pos: Point::new(60.0, 8.0),
    });
    assert_eq!(
        bar.open_index,
        Some(1),
        "moving the cursor over a different top menu after release \
         must switch the open popup (desktop hover-switch)"
    );
}

/// Desktop drag-and-release on a sibling top menu's bar: the
/// popup switches to the new menu and stays open after release.
/// Spec row 3.
#[test]
fn desktop_drag_and_release_on_sibling_keeps_new_menu_open() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![
            TopMenu::new(
                "File",
                vec![MenuItem::action("New", "file.new").into()],
            ),
            TopMenu::new(
                "Edit",
                vec![MenuItem::action("Copy", "edit.copy").into()],
            ),
        ],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseMove {
        pos: Point::new(60.0, 8.0),
    });
    assert_eq!(bar.open_index, Some(1), "drag-switch must reach Edit");
    bar.on_event(&Event::MouseUp {
        pos: Point::new(60.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(
        bar.popup.is_open(),
        "release on sibling top-menu bar must keep its popup open"
    );
    assert_eq!(bar.open_index, Some(1));
}

/// Desktop drag-switch to sibling, drag off, release in neutral
/// space: closes.  Spec row 4.
#[test]
fn desktop_drag_switch_then_release_off_closes() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![
            TopMenu::new(
                "File",
                vec![MenuItem::action("New", "file.new").into()],
            ),
            TopMenu::new(
                "Edit",
                vec![MenuItem::action("Copy", "edit.copy").into()],
            ),
        ],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseMove {
        pos: Point::new(60.0, 8.0),
    });
    // Now drag off the bar AND off the popup body.
    bar.on_event(&Event::MouseMove {
        pos: Point::new(280.0, 170.0),
    });
    bar.on_event(&Event::MouseUp {
        pos: Point::new(280.0, 170.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(!bar.popup.is_open());
}

/// Desktop press-press-press without intervening releases: A opens,
/// B opens (switch), neutral closes.  Spec row 5.
#[test]
fn desktop_press_press_press_neutral_closes_active_menu() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![
            TopMenu::new(
                "File",
                vec![MenuItem::action("New", "file.new").into()],
            ),
            TopMenu::new(
                "Edit",
                vec![MenuItem::action("Copy", "edit.copy").into()],
            ),
        ],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseDown {
        pos: Point::new(60.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert_eq!(bar.open_index, Some(1));
    bar.on_event(&Event::MouseDown {
        pos: Point::new(280.0, 170.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(!bar.popup.is_open());
}

/// Mobile: tap currently-open top menu again toggles closed.
/// Spec row 2 of Mobile.
#[test]
fn mobile_tap_currently_open_top_menu_closes() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![TopMenu::new(
            "File",
            vec![MenuItem::action("New", "file.new").into()],
        )],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));

    let file_pos = Point::new(8.0, 8.0);
    // First tap: open.
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseMove { pos: file_pos });
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseDown {
        pos: file_pos,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: file_pos,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(bar.popup.is_open());

    // Second tap on the same top-menu bar: toggle close.
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseMove { pos: file_pos });
    crate::touch_state::note_touch_event();
    bar.on_event(&Event::MouseDown {
        pos: file_pos,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: file_pos,
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(!bar.popup.is_open());
}

/// Click-to-close-toggle: after closing the menu by clicking its
/// own bar item, the bar item must NOT keep painting the hover
/// highlight (cursor is still over it but the user just dismissed
/// the popup, so the item reading as "still selected" is wrong).
/// Hover suppression clears once the cursor moves to a different
/// item (or off the bar).
#[test]
fn click_close_suppresses_hover_until_cursor_leaves() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![
            TopMenu::new(
                "File",
                vec![MenuItem::action("New", "file.new").into()],
            ),
            TopMenu::new(
                "Edit",
                vec![MenuItem::action("Copy", "edit.copy").into()],
            ),
        ],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));
    // Open File then click File again to close.  Cursor stayed
    // over File the whole time.
    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(!bar.popup.is_open());
    assert_eq!(
        bar.suppress_hover_for,
        Some(0),
        "click-to-close must suppress hover on the just-closed bar item",
    );

    // Move the cursor over Edit — suppression clears for File and
    // Edit gets normal hover.
    bar.on_event(&Event::MouseMove {
        pos: Point::new(60.0, 8.0),
    });
    assert_eq!(bar.suppress_hover_for, None);
    assert_eq!(bar.hover_index, Some(1));
}

/// ESC closes the menu (universal dismiss).  Already covered by
/// the popup-state-level outside-click test, but the bar-level
/// path is asserted here so a future event-routing change can't
/// silently break it.
#[test]
fn escape_closes_active_menu() {
    crate::touch_state::clear_last_touch_event_for_testing();
    let viewport = Size::new(300.0, 180.0);
    crate::widget::set_current_viewport(viewport);
    let mut bar = MenuBar::new(
        test_font(),
        vec![TopMenu::new(
            "File",
            vec![MenuItem::action("New", "file.new").into()],
        )],
        |_| {},
    );
    bar.layout(Size::new(300.0, BAR_H));
    bar.on_event(&Event::MouseDown {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    bar.on_event(&Event::MouseUp {
        pos: Point::new(8.0, 8.0),
        button: MouseButton::Left,
        modifiers: Modifiers::default(),
    });
    assert!(bar.popup.is_open());

    bar.on_event(&Event::KeyDown {
        key: Key::Escape,
        modifiers: Modifiers::default(),
    });
    assert!(!bar.popup.is_open(), "ESC must close the active menu");
}