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}