quadraui 0.6.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
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
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! Cross-backend modal-overlay tracking.
//!
//! Backends hold a [`ModalStack`] and apps push to it when they open a
//! modal overlay (command palette, file picker, context menu, dialog,
//! tab switcher, …) and pop when it closes. Quadraui's event dispatcher
//! ([`crate::dispatch`]) consults the stack **before** running any
//! base-layer hit test so events that land inside a modal's bounds
//! can't fall through to whatever is behind it.
//!
//! # Why this belongs in quadraui
//!
//! Every backend before quadraui had its own "is a modal open?" check
//! scattered across its event handlers — TUI's `mouse.rs` had a chain
//! of `if engine.picker_open { ... return; }` early-outs, GTK's
//! `mod.rs` had the same logic duplicated per modal kind. The "drag
//! leaks through the palette" family of bugs is the direct
//! consequence of one backend forgetting to add a new modal to its
//! own list.
//!
//! Centralising the stack here means:
//! - Apps call [`Self::push`] / [`Self::pop`] once per modal open/close.
//! - Adding a new modal kind doesn't require any per-backend change.
//! - The dispatch algorithm is the same on TUI / GTK / Win-GUI / macOS.
//!
//! # What this does **not** do
//!
//! - **Focus**: focus tracking for the general case lives in
//!   [`crate::Backend`] (v1.x) — the modal stack is about hit-test
//!   precedence, not keyboard focus, for most of its entries. The one
//!   exception is [`ModalEntry::focusable`], added for [`crate::Float`]:
//!   a *hint*, not an owner — see "Focus vs. non-focus floats" below.
//! - **Painting**: the stack has no opinions on draw order. Apps still
//!   paint modals last (highest z); the stack is queried only when
//!   *events* arrive.
//! - **Inner hit refinement**: the stack resolves modal-vs-base
//!   arbitration only. Once a hit lands inside a modal, the app still
//!   asks the primitive itself (e.g. [`crate::PaletteLayout::hit_test`])
//!   for the semantic hit inside it.
//!
//! # Focus vs. non-focus floats
//!
//! [`crate::Float`] is the first entry kind pushed onto this stack that
//! can legitimately *not* want keyboard focus while still wanting
//! z-order and hit-test precedence — a which-key-style hint popup must
//! intercept nothing but a dismiss click, and must never steal keys
//! from whatever else is focused (an already-open interactive float, or
//! the base layer). [`Self::push_focusable`] carries that bit per entry;
//! [`Self::top_focusable`] is the read side — the topmost entry whose
//! `focusable` is `true`, skipping any non-focusable entries stacked
//! above it. [`Self::push`] calls `push_focusable(id, bounds, true)`, so
//! every modal pushed through it (`Dialog`, `Palette`, `ContextMenu`,
//! …) is focusable by default.
//!
//! A host routes keyboard input by asking `top_focusable()` who should
//! receive it, falling through to the base layer when it returns
//! `None` — see `examples/common/float_app.rs`'s `handle` for the
//! worked pattern. Mouse hit-testing is unaffected: [`Self::hit_test`]
//! still walks every entry top-down regardless of `focusable`, because
//! a non-focusable float still needs to swallow a click landing on it
//! (e.g. to dismiss itself) even though it never owns the keyboard.
//!
//! # Paint-consistency detection
//!
//! Registering a modal in this stack makes it hit-testable, but nothing
//! *forces* a backend to also paint it — those are two independent code
//! paths (`modal_stack_handle().borrow_mut().push(...)` vs.
//! `backend.draw_dialog(...)`),
//! and a backend that pushes without ever painting (or paints without
//! ever pushing) produces a defect that reads correctly at every other
//! layer: input is healthy, state is right, hit-testing works — the
//! feature is simply invisible (vimcode#587: a command palette
//! captured clicks and had 78 items for weeks before anyone noticed it
//! never painted).
//!
//! This module can't force a paint (that's the "full paint-ownership"
//! redesign #455 sketches as future work), but it *can* make the drift
//! detectable for free: [`Self::mark_painted`] records that a modal's
//! surface was actually drawn during the current frame, backends call
//! [`Self::reset_frame_paint`] from `Backend::begin_frame` and
//! [`Self::unpainted_ids`] from `Backend::end_frame` to warn (in debug
//! builds) about any entry that's registered but was never marked
//! painted that frame. See `TuiBackend::draw_dialog` /
//! `GtkBackend::draw_dialog` / `MacBackend::draw_dialog` (and the
//! `draw_palette` / `draw_context_menu` siblings) for where the mark
//! happens, and the same backends' `begin_frame` / `end_frame` for
//! where the reset/check happens.

