Skip to main content

teksilo_widgets/
menu_list.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MenuList — a themed vertical menu container with keyboard navigation.
5//!
6//! `MenuList` is the dropdown panel used by `MenuBar`, `MenuContext`, and
7//! popover-style menus. It provides a themed surface (background, rounded
8//! border, drop shadow) and owns the full keyboard navigation stack:
9//! ArrowUp/Down moves focus, Enter activates, Escape bubbles to the
10//! enclosing overlay host, Home and End jump to the first/last visible item.
11//! Type-ahead search jumps to the next item whose stripped label starts with
12//! the accumulated keystrokes (500 ms reset window by default).
13//!
14//! Items are added with `.item(widget)` (any `impl Widget`, but typically a
15//! `MenuItem`); separators with `.separator()`. Conditional rows use
16//! `.item_when(widget, visible_prop)` — a hidden row collapses to zero height
17//! and is skipped by keyboard navigation. For very long lists (recent files,
18//! etc.) call `.max_visible_items(n)` to cap the panel height and wrap the
19//! content in a `ScrollArea`.
20//!
21//! Two knobs shape the surface. `.inset(dp)` sets the gap between the menu
22//! surface and its rows (default 4 dp; the `max_visible_items` scroll cap
23//! follows it). `.surface_style(style)` gives this one list its own
24//! `PopoverStyle` — frame, fill, shadow — ahead of the theme's popover slot,
25//! which a `ComboBox` dropdown shares; other lists are unaffected.
26//!
27//! **Safe-triangle hover gate.** When the pointer leaves a submenu trigger's
28//! row with the submenu still up, the trigger arms the safe region (the apex
29//! and the cone live in `teksilo_core::overlay`) and publishes that submenu's
30//! id on a `MenuList`-wide shared state, so sibling items can skip their
31//! hover-switch while the cursor travels diagonally toward the submenu.
32//!
33//! ## Accessibility
34//!
35//! `Role::Menu`, named after what opened it (a `MenuBar` trigger, the
36//! `MenuItem::submenu` row, a `PopoverButton`'s button), with the keyboard
37//! highlight as its active descendant: the menu keeps keyboard focus, and a
38//! screen reader is told of each move of the highlight as a focus change to
39//! the row. Each row is `Role::MenuItem` / `Role::MenuItemCheckBox` /
40//! `Role::MenuItemRadio` as declared by the item, and carries its position
41//! among the menu's shown rows, whose count the menu carries. Radio items in
42//! the same list also auto-group via `push_to_radio_group`.
43//!
44//! ```rust
45//! # use teksilo_widgets::{MenuList, MenuItem};
46//! # use teksilo_i18n::lit;
47//! # use teksilo_core::Intent;
48//! let _w = MenuList::new()
49//!     .item(MenuItem::new(lit!("Cut")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.cut"))))
50//!     .item(MenuItem::new(lit!("Copy")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.copy"))))
51//!     .separator()
52//!     .item(MenuItem::new(lit!("Paste")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.paste"))));
53//! ```
54
55use std::cell::{Cell, RefCell};
56use std::collections::HashMap;
57use std::rc::Rc;
58use std::time::{Duration, Instant};
59
60use teksilo_canvas::{Rect, Size, SizeProposal};
61use teksilo_core::accessibility::AccessNodeBuilder;
62use teksilo_core::build_context::BuildContext;
63use teksilo_core::event::{EventResponse, Key, WidgetEvent};
64use teksilo_core::overlay::OverlayPlacement;
65use teksilo_core::signal::Signal;
66use teksilo_core::styles::{PopoverStyle, PopoverStyleConfig, PopoverVariant, SharedPopoverStyle};
67use teksilo_core::widget::{
68    EventContext, LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement,
69};
70use teksilo_core::widget_builder::HandlerSet;
71use teksilo_core::widget_id::WidgetId;
72use teksilo_tokens::SurfaceRole;
73
74use crate::primitives::{MaxSize, Padding, RectWidget, VStack, ZStack};
75use crate::scroll_area::ScrollArea;
76
77/// Default gap between the menu surface and its rows, in dp.
78const DEFAULT_INSET: f32 = 4.0;
79
80/// Marker for whether a pending entry is a menu item, a separator, or a header.
81enum MenuEntry {
82    /// A menu item with an optional reactive visibility gate. When the gate
83    /// is `Some(false)` the item's row collapses to zero height (no gap) and
84    /// is skipped by keyboard navigation — the conditionally-shown menu row.
85    Item {
86        pending: PendingChild,
87        visible: Option<teksilo_core::signal::Prop<bool>>,
88    },
89    Separator,
90    /// A non-interactive section caption (e.g. a `GroupHeader`). Excluded from
91    /// keyboard navigation and type-ahead exactly like `Separator` — it never
92    /// occupies a slot in `item_widget_ids`/`resolved_labels`, so no runtime
93    /// "skip if header" branch is needed anywhere. Still reachable by assistive
94    /// technology: the wrapped widget declares its own name/role (`GroupHeader`
95    /// sets `Role::Label` + the caption), which survives a11y-tree pruning as a
96    /// flat sibling under the menu, exactly like `MenuSeparator`'s `Role::Splitter`.
97    Header(PendingChild),
98}
99
100/// The active `MenuItemStyle`'s row metrics, from the theme slot.
101///
102/// A `MenuSeparator` and the scroll viewport are siblings of the rows, not
103/// rows themselves, so there is no per-call `.style(...)` to consult — the
104/// theme slot is the only style they can share with the items around them.
105fn menu_metrics(theme: &teksilo_core::Theme) -> teksilo_core::styles::MenuItemMetrics {
106    use teksilo_core::styles::MenuItemStyle;
107
108    theme
109        .style_slots
110        .menu_item
111        .as_ref()
112        .map(|s| s.metrics())
113        .unwrap_or_else(|| crate::styles::RecipeMenuItemStyle::for_tokens(&theme.input).metrics())
114}
115
116/// A 1 dp horizontal divider line between groups of menu items.
117#[derive(Debug)]
118pub struct MenuSeparator;
119
120impl Widget for MenuSeparator {
121    fn layout_response(
122        &self,
123        proposal: SizeProposal,
124        ctx: &LayoutContext,
125    ) -> teksilo_core::widget::LayoutResponse {
126        let width = proposal.width.unwrap_or(0.0);
127        Size::new(width, menu_metrics(ctx.theme).separator_height).into()
128    }
129
130    fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
131        // Int UI menu separator: a flush-edge 1 dp line in `divider` color,
132        // vertically centered in the `separator_height` slot (read from the
133        // theme's menu-item style, so it follows the preset) — the slot
134        // provides the breathing room above and below the line.
135        let color = ctx.theme.colors.divider;
136        let thickness = ctx.theme.shape.border_width;
137        let y = bounds.y + (bounds.height - thickness) * 0.5;
138        canvas.fill_rect(Rect::new(bounds.x, y, bounds.width, thickness), color);
139    }
140
141    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
142        builder.set_role(teksilo_core::accesskit::Role::Splitter);
143    }
144}
145
146// Note: MenuList's `max_visible_items` caps the panel height and wraps
147// the item column in a `ScrollArea`, but does **not** yet virtualize —
148// every item widget (plus separators) is still built eagerly. True
149// virtualization requires a model-backed MenuList API (item descriptor
150// → delegate builds the row) because today's surface accepts arbitrary
151// `impl Widget` children directly. Tracked as follow-up; eager build
152// is cheap enough that ScrollArea-capped panels of 100+ items are
153// already fine in practice.
154
155/// Wrapper that adds a keyboard-focus highlight behind a menu item.
156/// The highlight is driven by a shared `focused_index` signal — when
157/// `focused_index == Some(my_index)`, a subtle background appears.
158/// The binding registry automatically marks this widget for repaint
159/// when the signal changes (same mechanism as ComboBox DropdownItem).
160#[derive(Debug)]
161struct KeyboardHighlightWrapper {
162    item_id: WidgetId,
163    index: usize,
164    focused_index: Signal<Option<usize>>,
165    root_child_id: Option<WidgetId>,
166}
167
168impl Widget for KeyboardHighlightWrapper {
169    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
170        let index = self.index;
171
172        // Keyboard focus highlight uses the dedicated `surface_selected`
173        // token (not an alpha wash over `accent`) so it tracks theme
174        // changes and stays distinct from mouse hover (`surface_hover`).
175        // Role-based: no theme_signal zip; paint resolves the role.
176        let bg_role = self.focused_index.map(move |focused| {
177            if *focused == Some(index) {
178                SurfaceRole::Selected
179            } else {
180                SurfaceRole::Transparent
181            }
182        });
183
184        let bg = RectWidget::new().background(bg_role);
185        let bg_id = ctx.add(bg);
186
187        let zstack = ZStack::new().child(bg_id).child(self.item_id);
188        let root_id = ctx.add(zstack);
189        self.root_child_id = Some(root_id);
190
191        vec![root_id]
192    }
193
194    fn layout_response(
195        &self,
196        proposal: SizeProposal,
197        ctx: &LayoutContext,
198    ) -> teksilo_core::widget::LayoutResponse {
199        // Forward the proposal to the wrapped MenuItem directly rather than
200        // going through the internal ZStack. ZStack::size_that_fits always
201        // queries its children with `unspecified` (correct for most uses,
202        // since ZStack layers typically have independent natural sizes),
203        // which would strip the parent's width proposal. But for this
204        // wrapper the whole point is that the MenuItem fills the VStack's
205        // cross-axis width — bypass the ZStack in the sizing path so the
206        // width propagates to the MenuItem → HStack → spacer chain.
207        let item_size = ctx
208            .child_size(self.item_id, proposal)
209            .unwrap_or_else(|| proposal.resolve(0.0, 32.0));
210        // Respect the proposed width when offered, so VStack::place_children
211        // places this wrapper at the full popup width.
212        let width = proposal.width.unwrap_or(item_size.width);
213        Size::new(width, item_size.height).into()
214    }
215
216    fn place_children(
217        &self,
218        bounds: Rect,
219        _proposal: SizeProposal,
220        children: &mut [WidgetPlacement],
221        _ctx: &LayoutContext,
222    ) {
223        for child in children.iter_mut() {
224            child.origin = bounds.origin();
225            child.size = bounds.size();
226        }
227    }
228
229    fn children(&self) -> Vec<WidgetId> {
230        self.root_child_id.into_iter().collect()
231    }
232
233    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
234        // Presentational wrapper — the real semantics live on the
235        // wrapped MenuItem. Without this, the default node would
236        // insert an unannotated container between `Role::Menu` and
237        // `Role::MenuItem` in the a11y tree.
238        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
239    }
240}
241
242/// Scroll the row at `idx` into view after the keyboard highlight moved onto it.
243///
244/// Arrow / Home / End / type-ahead navigation moves `focused_index`, **not**
245/// real tree focus (which stays on the panel so the key handler keeps
246/// receiving keys; assistive technology follows the highlight through the
247/// panel's active descendant), so the framework's own focus-follow scroll
248/// never runs.
249/// Past `max_visible_items` the panel is a `ScrollArea`, and without this the
250/// highlight walks straight out of the viewport and the menu looks frozen.
251///
252/// The id-based reveal is the right one here: a menu row is a real, mounted,
253/// non-virtualized child, so the arena already knows its bounds. It is a no-op
254/// when the row is already visible or nothing above it scrolls.
255fn reveal(idx: usize, item_ids: &[WidgetId], ctx: &mut EventContext) {
256    if let Some(&id) = item_ids.get(idx) {
257        ctx.ensure_widget_visible(id);
258    }
259}
260
261/// Activate the row at `idx` from the keyboard: through the `MenuItem`'s own
262/// keyboard route when it is one (see [`KeyboardActivationSlot`]), and with a
263/// click on any other row, which has no such route.
264fn activate_row(
265    idx: usize,
266    item_ids: &[WidgetId],
267    activations: &[Option<KeyboardActivationSlot>],
268    ctx: &mut EventContext,
269) {
270    let keyboard = activations
271        .get(idx)
272        .and_then(|slot| slot.as_ref())
273        .and_then(|slot| slot.borrow().clone());
274    match keyboard {
275        Some(activate) => activate(ctx),
276        None => ctx.synthetic_click(item_ids[idx]),
277    }
278}
279
280/// A themed vertical dropdown menu panel with keyboard navigation and type-ahead.
281///
282/// See the module documentation for the full feature description.
283pub struct MenuList {
284    entries: Vec<MenuEntry>,
285    root_child_id: Option<WidgetId>,
286    /// Widget IDs of actual menu items (not separators), for keyboard navigation.
287    item_widget_ids: Vec<WidgetId>,
288    /// Per-item reactive visibility gate (parallel to `item_widget_ids`).
289    /// `None` → always visible; `Some(prop)` → the item is shown only while
290    /// the prop is `true`. Keyboard navigation skips items whose gate is
291    /// currently `false`.
292    item_visibility: Vec<Option<teksilo_core::signal::Prop<bool>>>,
293    /// Whether each item (by index into item_widget_ids) is a submenu trigger.
294    submenu_flags: Vec<bool>,
295    /// When set and the item count (counting items only — separators and
296    /// headers contribute nothing — against the row count, not pixels)
297    /// exceeds the limit, the content column is wrapped in a `ScrollArea`
298    /// and the panel height is capped to `n * item_height`. `None`
299    /// (default) lets the menu grow with its content.
300    max_visible_items: Option<usize>,
301    /// Side of the menu panel that is visually attached to its trigger
302    /// (e.g. a menu button or combo-box). When set, drop shadow
303    /// drawing is suppressed on that side so the menu reads as one
304    /// piece with the trigger. Set by the opener based on the chosen
305    /// placement; `None` leaves the full halo intact.
306    attached_side: Option<crate::shadow::AttachedSide>,
307    /// Type-ahead buffer reset window. After this much time since the
308    /// last typed character with no match-extension, the buffer is
309    /// cleared on the next keypress. Defaults to 500 ms (Windows
310    /// menubar convention).
311    type_ahead_timeout: Duration,
312    /// The keyboard highlight: the index into `item_widget_ids` of the row
313    /// arrows, Home / End, page keys and type-ahead last moved to. Read by
314    /// `accessibility()` to name that row as the menu's active descendant.
315    highlight: Signal<Option<usize>>,
316    /// The rows a reader counts, shared with every `MenuItem` in the list
317    /// so each can say where it stands ("3 of 7").
318    rows: SharedMenuRows,
319    /// What opened this menu, when the opener said so (a menu-bar trigger,
320    /// a submenu row, a popover's button). The menu is named after it.
321    opener: Option<WidgetId>,
322    /// Gap in dp between the menu surface and its rows, on every side.
323    inset: f32,
324    /// Per-list surface override; wins over the theme's popover slot.
325    surface_style: Option<SharedPopoverStyle>,
326}
327
328/// The rows of one [`MenuList`], as assistive technology counts them.
329///
330/// A separator and a header are not rows, and a row an `item_when` gate
331/// hides is not one while it is hidden: none of them is in the platform's
332/// tree. Shared with each `MenuItem` so the item can publish its own
333/// position, and read by the list for the set size, because AccessKit reads
334/// an item's set size from its container, never from the item
335/// (`accesskit_consumer` `node.rs:629-641`).
336#[derive(Debug, Default)]
337pub(crate) struct MenuRows {
338    ids: Vec<WidgetId>,
339    visible: Vec<Option<teksilo_core::signal::Prop<bool>>>,
340}
341
342impl MenuRows {
343    fn is_shown(&self, index: usize) -> bool {
344        self.visible
345            .get(index)
346            .and_then(|gate| gate.as_ref())
347            .is_none_or(|gate| gate.get())
348    }
349
350    /// How many rows are shown.
351    fn shown_count(&self) -> usize {
352        (0..self.ids.len()).filter(|&i| self.is_shown(i)).count()
353    }
354
355    /// The 1-based position of the row `id` among the shown rows, or `None`
356    /// when it is not a shown row of this menu.
357    pub(crate) fn position_of(&self, id: WidgetId) -> Option<usize> {
358        let index = self.ids.iter().position(|&row| row == id)?;
359        self.is_shown(index)
360            .then(|| (0..=index).filter(|&i| self.is_shown(i)).count())
361    }
362}
363
364/// Shared handle to a menu's [`MenuRows`].
365pub(crate) type SharedMenuRows = Rc<RefCell<MenuRows>>;
366
367/// How a `MenuItem` is activated from its menu's keyboard: filled in by the
368/// item when it builds, called by the enclosing [`MenuList`] on Enter, Space,
369/// the inline-forward arrow and a mnemonic.
370///
371/// The menu keeps focus while its rows are highlighted, so the item's own key
372/// handler never runs. Activating the row with a synthesised click instead
373/// made every submenu the keyboard opened a *mouse*-opened one, whose
374/// dismissal is the 150 ms pointer-leave grace: a mouse resting anywhere else
375/// closed it at once. This is the item's keyboard and assistive-technology
376/// route, the one `Action::Click` takes.
377pub(crate) type KeyboardActivationSlot = Rc<RefCell<Option<Rc<dyn Fn(&mut EventContext)>>>>;
378
379/// Per-MenuList shared state for the safe-triangle submenu hover gate:
380/// which submenu in this list is currently open, so a sibling row can
381/// ask the framework whether the pointer is inside *that* overlay's
382/// armed safe region before it fires `dismiss_child_overlays`.
383///
384/// The geometry — the apex, the cone, its budget — lives in
385/// `teksilo_core::overlay`, because the overlay's own pointer-leave
386/// grace has to honour the same region; the two would drift if the
387/// widget kept a private copy. All this side has to carry is the
388/// identity of the overlay to ask about.
389#[derive(Debug, Default)]
390pub(crate) struct SafeTriangleState {
391    /// The currently-open submenu's root content widget id, or `None`
392    /// when no submenu in this list is open. Published by the trigger
393    /// when the pointer leaves its row with the submenu up.
394    pub submenu_content_id: Option<WidgetId>,
395}
396
397/// Shared handle installed on every MenuItem that participates in
398/// safe-triangle gating. The same `Rc` is held by the MenuList and
399/// by each child MenuItem; updates flow both directions.
400pub(crate) type SharedSafeTriangleState = Rc<RefCell<SafeTriangleState>>;
401
402impl MenuList {
403    /// Create an empty menu list with no items, no height cap, and the default
404    /// 500 ms type-ahead reset window.
405    pub fn new() -> Self {
406        Self {
407            entries: Vec::new(),
408            root_child_id: None,
409            item_widget_ids: Vec::new(),
410            item_visibility: Vec::new(),
411            submenu_flags: Vec::new(),
412            max_visible_items: None,
413            attached_side: None,
414            type_ahead_timeout: Duration::from_millis(500),
415            highlight: Signal::new(None),
416            rows: SharedMenuRows::default(),
417            opener: None,
418            inset: DEFAULT_INSET,
419            surface_style: None,
420        }
421    }
422
423    /// Name this menu after the widget that opened it: a menu-bar trigger,
424    /// the submenu row it hangs from, the button of the popover it sits in.
425    /// A reader then hears "File menu" as focus enters it, where it heard
426    /// "menu" and nothing else. Set by the opener before the menu is built.
427    pub(crate) fn set_opener(&mut self, opener: WidgetId) {
428        self.opener = Some(opener);
429    }
430
431    /// Set the gap, in dp, between the menu surface and its rows, on every
432    /// side. Defaults to 4 dp. A smaller value lets the rows (and their
433    /// hover / highlight bands) reach nearer the surface's frame, which
434    /// suits a thin or accent-coloured border. Negative values are treated
435    /// as zero. The scroll cap of [`max_visible_items`](Self::max_visible_items)
436    /// follows the same value.
437    pub fn inset(mut self, inset: f32) -> Self {
438        self.inset = inset.max(0.0);
439        self
440    }
441
442    /// Give this list its own surface: background, border, corner radius
443    /// and shadow come from `style` instead of the theme's popover slot.
444    ///
445    /// Precedence, highest first: this per-list style, the theme's
446    /// `style_slots.popover`, the built-in `RecipePopoverStyle`. Only this
447    /// list is affected; a `ComboBox` dropdown or another menu keeps the
448    /// theme's. The style is asked for `PopoverVariant::Menu`.
449    pub fn surface_style(mut self, style: impl PopoverStyle) -> Self {
450        self.surface_style = Some(Rc::new(style));
451        self
452    }
453
454    /// Override the type-ahead buffer reset window. Defaults to 500ms
455    /// to match Windows' menubar convention. Tests use
456    /// `Duration::ZERO` to force every keypress to start a fresh
457    /// search.
458    pub fn type_ahead_timeout(mut self, d: Duration) -> Self {
459        self.type_ahead_timeout = d;
460        self
461    }
462
463    /// Suppress drop-shadow drawing on the side that visually merges
464    /// with the menu's trigger. See [`crate::shadow::AttachedSide`]
465    /// for the available edges.
466    pub fn attached_side(mut self, side: crate::shadow::AttachedSide) -> Self {
467        self.attached_side = Some(side);
468        self
469    }
470
471    /// Add a menu item (typically a `MenuItem`).
472    pub fn item(mut self, widget: impl Widget + 'static) -> Self {
473        // Probe through the `as_any` hook rather than downcasting the generic
474        // directly: a `MenuItem` carrying any builder method (`.context_menu`,
475        // `.focusable`, …) arrives here as `WidgetWithHandlers<MenuItem>`, which
476        // a concrete-type downcast misses while `as_any` forwards through it.
477        // This is the probe [`item_boxed_when`](Self::item_boxed_when) already
478        // uses; the two disagreeing is what let a decorated submenu trigger lose
479        // its inline-forward arrow.
480        let is_submenu = widget
481            .as_any()
482            .and_then(|a| a.downcast_ref::<crate::menu_item::MenuItem>())
483            .is_some_and(|mi| mi.is_submenu());
484        self.submenu_flags.push(is_submenu);
485        self.entries.push(MenuEntry::Item {
486            pending: teksilo_core::IntoTeksiChild::into_pending(widget),
487            visible: None,
488        });
489        self
490    }
491
492    /// Add several menu items from an iterator, in order.
493    ///
494    /// The loop form of [`item`](Self::item): reach for it when the rows come
495    /// from data rather than being written out one call at a time.
496    pub fn items(self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
497        iter.into_iter().fold(self, Self::item)
498    }
499
500    /// Add a menu item that is shown only while `visible` is `true`. When the
501    /// gate is `false` the row collapses to zero height (no gap) and keyboard
502    /// navigation skips it — arrows, `Home`/`End`, `Enter`, type-ahead, and
503    /// mnemonic activation all ignore it. Used e.g. by a `Toolbar`'s overflow
504    /// menu, where each row is present only while its inline twin is collapsed.
505    ///
506    /// Because a hidden row never claims its mnemonic letter, two gated rows
507    /// that are mutually exclusive may share one — the letter resolves to
508    /// whichever is visible when it is pressed.
509    pub fn item_when(
510        self,
511        widget: impl Widget + 'static,
512        visible: impl Into<teksilo_core::signal::Prop<bool>>,
513    ) -> Self {
514        self.item_boxed_when(Box::new(widget), visible)
515    }
516
517    /// Add several gated rows from an iterator of `(widget, visible)` pairs.
518    ///
519    /// The loop form of [`item_when`](Self::item_when), for a gated row set
520    /// built from data. Each pair carries its own gate, so the rows appear and
521    /// disappear independently.
522    pub fn items_when<W, V>(self, iter: impl IntoIterator<Item = (W, V)>) -> Self
523    where
524        W: Widget + 'static,
525        V: Into<teksilo_core::signal::Prop<bool>>,
526    {
527        iter.into_iter().fold(self, |list, (widget, visible)| {
528            list.item_when(widget, visible)
529        })
530    }
531
532    /// [`item_when`](Self::item_when) for an already-boxed widget — used when
533    /// the row type is decided at runtime (e.g. a menu row that is either a
534    /// `MenuItem` or an embedded control).
535    pub fn item_boxed_when(
536        mut self,
537        widget: Box<dyn Widget>,
538        visible: impl Into<teksilo_core::signal::Prop<bool>>,
539    ) -> Self {
540        let is_submenu = widget
541            .as_any()
542            .and_then(|a| a.downcast_ref::<crate::menu_item::MenuItem>())
543            .is_some_and(|mi| mi.is_submenu());
544        self.submenu_flags.push(is_submenu);
545        self.entries.push(MenuEntry::Item {
546            pending: PendingChild::Deferred(widget),
547            visible: Some(visible.into()),
548        });
549        self
550    }
551
552    /// Add several gated rows from an iterator of `(widget, visible)` pairs.
553    ///
554    /// The loop form of [`item_boxed_when`](Self::item_boxed_when), for a gated
555    /// row set built from data. Each pair carries its own gate, so the rows may
556    /// appear and disappear independently.
557    pub fn items_boxed_when<V>(self, iter: impl IntoIterator<Item = (Box<dyn Widget>, V)>) -> Self
558    where
559        V: Into<teksilo_core::signal::Prop<bool>>,
560    {
561        iter.into_iter().fold(self, |list, (widget, visible)| {
562            list.item_boxed_when(widget, visible)
563        })
564    }
565
566    /// Add a separator line.
567    pub fn separator(mut self) -> Self {
568        self.entries.push(MenuEntry::Separator);
569        self
570    }
571
572    /// Add a non-interactive section caption (typically a [`crate::GroupHeader`]).
573    /// Skipped by Arrow/Home/End navigation and type-ahead, exactly like
574    /// [`separator`](Self::separator). The caller passes any `impl Widget`, but it
575    /// must expose its own accessible name/role via `accessibility()` (as
576    /// `GroupHeader` does) or it is silently pruned from the AT tree as a
577    /// content-free container.
578    pub fn header(mut self, widget: impl Widget + 'static) -> Self {
579        self.entries.push(MenuEntry::Header(
580            teksilo_core::IntoTeksiChild::into_pending(widget),
581        ));
582        self
583    }
584
585    /// Derive the `OverlayPlacement` the `PopoverStyle` needs from the
586    /// caller-supplied `attached_side`. `PopoverSurface` re-resolves the
587    /// concrete suppressed shadow edge from this placement plus the live
588    /// layout direction, so the menu reads as one piece with its trigger.
589    fn derived_placement(&self) -> OverlayPlacement {
590        match self.attached_side {
591            Some(crate::shadow::AttachedSide::Top) => OverlayPlacement::Below,
592            Some(crate::shadow::AttachedSide::Bottom) => OverlayPlacement::Above,
593            // A trigger on the leading edge → menu opens trailing.
594            // `Right` (trigger on the trailing edge, menu opens leading)
595            // has no dedicated placement; fall back to the full halo.
596            Some(crate::shadow::AttachedSide::Left) => OverlayPlacement::TrailingEdge,
597            Some(crate::shadow::AttachedSide::Right) | None => OverlayPlacement::Centered,
598        }
599    }
600
601    /// Cap the panel height to roughly `n * item_height` and make the
602    /// content scrollable when that height is exceeded. Clamped to at
603    /// least 1. Useful for long menus (e.g. a "Recent files" list) —
604    /// without this, a very long menu grows to exceed the window.
605    ///
606    /// Note: items are still materialized eagerly; this is a viewport
607    /// cap, not virtualization. See the module-level note.
608    pub fn max_visible_items(mut self, n: usize) -> Self {
609        self.max_visible_items = Some(n.max(1));
610        self
611    }
612}
613
614impl Default for MenuList {
615    fn default() -> Self {
616        Self::new()
617    }
618}
619
620impl std::fmt::Debug for MenuList {
621    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
622        f.debug_struct("MenuList")
623            .field("entries", &self.entries.len())
624            .finish()
625    }
626}
627
628impl Widget for MenuList {
629    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
630        let _theme_signal = ctx.theme_signal();
631
632        // Keyboard-focused item index (shared with the key handler and wrappers).
633        // The binding registry propagates repaints when this changes, and
634        // re-walks this node's accessibility, which names the highlighted row
635        // as the menu's active descendant.
636        let focused_index: Signal<Option<usize>> = ctx.signal(None);
637        focused_index.bind_to(
638            ctx.self_id(),
639            ctx.binding_registry(),
640            teksilo_core::binding::BindingLevel::AccessibilityOnly,
641        );
642        self.highlight = focused_index.clone();
643        // The rows as a reader counts them, filled in once every row has an id.
644        let rows: SharedMenuRows = Rc::new(RefCell::new(MenuRows::default()));
645        self.rows = rows.clone();
646        // Each `MenuItem`'s keyboard activation, parallel to `item_widget_ids`;
647        // `None` for a row that is not a `MenuItem`, which is clicked instead.
648        let mut activations: Vec<Option<KeyboardActivationSlot>> = Vec::new();
649
650        // Build all entries into a VStack, wrapping items in highlight wrappers
651        let mut vstack = VStack::new();
652        self.item_widget_ids.clear();
653        self.item_visibility.clear();
654        let mut item_counter = 0_usize;
655
656        // Radio-group buffers keyed by `Signal<usize>` identity. Linear
657        // search is fine — a single menu rarely carries more than a
658        // handful of radio groups. The same shared `Rc<RefCell<Vec<…>>>`
659        // is installed on every member via
660        // [`MenuItem::set_radio_group_ids`](crate::menu_item::MenuItem::set_radio_group_ids)
661        // BEFORE the items reach the arena; the buffer's contents are
662        // filled in below as each member id is allocated. By the time
663        // the AT walker reads `MenuItem::accessibility`, all sibling
664        // ids are in place.
665        let mut radio_buffers: Vec<(Signal<usize>, Rc<RefCell<Vec<WidgetId>>>)> = Vec::new();
666        // Tracks which radio buffer (if any) each newly-added item
667        // belongs to, so we can push the item's id once known.
668        let mut pending_radio_pushes: Vec<(usize, Rc<RefCell<Vec<WidgetId>>>)> = Vec::new();
669
670        // Keyboard-navigation caches:
671        // * `resolved_labels[i]` is the ASCII-lowercased stripped
672        //   label of the item at item-array position `i`. Used by
673        //   the type-ahead branch in the keyboard handler.
674        // * `mnemonic_table[c]` maps a lowercase mnemonic char to every
675        //   item-array position claiming it, in declaration order. Used
676        //   by the in-menu mnemonic branch ("press the underlined letter
677        //   to activate"), which picks the first claimant that is
678        //   currently visible — so two `item_when`-gated rows that are
679        //   mutually exclusive may share one letter.
680        // * `unconditional[i]` is `true` when the item at `i` has no
681        //   visibility gate. Two unconditional rows sharing a mnemonic
682        //   can never disambiguate, which is the one statically-decidable
683        //   authoring bug — see the `debug_assert` below.
684        // All three are sized to `item_widget_ids.len()`; separators
685        // contribute nothing.
686        let mut resolved_labels: Vec<String> = Vec::new();
687        let mut unconditional: Vec<bool> = Vec::new();
688        let mut mnemonic_table: HashMap<char, Vec<usize>> = HashMap::new();
689
690        // Safe-triangle shared state. Installed on every MenuItem in
691        // this list so a submenu trigger can stamp the anchor and
692        // sibling items can read it from their hover gate.
693        let safe_triangle: SharedSafeTriangleState =
694            Rc::new(RefCell::new(SafeTriangleState::default()));
695
696        for entry in self.entries.drain(..) {
697            match entry {
698                MenuEntry::Item { pending, visible } => {
699                    let (item_id, radio_buf, item_label, item_mnemonic, activation) = match pending
700                    {
701                        PendingChild::Id(id) => (id, None, None, None, None),
702                        PendingChild::Deferred(mut w) => {
703                            // Single downcast pass: read the radio
704                            // selection signal AND the parsed mnemonic
705                            // AND install the safe-triangle shared
706                            // state, the shared rows and the keyboard
707                            // activation slot, before moving the box
708                            // into the arena.
709                            let (radio_buf, item_label, item_mnemonic, activation) = w
710                                .as_any_mut()
711                                .and_then(|a| a.downcast_mut::<crate::menu_item::MenuItem>())
712                                .map(|mi| {
713                                    // Ensure the label has been parsed
714                                    // for `&`-markers BEFORE the item
715                                    // builds — so `mnemonic()` returns
716                                    // a value even pre-build.
717                                    mi.ensure_mnemonic_parsed();
718                                    let label =
719                                        mi.mnemonic().map(|p| p.stripped.to_ascii_lowercase());
720                                    let mnemonic = mi.mnemonic().and_then(|p| p.key_lower);
721                                    let radio = mi.radio_selection_handle().map(|(_, sig)| {
722                                        let buf = if let Some((_, b)) = radio_buffers
723                                            .iter()
724                                            .find(|(s, _)| Signal::same(s, &sig))
725                                        {
726                                            b.clone()
727                                        } else {
728                                            let b = Rc::new(RefCell::new(Vec::new()));
729                                            radio_buffers.push((sig.clone(), b.clone()));
730                                            b
731                                        };
732                                        mi.set_radio_group_ids(buf.clone());
733                                        buf
734                                    });
735                                    mi.set_safe_triangle_state(safe_triangle.clone());
736                                    mi.set_menu_rows(rows.clone());
737                                    let activation = KeyboardActivationSlot::default();
738                                    mi.set_keyboard_activation_slot(activation.clone());
739                                    (radio, label, mnemonic, Some(activation))
740                                })
741                                .unwrap_or((None, None, None, None));
742                            (
743                                ctx.add_boxed(w),
744                                radio_buf,
745                                item_label,
746                                item_mnemonic,
747                                activation,
748                            )
749                        }
750                    };
751                    self.item_widget_ids.push(item_id);
752                    self.item_visibility.push(visible.clone());
753                    activations.push(activation);
754                    let item_idx = self.item_widget_ids.len() - 1;
755                    if let Some(buf) = radio_buf {
756                        pending_radio_pushes.push((item_idx, buf));
757                    }
758                    resolved_labels.push(item_label.unwrap_or_default());
759                    unconditional.push(visible.is_none());
760                    if let Some(c) = item_mnemonic {
761                        let claims = mnemonic_table.entry(c).or_default();
762                        // A collision only *has* to be a bug when both
763                        // rows are always on screen. Gated rows are
764                        // typically mutually exclusive (`item_when`), and
765                        // dispatch resolves those to whichever is visible
766                        // at the time — so don't cry wolf on them.
767                        debug_assert!(
768                            !unconditional[item_idx] || !claims.iter().any(|&p| unconditional[p]),
769                            "MenuList: duplicate item mnemonic {c:?} — item {item_idx} and an \
770                             earlier item among {claims:?} are both unconditionally visible, \
771                             so the letter is ambiguous"
772                        );
773                        claims.push(item_idx);
774                    }
775
776                    // Wrap in a highlight container driven by focused_index.
777                    // A per-item visibility gate is applied to the WRAPPER (not
778                    // the inner item) so a hidden row collapses to zero height
779                    // — no empty gap — while keeping `item_widget_ids` pointing
780                    // at the real item, the one keyboard activation reaches and
781                    // the active descendant names.
782                    let wrapper_id = ctx.add(KeyboardHighlightWrapper {
783                        item_id,
784                        index: item_counter,
785                        focused_index: focused_index.clone(),
786                        root_child_id: None,
787                    });
788                    if let Some(vis) = visible {
789                        ctx.visible_when(wrapper_id, vis);
790                    }
791                    vstack = vstack.child(wrapper_id);
792                    item_counter += 1;
793                }
794                MenuEntry::Separator => {
795                    vstack = vstack.child(MenuSeparator);
796                }
797                MenuEntry::Header(pending) => {
798                    // Rendered as a plain child — never pushed into
799                    // `item_widget_ids`/`resolved_labels`/`item_counter`, so it is
800                    // structurally excluded from keyboard nav + type-ahead (same
801                    // mechanism as `Separator`). Its own `accessibility()` carries
802                    // the section name for screen readers.
803                    let header_id = match pending {
804                        PendingChild::Id(id) => id,
805                        PendingChild::Deferred(w) => ctx.add_boxed(w),
806                    };
807                    vstack = vstack.child(header_id);
808                }
809            }
810        }
811
812        // Fill each radio group's id list now that every item has a
813        // WidgetId. Each id is pushed exactly once.
814        for (item_idx, buf) in pending_radio_pushes {
815            buf.borrow_mut().push(self.item_widget_ids[item_idx]);
816        }
817        *rows.borrow_mut() = MenuRows {
818            ids: self.item_widget_ids.clone(),
819            visible: self.item_visibility.clone(),
820        };
821
822        let vstack_id = ctx.add(vstack);
823
824        let padding = Padding::uniform(self.inset).child(vstack_id);
825        let padding_id = ctx.add(padding);
826
827        // Viewport cap. When `max_visible_items` is set and the real
828        // item count exceeds it, wrap the padded column in a
829        // `ScrollArea` + `MaxSize` pair sized to `cap * item_height`
830        // + the inset on each edge. Separators don't
831        // count against the cap — they're visually small and no real
832        // menu stacks enough of them for the slight under-shoot to
833        // matter.
834        let visible_cap_id = match self.max_visible_items {
835            Some(cap) if self.item_widget_ids.len() > cap => {
836                let max_height =
837                    cap as f32 * menu_metrics(ctx.theme()).item_height + 2.0 * self.inset;
838                // Cap the HEIGHT only. `preferred_size(0.0, ..)` would set the preferred
839                // *width* to zero — and a popover proposes an unconstrained width (it
840                // hugs its content), so the zero was taken literally: the menu collapsed
841                // to its minimum width and every row was clipped to a middle slice.
842                let scrollable = ScrollArea::from_id(padding_id).preferred_height(max_height);
843                let scrollable_id = ctx.add(scrollable);
844                ctx.add(MaxSize::height(max_height).child(scrollable_id))
845            }
846            _ => padding_id,
847        };
848
849        // Themed surface — routed through `PopoverStyle` (the
850        // `Menu`-flavoured variant), so the menu panel's background,
851        // border, corner radius, and drop shadow are all owned by the
852        // active popover style instead of a hand-rolled bg `RectWidget`
853        // + `MenuList::paint`. The full-halo vs trigger-attached
854        // shadow choice is derived from `attached_side`.
855        let surface_style: SharedPopoverStyle = self
856            .surface_style
857            .clone()
858            .or_else(|| ctx.theme().style_slots.popover.clone())
859            .unwrap_or_else(|| {
860                Rc::new(crate::styles::RecipePopoverStyle::for_tokens(
861                    &ctx.theme().input,
862                ))
863            });
864        let surface_cfg = PopoverStyleConfig {
865            content: visible_cap_id,
866            variant: PopoverVariant::Menu,
867            name: String::new(),
868            placement: self.derived_placement(),
869            show_caret: false,
870            caret_size: 0.0,
871        };
872        let root_id = surface_style.make_body(&surface_cfg, ctx);
873
874        self.root_child_id = Some(root_id);
875
876        // Keyboard navigation handler
877        let item_count = self.item_widget_ids.len();
878        let item_ids = self.item_widget_ids.clone();
879        let sub_flags = self.submenu_flags.clone();
880        // Type-ahead state. Shared across keypresses via `Rc` so the
881        // `Fn` closure can mutate the buffer without taking `&mut self`.
882        let type_ahead_buffer: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
883        let type_ahead_last_input: Rc<Cell<Option<Instant>>> = Rc::new(Cell::new(None));
884        let type_ahead_timeout = self.type_ahead_timeout;
885        let resolved_labels = Rc::new(resolved_labels);
886        let mnemonic_table = Rc::new(mnemonic_table);
887        // Per-item visibility gates, so navigation skips collapsed rows.
888        let visibilities = Rc::new(self.item_visibility.clone());
889        let activations = Rc::new(activations);
890        let handler_set = HandlerSet::new()
891            .on_key(
892                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
893                    let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
894                        return EventResponse::Ignored;
895                    };
896                    // Inline-forward (open submenu) vs inline-back arrows
897                    // mirror under RTL: forward is ArrowRight in LTR /
898                    // ArrowLeft in RTL; back is the opposite.
899                    let open_submenu_key = if ctx.is_rtl() {
900                        Key::ArrowLeft
901                    } else {
902                        Key::ArrowRight
903                    };
904                    let back_key = if ctx.is_rtl() {
905                        Key::ArrowRight
906                    } else {
907                        Key::ArrowLeft
908                    };
909                    // Currently-visible item indices, in order. Hidden
910                    // (collapsed) rows are skipped by arrow / Home / End nav.
911                    let visible_indices: Vec<usize> = (0..item_count)
912                        .filter(|&i| {
913                            visibilities
914                                .get(i)
915                                .and_then(|o| o.as_ref())
916                                .map(|p| p.get())
917                                .unwrap_or(true)
918                        })
919                        .collect();
920                    match key {
921                        Key::ArrowDown => {
922                            if visible_indices.is_empty() {
923                                return EventResponse::Ignored;
924                            }
925                            let pos = focused_index
926                                .get()
927                                .and_then(|c| visible_indices.iter().position(|&x| x == c));
928                            let next = match pos {
929                                Some(p) => visible_indices[(p + 1) % visible_indices.len()],
930                                None => visible_indices[0],
931                            };
932                            focused_index.set(Some(next));
933                            ctx.show_highlight_tooltip(item_ids[next]);
934                            reveal(next, &item_ids, ctx);
935                            EventResponse::Handled
936                        }
937                        Key::ArrowUp => {
938                            if visible_indices.is_empty() {
939                                return EventResponse::Ignored;
940                            }
941                            let n = visible_indices.len();
942                            let pos = focused_index
943                                .get()
944                                .and_then(|c| visible_indices.iter().position(|&x| x == c));
945                            let next = match pos {
946                                Some(p) => visible_indices[(p + n - 1) % n],
947                                None => visible_indices[n - 1],
948                            };
949                            focused_index.set(Some(next));
950                            ctx.show_highlight_tooltip(item_ids[next]);
951                            reveal(next, &item_ids, ctx);
952                            EventResponse::Handled
953                        }
954                        Key::Home => {
955                            let Some(&first) = visible_indices.first() else {
956                                return EventResponse::Ignored;
957                            };
958                            focused_index.set(Some(first));
959                            ctx.show_highlight_tooltip(item_ids[first]);
960                            reveal(first, &item_ids, ctx);
961                            EventResponse::Handled
962                        }
963                        Key::End => {
964                            let Some(&last) = visible_indices.last() else {
965                                return EventResponse::Ignored;
966                            };
967                            focused_index.set(Some(last));
968                            ctx.show_highlight_tooltip(item_ids[last]);
969                            reveal(last, &item_ids, ctx);
970                            EventResponse::Handled
971                        }
972                        // A long menu (a language list, a recent-files list)
973                        // is the only place these earn their keep, and they
974                        // were the one list chord `MenuList` lacked. A page is
975                        // ten visible rows — menus have no viewport of their
976                        // own to measure, and ten is the step every menu
977                        // implementation that has one uses.
978                        Key::PageUp | Key::PageDown => {
979                            const MENU_PAGE: usize = 10;
980                            let n = visible_indices.len();
981                            if n == 0 {
982                                return EventResponse::Ignored;
983                            }
984                            let pos = focused_index
985                                .get()
986                                .and_then(|f| visible_indices.iter().position(|&v| v == f));
987                            let next_pos = match (*key == Key::PageDown, pos) {
988                                (true, Some(p)) => (p + MENU_PAGE).min(n - 1),
989                                (true, None) => 0,
990                                (false, Some(p)) => p.saturating_sub(MENU_PAGE),
991                                (false, None) => n - 1,
992                            };
993                            let next = visible_indices[next_pos];
994                            focused_index.set(Some(next));
995                            ctx.show_highlight_tooltip(item_ids[next]);
996                            reveal(next, &item_ids, ctx);
997                            EventResponse::Handled
998                        }
999                        Key::Enter | Key::Space => {
1000                            // Activate the focused item (see `activate_row`),
1001                            // but only if it is currently visible.
1002                            if let Some(idx) = focused_index.get()
1003                                && visible_indices.contains(&idx)
1004                                && idx < item_ids.len()
1005                            {
1006                                activate_row(idx, &item_ids, &activations, ctx);
1007                                return EventResponse::Handled;
1008                            }
1009                            EventResponse::Ignored
1010                        }
1011                        k if *k == open_submenu_key => {
1012                            // Inline-forward arrow: only opens submenus; for
1013                            // non-submenu items let it bubble to
1014                            // MenuOverlayHost, which navigates to the next bar
1015                            // menu. RTL-flipped via `open_submenu_key`.
1016                            if let Some(idx) = focused_index.get()
1017                                && idx < sub_flags.len()
1018                                && sub_flags[idx]
1019                            {
1020                                activate_row(idx, &item_ids, &activations, ctx);
1021                                return EventResponse::Handled;
1022                            }
1023                            EventResponse::Ignored
1024                        }
1025                        k if *k == back_key => {
1026                            // Inline-back arrow: bubble to MenuOverlayHost (bar
1027                            // navigation) or the tree-level back/overlay
1028                            // dismissal. RTL-flipped via `back_key`.
1029                            EventResponse::Ignored
1030                        }
1031                        Key::Escape => {
1032                            // Bubble to the tree-level Escape overlay dismissal.
1033                            EventResponse::Ignored
1034                        }
1035                        _ => {
1036                            // Letter handling: in-menu mnemonic
1037                            // activation (bare letter) wins over
1038                            // type-ahead, which wins over ignored.
1039                            // We accept Shift here because Windows /
1040                            // GNOME convention activates the
1041                            // mnemonic regardless of Shift state
1042                            // (otherwise Shift-Lock users couldn't
1043                            // mnemonic-activate items at all). Ctrl
1044                            // / Alt / Cmd chords fall through to the
1045                            // global Shortcut/Action pipeline —
1046                            // except `AltGr`, which is how a non-US
1047                            // layout types a character rather than an
1048                            // accelerator. See
1049                            // `range_nav::is_text_entry_chord`.
1050                            if !crate::common::range_nav::is_text_entry_chord(*modifiers) {
1051                                return EventResponse::Ignored;
1052                            }
1053                            let ch = match key {
1054                                Key::Character(c) => Some(c.to_ascii_lowercase()),
1055                                k => k.to_char().map(|c| c.to_ascii_lowercase()),
1056                            };
1057                            let Some(ch) = ch else {
1058                                return EventResponse::Ignored;
1059                            };
1060                            if item_count == 0 {
1061                                return EventResponse::Ignored;
1062                            }
1063
1064                            // 1) Mnemonic match — explicit accelerator,
1065                            //    activates the item. A hidden
1066                            //    (`item_when`-gated) row never claims its
1067                            //    letter, matching the visibility gate the
1068                            //    arrow / Enter branches apply; the first
1069                            //    currently-visible claimant wins. Falls
1070                            //    through to type-ahead when every claimant
1071                            //    is hidden.
1072                            if let Some(idx) = mnemonic_table.get(&ch).and_then(|claims| {
1073                                claims.iter().copied().find(|i| visible_indices.contains(i))
1074                            }) {
1075                                activate_row(idx, &item_ids, &activations, ctx);
1076                                return EventResponse::Handled;
1077                            }
1078
1079                            // 2) Type-ahead — incremental prefix match
1080                            //    against the resolved labels of the
1081                            //    currently-visible rows.
1082                            let now = Instant::now();
1083                            let mut buf = type_ahead_buffer.borrow_mut();
1084                            if let Some(prev) = type_ahead_last_input.get() {
1085                                if now.duration_since(prev) > type_ahead_timeout {
1086                                    buf.clear();
1087                                }
1088                            }
1089                            buf.push(ch);
1090                            type_ahead_last_input.set(Some(now));
1091
1092                            if visible_indices.is_empty() {
1093                                return EventResponse::Ignored;
1094                            }
1095                            let n = visible_indices.len();
1096                            // Position of the focused row *within the
1097                            // visible run*; an unfocused (or hidden-row)
1098                            // focus anchors at the first visible row.
1099                            let start = focused_index
1100                                .get()
1101                                .and_then(|c| visible_indices.iter().position(|&x| x == c))
1102                                .unwrap_or(0);
1103                            // Search wrapping from start+1 through start
1104                            // itself, so a single repeated letter cycles
1105                            // through matching items.
1106                            for offset in 1..=n {
1107                                let i = visible_indices[(start + offset) % n];
1108                                if let Some(label) = resolved_labels.get(i)
1109                                    && label.starts_with(buf.as_str())
1110                                {
1111                                    focused_index.set(Some(i));
1112                                    ctx.show_highlight_tooltip(item_ids[i]);
1113                                    reveal(i, &item_ids, ctx);
1114                                    return EventResponse::Handled;
1115                                }
1116                            }
1117                            EventResponse::Ignored
1118                        }
1119                    }
1120                },
1121            )
1122            .focusable(true);
1123
1124        ctx.apply_self_handlers(handler_set);
1125
1126        vec![root_id]
1127    }
1128
1129    fn layout_response(
1130        &self,
1131        proposal: SizeProposal,
1132        ctx: &LayoutContext,
1133    ) -> teksilo_core::widget::LayoutResponse {
1134        match self.root_child_id {
1135            Some(id) => {
1136                // Menu lists size to their content, with a minimum width
1137                let child_size = ctx
1138                    .child_size(id, proposal)
1139                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1140                Size::new(child_size.width.max(120.0), child_size.height)
1141            }
1142            None => proposal.resolve(120.0, 0.0),
1143        }
1144        .into()
1145    }
1146
1147    fn place_children(
1148        &self,
1149        bounds: Rect,
1150        _proposal: SizeProposal,
1151        children: &mut [WidgetPlacement],
1152        _ctx: &LayoutContext,
1153    ) {
1154        for child in children.iter_mut() {
1155            child.origin = bounds.origin();
1156            child.size = bounds.size();
1157        }
1158    }
1159
1160    // No `paint()`: the menu panel's surface (background, border,
1161    // corner radius) and drop shadow are owned by the `PopoverStyle`
1162    // wrapper resolved in `build()`.
1163
1164    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1165        use teksilo_core::accessibility::widget_id_to_node_id;
1166
1167        builder.set_role(teksilo_core::accesskit::Role::Menu);
1168        // "File menu", not "menu": named after what opened it, through the
1169        // opener's own name, so the two cannot disagree.
1170        if let Some(opener) = self.opener {
1171            builder.push_labelled_by(widget_id_to_node_id(opener));
1172        }
1173        let rows = self.rows.borrow();
1174        builder.set_size_of_set(rows.shown_count());
1175
1176        // The highlighted row, as the menu's active descendant.
1177        //
1178        // Focus stays on this panel while the arrows, Home / End, the page
1179        // keys and type-ahead move a highlight through its rows, so a reader
1180        // is told about the highlight only if it becomes a focus change.
1181        // `accesskit_consumer` resolves the platform's focus as
1182        // `focused.active_descendant().unwrap_or(focused)` (`tree.rs:537-543`)
1183        // and hands that node to all three adapters: AT-SPI raises
1184        // `state-changed:focused` on it (`accesskit_atspi_common`
1185        // `adapter.rs:324-340`), which Orca speaks as its new locus of focus;
1186        // UIA raises its focus-changed event (`accesskit_windows`
1187        // `adapter.rs:341-345`); macOS `FocusedUIElementChanged`
1188        // (`accesskit_macos` `event.rs:319-326`). Without it every move was
1189        // silent: Orca said "menu." as the menu opened and nothing after, and
1190        // Enter ran a command the reader had never heard. It is `ListView`'s
1191        // current row again (see `list_view/widget_impl.rs`). A highlight on a
1192        // row that is hidden names nothing, and the menu stays the focus.
1193        if let Some(index) = self.highlight.get()
1194            && rows.is_shown(index)
1195            && let Some(&row) = rows.ids.get(index)
1196        {
1197            builder.set_active_descendant(widget_id_to_node_id(row));
1198        }
1199    }
1200
1201    fn children(&self) -> Vec<WidgetId> {
1202        match self.root_child_id {
1203            Some(id) => vec![id],
1204            None => Vec::new(),
1205        }
1206    }
1207
1208    /// Opt into reflection so an opener holding the menu as a boxed widget
1209    /// can name the menu after itself.
1210    fn as_any(&self) -> Option<&dyn std::any::Any> {
1211        Some(self)
1212    }
1213
1214    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1215        Some(self)
1216    }
1217}
1218
1219#[cfg(test)]
1220mod reader_tests;
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225    use crate::menu_item::MenuItem;
1226    use teksilo_core::WidgetBuilder;
1227    use teksilo_core::widget_tree::WidgetTree;
1228    use teksilo_i18n::lit;
1229
1230    fn light_tree() -> WidgetTree {
1231        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
1232    }
1233
1234    #[test]
1235    fn unbounded_menu_grows_with_content() {
1236        // Without `max_visible_items`, a long menu should size to its
1237        // content — not be silently clipped. Use `with_width` so the
1238        // root takes its natural height from `size_that_fits` rather
1239        // than the proposal's exact height.
1240        let mut tree = light_tree();
1241        let mut menu = MenuList::new();
1242        for i in 0..20 {
1243            menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
1244        }
1245        let id = tree.add(menu);
1246        tree.layout(SizeProposal::with_width(300.0));
1247        let h = tree.bounds(id).height;
1248        // 20 items × 24 px ≈ 480 px — well above a capped viewport.
1249        assert!(
1250            h > 400.0,
1251            "uncapped menu should grow to fit all items, got height={}",
1252            h
1253        );
1254    }
1255
1256    #[test]
1257    fn item_when_collapses_a_hidden_row_to_zero_height() {
1258        use teksilo_core::signal::Signal;
1259        // A gated row that is currently hidden must add no height — the menu
1260        // is the same height as if the row weren't there; revealing it grows
1261        // the menu by one row.
1262        let gate = Signal::new(false);
1263
1264        let mut tree_gated = light_tree();
1265        let menu_gated = MenuList::new()
1266            .item(MenuItem::new(lit!("A")))
1267            .item_when(MenuItem::new(lit!("Gated")), gate.clone())
1268            .item(MenuItem::new(lit!("B")));
1269        let id_gated = tree_gated.add(menu_gated);
1270        tree_gated.layout(SizeProposal::with_width(300.0));
1271        let h_hidden = tree_gated.bounds(id_gated).height;
1272
1273        let mut tree_two = light_tree();
1274        let menu_two = MenuList::new()
1275            .item(MenuItem::new(lit!("A")))
1276            .item(MenuItem::new(lit!("B")));
1277        let id_two = tree_two.add(menu_two);
1278        tree_two.layout(SizeProposal::with_width(300.0));
1279        let h_two = tree_two.bounds(id_two).height;
1280
1281        assert!(
1282            (h_hidden - h_two).abs() < 0.5,
1283            "a hidden item_when row must add no height: {h_hidden} vs {h_two}"
1284        );
1285
1286        gate.set(true);
1287        tree_gated.layout(SizeProposal::with_width(300.0));
1288        let h_shown = tree_gated.bounds(id_gated).height;
1289        assert!(
1290            h_shown > h_hidden + 10.0,
1291            "revealing the gated row must add a row's height: {h_shown} vs {h_hidden}"
1292        );
1293    }
1294
1295    #[test]
1296    fn max_visible_items_caps_height() {
1297        // With `max_visible_items(5)`, a 20-entry menu must cap near
1298        // `5 * item_height + outer padding` rather than growing to fit
1299        // every row.
1300        let mut tree = light_tree();
1301        let mut menu = MenuList::new().max_visible_items(5);
1302        for i in 0..20 {
1303            menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
1304        }
1305        let id = tree.add(menu);
1306        tree.layout(SizeProposal::with_width(300.0));
1307        let h = tree.bounds(id).height;
1308        // 5 rows × 24 px + 8 px padding = 128. Give a generous
1309        // tolerance band (theme may tweak item_height); the key
1310        // regression to catch is "grew to fit everything" (~480 px).
1311        assert!(
1312            h < 200.0,
1313            "capped menu height should be bounded by max_visible_items, got {}",
1314            h
1315        );
1316        assert!(h > 0.0, "capped menu should have positive height");
1317    }
1318
1319    #[test]
1320    fn max_visible_items_below_count_has_no_effect() {
1321        // When item count fits under the cap, the ScrollArea wrapper
1322        // must not be inserted — sanity check that we don't pay the
1323        // wrapper cost (or its minor layout overhead) for small menus.
1324        let mut tree = light_tree();
1325        let menu = MenuList::new()
1326            .max_visible_items(10)
1327            .item(MenuItem::new(lit!("A")))
1328            .item(MenuItem::new(lit!("B")));
1329        let id = tree.add(menu);
1330        tree.layout(SizeProposal::with_width(300.0));
1331        let h = tree.bounds(id).height;
1332        // 2 items × 24 = 48 px + padding ≈ 56 px. Much less than the
1333        // cap of 10 × 24 = 240 px.
1334        assert!(h < 100.0, "small menu should size to content, got {}", h);
1335    }
1336
1337    // --- Keyboard activation: mnemonic, type-ahead, Home/End ---
1338
1339    use std::cell::Cell as StdCell;
1340    use std::rc::Rc as StdRc;
1341    use teksilo_core::event::{Key, Modifiers};
1342    use teksilo_core::signal::Signal;
1343
1344    /// Build a menu list with an `on_activate_fn` for each entry that
1345    /// flips the matching slot in `fired`. Returns the list's
1346    /// `WidgetId` so the test can focus it and dispatch keys.
1347    fn menu_with_activation_probe(
1348        tree: &mut WidgetTree,
1349        labels: &[&str],
1350        fired: StdRc<StdCell<Option<usize>>>,
1351    ) -> WidgetId {
1352        let mut menu = MenuList::new();
1353        for (i, label) in labels.iter().enumerate() {
1354            let fired_for_this = fired.clone();
1355            menu = menu.item(
1356                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
1357            );
1358        }
1359        tree.add(menu)
1360    }
1361
1362    /// A `WindowOps` that only counts `open_window`. Enough to tell "the
1363    /// row's handler reached the app's window sink" from the panic a
1364    /// standalone dispatch used to raise there.
1365    #[derive(Default)]
1366    struct CountingWindowOps {
1367        opened: usize,
1368    }
1369
1370    impl teksilo_core::WindowOps for CountingWindowOps {
1371        fn open_window(
1372            &mut self,
1373            _config: teksilo_core::WindowConfig,
1374        ) -> teksilo_core::window::TeksiloWindowId {
1375            self.opened += 1;
1376            teksilo_core::window::TeksiloWindowId::new(1)
1377        }
1378
1379        fn find_window(&self, _string_id: &str) -> Option<teksilo_core::window::TeksiloWindowId> {
1380            None
1381        }
1382
1383        fn window_state(
1384            &self,
1385            _id: teksilo_core::window::TeksiloWindowId,
1386        ) -> Option<teksilo_core::window::WindowState> {
1387            None
1388        }
1389
1390        fn windows(&self) -> Vec<teksilo_core::window::WindowState> {
1391            Vec::new()
1392        }
1393
1394        fn focus_window(&mut self, _id: teksilo_core::window::TeksiloWindowId) {}
1395
1396        fn close_window_by_id(&mut self, _id: teksilo_core::window::TeksiloWindowId) {}
1397    }
1398
1399    #[test]
1400    fn keyboard_activation_keeps_the_window_ops() {
1401        // Enter on a menu row does not dispatch the click the pointer
1402        // would: it runs the row's keyboard activation inside the menu's own
1403        // key dispatch (a row that is not a `MenuItem` gets a queued
1404        // `EventContext::synthetic_click`, which the tree drains as a
1405        // *nested* dispatch). Draining that tap standalone once handed the
1406        // handler a context with no window sink, so a row that opened a
1407        // window by mouse panicked in `NoopWindowOps::open_window` by
1408        // keyboard (Skribisto's Help ▸ Help Topics). Same for Space, a
1409        // mnemonic and type-ahead.
1410        for activate in [Key::Enter, Key::Space] {
1411            let mut tree = light_tree();
1412            let menu = MenuList::new().item(MenuItem::new(lit!("Help")).on_activate_fn(|ctx| {
1413                ctx.open_window(teksilo_core::WindowConfig::new().title(lit!("Help")));
1414            }));
1415            let menu_id = tree.add(menu);
1416            tree.layout(SizeProposal::with_width(300.0));
1417            tree.focus(menu_id);
1418
1419            let mut ops = CountingWindowOps::default();
1420            for key in [Key::ArrowDown, activate] {
1421                tree.dispatch_event_with_ops(
1422                    teksilo_core::event::WidgetEvent::KeyDown {
1423                        key,
1424                        modifiers: Modifiers::NONE,
1425                        text: None,
1426                    },
1427                    &mut ops,
1428                );
1429            }
1430            assert_eq!(
1431                ops.opened, 1,
1432                "{activate:?} on a menu row must reach the caller's WindowOps"
1433            );
1434        }
1435    }
1436
1437    #[test]
1438    fn keyboard_activation_leaves_a_disabled_row_alone() {
1439        // Enter, Space and a mnemonic reach a `MenuItem` through its keyboard
1440        // route, not through a click the framework would refuse a disabled
1441        // row: the route has to refuse it itself.
1442        for key in [Key::Enter, Key::Space, Key::D] {
1443            let fired = StdRc::new(StdCell::new(false));
1444            let mut tree = light_tree();
1445            let fired_for_row = fired.clone();
1446            let menu_id = tree.add(
1447                MenuList::new().item(
1448                    MenuItem::new(lit!("&Delete"))
1449                        .enabled(false)
1450                        .on_activate_fn(move |_| fired_for_row.set(true)),
1451                ),
1452            );
1453            tree.layout(SizeProposal::with_width(300.0));
1454            tree.focus(menu_id);
1455            tree.press_key(Key::ArrowDown, Modifiers::NONE);
1456            tree.press_key(key, Modifiers::NONE);
1457            assert!(!fired.get(), "{key:?} must not run a disabled row");
1458        }
1459    }
1460
1461    #[test]
1462    fn mnemonic_letter_activates_matching_item() {
1463        // Bare letter that matches an item's `&`-marker activates it
1464        // immediately (no Enter required).
1465        let fired = StdRc::new(StdCell::new(None));
1466        let mut tree = light_tree();
1467        let menu_id =
1468            menu_with_activation_probe(&mut tree, &["&Save", "&Open", "&Quit"], fired.clone());
1469        tree.layout(SizeProposal::with_width(300.0));
1470        tree.focus(menu_id);
1471        tree.press_key(Key::O, Modifiers::NONE);
1472        assert_eq!(fired.get(), Some(1), "Alt+O should activate 'Open'");
1473    }
1474
1475    #[test]
1476    fn mnemonic_letter_is_case_insensitive() {
1477        let fired = StdRc::new(StdCell::new(None));
1478        let mut tree = light_tree();
1479        let menu_id = menu_with_activation_probe(&mut tree, &["&Save", "&Quit"], fired.clone());
1480        tree.layout(SizeProposal::with_width(300.0));
1481        tree.focus(menu_id);
1482        // The `S` Key variant produces lowercase 's' via `to_char`,
1483        // matching the mnemonic 's' regardless of case.
1484        tree.press_key(Key::S, Modifiers::NONE);
1485        assert_eq!(fired.get(), Some(0));
1486    }
1487
1488    #[test]
1489    fn mnemonic_does_not_fire_with_ctrl_modifier() {
1490        // Ctrl+S is an accelerator chord, not a menu mnemonic. The
1491        // dispatcher should leave it alone so the Shortcut/Action
1492        // pipeline can handle it instead.
1493        let fired = StdRc::new(StdCell::new(None));
1494        let mut tree = light_tree();
1495        let menu_id = menu_with_activation_probe(&mut tree, &["&Save"], fired.clone());
1496        tree.layout(SizeProposal::with_width(300.0));
1497        tree.focus(menu_id);
1498        tree.press_key(Key::S, Modifiers::CTRL);
1499        assert_eq!(fired.get(), None);
1500    }
1501
1502    #[test]
1503    fn mnemonic_fires_with_shift_modifier() {
1504        // Windows / GNOME convention: bare letter activation works
1505        // regardless of the Shift state (Shift-Lock users would
1506        // otherwise be locked out of mnemonic activation). Only
1507        // Ctrl / Alt / Cmd disqualify the keystroke from in-menu
1508        // activation.
1509        let fired = StdRc::new(StdCell::new(None));
1510        let mut tree = light_tree();
1511        let menu_id = menu_with_activation_probe(&mut tree, &["&Save", "&Quit"], fired.clone());
1512        tree.layout(SizeProposal::with_width(300.0));
1513        tree.focus(menu_id);
1514        tree.press_key(Key::S, Modifiers::SHIFT);
1515        assert_eq!(fired.get(), Some(0));
1516    }
1517
1518    /// [`menu_with_activation_probe`] with a per-entry static visibility
1519    /// gate. The type-ahead timeout is zeroed so each keystroke starts a
1520    /// fresh prefix — these tests probe several letters in a row and
1521    /// aren't about buffer accumulation.
1522    fn menu_with_gated_probe(
1523        tree: &mut WidgetTree,
1524        entries: &[(&str, bool)],
1525        fired: StdRc<StdCell<Option<usize>>>,
1526    ) -> WidgetId {
1527        let mut menu = MenuList::new().type_ahead_timeout(Duration::ZERO);
1528        for (i, (label, visible)) in entries.iter().enumerate() {
1529            let fired_for_this = fired.clone();
1530            menu = menu.item_when(
1531                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
1532                *visible,
1533            );
1534        }
1535        tree.add(menu)
1536    }
1537
1538    #[test]
1539    fn mnemonic_ignores_a_hidden_item() {
1540        // A row collapsed by `item_when(.., false)` must not be reachable
1541        // by its mnemonic — the same visibility gate the arrow / Home /
1542        // End / Enter branches already apply.
1543        let fired = StdRc::new(StdCell::new(None));
1544        let mut tree = light_tree();
1545        let menu_id = menu_with_gated_probe(
1546            &mut tree,
1547            &[("&Save", false), ("&Quit", true)],
1548            fired.clone(),
1549        );
1550        tree.layout(SizeProposal::with_width(300.0));
1551        tree.focus(menu_id);
1552        tree.press_key(Key::S, Modifiers::NONE);
1553        assert_eq!(fired.get(), None, "hidden 'Save' must not activate");
1554        tree.press_key(Key::Q, Modifiers::NONE);
1555        assert_eq!(fired.get(), Some(1), "visible 'Quit' still activates");
1556    }
1557
1558    #[test]
1559    fn mnemonic_resolves_to_the_visible_claimant() {
1560        // Two mutually-exclusive rows may share a letter — the `item_when`
1561        // pattern behind a Toolbar overflow menu's inline/collapsed twins.
1562        // Whichever is visible when the letter is pressed wins.
1563        for visible_idx in [0usize, 1] {
1564            let fired = StdRc::new(StdCell::new(None));
1565            let mut tree = light_tree();
1566            let mut entries = [("&Stop", false), ("&Start", false)];
1567            entries[visible_idx].1 = true;
1568            let menu_id = menu_with_gated_probe(&mut tree, &entries, fired.clone());
1569            tree.layout(SizeProposal::with_width(300.0));
1570            tree.focus(menu_id);
1571            tree.press_key(Key::S, Modifiers::NONE);
1572            assert_eq!(
1573                fired.get(),
1574                Some(visible_idx),
1575                "'s' should reach the visible claimant"
1576            );
1577        }
1578    }
1579
1580    #[test]
1581    fn type_ahead_ignores_a_hidden_item() {
1582        let fired = StdRc::new(StdCell::new(None));
1583        let mut tree = light_tree();
1584        let menu_id = menu_with_gated_probe(
1585            &mut tree,
1586            &[("Save", false), ("Open", true), ("Quit", true)],
1587            fired.clone(),
1588        );
1589        tree.layout(SizeProposal::with_width(300.0));
1590        tree.focus(menu_id);
1591        // "s" matches only the hidden row, so nothing takes focus and
1592        // the following Enter has nothing to activate.
1593        tree.press_key(Key::S, Modifiers::NONE);
1594        tree.press_key(Key::Enter, Modifiers::NONE);
1595        assert_eq!(fired.get(), None);
1596        // A visible row is still reachable.
1597        tree.press_key(Key::O, Modifiers::NONE);
1598        tree.press_key(Key::Enter, Modifiers::NONE);
1599        assert_eq!(fired.get(), Some(1));
1600    }
1601
1602    #[test]
1603    fn type_ahead_fires_with_shift_modifier() {
1604        // Same Shift-tolerance applies to type-ahead navigation.
1605        let fired = StdRc::new(StdCell::new(None));
1606        let mut tree = light_tree();
1607        let menu_id =
1608            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1609        tree.layout(SizeProposal::with_width(300.0));
1610        tree.focus(menu_id);
1611        tree.press_key(Key::O, Modifiers::SHIFT);
1612        tree.press_key(Key::Enter, Modifiers::NONE);
1613        assert_eq!(fired.get(), Some(1));
1614    }
1615
1616    #[test]
1617    fn type_ahead_first_letter_focuses_and_enter_activates() {
1618        // No `&`-markers — letters drive type-ahead, not mnemonics.
1619        // Pressing 'o' focuses the matching item; Enter activates it.
1620        let fired = StdRc::new(StdCell::new(None));
1621        let mut tree = light_tree();
1622        let menu_id =
1623            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1624        tree.layout(SizeProposal::with_width(300.0));
1625        tree.focus(menu_id);
1626        tree.press_key(Key::O, Modifiers::NONE);
1627        // Type-ahead only focuses; nothing fired yet.
1628        assert_eq!(fired.get(), None);
1629        tree.press_key(Key::Enter, Modifiers::NONE);
1630        assert_eq!(fired.get(), Some(1));
1631    }
1632
1633    #[test]
1634    fn type_ahead_extends_prefix_within_timeout() {
1635        // Typing 'q' then 'u' selects "Quit" (only item starting with "qu").
1636        let fired = StdRc::new(StdCell::new(None));
1637        let mut tree = light_tree();
1638        let menu_id = menu_with_activation_probe(
1639            &mut tree,
1640            &["Save", "Open", "Quack", "Quit"],
1641            fired.clone(),
1642        );
1643        tree.layout(SizeProposal::with_width(300.0));
1644        tree.focus(menu_id);
1645        tree.press_key(Key::Q, Modifiers::NONE);
1646        // 'q' alone matches "Quack" first (start+1 wrap → Save..Quack).
1647        tree.press_key(Key::U, Modifiers::NONE);
1648        // 'qu' still matches "Quack" — but the search starts from the
1649        // currently focused item ("Quack"), and from current+1 wraps
1650        // around to "Quit", which also starts with "qu". So Quit wins.
1651        tree.press_key(Key::I, Modifiers::NONE);
1652        // 'qui' — only "Quit" matches.
1653        tree.press_key(Key::T, Modifiers::NONE);
1654        // 'quit' — still "Quit".
1655        tree.press_key(Key::Enter, Modifiers::NONE);
1656        assert_eq!(fired.get(), Some(3));
1657    }
1658
1659    #[test]
1660    fn type_ahead_zero_timeout_treats_each_key_independently() {
1661        // With `type_ahead_timeout(Duration::ZERO)`, every keypress
1662        // clears the buffer first, so the search always restarts from
1663        // a single-character prefix.
1664        let fired = StdRc::new(StdCell::new(None));
1665        let mut tree = light_tree();
1666        let menu_id = {
1667            let mut menu = MenuList::new().type_ahead_timeout(Duration::ZERO);
1668            for (i, label) in ["Save", "Open", "Quit"].iter().enumerate() {
1669                let fired_for_this = fired.clone();
1670                menu = menu.item(
1671                    MenuItem::new(lit!(*label))
1672                        .on_activate_fn(move |_| fired_for_this.set(Some(i))),
1673                );
1674            }
1675            tree.add(menu)
1676        };
1677        tree.layout(SizeProposal::with_width(300.0));
1678        tree.focus(menu_id);
1679        tree.press_key(Key::S, Modifiers::NONE);
1680        tree.press_key(Key::Q, Modifiers::NONE);
1681        // 'q' wins the most recent search; Enter activates Quit.
1682        tree.press_key(Key::Enter, Modifiers::NONE);
1683        assert_eq!(fired.get(), Some(2));
1684    }
1685
1686    #[test]
1687    fn page_keys_step_a_long_menu_and_clamp_at_the_ends() {
1688        // A language list or a recents list is long enough for the arrows to
1689        // be tedious; these were the one list chord `MenuList` did not answer.
1690        let labels: Vec<String> = (0..25).map(|i| format!("Item {i}")).collect();
1691        let refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
1692        let fired = StdRc::new(StdCell::new(None));
1693        let mut tree = light_tree();
1694        let menu_id = menu_with_activation_probe(&mut tree, &refs, fired.clone());
1695        tree.layout(SizeProposal::with_width(300.0));
1696        tree.focus(menu_id);
1697
1698        // With no focus yet, PageDown enters at the top.
1699        tree.press_key(Key::PageDown, Modifiers::NONE);
1700        tree.press_key(Key::PageDown, Modifiers::NONE);
1701        tree.press_key(Key::Enter, Modifiers::NONE);
1702        assert_eq!(fired.get(), Some(10), "one page in from the first row");
1703
1704        fired.set(None);
1705        tree.press_key(Key::PageDown, Modifiers::NONE);
1706        tree.press_key(Key::PageDown, Modifiers::NONE);
1707        tree.press_key(Key::Enter, Modifiers::NONE);
1708        assert_eq!(fired.get(), Some(24), "and it clamps at the last row");
1709
1710        fired.set(None);
1711        tree.press_key(Key::PageUp, Modifiers::NONE);
1712        tree.press_key(Key::PageUp, Modifiers::NONE);
1713        tree.press_key(Key::PageUp, Modifiers::NONE);
1714        tree.press_key(Key::Enter, Modifiers::NONE);
1715        assert_eq!(fired.get(), Some(0), "and at the first");
1716    }
1717
1718    #[test]
1719    fn home_focuses_first_item() {
1720        let fired = StdRc::new(StdCell::new(None));
1721        let mut tree = light_tree();
1722        let menu_id =
1723            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1724        tree.layout(SizeProposal::with_width(300.0));
1725        tree.focus(menu_id);
1726        // Navigate down twice to land on index 2, then Home → index 0.
1727        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1728        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1729        tree.press_key(Key::Home, Modifiers::NONE);
1730        tree.press_key(Key::Enter, Modifiers::NONE);
1731        assert_eq!(fired.get(), Some(0));
1732    }
1733
1734    #[test]
1735    fn end_focuses_last_item() {
1736        let fired = StdRc::new(StdCell::new(None));
1737        let mut tree = light_tree();
1738        let menu_id =
1739            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1740        tree.layout(SizeProposal::with_width(300.0));
1741        tree.focus(menu_id);
1742        tree.press_key(Key::End, Modifiers::NONE);
1743        tree.press_key(Key::Enter, Modifiers::NONE);
1744        assert_eq!(fired.get(), Some(2));
1745    }
1746
1747    #[test]
1748    fn arrow_down_wraps_past_last() {
1749        let fired = StdRc::new(StdCell::new(None));
1750        let mut tree = light_tree();
1751        let menu_id = menu_with_activation_probe(&mut tree, &["A", "B", "C"], fired.clone());
1752        tree.layout(SizeProposal::with_width(200.0));
1753        tree.focus(menu_id);
1754        for _ in 0..4 {
1755            tree.press_key(Key::ArrowDown, Modifiers::NONE);
1756        }
1757        // After 4 downs from "no focus", focus lands on index 0 (wrap).
1758        tree.press_key(Key::Enter, Modifiers::NONE);
1759        assert_eq!(fired.get(), Some(0));
1760    }
1761
1762    #[test]
1763    fn arrow_up_wraps_to_last() {
1764        let fired = StdRc::new(StdCell::new(None));
1765        let mut tree = light_tree();
1766        let menu_id = menu_with_activation_probe(&mut tree, &["A", "B", "C"], fired.clone());
1767        tree.layout(SizeProposal::with_width(200.0));
1768        tree.focus(menu_id);
1769        tree.press_key(Key::ArrowUp, Modifiers::NONE);
1770        // From "no focus" (treated as index 0), Up wraps to last (index 2).
1771        tree.press_key(Key::Enter, Modifiers::NONE);
1772        assert_eq!(fired.get(), Some(2));
1773    }
1774
1775    fn menu_with_submenu(tree: &mut WidgetTree) -> WidgetId {
1776        // Index 0 is a submenu trigger; index 1 is a plain item.
1777        let menu = MenuList::new()
1778            .item(MenuItem::submenu(lit!("More"), || {
1779                Box::new(MenuList::new().item(MenuItem::new(lit!("Child"))))
1780            }))
1781            .item(MenuItem::new(lit!("Plain")));
1782        tree.add(menu)
1783    }
1784
1785    #[test]
1786    fn submenu_opens_on_arrow_right_under_ltr() {
1787        let mut tree = light_tree();
1788        let menu_id = menu_with_submenu(&mut tree);
1789        tree.layout(SizeProposal::with_width(300.0));
1790        tree.focus(menu_id);
1791        tree.press_key(Key::ArrowDown, Modifiers::NONE); // focus submenu item (idx 0)
1792        assert!(tree.active_overlays().is_empty());
1793
1794        // Inline-back arrow under LTR (ArrowLeft) does not open.
1795        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1796        assert!(tree.active_overlays().is_empty());
1797
1798        // Inline-forward arrow (ArrowRight) opens the submenu.
1799        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1800        assert_eq!(tree.active_overlays().len(), 1);
1801    }
1802
1803    #[test]
1804    fn submenu_opens_on_arrow_left_under_rtl() {
1805        let mut tree = light_tree();
1806        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1807        let menu_id = menu_with_submenu(&mut tree);
1808        tree.layout(SizeProposal::with_width(300.0));
1809        tree.focus(menu_id);
1810        tree.press_key(Key::ArrowDown, Modifiers::NONE); // focus submenu item (idx 0)
1811        assert!(tree.active_overlays().is_empty());
1812
1813        // Under RTL, ArrowRight is the inline-back key — must NOT open.
1814        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1815        assert!(tree.active_overlays().is_empty());
1816
1817        // ArrowLeft is inline-forward under RTL — opens the submenu.
1818        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1819        assert_eq!(tree.active_overlays().len(), 1);
1820    }
1821
1822    #[test]
1823    fn type_ahead_no_match_does_not_change_focus() {
1824        // Typing a letter that doesn't prefix any label should leave
1825        // focus untouched — Enter then activates whatever was focused
1826        // before (or nothing).
1827        let fired = StdRc::new(StdCell::new(None));
1828        let mut tree = light_tree();
1829        let menu_id = menu_with_activation_probe(&mut tree, &["Save", "Open"], fired.clone());
1830        tree.layout(SizeProposal::with_width(200.0));
1831        tree.focus(menu_id);
1832        // Focus the first item explicitly.
1833        tree.press_key(Key::Home, Modifiers::NONE);
1834        // Type a no-match letter.
1835        tree.press_key(Key::Z, Modifiers::NONE);
1836        tree.press_key(Key::Enter, Modifiers::NONE);
1837        // Save (index 0) should still fire.
1838        assert_eq!(fired.get(), Some(0));
1839    }
1840
1841    #[test]
1842    fn mnemonic_beats_type_ahead_when_both_match() {
1843        // If a label like "&Open" is set up, pressing 'o' fires the
1844        // mnemonic directly, even though type-ahead would also match
1845        // "Open".
1846        let fired = StdRc::new(StdCell::new(None));
1847        let mut tree = light_tree();
1848        let menu_id = menu_with_activation_probe(&mut tree, &["&Save", "&Open"], fired.clone());
1849        tree.layout(SizeProposal::with_width(200.0));
1850        tree.focus(menu_id);
1851        tree.press_key(Key::O, Modifiers::NONE);
1852        // Mnemonic fires immediately — no Enter needed.
1853        assert_eq!(fired.get(), Some(1));
1854    }
1855
1856    #[test]
1857    fn separator_does_not_interfere_with_navigation() {
1858        let fired = StdRc::new(StdCell::new(None));
1859        let mut tree = light_tree();
1860        let menu_id = {
1861            let mut menu = MenuList::new();
1862            for (i, label) in ["Save", "Open", "Quit"].iter().enumerate() {
1863                let fired_for_this = fired.clone();
1864                menu = menu.item(
1865                    MenuItem::new(lit!(*label))
1866                        .on_activate_fn(move |_| fired_for_this.set(Some(i))),
1867                );
1868                if i == 0 {
1869                    menu = menu.separator();
1870                }
1871            }
1872            tree.add(menu)
1873        };
1874        tree.layout(SizeProposal::with_width(300.0));
1875        tree.focus(menu_id);
1876        // Type-ahead should still find "Open" — separator skipped.
1877        tree.press_key(Key::O, Modifiers::NONE);
1878        tree.press_key(Key::Enter, Modifiers::NONE);
1879        assert_eq!(fired.get(), Some(1));
1880    }
1881
1882    #[test]
1883    fn header_does_not_interfere_with_navigation() {
1884        let fired = StdRc::new(StdCell::new(None));
1885        let mut tree = light_tree();
1886        let menu_id = {
1887            let mut menu = MenuList::new();
1888            for (i, label) in ["Save", "Open", "Quit"].iter().enumerate() {
1889                let fired_for_this = fired.clone();
1890                menu = menu.item(
1891                    MenuItem::new(lit!(*label))
1892                        .on_activate_fn(move |_| fired_for_this.set(Some(i))),
1893                );
1894                if i == 0 {
1895                    // A non-navigable section caption between item 0 and item 1.
1896                    menu = menu.header(crate::GroupHeader::new(lit!("Recent")));
1897                }
1898            }
1899            tree.add(menu)
1900        };
1901        tree.layout(SizeProposal::with_width(300.0));
1902        tree.focus(menu_id);
1903        // Type-ahead resolves "Open" at item index 1 — the header occupies no
1904        // slot in the item/label index space, exactly like a separator.
1905        tree.press_key(Key::O, Modifiers::NONE);
1906        tree.press_key(Key::Enter, Modifiers::NONE);
1907        assert_eq!(fired.get(), Some(1));
1908    }
1909
1910    // Keeps the test module's `Signal` import referenced; nothing else in
1911    // these tests names the type at module scope.
1912    #[allow(dead_code)]
1913    fn _ignore_unused() {
1914        let _: Option<Signal<bool>> = None;
1915    }
1916
1917    #[derive(Debug)]
1918    struct FocusableLeaf;
1919    impl Widget for FocusableLeaf {
1920        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1921            ctx.apply_self_handlers(
1922                teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1923            );
1924            vec![]
1925        }
1926        fn layout_response(
1927            &self,
1928            proposal: SizeProposal,
1929            _ctx: &LayoutContext,
1930        ) -> teksilo_core::widget::LayoutResponse {
1931            proposal.resolve(12.0, 12.0).into()
1932        }
1933    }
1934
1935    /// Opening a submenu must not be mistaken for leaving the parent menu.
1936    ///
1937    /// A submenu's content is `add_detached_boxed`, so it is never an arena
1938    /// descendant of the menu that owns it — the only thing relating the two is
1939    /// the overlay manager's `parent_overlay` graph. A focus-out rule that
1940    /// asked the arena instead would close the parent the instant its own
1941    /// submenu opened.
1942    #[test]
1943    fn opening_a_submenu_keeps_the_parent_menu_open() {
1944        let mut tree = light_tree();
1945        let menu_id = menu_with_submenu(&mut tree);
1946        tree.layout(SizeProposal::with_width(300.0));
1947        tree.focus(menu_id);
1948        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1949        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1950
1951        assert_eq!(
1952            tree.active_overlays().len(),
1953            1,
1954            "the submenu is up and the parent menu is untouched"
1955        );
1956        assert!(
1957            tree.is_active(menu_id),
1958            "the parent MenuList must not have been dormanted"
1959        );
1960    }
1961
1962    /// Tab out of a submenu closes the whole cascade, not one level.
1963    ///
1964    /// APG is unqualified and plural about it: Tab "closes all menus and
1965    /// submenus". Walking up `parent_overlay` and dismissing the outermost
1966    /// level gets that for free — `dismiss_immediate` already cascades back
1967    /// down to every descendant.
1968    #[test]
1969    fn tab_out_of_a_submenu_closes_the_whole_cascade() {
1970        let mut tree = light_tree();
1971        let menu_id = menu_with_submenu(&mut tree);
1972        let after = tree.add(FocusableLeaf);
1973        tree.layout(SizeProposal::with_width(300.0));
1974        tree.focus(menu_id);
1975        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1976        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1977        assert_eq!(
1978            tree.active_overlays().len(),
1979            1,
1980            "precondition: submenu open"
1981        );
1982
1983        tree.press_key(Key::Tab, Modifiers::NONE);
1984        assert_eq!(tree.focused(), Some(after));
1985        assert!(
1986            tree.active_overlays().is_empty(),
1987            "one Tab must leave no menu behind"
1988        );
1989    }
1990
1991    // --- Decorated rows: a `MenuItem` carrying a builder method ---
1992    //
1993    // Any `WidgetBuilder` call (`.context_menu`, `.focusable`, …) wraps the
1994    // item in a `WidgetWithHandlers<MenuItem>`. Every `MenuList` feature that
1995    // reads the item's concrete type has to keep working through that wrapper,
1996    // or a row silently degrades with no error anywhere.
1997
1998    /// Wrap a `MenuItem` the way a caller that needs a per-row context menu
1999    /// does — the shape that used to de-register the row from `MenuList`.
2000    fn decorated(item: MenuItem) -> impl Widget + 'static {
2001        item.context_menu(|_pos, _ctx| None)
2002    }
2003
2004    #[test]
2005    fn a_decorated_item_keeps_its_mnemonic() {
2006        let fired = StdRc::new(StdCell::new(None));
2007        let mut tree = light_tree();
2008        let mut menu = MenuList::new();
2009        for (i, label) in ["&Save", "&Open", "&Quit"].iter().enumerate() {
2010            let fired_for_this = fired.clone();
2011            menu = menu.item(decorated(
2012                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
2013            ));
2014        }
2015        let menu_id = tree.add(menu);
2016        tree.layout(SizeProposal::with_width(300.0));
2017        tree.focus(menu_id);
2018
2019        tree.press_key(Key::O, Modifiers::NONE);
2020        assert_eq!(
2021            fired.get(),
2022            Some(1),
2023            "the mnemonic is read off the MenuItem; decorating it must not hide it"
2024        );
2025    }
2026
2027    #[test]
2028    fn a_decorated_item_keeps_its_type_ahead_label() {
2029        let fired = StdRc::new(StdCell::new(None));
2030        let mut tree = light_tree();
2031        let mut menu = MenuList::new();
2032        // No `&` markers here, so only the type-ahead path can reach a row.
2033        for (i, label) in ["Alpha", "Beta", "Gamma"].iter().enumerate() {
2034            let fired_for_this = fired.clone();
2035            menu = menu.item(decorated(
2036                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
2037            ));
2038        }
2039        let menu_id = tree.add(menu);
2040        tree.layout(SizeProposal::with_width(300.0));
2041        tree.focus(menu_id);
2042
2043        tree.press_key(Key::G, Modifiers::NONE);
2044        tree.press_key(Key::Enter, Modifiers::NONE);
2045        assert_eq!(
2046            fired.get(),
2047            Some(2),
2048            "type-ahead reads the label off the MenuItem, through any wrapper"
2049        );
2050    }
2051
2052    #[test]
2053    fn a_decorated_submenu_trigger_still_opens_on_the_inline_arrow() {
2054        let mut tree = light_tree();
2055        let menu = MenuList::new()
2056            .item(decorated(MenuItem::submenu(lit!("More"), || {
2057                Box::new(MenuList::new().item(MenuItem::new(lit!("Child"))))
2058            })))
2059            .item(MenuItem::new(lit!("Plain")));
2060        let menu_id = tree.add(menu);
2061        tree.layout(SizeProposal::with_width(300.0));
2062        tree.focus(menu_id);
2063
2064        tree.press_key(Key::ArrowDown, Modifiers::NONE); // highlight the trigger
2065        assert!(tree.active_overlays().is_empty(), "precondition: closed");
2066
2067        tree.press_key(Key::ArrowRight, Modifiers::NONE);
2068        assert_eq!(
2069            tree.active_overlays().len(),
2070            1,
2071            "the submenu flag is read off the MenuItem, through any wrapper"
2072        );
2073    }
2074
2075    // --- Keyboard navigation scrolls a capped menu ---
2076
2077    /// A menu row that records the absolute bounds it was last laid out at.
2078    ///
2079    /// The accessibility tree is not a usable probe here: its node bounds are
2080    /// captured when the node is emitted and a pure scroll does not re-emit
2081    /// them, so a stale rect reads back as "nothing moved" whether or not the
2082    /// scroll happened. `place_children` is the layout's own answer.
2083    #[derive(Debug)]
2084    struct ProbeRow {
2085        seen: StdRc<Cell<Rect>>,
2086    }
2087
2088    impl Widget for ProbeRow {
2089        fn layout_response(
2090            &self,
2091            proposal: SizeProposal,
2092            _ctx: &LayoutContext,
2093        ) -> teksilo_core::widget::LayoutResponse {
2094            proposal.resolve(200.0, 24.0).into()
2095        }
2096
2097        fn place_children(
2098            &self,
2099            bounds: Rect,
2100            _proposal: SizeProposal,
2101            _children: &mut [WidgetPlacement],
2102            _ctx: &LayoutContext,
2103        ) {
2104            self.seen.set(bounds);
2105        }
2106    }
2107
2108    #[test]
2109    fn keyboard_navigation_scrolls_a_capped_menu_to_the_highlight() {
2110        // Past `max_visible_items` the panel is a `ScrollArea`, and arrow / End
2111        // navigation moves `focused_index` rather than real tree focus — so the
2112        // framework's own focus-follow scroll never runs. Without an explicit
2113        // reveal the highlight walks straight out of the viewport and the menu
2114        // looks frozen from the fifth row down.
2115        let seen = StdRc::new(Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0)));
2116        let mut tree = light_tree();
2117        let mut menu = MenuList::new().max_visible_items(4);
2118        for i in 0..19 {
2119            menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
2120        }
2121        // The last row is the probe, so `End` lands on it.
2122        menu = menu.item(ProbeRow { seen: seen.clone() });
2123        let menu_id = tree.add(menu);
2124        tree.layout(SizeProposal::with_width(300.0));
2125        tree.focus(menu_id);
2126
2127        let panel = tree.bounds(menu_id);
2128        let before = seen.get();
2129        assert!(
2130            before.y > panel.bottom(),
2131            "precondition: the last row starts below the capped panel \
2132             (row y={}, panel bottom={})",
2133            before.y,
2134            panel.bottom()
2135        );
2136
2137        tree.press_key(Key::End, Modifiers::NONE);
2138        // The reveal is queued from the handler and applied by the enclosing
2139        // ScrollArea; bounds only move on the next layout pass.
2140        tree.layout(SizeProposal::with_width(300.0));
2141
2142        let after = seen.get();
2143        assert!(
2144            after.y >= panel.y - 0.5 && after.bottom() <= panel.bottom() + 0.5,
2145            "End must scroll the last row into the panel, got {}..{} for a panel of {}..{}",
2146            after.y,
2147            after.bottom(),
2148            panel.y,
2149            panel.bottom()
2150        );
2151    }
2152
2153    // --- `inset` ---
2154
2155    /// Lay `menu` (with a probe row as its only row) out at a 300 dp width
2156    /// and return the menu panel's bounds and the probe row's.
2157    fn panel_and_row(menu: MenuList) -> (Rect, Rect) {
2158        let seen = StdRc::new(Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0)));
2159        let mut tree = light_tree();
2160        let id = tree.add(menu.item(ProbeRow { seen: seen.clone() }));
2161        tree.layout(SizeProposal::with_width(300.0));
2162        (tree.bounds(id), seen.get())
2163    }
2164
2165    #[test]
2166    fn inset_defaults_to_four_dp() {
2167        let (panel, row) = panel_and_row(MenuList::new());
2168        assert!((row.x - panel.x - 4.0).abs() < 0.01, "row x {}", row.x);
2169        assert!((row.y - panel.y - 4.0).abs() < 0.01, "row y {}", row.y);
2170        assert!((row.width - (panel.width - 8.0)).abs() < 0.01);
2171    }
2172
2173    #[test]
2174    fn inset_one_lets_rows_span_the_menu() {
2175        let (panel, row) = panel_and_row(MenuList::new().inset(1.0));
2176        assert!((row.x - panel.x - 1.0).abs() < 0.01, "row x {}", row.x);
2177        assert!((row.y - panel.y - 1.0).abs() < 0.01, "row y {}", row.y);
2178        assert!(
2179            (row.width - (panel.width - 2.0)).abs() < 0.01,
2180            "row width {} in a panel {}",
2181            row.width,
2182            panel.width
2183        );
2184        assert!((panel.bottom() - row.bottom() - 1.0).abs() < 0.01);
2185    }
2186
2187    #[test]
2188    fn inset_below_zero_is_clamped() {
2189        let (panel, row) = panel_and_row(MenuList::new().inset(-5.0));
2190        assert!((row.x - panel.x).abs() < 0.01);
2191        assert!((row.width - panel.width).abs() < 0.01);
2192    }
2193
2194    #[test]
2195    fn scroll_cap_follows_the_inset() {
2196        // The cap is `cap * item_height + 2 * inset`: shrinking the inset by
2197        // 3 dp must shrink the capped panel by 6 dp, not leave the old
2198        // literal `+ 8.0` behind.
2199        let capped = |inset: Option<f32>| {
2200            let mut tree = light_tree();
2201            let mut menu = MenuList::new().max_visible_items(5);
2202            if let Some(i) = inset {
2203                menu = menu.inset(i);
2204            }
2205            for i in 0..20 {
2206                menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
2207            }
2208            let id = tree.add(menu);
2209            tree.layout(SizeProposal::with_width(300.0));
2210            tree.bounds(id).height
2211        };
2212        let default = capped(None);
2213        let thin = capped(Some(1.0));
2214        let wide = capped(Some(10.0));
2215        assert!((default - thin - 6.0).abs() < 0.01, "{default} vs {thin}");
2216        assert!((wide - default - 12.0).abs() < 0.01, "{wide} vs {default}");
2217    }
2218
2219    // --- `surface_style` ---
2220
2221    /// A popover style that counts its calls and delegates the drawing.
2222    struct SpyPopoverStyle {
2223        calls: StdRc<Cell<u32>>,
2224        menu_variant: StdRc<Cell<bool>>,
2225    }
2226
2227    impl PopoverStyle for SpyPopoverStyle {
2228        fn make_body(&self, cfg: &PopoverStyleConfig, ctx: &mut BuildContext) -> WidgetId {
2229            self.calls.set(self.calls.get() + 1);
2230            self.menu_variant
2231                .set(matches!(cfg.variant, PopoverVariant::Menu));
2232            crate::styles::RecipePopoverStyle::default().make_body(cfg, ctx)
2233        }
2234    }
2235
2236    fn spy() -> (SpyPopoverStyle, StdRc<Cell<u32>>, StdRc<Cell<bool>>) {
2237        let calls = StdRc::new(Cell::new(0));
2238        let menu_variant = StdRc::new(Cell::new(false));
2239        (
2240            SpyPopoverStyle {
2241                calls: calls.clone(),
2242                menu_variant: menu_variant.clone(),
2243            },
2244            calls,
2245            menu_variant,
2246        )
2247    }
2248
2249    #[test]
2250    fn surface_style_is_used_for_that_list_only() {
2251        let (style, calls, menu_variant) = spy();
2252        let mut tree = light_tree();
2253        let styled = MenuList::new()
2254            .surface_style(style)
2255            .item(MenuItem::new(lit!("A")));
2256        let plain = MenuList::new().item(MenuItem::new(lit!("B")));
2257        tree.add(plain);
2258        tree.layout(SizeProposal::with_width(300.0));
2259        assert_eq!(calls.get(), 0, "another list must not reach the spy");
2260        tree.add(styled);
2261        tree.layout(SizeProposal::with_width(300.0));
2262        assert_eq!(calls.get(), 1, "the styled list builds its surface once");
2263        assert!(
2264            menu_variant.get(),
2265            "the surface is asked for the Menu variant"
2266        );
2267    }
2268
2269    #[test]
2270    fn surface_style_wins_over_the_theme_slot() {
2271        let (slot_style, slot_calls, _) = spy();
2272        let (list_style, list_calls, _) = spy();
2273        let mut theme = teksilo_core::presets::intui::light();
2274        theme.style_slots.popover = Some(StdRc::new(slot_style));
2275        let mut tree = WidgetTree::new().with_theme(theme);
2276
2277        tree.add(MenuList::new().item(MenuItem::new(lit!("A"))));
2278        tree.layout(SizeProposal::with_width(300.0));
2279        assert_eq!(
2280            slot_calls.get(),
2281            1,
2282            "without a per-list style the slot rules"
2283        );
2284
2285        tree.add(
2286            MenuList::new()
2287                .surface_style(list_style)
2288                .item(MenuItem::new(lit!("B"))),
2289        );
2290        tree.layout(SizeProposal::with_width(300.0));
2291        assert_eq!(list_calls.get(), 1);
2292        assert_eq!(slot_calls.get(), 1, "the per-list style bypasses the slot");
2293    }
2294}