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                        // A long menu (a language list, a recent-files list)
756                        // is the only place these earn their keep, and they
757                        // were the one list chord `MenuList` lacked. A page is
758                        // ten visible rows — menus have no viewport of their
759                        // own to measure, and ten is the step every menu
760                        // implementation that has one uses.
761                        Key::PageUp | Key::PageDown => {
762                            const MENU_PAGE: usize = 10;
763                            let n = visible_indices.len();
764                            if n == 0 {
765                                return EventResponse::Ignored;
766                            }
767                            let pos = focused_index
768                                .get()
769                                .and_then(|f| visible_indices.iter().position(|&v| v == f));
770                            let next_pos = match (*key == Key::PageDown, pos) {
771                                (true, Some(p)) => (p + MENU_PAGE).min(n - 1),
772                                (true, None) => 0,
773                                (false, Some(p)) => p.saturating_sub(MENU_PAGE),
774                                (false, None) => n - 1,
775                            };
776                            let next = visible_indices[next_pos];
777                            focused_index.set(Some(next));
778                            ctx.show_highlight_tooltip(item_ids[next]);
779                            reveal(next, &item_ids, ctx);
780                            EventResponse::Handled
781                        }
782                        Key::Enter | Key::Space => {
783                            // Activate the focused item via synthetic click —
784                            // but only if it is currently visible.
785                            if let Some(idx) = focused_index.get()
786                                && visible_indices.contains(&idx)
787                                && idx < item_ids.len()
788                            {
789                                ctx.synthetic_click(item_ids[idx]);
790                                return EventResponse::Handled;
791                            }
792                            EventResponse::Ignored
793                        }
794                        k if *k == open_submenu_key => {
795                            // Inline-forward arrow: only opens submenus; for
796                            // non-submenu items let it bubble to
797                            // MenuOverlayHost, which navigates to the next bar
798                            // menu. RTL-flipped via `open_submenu_key`.
799                            if let Some(idx) = focused_index.get()
800                                && idx < sub_flags.len()
801                                && sub_flags[idx]
802                            {
803                                ctx.synthetic_click(item_ids[idx]);
804                                return EventResponse::Handled;
805                            }
806                            EventResponse::Ignored
807                        }
808                        k if *k == back_key => {
809                            // Inline-back arrow: bubble to MenuOverlayHost (bar
810                            // navigation) or the tree-level back/overlay
811                            // dismissal. RTL-flipped via `back_key`.
812                            EventResponse::Ignored
813                        }
814                        Key::Escape => {
815                            // Bubble to the tree-level Escape overlay dismissal.
816                            EventResponse::Ignored
817                        }
818                        _ => {
819                            // Letter handling: in-menu mnemonic
820                            // activation (bare letter) wins over
821                            // type-ahead, which wins over ignored.
822                            // We accept Shift here because Windows /
823                            // GNOME convention activates the
824                            // mnemonic regardless of Shift state
825                            // (otherwise Shift-Lock users couldn't
826                            // mnemonic-activate items at all). Ctrl
827                            // / Alt / Cmd chords fall through to the
828                            // global Shortcut/Action pipeline.
829                            if modifiers.ctrl() || modifiers.alt() || modifiers.super_key() {
830                                return EventResponse::Ignored;
831                            }
832                            let ch = match key {
833                                Key::Character(c) => Some(c.to_ascii_lowercase()),
834                                k => k.to_char().map(|c| c.to_ascii_lowercase()),
835                            };
836                            let Some(ch) = ch else {
837                                return EventResponse::Ignored;
838                            };
839                            if item_count == 0 {
840                                return EventResponse::Ignored;
841                            }
842
843                            // 1) Mnemonic match — explicit accelerator,
844                            //    activates the item. A hidden
845                            //    (`item_when`-gated) row never claims its
846                            //    letter, matching the visibility gate the
847                            //    arrow / Enter branches apply; the first
848                            //    currently-visible claimant wins. Falls
849                            //    through to type-ahead when every claimant
850                            //    is hidden.
851                            if let Some(idx) = mnemonic_table.get(&ch).and_then(|claims| {
852                                claims.iter().copied().find(|i| visible_indices.contains(i))
853                            }) {
854                                ctx.synthetic_click(item_ids[idx]);
855                                return EventResponse::Handled;
856                            }
857
858                            // 2) Type-ahead — incremental prefix match
859                            //    against the resolved labels of the
860                            //    currently-visible rows.
861                            let now = Instant::now();
862                            let mut buf = type_ahead_buffer.borrow_mut();
863                            if let Some(prev) = type_ahead_last_input.get() {
864                                if now.duration_since(prev) > type_ahead_timeout {
865                                    buf.clear();
866                                }
867                            }
868                            buf.push(ch);
869                            type_ahead_last_input.set(Some(now));
870
871                            if visible_indices.is_empty() {
872                                return EventResponse::Ignored;
873                            }
874                            let n = visible_indices.len();
875                            // Position of the focused row *within the
876                            // visible run*; an unfocused (or hidden-row)
877                            // focus anchors at the first visible row.
878                            let start = focused_index
879                                .get()
880                                .and_then(|c| visible_indices.iter().position(|&x| x == c))
881                                .unwrap_or(0);
882                            // Search wrapping from start+1 through start
883                            // itself, so a single repeated letter cycles
884                            // through matching items.
885                            for offset in 1..=n {
886                                let i = visible_indices[(start + offset) % n];
887                                if let Some(label) = resolved_labels.get(i)
888                                    && label.starts_with(buf.as_str())
889                                {
890                                    focused_index.set(Some(i));
891                                    ctx.show_highlight_tooltip(item_ids[i]);
892                                    reveal(i, &item_ids, ctx);
893                                    return EventResponse::Handled;
894                                }
895                            }
896                            EventResponse::Ignored
897                        }
898                    }
899                },
900            )
901            .focusable(true);
902
903        ctx.apply_self_handlers(handler_set);
904
905        vec![root_id]
906    }
907
908    fn layout_response(
909        &self,
910        proposal: SizeProposal,
911        ctx: &LayoutContext,
912    ) -> teksilo_core::widget::LayoutResponse {
913        match self.root_child_id {
914            Some(id) => {
915                // Menu lists size to their content, with a minimum width
916                let child_size = ctx
917                    .child_size(id, proposal)
918                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
919                Size::new(child_size.width.max(120.0), child_size.height)
920            }
921            None => proposal.resolve(120.0, 0.0),
922        }
923        .into()
924    }
925
926    fn place_children(
927        &self,
928        bounds: Rect,
929        _proposal: SizeProposal,
930        children: &mut [WidgetPlacement],
931        _ctx: &LayoutContext,
932    ) {
933        for child in children.iter_mut() {
934            child.origin = bounds.origin();
935            child.size = bounds.size();
936        }
937    }
938
939    // No `paint()`: the menu panel's surface (background, border,
940    // corner radius) and drop shadow are owned by the `PopoverStyle`
941    // wrapper resolved in `build()`.
942
943    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
944        builder.set_role(teksilo_core::accesskit::Role::Menu);
945    }
946
947    fn children(&self) -> Vec<WidgetId> {
948        match self.root_child_id {
949            Some(id) => vec![id],
950            None => Vec::new(),
951        }
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use crate::menu_item::MenuItem;
959    use teksilo_core::WidgetBuilder;
960    use teksilo_core::widget_tree::WidgetTree;
961    use teksilo_i18n::lit;
962
963    fn light_tree() -> WidgetTree {
964        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
965    }
966
967    #[test]
968    fn unbounded_menu_grows_with_content() {
969        // Without `max_visible_items`, a long menu should size to its
970        // content — not be silently clipped. Use `with_width` so the
971        // root takes its natural height from `size_that_fits` rather
972        // than the proposal's exact height.
973        let mut tree = light_tree();
974        let mut menu = MenuList::new();
975        for i in 0..20 {
976            menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
977        }
978        let id = tree.add(menu);
979        tree.layout(SizeProposal::with_width(300.0));
980        let h = tree.bounds(id).height;
981        // 20 items × 24 px ≈ 480 px — well above a capped viewport.
982        assert!(
983            h > 400.0,
984            "uncapped menu should grow to fit all items, got height={}",
985            h
986        );
987    }
988
989    #[test]
990    fn item_when_collapses_a_hidden_row_to_zero_height() {
991        use teksilo_core::signal::Signal;
992        // A gated row that is currently hidden must add no height — the menu
993        // is the same height as if the row weren't there; revealing it grows
994        // the menu by one row.
995        let gate = Signal::new(false);
996
997        let mut tree_gated = light_tree();
998        let menu_gated = MenuList::new()
999            .item(MenuItem::new(lit!("A")))
1000            .item_when(MenuItem::new(lit!("Gated")), gate.clone())
1001            .item(MenuItem::new(lit!("B")));
1002        let id_gated = tree_gated.add(menu_gated);
1003        tree_gated.layout(SizeProposal::with_width(300.0));
1004        let h_hidden = tree_gated.bounds(id_gated).height;
1005
1006        let mut tree_two = light_tree();
1007        let menu_two = MenuList::new()
1008            .item(MenuItem::new(lit!("A")))
1009            .item(MenuItem::new(lit!("B")));
1010        let id_two = tree_two.add(menu_two);
1011        tree_two.layout(SizeProposal::with_width(300.0));
1012        let h_two = tree_two.bounds(id_two).height;
1013
1014        assert!(
1015            (h_hidden - h_two).abs() < 0.5,
1016            "a hidden item_when row must add no height: {h_hidden} vs {h_two}"
1017        );
1018
1019        gate.set(true);
1020        tree_gated.layout(SizeProposal::with_width(300.0));
1021        let h_shown = tree_gated.bounds(id_gated).height;
1022        assert!(
1023            h_shown > h_hidden + 10.0,
1024            "revealing the gated row must add a row's height: {h_shown} vs {h_hidden}"
1025        );
1026    }
1027
1028    #[test]
1029    fn max_visible_items_caps_height() {
1030        // With `max_visible_items(5)`, a 20-entry menu must cap near
1031        // `5 * item_height + outer padding` rather than growing to fit
1032        // every row.
1033        let mut tree = light_tree();
1034        let mut menu = MenuList::new().max_visible_items(5);
1035        for i in 0..20 {
1036            menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
1037        }
1038        let id = tree.add(menu);
1039        tree.layout(SizeProposal::with_width(300.0));
1040        let h = tree.bounds(id).height;
1041        // 5 rows × 24 px + 8 px padding = 128. Give a generous
1042        // tolerance band (theme may tweak item_height); the key
1043        // regression to catch is "grew to fit everything" (~480 px).
1044        assert!(
1045            h < 200.0,
1046            "capped menu height should be bounded by max_visible_items, got {}",
1047            h
1048        );
1049        assert!(h > 0.0, "capped menu should have positive height");
1050    }
1051
1052    #[test]
1053    fn max_visible_items_below_count_has_no_effect() {
1054        // When item count fits under the cap, the ScrollArea wrapper
1055        // must not be inserted — sanity check that we don't pay the
1056        // wrapper cost (or its minor layout overhead) for small menus.
1057        let mut tree = light_tree();
1058        let menu = MenuList::new()
1059            .max_visible_items(10)
1060            .item(MenuItem::new(lit!("A")))
1061            .item(MenuItem::new(lit!("B")));
1062        let id = tree.add(menu);
1063        tree.layout(SizeProposal::with_width(300.0));
1064        let h = tree.bounds(id).height;
1065        // 2 items × 24 = 48 px + padding ≈ 56 px. Much less than the
1066        // cap of 10 × 24 = 240 px.
1067        assert!(h < 100.0, "small menu should size to content, got {}", h);
1068    }
1069
1070    // --- Keyboard activation: mnemonic, type-ahead, Home/End ---
1071
1072    use std::cell::Cell as StdCell;
1073    use std::rc::Rc as StdRc;
1074    use teksilo_core::event::{Key, Modifiers};
1075    use teksilo_core::signal::Signal;
1076
1077    /// Build a menu list with an `on_activate_fn` for each entry that
1078    /// flips the matching slot in `fired`. Returns the list's
1079    /// `WidgetId` so the test can focus it and dispatch keys.
1080    fn menu_with_activation_probe(
1081        tree: &mut WidgetTree,
1082        labels: &[&str],
1083        fired: StdRc<StdCell<Option<usize>>>,
1084    ) -> WidgetId {
1085        let mut menu = MenuList::new();
1086        for (i, label) in labels.iter().enumerate() {
1087            let fired_for_this = fired.clone();
1088            menu = menu.item(
1089                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
1090            );
1091        }
1092        tree.add(menu)
1093    }
1094
1095    /// A `WindowOps` that only counts `open_window`. Enough to tell "the
1096    /// row's handler reached the app's window sink" from the panic a
1097    /// standalone dispatch used to raise there.
1098    #[derive(Default)]
1099    struct CountingWindowOps {
1100        opened: usize,
1101    }
1102
1103    impl teksilo_core::WindowOps for CountingWindowOps {
1104        fn open_window(
1105            &mut self,
1106            _config: teksilo_core::WindowConfig,
1107        ) -> teksilo_core::window::TeksiloWindowId {
1108            self.opened += 1;
1109            teksilo_core::window::TeksiloWindowId::new(1)
1110        }
1111
1112        fn find_window(&self, _string_id: &str) -> Option<teksilo_core::window::TeksiloWindowId> {
1113            None
1114        }
1115
1116        fn window_state(
1117            &self,
1118            _id: teksilo_core::window::TeksiloWindowId,
1119        ) -> Option<teksilo_core::window::WindowState> {
1120            None
1121        }
1122
1123        fn windows(&self) -> Vec<teksilo_core::window::WindowState> {
1124            Vec::new()
1125        }
1126
1127        fn focus_window(&mut self, _id: teksilo_core::window::TeksiloWindowId) {}
1128
1129        fn close_window_by_id(&mut self, _id: teksilo_core::window::TeksiloWindowId) {}
1130    }
1131
1132    #[test]
1133    fn keyboard_activation_keeps_the_window_ops() {
1134        // Enter on a menu row does not dispatch the click the pointer
1135        // would: it queues `EventContext::synthetic_click`, which the tree
1136        // drains as a *nested* dispatch — and the row's own handler runs
1137        // inside it. Draining that tap standalone handed the handler a
1138        // context with no window sink, so a row that opened a window by
1139        // mouse panicked in `NoopWindowOps::open_window` by keyboard
1140        // (Skribisto's Help ▸ Help Topics). Same for Space, a mnemonic and
1141        // type-ahead: all four activate through `synthetic_click`.
1142        for activate in [Key::Enter, Key::Space] {
1143            let mut tree = light_tree();
1144            let menu = MenuList::new().item(MenuItem::new(lit!("Help")).on_activate_fn(|ctx| {
1145                ctx.open_window(teksilo_core::WindowConfig::new().title(lit!("Help")));
1146            }));
1147            let menu_id = tree.add(menu);
1148            tree.layout(SizeProposal::with_width(300.0));
1149            tree.focus(menu_id);
1150
1151            let mut ops = CountingWindowOps::default();
1152            for key in [Key::ArrowDown, activate] {
1153                tree.dispatch_event_with_ops(
1154                    teksilo_core::event::WidgetEvent::KeyDown {
1155                        key,
1156                        modifiers: Modifiers::NONE,
1157                        text: None,
1158                    },
1159                    &mut ops,
1160                );
1161            }
1162            assert_eq!(
1163                ops.opened, 1,
1164                "{activate:?} on a menu row must reach the caller's WindowOps"
1165            );
1166        }
1167    }
1168
1169    #[test]
1170    fn mnemonic_letter_activates_matching_item() {
1171        // Bare letter that matches an item's `&`-marker activates it
1172        // immediately (no Enter required).
1173        let fired = StdRc::new(StdCell::new(None));
1174        let mut tree = light_tree();
1175        let menu_id =
1176            menu_with_activation_probe(&mut tree, &["&Save", "&Open", "&Quit"], fired.clone());
1177        tree.layout(SizeProposal::with_width(300.0));
1178        tree.focus(menu_id);
1179        tree.press_key(Key::O, Modifiers::NONE);
1180        assert_eq!(fired.get(), Some(1), "Alt+O should activate 'Open'");
1181    }
1182
1183    #[test]
1184    fn mnemonic_letter_is_case_insensitive() {
1185        let fired = StdRc::new(StdCell::new(None));
1186        let mut tree = light_tree();
1187        let menu_id = menu_with_activation_probe(&mut tree, &["&Save", "&Quit"], fired.clone());
1188        tree.layout(SizeProposal::with_width(300.0));
1189        tree.focus(menu_id);
1190        // The `S` Key variant produces lowercase 's' via `to_char`,
1191        // matching the mnemonic 's' regardless of case.
1192        tree.press_key(Key::S, Modifiers::NONE);
1193        assert_eq!(fired.get(), Some(0));
1194    }
1195
1196    #[test]
1197    fn mnemonic_does_not_fire_with_ctrl_modifier() {
1198        // Ctrl+S is an accelerator chord, not a menu mnemonic. The
1199        // dispatcher should leave it alone so the Shortcut/Action
1200        // pipeline can handle it instead.
1201        let fired = StdRc::new(StdCell::new(None));
1202        let mut tree = light_tree();
1203        let menu_id = menu_with_activation_probe(&mut tree, &["&Save"], fired.clone());
1204        tree.layout(SizeProposal::with_width(300.0));
1205        tree.focus(menu_id);
1206        tree.press_key(Key::S, Modifiers::CTRL);
1207        assert_eq!(fired.get(), None);
1208    }
1209
1210    #[test]
1211    fn mnemonic_fires_with_shift_modifier() {
1212        // Windows / GNOME convention: bare letter activation works
1213        // regardless of the Shift state (Shift-Lock users would
1214        // otherwise be locked out of mnemonic activation). Only
1215        // Ctrl / Alt / Cmd disqualify the keystroke from in-menu
1216        // activation.
1217        let fired = StdRc::new(StdCell::new(None));
1218        let mut tree = light_tree();
1219        let menu_id = menu_with_activation_probe(&mut tree, &["&Save", "&Quit"], fired.clone());
1220        tree.layout(SizeProposal::with_width(300.0));
1221        tree.focus(menu_id);
1222        tree.press_key(Key::S, Modifiers::SHIFT);
1223        assert_eq!(fired.get(), Some(0));
1224    }
1225
1226    /// [`menu_with_activation_probe`] with a per-entry static visibility
1227    /// gate. The type-ahead timeout is zeroed so each keystroke starts a
1228    /// fresh prefix — these tests probe several letters in a row and
1229    /// aren't about buffer accumulation.
1230    fn menu_with_gated_probe(
1231        tree: &mut WidgetTree,
1232        entries: &[(&str, bool)],
1233        fired: StdRc<StdCell<Option<usize>>>,
1234    ) -> WidgetId {
1235        let mut menu = MenuList::new().type_ahead_timeout(Duration::ZERO);
1236        for (i, (label, visible)) in entries.iter().enumerate() {
1237            let fired_for_this = fired.clone();
1238            menu = menu.item_when(
1239                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
1240                *visible,
1241            );
1242        }
1243        tree.add(menu)
1244    }
1245
1246    #[test]
1247    fn mnemonic_ignores_a_hidden_item() {
1248        // A row collapsed by `item_when(.., false)` must not be reachable
1249        // by its mnemonic — the same visibility gate the arrow / Home /
1250        // End / Enter branches already apply.
1251        let fired = StdRc::new(StdCell::new(None));
1252        let mut tree = light_tree();
1253        let menu_id = menu_with_gated_probe(
1254            &mut tree,
1255            &[("&Save", false), ("&Quit", true)],
1256            fired.clone(),
1257        );
1258        tree.layout(SizeProposal::with_width(300.0));
1259        tree.focus(menu_id);
1260        tree.press_key(Key::S, Modifiers::NONE);
1261        assert_eq!(fired.get(), None, "hidden 'Save' must not activate");
1262        tree.press_key(Key::Q, Modifiers::NONE);
1263        assert_eq!(fired.get(), Some(1), "visible 'Quit' still activates");
1264    }
1265
1266    #[test]
1267    fn mnemonic_resolves_to_the_visible_claimant() {
1268        // Two mutually-exclusive rows may share a letter — the `item_when`
1269        // pattern behind a Toolbar overflow menu's inline/collapsed twins.
1270        // Whichever is visible when the letter is pressed wins.
1271        for visible_idx in [0usize, 1] {
1272            let fired = StdRc::new(StdCell::new(None));
1273            let mut tree = light_tree();
1274            let mut entries = [("&Stop", false), ("&Start", false)];
1275            entries[visible_idx].1 = true;
1276            let menu_id = menu_with_gated_probe(&mut tree, &entries, fired.clone());
1277            tree.layout(SizeProposal::with_width(300.0));
1278            tree.focus(menu_id);
1279            tree.press_key(Key::S, Modifiers::NONE);
1280            assert_eq!(
1281                fired.get(),
1282                Some(visible_idx),
1283                "'s' should reach the visible claimant"
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn type_ahead_ignores_a_hidden_item() {
1290        let fired = StdRc::new(StdCell::new(None));
1291        let mut tree = light_tree();
1292        let menu_id = menu_with_gated_probe(
1293            &mut tree,
1294            &[("Save", false), ("Open", true), ("Quit", true)],
1295            fired.clone(),
1296        );
1297        tree.layout(SizeProposal::with_width(300.0));
1298        tree.focus(menu_id);
1299        // "s" matches only the hidden row, so nothing takes focus and
1300        // the following Enter has nothing to activate.
1301        tree.press_key(Key::S, Modifiers::NONE);
1302        tree.press_key(Key::Enter, Modifiers::NONE);
1303        assert_eq!(fired.get(), None);
1304        // A visible row is still reachable.
1305        tree.press_key(Key::O, Modifiers::NONE);
1306        tree.press_key(Key::Enter, Modifiers::NONE);
1307        assert_eq!(fired.get(), Some(1));
1308    }
1309
1310    #[test]
1311    fn type_ahead_fires_with_shift_modifier() {
1312        // Same Shift-tolerance applies to type-ahead navigation.
1313        let fired = StdRc::new(StdCell::new(None));
1314        let mut tree = light_tree();
1315        let menu_id =
1316            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1317        tree.layout(SizeProposal::with_width(300.0));
1318        tree.focus(menu_id);
1319        tree.press_key(Key::O, Modifiers::SHIFT);
1320        tree.press_key(Key::Enter, Modifiers::NONE);
1321        assert_eq!(fired.get(), Some(1));
1322    }
1323
1324    #[test]
1325    fn type_ahead_first_letter_focuses_and_enter_activates() {
1326        // No `&`-markers — letters drive type-ahead, not mnemonics.
1327        // Pressing 'o' focuses the matching item; Enter activates it.
1328        let fired = StdRc::new(StdCell::new(None));
1329        let mut tree = light_tree();
1330        let menu_id =
1331            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1332        tree.layout(SizeProposal::with_width(300.0));
1333        tree.focus(menu_id);
1334        tree.press_key(Key::O, Modifiers::NONE);
1335        // Type-ahead only focuses; nothing fired yet.
1336        assert_eq!(fired.get(), None);
1337        tree.press_key(Key::Enter, Modifiers::NONE);
1338        assert_eq!(fired.get(), Some(1));
1339    }
1340
1341    #[test]
1342    fn type_ahead_extends_prefix_within_timeout() {
1343        // Typing 'q' then 'u' selects "Quit" (only item starting with "qu").
1344        let fired = StdRc::new(StdCell::new(None));
1345        let mut tree = light_tree();
1346        let menu_id = menu_with_activation_probe(
1347            &mut tree,
1348            &["Save", "Open", "Quack", "Quit"],
1349            fired.clone(),
1350        );
1351        tree.layout(SizeProposal::with_width(300.0));
1352        tree.focus(menu_id);
1353        tree.press_key(Key::Q, Modifiers::NONE);
1354        // 'q' alone matches "Quack" first (start+1 wrap → Save..Quack).
1355        tree.press_key(Key::U, Modifiers::NONE);
1356        // 'qu' still matches "Quack" — but the search starts from the
1357        // currently focused item ("Quack"), and from current+1 wraps
1358        // around to "Quit", which also starts with "qu". So Quit wins.
1359        tree.press_key(Key::I, Modifiers::NONE);
1360        // 'qui' — only "Quit" matches.
1361        tree.press_key(Key::T, Modifiers::NONE);
1362        // 'quit' — still "Quit".
1363        tree.press_key(Key::Enter, Modifiers::NONE);
1364        assert_eq!(fired.get(), Some(3));
1365    }
1366
1367    #[test]
1368    fn type_ahead_zero_timeout_treats_each_key_independently() {
1369        // With `type_ahead_timeout(Duration::ZERO)`, every keypress
1370        // clears the buffer first, so the search always restarts from
1371        // a single-character prefix.
1372        let fired = StdRc::new(StdCell::new(None));
1373        let mut tree = light_tree();
1374        let menu_id = {
1375            let mut menu = MenuList::new().type_ahead_timeout(Duration::ZERO);
1376            for (i, label) in ["Save", "Open", "Quit"].iter().enumerate() {
1377                let fired_for_this = fired.clone();
1378                menu = menu.item(
1379                    MenuItem::new(lit!(*label))
1380                        .on_activate_fn(move |_| fired_for_this.set(Some(i))),
1381                );
1382            }
1383            tree.add(menu)
1384        };
1385        tree.layout(SizeProposal::with_width(300.0));
1386        tree.focus(menu_id);
1387        tree.press_key(Key::S, Modifiers::NONE);
1388        tree.press_key(Key::Q, Modifiers::NONE);
1389        // 'q' wins the most recent search; Enter activates Quit.
1390        tree.press_key(Key::Enter, Modifiers::NONE);
1391        assert_eq!(fired.get(), Some(2));
1392    }
1393
1394    #[test]
1395    fn page_keys_step_a_long_menu_and_clamp_at_the_ends() {
1396        // A language list or a recents list is long enough for the arrows to
1397        // be tedious; these were the one list chord `MenuList` did not answer.
1398        let labels: Vec<String> = (0..25).map(|i| format!("Item {i}")).collect();
1399        let refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
1400        let fired = StdRc::new(StdCell::new(None));
1401        let mut tree = light_tree();
1402        let menu_id = menu_with_activation_probe(&mut tree, &refs, fired.clone());
1403        tree.layout(SizeProposal::with_width(300.0));
1404        tree.focus(menu_id);
1405
1406        // With no focus yet, PageDown enters at the top.
1407        tree.press_key(Key::PageDown, Modifiers::NONE);
1408        tree.press_key(Key::PageDown, Modifiers::NONE);
1409        tree.press_key(Key::Enter, Modifiers::NONE);
1410        assert_eq!(fired.get(), Some(10), "one page in from the first row");
1411
1412        fired.set(None);
1413        tree.press_key(Key::PageDown, Modifiers::NONE);
1414        tree.press_key(Key::PageDown, Modifiers::NONE);
1415        tree.press_key(Key::Enter, Modifiers::NONE);
1416        assert_eq!(fired.get(), Some(24), "and it clamps at the last row");
1417
1418        fired.set(None);
1419        tree.press_key(Key::PageUp, Modifiers::NONE);
1420        tree.press_key(Key::PageUp, Modifiers::NONE);
1421        tree.press_key(Key::PageUp, Modifiers::NONE);
1422        tree.press_key(Key::Enter, Modifiers::NONE);
1423        assert_eq!(fired.get(), Some(0), "and at the first");
1424    }
1425
1426    #[test]
1427    fn home_focuses_first_item() {
1428        let fired = StdRc::new(StdCell::new(None));
1429        let mut tree = light_tree();
1430        let menu_id =
1431            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1432        tree.layout(SizeProposal::with_width(300.0));
1433        tree.focus(menu_id);
1434        // Navigate down twice to land on index 2, then Home → index 0.
1435        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1436        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1437        tree.press_key(Key::Home, Modifiers::NONE);
1438        tree.press_key(Key::Enter, Modifiers::NONE);
1439        assert_eq!(fired.get(), Some(0));
1440    }
1441
1442    #[test]
1443    fn end_focuses_last_item() {
1444        let fired = StdRc::new(StdCell::new(None));
1445        let mut tree = light_tree();
1446        let menu_id =
1447            menu_with_activation_probe(&mut tree, &["Save", "Open", "Quit"], fired.clone());
1448        tree.layout(SizeProposal::with_width(300.0));
1449        tree.focus(menu_id);
1450        tree.press_key(Key::End, Modifiers::NONE);
1451        tree.press_key(Key::Enter, Modifiers::NONE);
1452        assert_eq!(fired.get(), Some(2));
1453    }
1454
1455    #[test]
1456    fn arrow_down_wraps_past_last() {
1457        let fired = StdRc::new(StdCell::new(None));
1458        let mut tree = light_tree();
1459        let menu_id = menu_with_activation_probe(&mut tree, &["A", "B", "C"], fired.clone());
1460        tree.layout(SizeProposal::with_width(200.0));
1461        tree.focus(menu_id);
1462        for _ in 0..4 {
1463            tree.press_key(Key::ArrowDown, Modifiers::NONE);
1464        }
1465        // After 4 downs from "no focus", focus lands on index 0 (wrap).
1466        tree.press_key(Key::Enter, Modifiers::NONE);
1467        assert_eq!(fired.get(), Some(0));
1468    }
1469
1470    #[test]
1471    fn arrow_up_wraps_to_last() {
1472        let fired = StdRc::new(StdCell::new(None));
1473        let mut tree = light_tree();
1474        let menu_id = menu_with_activation_probe(&mut tree, &["A", "B", "C"], fired.clone());
1475        tree.layout(SizeProposal::with_width(200.0));
1476        tree.focus(menu_id);
1477        tree.press_key(Key::ArrowUp, Modifiers::NONE);
1478        // From "no focus" (treated as index 0), Up wraps to last (index 2).
1479        tree.press_key(Key::Enter, Modifiers::NONE);
1480        assert_eq!(fired.get(), Some(2));
1481    }
1482
1483    fn menu_with_submenu(tree: &mut WidgetTree) -> WidgetId {
1484        // Index 0 is a submenu trigger; index 1 is a plain item.
1485        let menu = MenuList::new()
1486            .item(MenuItem::submenu(lit!("More"), || {
1487                Box::new(MenuList::new().item(MenuItem::new(lit!("Child"))))
1488            }))
1489            .item(MenuItem::new(lit!("Plain")));
1490        tree.add(menu)
1491    }
1492
1493    #[test]
1494    fn submenu_opens_on_arrow_right_under_ltr() {
1495        let mut tree = light_tree();
1496        let menu_id = menu_with_submenu(&mut tree);
1497        tree.layout(SizeProposal::with_width(300.0));
1498        tree.focus(menu_id);
1499        tree.press_key(Key::ArrowDown, Modifiers::NONE); // focus submenu item (idx 0)
1500        assert!(tree.active_overlays().is_empty());
1501
1502        // Inline-back arrow under LTR (ArrowLeft) does not open.
1503        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1504        assert!(tree.active_overlays().is_empty());
1505
1506        // Inline-forward arrow (ArrowRight) opens the submenu.
1507        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1508        assert_eq!(tree.active_overlays().len(), 1);
1509    }
1510
1511    #[test]
1512    fn submenu_opens_on_arrow_left_under_rtl() {
1513        let mut tree = light_tree();
1514        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1515        let menu_id = menu_with_submenu(&mut tree);
1516        tree.layout(SizeProposal::with_width(300.0));
1517        tree.focus(menu_id);
1518        tree.press_key(Key::ArrowDown, Modifiers::NONE); // focus submenu item (idx 0)
1519        assert!(tree.active_overlays().is_empty());
1520
1521        // Under RTL, ArrowRight is the inline-back key — must NOT open.
1522        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1523        assert!(tree.active_overlays().is_empty());
1524
1525        // ArrowLeft is inline-forward under RTL — opens the submenu.
1526        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1527        assert_eq!(tree.active_overlays().len(), 1);
1528    }
1529
1530    #[test]
1531    fn type_ahead_no_match_does_not_change_focus() {
1532        // Typing a letter that doesn't prefix any label should leave
1533        // focus untouched — Enter then activates whatever was focused
1534        // before (or nothing).
1535        let fired = StdRc::new(StdCell::new(None));
1536        let mut tree = light_tree();
1537        let menu_id = menu_with_activation_probe(&mut tree, &["Save", "Open"], fired.clone());
1538        tree.layout(SizeProposal::with_width(200.0));
1539        tree.focus(menu_id);
1540        // Focus the first item explicitly.
1541        tree.press_key(Key::Home, Modifiers::NONE);
1542        // Type a no-match letter.
1543        tree.press_key(Key::Z, Modifiers::NONE);
1544        tree.press_key(Key::Enter, Modifiers::NONE);
1545        // Save (index 0) should still fire.
1546        assert_eq!(fired.get(), Some(0));
1547    }
1548
1549    #[test]
1550    fn mnemonic_beats_type_ahead_when_both_match() {
1551        // If a label like "&Open" is set up, pressing 'o' fires the
1552        // mnemonic directly, even though type-ahead would also match
1553        // "Open".
1554        let fired = StdRc::new(StdCell::new(None));
1555        let mut tree = light_tree();
1556        let menu_id = menu_with_activation_probe(&mut tree, &["&Save", "&Open"], fired.clone());
1557        tree.layout(SizeProposal::with_width(200.0));
1558        tree.focus(menu_id);
1559        tree.press_key(Key::O, Modifiers::NONE);
1560        // Mnemonic fires immediately — no Enter needed.
1561        assert_eq!(fired.get(), Some(1));
1562    }
1563
1564    #[test]
1565    fn separator_does_not_interfere_with_navigation() {
1566        let fired = StdRc::new(StdCell::new(None));
1567        let mut tree = light_tree();
1568        let menu_id = {
1569            let mut menu = MenuList::new();
1570            for (i, label) in ["Save", "Open", "Quit"].iter().enumerate() {
1571                let fired_for_this = fired.clone();
1572                menu = menu.item(
1573                    MenuItem::new(lit!(*label))
1574                        .on_activate_fn(move |_| fired_for_this.set(Some(i))),
1575                );
1576                if i == 0 {
1577                    menu = menu.separator();
1578                }
1579            }
1580            tree.add(menu)
1581        };
1582        tree.layout(SizeProposal::with_width(300.0));
1583        tree.focus(menu_id);
1584        // Type-ahead should still find "Open" — separator skipped.
1585        tree.press_key(Key::O, Modifiers::NONE);
1586        tree.press_key(Key::Enter, Modifiers::NONE);
1587        assert_eq!(fired.get(), Some(1));
1588    }
1589
1590    #[test]
1591    fn header_does_not_interfere_with_navigation() {
1592        let fired = StdRc::new(StdCell::new(None));
1593        let mut tree = light_tree();
1594        let menu_id = {
1595            let mut menu = MenuList::new();
1596            for (i, label) in ["Save", "Open", "Quit"].iter().enumerate() {
1597                let fired_for_this = fired.clone();
1598                menu = menu.item(
1599                    MenuItem::new(lit!(*label))
1600                        .on_activate_fn(move |_| fired_for_this.set(Some(i))),
1601                );
1602                if i == 0 {
1603                    // A non-navigable section caption between item 0 and item 1.
1604                    menu = menu.header(crate::GroupHeader::new(lit!("Recent")));
1605                }
1606            }
1607            tree.add(menu)
1608        };
1609        tree.layout(SizeProposal::with_width(300.0));
1610        tree.focus(menu_id);
1611        // Type-ahead resolves "Open" at item index 1 — the header occupies no
1612        // slot in the item/label index space, exactly like a separator.
1613        tree.press_key(Key::O, Modifiers::NONE);
1614        tree.press_key(Key::Enter, Modifiers::NONE);
1615        assert_eq!(fired.get(), Some(1));
1616    }
1617
1618    // Silence the unused-variable warning on the unused `list_id`
1619    // binding inside `menu_label`-style tests above, since each test
1620    // uses its locals.
1621    #[allow(dead_code)]
1622    fn _ignore_unused() {
1623        let _: Option<Signal<bool>> = None;
1624    }
1625
1626    #[derive(Debug)]
1627    struct FocusableLeaf;
1628    impl Widget for FocusableLeaf {
1629        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1630            ctx.apply_self_handlers(
1631                teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1632            );
1633            vec![]
1634        }
1635        fn layout_response(
1636            &self,
1637            proposal: SizeProposal,
1638            _ctx: &LayoutContext,
1639        ) -> teksilo_core::widget::LayoutResponse {
1640            proposal.resolve(12.0, 12.0).into()
1641        }
1642    }
1643
1644    /// Opening a submenu must not be mistaken for leaving the parent menu.
1645    ///
1646    /// A submenu's content is `add_detached_boxed`, so it is never an arena
1647    /// descendant of the menu that owns it — the only thing relating the two is
1648    /// the overlay manager's `parent_overlay` graph. A focus-out rule that
1649    /// asked the arena instead would close the parent the instant its own
1650    /// submenu opened.
1651    #[test]
1652    fn opening_a_submenu_keeps_the_parent_menu_open() {
1653        let mut tree = light_tree();
1654        let menu_id = menu_with_submenu(&mut tree);
1655        tree.layout(SizeProposal::with_width(300.0));
1656        tree.focus(menu_id);
1657        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1658        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1659
1660        assert_eq!(
1661            tree.active_overlays().len(),
1662            1,
1663            "the submenu is up and the parent menu is untouched"
1664        );
1665        assert!(
1666            tree.is_active(menu_id),
1667            "the parent MenuList must not have been dormanted"
1668        );
1669    }
1670
1671    /// Tab out of a submenu closes the whole cascade, not one level.
1672    ///
1673    /// APG is unqualified and plural about it: Tab "closes all menus and
1674    /// submenus". Walking up `parent_overlay` and dismissing the outermost
1675    /// level gets that for free — `dismiss_immediate` already cascades back
1676    /// down to every descendant.
1677    #[test]
1678    fn tab_out_of_a_submenu_closes_the_whole_cascade() {
1679        let mut tree = light_tree();
1680        let menu_id = menu_with_submenu(&mut tree);
1681        let after = tree.add(FocusableLeaf);
1682        tree.layout(SizeProposal::with_width(300.0));
1683        tree.focus(menu_id);
1684        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1685        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1686        assert_eq!(
1687            tree.active_overlays().len(),
1688            1,
1689            "precondition: submenu open"
1690        );
1691
1692        tree.press_key(Key::Tab, Modifiers::NONE);
1693        assert_eq!(tree.focused(), Some(after));
1694        assert!(
1695            tree.active_overlays().is_empty(),
1696            "one Tab must leave no menu behind"
1697        );
1698    }
1699
1700    // --- Decorated rows: a `MenuItem` carrying a builder method ---
1701    //
1702    // Any `WidgetBuilder` call (`.context_menu`, `.focusable`, …) wraps the
1703    // item in a `WidgetWithHandlers<MenuItem>`. Every `MenuList` feature that
1704    // reads the item's concrete type has to keep working through that wrapper,
1705    // or a row silently degrades with no error anywhere.
1706
1707    /// Wrap a `MenuItem` the way a caller that needs a per-row context menu
1708    /// does — the shape that used to de-register the row from `MenuList`.
1709    fn decorated(item: MenuItem) -> impl Widget + 'static {
1710        item.context_menu(|_pos, _ctx| None)
1711    }
1712
1713    #[test]
1714    fn a_decorated_item_keeps_its_mnemonic() {
1715        let fired = StdRc::new(StdCell::new(None));
1716        let mut tree = light_tree();
1717        let mut menu = MenuList::new();
1718        for (i, label) in ["&Save", "&Open", "&Quit"].iter().enumerate() {
1719            let fired_for_this = fired.clone();
1720            menu = menu.item(decorated(
1721                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
1722            ));
1723        }
1724        let menu_id = tree.add(menu);
1725        tree.layout(SizeProposal::with_width(300.0));
1726        tree.focus(menu_id);
1727
1728        tree.press_key(Key::O, Modifiers::NONE);
1729        assert_eq!(
1730            fired.get(),
1731            Some(1),
1732            "the mnemonic is read off the MenuItem; decorating it must not hide it"
1733        );
1734    }
1735
1736    #[test]
1737    fn a_decorated_item_keeps_its_type_ahead_label() {
1738        let fired = StdRc::new(StdCell::new(None));
1739        let mut tree = light_tree();
1740        let mut menu = MenuList::new();
1741        // No `&` markers here, so only the type-ahead path can reach a row.
1742        for (i, label) in ["Alpha", "Beta", "Gamma"].iter().enumerate() {
1743            let fired_for_this = fired.clone();
1744            menu = menu.item(decorated(
1745                MenuItem::new(lit!(*label)).on_activate_fn(move |_| fired_for_this.set(Some(i))),
1746            ));
1747        }
1748        let menu_id = tree.add(menu);
1749        tree.layout(SizeProposal::with_width(300.0));
1750        tree.focus(menu_id);
1751
1752        tree.press_key(Key::G, Modifiers::NONE);
1753        tree.press_key(Key::Enter, Modifiers::NONE);
1754        assert_eq!(
1755            fired.get(),
1756            Some(2),
1757            "type-ahead reads the label off the MenuItem, through any wrapper"
1758        );
1759    }
1760
1761    #[test]
1762    fn a_decorated_submenu_trigger_still_opens_on_the_inline_arrow() {
1763        let mut tree = light_tree();
1764        let menu = MenuList::new()
1765            .item(decorated(MenuItem::submenu(lit!("More"), || {
1766                Box::new(MenuList::new().item(MenuItem::new(lit!("Child"))))
1767            })))
1768            .item(MenuItem::new(lit!("Plain")));
1769        let menu_id = tree.add(menu);
1770        tree.layout(SizeProposal::with_width(300.0));
1771        tree.focus(menu_id);
1772
1773        tree.press_key(Key::ArrowDown, Modifiers::NONE); // highlight the trigger
1774        assert!(tree.active_overlays().is_empty(), "precondition: closed");
1775
1776        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1777        assert_eq!(
1778            tree.active_overlays().len(),
1779            1,
1780            "the submenu flag is read off the MenuItem, through any wrapper"
1781        );
1782    }
1783
1784    // --- Keyboard navigation scrolls a capped menu ---
1785
1786    /// A menu row that records the absolute bounds it was last laid out at.
1787    ///
1788    /// The accessibility tree is not a usable probe here: its node bounds are
1789    /// captured when the node is emitted and a pure scroll does not re-emit
1790    /// them, so a stale rect reads back as "nothing moved" whether or not the
1791    /// scroll happened. `place_children` is the layout's own answer.
1792    #[derive(Debug)]
1793    struct ProbeRow {
1794        seen: StdRc<Cell<Rect>>,
1795    }
1796
1797    impl Widget for ProbeRow {
1798        fn layout_response(
1799            &self,
1800            proposal: SizeProposal,
1801            _ctx: &LayoutContext,
1802        ) -> teksilo_core::widget::LayoutResponse {
1803            proposal.resolve(200.0, 24.0).into()
1804        }
1805
1806        fn place_children(
1807            &self,
1808            bounds: Rect,
1809            _proposal: SizeProposal,
1810            _children: &mut [WidgetPlacement],
1811            _ctx: &LayoutContext,
1812        ) {
1813            self.seen.set(bounds);
1814        }
1815    }
1816
1817    #[test]
1818    fn keyboard_navigation_scrolls_a_capped_menu_to_the_highlight() {
1819        // Past `max_visible_items` the panel is a `ScrollArea`, and arrow / End
1820        // navigation moves `focused_index` rather than real tree focus — so the
1821        // framework's own focus-follow scroll never runs. Without an explicit
1822        // reveal the highlight walks straight out of the viewport and the menu
1823        // looks frozen from the fifth row down.
1824        let seen = StdRc::new(Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0)));
1825        let mut tree = light_tree();
1826        let mut menu = MenuList::new().max_visible_items(4);
1827        for i in 0..19 {
1828            menu = menu.item(MenuItem::new(lit!(format!("Entry {i}"))));
1829        }
1830        // The last row is the probe, so `End` lands on it.
1831        menu = menu.item(ProbeRow { seen: seen.clone() });
1832        let menu_id = tree.add(menu);
1833        tree.layout(SizeProposal::with_width(300.0));
1834        tree.focus(menu_id);
1835
1836        let panel = tree.bounds(menu_id);
1837        let before = seen.get();
1838        assert!(
1839            before.y > panel.bottom(),
1840            "precondition: the last row starts below the capped panel \
1841             (row y={}, panel bottom={})",
1842            before.y,
1843            panel.bottom()
1844        );
1845
1846        tree.press_key(Key::End, Modifiers::NONE);
1847        // The reveal is queued from the handler and applied by the enclosing
1848        // ScrollArea; bounds only move on the next layout pass.
1849        tree.layout(SizeProposal::with_width(300.0));
1850
1851        let after = seen.get();
1852        assert!(
1853            after.y >= panel.y - 0.5 && after.bottom() <= panel.bottom() + 0.5,
1854            "End must scroll the last row into the panel, got {}..{} for a panel of {}..{}",
1855            after.y,
1856            after.bottom(),
1857            panel.y,
1858            panel.bottom()
1859        );
1860    }
1861}