use crate::event::{Point, Rect};
use crate::types::WidgetId;

/// One entry in the modal stack: a widget id + its outer bounds in
/// backend-native coordinates.
///
/// `Rect` rather than a full [`crate::PaletteLayout`] (or tree/dialog
/// layout) because the stack only arbitrates "modal vs base" — inner
/// hit refinement stays a concern of the individual primitive's
/// layout, which the app queries after the stack identifies the
/// topmost modal under the cursor.
#[derive(Debug, Clone, PartialEq)]
pub struct ModalEntry {
    pub id: WidgetId,
    pub bounds: Rect,
    /// Whether this entry should receive keyboard focus while it's the
    /// topmost *focusable* entry — see the module doc's "Focus vs.
    /// non-focus floats" section. `true` for every entry pushed via
    /// [`ModalStack::push`] (dialogs, palettes, context menus); set
    /// explicitly via [`ModalStack::push_focusable`] for a
    /// [`crate::Float`] that shouldn't steal keys (a hint popup).
    pub focusable: bool,
    /// Set by [`ModalStack::mark_painted`] when a backend's
    /// `draw_*` call for this modal actually runs during the current
    /// frame. Reset to `false` by [`ModalStack::reset_frame_paint`] at
    /// the start of every frame. Not `pub` — entries are only ever
    /// constructed by [`ModalStack::push`]/[`ModalStack::push_focusable`],
    /// which always start this `false` for a freshly (re-)opened modal.
    painted_this_frame: bool,
}

/// Top-of-stack-is-topmost ordered list of open modal overlays.
///
/// Backends hold one instance (typically on their concrete backend
/// struct behind `Rc<RefCell<_>>`; the trait exposes
/// [`crate::Backend::modal_stack_handle`]).
/// The app mutates it through modal-open and modal-close code paths
/// (typically in the engine's picker/dialog-open state transitions);
/// quadraui's dispatcher reads it.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModalStack {
    entries: Vec<ModalEntry>,
}

impl ModalStack {
    /// Empty stack — no modals open.
    pub fn new() -> Self {
        Self::default()
    }

    /// Push a modal onto the stack, focusable. The new entry becomes
    /// the topmost (most-recently-opened) modal. Equivalent to
    /// `push_focusable(id, bounds, true)` — see that method for the
    /// non-focusable case (hint-popup floats).
    ///
    /// If `id` is already present (misuse — apps should pop first),
    /// the existing entry is removed before the new one is pushed so
    /// the stack never contains duplicates.
    pub fn push(&mut self, id: WidgetId, bounds: Rect) {
        self.push_focusable(id, bounds, true);
    }

    /// Push a modal (or [`crate::Float`]) onto the stack with an
    /// explicit [`ModalEntry::focusable`] bit. The new entry becomes
    /// the topmost (most-recently-opened) one, same duplicate-replacement
    /// behaviour as [`Self::push`].
    ///
    /// Pass `focusable: false` for a transient hint-style float that
    /// must intercept clicks (so it can dismiss itself, and so a click
    /// on it doesn't fall through to whatever's behind it) but must
    /// never steal keyboard focus from whatever else is focused — see
    /// [`Self::top_focusable`] for the read side.
    pub fn push_focusable(&mut self, id: WidgetId, bounds: Rect, focusable: bool) {
        self.entries.retain(|e| e.id != id);
        self.entries.push(ModalEntry {
            id,
            bounds,
            focusable,
            painted_this_frame: false,
        });
    }

