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