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