    /// Push a [`crate::Float`] onto the stack at `bounds`, carrying over
    /// its own [`crate::Float::focusable`] bit — equivalent to
    /// `push_focusable(float.id.clone(), bounds, float.focusable)`. Use
    /// this instead of calling [`Self::push`]/[`Self::push_focusable`]
    /// directly with a hand-written literal, so a float's serde-described
    /// `focusable` field is the single source of truth for whether it
    /// takes keyboard focus.
    pub fn push_float(&mut self, float: &crate::Float, bounds: Rect) {
        self.push_focusable(float.id, bounds, float.focusable);
    }

    /// Remove the modal with this id, if present. Returns true on
    /// successful removal. Called by the app on palette/dialog close.
    pub fn pop(&mut self, id: &WidgetId) -> bool {
        let len_before = self.entries.len();
        self.entries.retain(|e| e.id != *id);
        self.entries.len() < len_before
    }

    /// Pop the topmost modal regardless of id. Used by "click outside
    /// any modal dismisses the topmost one" flows.
    pub fn pop_top(&mut self) -> Option<ModalEntry> {
        self.entries.pop()
    }

    /// Peek at the topmost modal without mutating.
    pub fn top(&self) -> Option<&ModalEntry> {
        self.entries.last()
    }

    /// The topmost entry whose [`ModalEntry::focusable`] is `true`,
    /// skipping any non-focusable entries stacked above it — see the
    /// module doc's "Focus vs. non-focus floats" section. `None` when
    /// the stack is empty, or every open entry is
    /// non-focusable, meaning keyboard input should fall through to the
    /// base layer.
    pub fn top_focusable(&self) -> Option<&ModalEntry> {
        self.iter_top_down().find(|e| e.focusable)
    }

    /// `true` when no modals are open.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Number of modals open (stacked).
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Iterate entries top-down (topmost first). Used by the
    /// dispatcher's hit walk.
    pub fn iter_top_down(&self) -> impl Iterator<Item = &ModalEntry> {
        self.entries.iter().rev()
    }

    /// Return the id of the topmost modal whose bounds contain `point`,
    /// or `None` if no modal contains it (click landed outside every
    /// open modal).
    pub fn hit_test(&self, point: Point) -> Option<&WidgetId> {
        for entry in self.iter_top_down() {
            if entry.bounds.contains(point) {
                return Some(&entry.id);
            }
        }
        None
    }

    // ─── Paint-consistency detection ────────────────────────────────

    /// Record that `id`'s surface was actually drawn this frame. A no-op
    /// if `id` isn't currently registered — some backends draw preview
    /// or ghost content by id outside the modal stack, and that isn't
    /// the "registered but invisible" defect this exists to catch.
    ///
    /// Call from a backend's `draw_dialog` / `draw_palette` /
    /// `draw_context_menu` (and any future modal-capable rasteriser)
    /// with the primitive's own `id` field, right where it already
    /// paints — see `TuiBackend::draw_dialog` for the pattern.
    pub fn mark_painted(&mut self, id: &WidgetId) {
        if let Some(entry) = self.entries.iter_mut().find(|e| &e.id == id) {
            entry.painted_this_frame = true;
        }
    }

    /// Clear every entry's per-frame painted flag. Call once per frame,
    /// before painting starts — backends do this from `Backend::begin_frame`,
    /// alongside their other "per-frame cache" resets (text regions,
    /// zones, tab-bar layouts, …).
    pub fn reset_frame_paint(&mut self) {
        for entry in &mut self.entries {
            entry.painted_this_frame = false;
        }
    }

