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