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