    /// Ids of modals that are registered (and therefore hit-testable)
    /// but were not [`Self::mark_painted`] this frame — a diagnostic for
    /// detecting the defect where a modal is registered but never painted.
    /// Call from `Backend::end_frame`, after `AppLogic::render` has had its
    /// chance to paint every open modal.
    ///
    /// Empty whenever every open modal was painted (the healthy case)
    /// or no modals are open at all.
    pub fn unpainted_ids(&self) -> Vec<WidgetId> {
        self.entries
            .iter()
            .filter(|e| !e.painted_this_frame)
            .map(|e| e.id)
            .collect()
    }

    /// Diagnostic text for a modal that was registered but not painted,
    /// shared by every backend's `end_frame` so the message exists in
    /// exactly one place instead of duplicated across backends. Callers
    /// route this through [`crate::diagnostics::emit`] rather than
    /// printing it directly — see that module for why.
    ///
    /// Scoped by `cfg_attr` to avoid dead-code warnings in builds where
    /// this is not called, since it is only compiled when debug assertions
    /// are enabled and at least one backend feature is active.
    #[cfg_attr(
        not(all(
            debug_assertions,
            any(
                feature = "tui",
                feature = "gtk",
                all(feature = "macos", target_os = "macos")
            )
        )),
        allow(dead_code)
    )]
    pub(crate) fn unpainted_modal_message(id: &WidgetId) -> String {
        format!(
            "quadraui: modal {id:?} is registered in ModalStack (and therefore \
             hit-testable) but was not painted this frame — see quadraui#455 \
             (\"ModalStack drives hit-testing but not paint\")"
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn id(s: &str) -> WidgetId {
        WidgetId::new(s)
    }

    fn pt(x: f32, y: f32) -> Point {
        Point { x, y }
    }

    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
        Rect {
            x,
            y,
            width: w,
            height: h,
        }
    }

    #[test]
    fn empty_stack_reports_empty() {
        let s = ModalStack::new();
        assert!(s.is_empty());
        assert_eq!(s.len(), 0);
        assert!(s.top().is_none());
        assert!(s.hit_test(pt(0.0, 0.0)).is_none());
    }

    #[test]
    fn push_adds_to_top() {
        let mut s = ModalStack::new();
        s.push(id("palette"), rect(10.0, 10.0, 100.0, 100.0));
        assert_eq!(s.len(), 1);
        assert_eq!(s.top().unwrap().id, id("palette"));
    }

    #[test]
    fn hit_test_walks_top_down() {
        // Two overlapping modals — the second pushed should win for
        // points inside both. This is the classic "dialog on top of
        // palette" case.
        let mut s = ModalStack::new();
        s.push(id("palette"), rect(0.0, 0.0, 100.0, 100.0));
        s.push(id("dialog"), rect(20.0, 20.0, 60.0, 60.0));

        // Point inside both → topmost (dialog) wins.
        assert_eq!(s.hit_test(pt(30.0, 30.0)), Some(&id("dialog")));
        // Point inside palette only → palette.
        assert_eq!(s.hit_test(pt(5.0, 5.0)), Some(&id("palette")));
        // Outside both → None.
        assert_eq!(s.hit_test(pt(200.0, 200.0)), None);
    }

    #[test]
    fn pop_by_id_removes_even_when_not_top() {
        // Stack tolerates out-of-order closes: not just LIFO (close the top)
        // but also non-LIFO when code paths race (e.g. an async result closes
        // a picker that is no longer the topmost modal).
        let mut s = ModalStack::new();
        s.push(id("a"), rect(0.0, 0.0, 10.0, 10.0));
        s.push(id("b"), rect(0.0, 0.0, 10.0, 10.0));
        s.push(id("c"), rect(0.0, 0.0, 10.0, 10.0));
        assert!(s.pop(&id("b")));
        assert_eq!(s.len(), 2);
        // Remaining order: a, c (c still top).
        assert_eq!(s.top().unwrap().id, id("c"));
        // Popping something that isn't there is a no-op.
        assert!(!s.pop(&id("missing")));
    }

    #[test]
    fn pop_top_returns_the_popped_entry() {
        let mut s = ModalStack::new();
        s.push(id("x"), rect(1.0, 2.0, 3.0, 4.0));
        let popped = s.pop_top().unwrap();
        assert_eq!(popped.id, id("x"));
        assert!(s.is_empty());
        assert!(s.pop_top().is_none());
    }

    #[test]
    fn push_with_existing_id_moves_to_top_without_duplication() {
        let mut s = ModalStack::new();
        s.push(id("a"), rect(0.0, 0.0, 10.0, 10.0));
        s.push(id("b"), rect(0.0, 0.0, 10.0, 10.0));
        // Re-push "a" with new bounds — should bump to top, not duplicate.
        s.push(id("a"), rect(5.0, 5.0, 20.0, 20.0));
        assert_eq!(s.len(), 2);
        assert_eq!(s.top().unwrap().id, id("a"));
        assert_eq!(s.top().unwrap().bounds, rect(5.0, 5.0, 20.0, 20.0));
    }

    #[test]
    fn hit_test_edges_are_exclusive_on_right_and_bottom() {
        // `x < rect.x + width`, not `<=` — matches half-open-interval
        // convention used everywhere else in quadraui (tab bar
        // segments, status bar hit regions).
        let mut s = ModalStack::new();
        s.push(id("m"), rect(0.0, 0.0, 10.0, 10.0));
        assert_eq!(s.hit_test(pt(0.0, 0.0)), Some(&id("m"))); // inclusive
        assert!(s.hit_test(pt(10.0, 5.0)).is_none()); // exclusive right edge
        assert!(s.hit_test(pt(5.0, 10.0)).is_none()); // exclusive bottom edge
    }

    // ─── Paint-consistency detection ────────────────────────────────

    #[test]
    fn freshly_pushed_modal_is_unpainted_until_marked() {
        // A modal pushed but not yet marked painted: registered (hit-testable)
        // but no corresponding draw call has happened yet.
        let mut s = ModalStack::new();
        s.push(id("palette"), rect(0.0, 0.0, 10.0, 10.0));
        assert_eq!(s.unpainted_ids(), vec![id("palette")]);

        s.mark_painted(&id("palette"));
        assert!(s.unpainted_ids().is_empty());
    }

    #[test]
    fn reset_frame_paint_clears_marks_for_the_next_frame() {
        // A modal painted last frame must be painted *again* this frame
        // to count — otherwise a backend that paints once and then stops
        // (e.g. a redraw path that silently drops the modal) would never
        // be flagged.
        let mut s = ModalStack::new();
        s.push(id("dialog"), rect(0.0, 0.0, 10.0, 10.0));
        s.mark_painted(&id("dialog"));
        assert!(s.unpainted_ids().is_empty());

        s.reset_frame_paint();
        assert_eq!(s.unpainted_ids(), vec![id("dialog")]);
    }

    #[test]
    fn mark_painted_on_unregistered_id_is_a_no_op() {
        // Backends may draw preview/ghost content by id outside the
        // modal stack; marking an id that was never pushed must not
        // panic or fabricate an entry.
        let mut s = ModalStack::new();
        s.mark_painted(&id("nothing-registered"));
        assert!(s.is_empty());
        assert!(s.unpainted_ids().is_empty());
    }

    #[test]
    fn only_the_unpainted_entry_is_reported_among_several() {
        let mut s = ModalStack::new();
        s.push(id("palette"), rect(0.0, 0.0, 10.0, 10.0));
        s.push(id("dialog"), rect(0.0, 0.0, 10.0, 10.0));
        s.mark_painted(&id("palette"));
        // "dialog" is registered (topmost, wins hit-testing) but never
        // painted — exactly the bug this exists to surface.
        assert_eq!(s.unpainted_ids(), vec![id("dialog")]);
        assert_eq!(s.hit_test(pt(5.0, 5.0)), Some(&id("dialog")));
    }

    // ─── Focus vs. non-focus floats ──────────────────────────────────

    #[test]
    fn push_defaults_to_focusable() {
        let mut s = ModalStack::new();
        s.push(id("dialog"), rect(0.0, 0.0, 10.0, 10.0));
        assert!(s.top().unwrap().focusable);
        assert_eq!(s.top_focusable().unwrap().id, id("dialog"));
    }

    #[test]
    fn top_focusable_skips_a_non_focusable_entry_on_top() {
        // A which-key hint popup (non-focusable) opens on top of an
        // already-open interactive float (focusable). Mouse hit-testing
        // still sees the hint first (it's visually topmost and must be
        // able to swallow a dismiss click), but keyboard focus must stay
        // with the interactive float underneath it.
        let mut s = ModalStack::new();
        s.push_focusable(id("menu-float"), rect(0.0, 0.0, 10.0, 10.0), true);
        s.push_focusable(id("hint-float"), rect(0.0, 0.0, 10.0, 10.0), false);

        assert_eq!(s.top().unwrap().id, id("hint-float"));
        assert_eq!(
            s.hit_test(pt(5.0, 5.0)),
            Some(&id("hint-float")),
            "hit-test still sees the topmost entry regardless of focusable"
        );
        assert_eq!(
            s.top_focusable().unwrap().id,
            id("menu-float"),
            "keyboard focus skips the non-focusable hint and lands on the \
             focusable float beneath it"
        );
    }

    #[test]
    fn top_focusable_is_none_when_every_open_entry_is_non_focusable() {
        let mut s = ModalStack::new();
        s.push_focusable(id("hint"), rect(0.0, 0.0, 10.0, 10.0), false);
        assert!(
            s.top_focusable().is_none(),
            "no focusable entry open — keyboard input should fall through \
             to the base layer"
        );
    }

    #[test]
    fn push_float_carries_over_the_floats_own_focusable_bit() {
        use crate::layout::{Anchor, Side};
        use crate::Float;

        let focusable = Float::new(
            id("menu-float"),
            Anchor::new(rect(0.0, 0.0, 5.0, 1.0), Side::Bottom),
        );
        let mut hint = Float::new(
            id("hint-float"),
            Anchor::new(rect(0.0, 0.0, 5.0, 1.0), Side::Bottom),
        );
        hint.focusable = false;

        let mut s = ModalStack::new();
        s.push_float(&focusable, rect(0.0, 0.0, 10.0, 10.0));
        s.push_float(&hint, rect(0.0, 0.0, 10.0, 10.0));

        assert_eq!(s.top().unwrap().id, id("hint-float"));
        assert_eq!(
            s.top_focusable().unwrap().id,
            id("menu-float"),
            "push_float must read focusable off the Float itself, not \
             default to true like push()"
        );
    }

    #[test]
    fn top_focusable_is_none_on_an_empty_stack() {
        let s = ModalStack::new();
        assert!(s.top_focusable().is_none());
    }

    #[test]
    fn re_pushing_an_existing_id_clears_its_painted_mark() {
        // Re-push (bump-to-top) is treated as a fresh open — an entry
        // that was painted before the bump shouldn't be considered
        // painted this frame just because its old self was.
        let mut s = ModalStack::new();
        s.push(id("a"), rect(0.0, 0.0, 10.0, 10.0));
        s.mark_painted(&id("a"));
        s.push(id("a"), rect(1.0, 1.0, 5.0, 5.0));
        assert_eq!(s.unpainted_ids(), vec![id("a")]);
    }
}