Skip to main content

teksilo_widgets/
menu_list.rs

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