1use tessera_ui::{
7 AxisConstraint, Callback, ClipRegion, ClipShape, Color, ComputedData, CursorEventContent, Dp,
8 DrawModifierContent, DrawModifierContext, DrawModifierNode, FocusDirection, FocusManager,
9 FocusRequester, FocusScopeNode, FocusTraversalPolicy, FrameNanosControl, KeyboardInput,
10 KeyboardInputModifierNode, LayoutResult, MOUSE_POINTER_ID, MeasurementError, Modifier,
11 ParentConstraint, PointerInput, PointerInputModifierNode, Px, PxPosition, PxSize, RenderSlot,
12 RenderSlotWith, State,
13 accesskit::{HasPopup, Role, Toggled},
14 current_frame_nanos,
15 layout::{LayoutPlacementTarget, MeasureScope, PlacementResult, PlacementScope, layout},
16 modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
17 provide_context, receive_frame_nanos, remember, tessera, use_context, winit,
18};
19
20use crate::{
21 alignment::CrossAxisAlignment,
22 checkmark::checkmark,
23 column::column,
24 icon::icon,
25 material_icons::filled,
26 modifier::{ModifierExt as _, with_keyboard_input, with_pointer_input},
27 painter::Painter,
28 pos_misc::is_position_in_rect,
29 row::row,
30 scrollable::ScrollableController,
31 shape_def::Shape,
32 spacer::spacer,
33 surface::{SurfaceStyle, surface},
34 text::text,
35 theme::{MaterialAlpha, MaterialTheme},
36};
37
38use host::{MenuHost, MenuPopupLease, MenuPopupRequest, menu_host};
39use motion::{MenuShapeHover, MenuShapeMotion};
40
41pub use appearance::{MenuColorStyle, MenuDefaults, MenuPosition, menu_group};
42
43mod appearance;
44mod host;
45mod motion;
46mod typeahead;
47
48const MENU_MIN_WIDTH: Dp = Dp(112.0);
49const MENU_MAX_WIDTH: Dp = Dp(280.0);
50const MENU_MAX_HEIGHT: Dp = Dp(320.0);
51const MENU_VERTICAL_GAP: Dp = Dp(4.0);
52const MENU_HORIZONTAL_PADDING: Dp = Dp(12.0);
53const MENU_LEADING_SIZE: Dp = Dp(20.0);
54const MENU_ITEM_HEIGHT: Dp = MenuDefaults::ITEM_HEIGHT;
55const MENU_TRAILING_SPACING: Dp = Dp(12.0);
56const MENU_HOVER_DELAY_NANOS: u64 = 150_000_000;
57
58fn default_menu_width() -> AxisConstraint {
59 AxisConstraint::new(Px::from(MENU_MIN_WIDTH), Some(Px::from(MENU_MAX_WIDTH)))
60}
61
62fn default_menu_modifier() -> Modifier {
63 Modifier::new().constrain(Some(default_menu_width()), None)
64}
65
66fn default_max_height() -> Option<Px> {
67 Some(Px::from(MENU_MAX_HEIGHT))
68}
69
70fn default_menu_shape() -> Shape {
71 MenuDefaults::group_shape(MenuPosition::Standalone)
72}
73
74fn default_menu_color() -> Color {
75 Color::new(1.0, 1.0, 1.0, 1.0)
76}
77
78fn default_scrim_color() -> Color {
79 Color::new(0.0, 0.0, 0.0, 0.0)
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct MenuAnchor {
85 pub origin: PxPosition,
87 pub size: PxSize,
89}
90
91impl MenuAnchor {
92 pub fn new(origin: PxPosition, size: PxSize) -> Self {
94 Self { origin, size }
95 }
96
97 pub fn at(origin: PxPosition) -> Self {
99 Self::new(origin, PxSize::new(Px::ZERO, Px::ZERO))
100 }
101
102 pub fn from_dp(origin: (Dp, Dp), size: (Dp, Dp)) -> Self {
104 Self::new(
105 PxPosition::new(origin.0.into(), origin.1.into()),
106 PxSize::new(size.0.into(), size.1.into()),
107 )
108 }
109}
110
111impl Default for MenuAnchor {
112 fn default() -> Self {
113 Self::at(PxPosition::new(Px::ZERO, Px::ZERO))
114 }
115}
116
117#[derive(Default, Clone, PartialEq)]
119pub struct MenuController {
120 is_open: bool,
121 anchor: Option<MenuAnchor>,
122 exit_direction: Option<FocusDirection>,
123}
124
125impl MenuController {
126 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn open(&mut self) {
137 self.is_open = true;
138 self.exit_direction = None;
139 }
140
141 pub fn open_at(&mut self, anchor: MenuAnchor) {
146 self.anchor = Some(anchor);
147 self.is_open = true;
148 self.exit_direction = None;
149 }
150
151 pub fn close(&mut self) {
153 self.is_open = false;
154 self.exit_direction = None;
155 }
156
157 pub fn toggle(&mut self) {
159 self.is_open = !self.is_open;
160 self.exit_direction = None;
161 }
162
163 pub fn is_open(&self) -> bool {
165 self.is_open
166 }
167
168 fn snapshot(&self) -> (bool, Option<MenuAnchor>) {
169 (self.is_open, self.anchor)
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub enum MenuPlacement {
176 #[default]
178 BelowStart,
179 BelowEnd,
181 AboveStart,
183 AboveEnd,
185 RightStart,
187 LeftStart,
189}
190
191#[derive(Clone, Copy, PartialEq)]
193pub struct MenuScope {
194 host: MenuHost,
195 controller: State<MenuController>,
196 typeahead: typeahead::MenuTypeahead,
197}
198
199#[derive(Clone, PartialEq)]
200struct MenuProviderConfig {
201 pub host: Option<MenuHost>,
202 pub anchor: Option<MenuAnchor>,
203 pub window_anchor: Option<State<tessera_ui::LayoutCoordinates>>,
204 pub bounds: Option<MenuBoundsHandle>,
205 pub focus_scope: Option<FocusScopeNode>,
206 pub parent_request: Option<State<MenuPopupRequest>>,
207 pub grouped: bool,
208 pub color_style: MenuColorStyle,
209 pub placement: MenuPlacement,
210 pub offset: [Dp; 2],
211 pub modifier: Modifier,
212 pub max_height: Option<Px>,
213 pub shape: Shape,
214 pub animate_shape: bool,
215 pub elevation: Dp,
216 pub container_color: Color,
217 pub scrim_color: Color,
218 pub close_on_background: bool,
219 pub close_on_escape: bool,
220 pub on_dismiss: Option<Callback>,
221 pub is_open: bool,
222 pub controller: Option<State<MenuController>>,
223 pub focus_restorer_fallback: Option<FocusRequester>,
224 pub main_content: Option<RenderSlot>,
225 pub menu_content: Option<RenderSlotWith<MenuScope>>,
226}
227
228impl Default for MenuProviderConfig {
229 fn default() -> Self {
230 Self {
231 host: None,
232 anchor: None,
233 window_anchor: None,
234 bounds: None,
235 focus_scope: None,
236 parent_request: None,
237 grouped: false,
238 color_style: MenuColorStyle::Standard,
239 placement: MenuPlacement::default(),
240 offset: [Dp(0.0), MENU_VERTICAL_GAP],
241 modifier: default_menu_modifier(),
242 max_height: default_max_height(),
243 shape: default_menu_shape(),
244 animate_shape: true,
245 elevation: Dp(3.0),
246 container_color: default_menu_color(),
247 scrim_color: default_scrim_color(),
248 close_on_background: true,
249 close_on_escape: true,
250 on_dismiss: None,
251 is_open: false,
252 controller: None,
253 focus_restorer_fallback: None,
254 main_content: None,
255 menu_content: None,
256 }
257 }
258}
259
260#[derive(Clone, PartialEq, Copy)]
261struct MenuBounds {
262 origin: PxPosition,
263 size: ComputedData,
264}
265
266#[derive(Clone, Copy)]
267struct MenuBoundsHandle(State<Option<MenuBounds>>);
268
269impl PartialEq for MenuBoundsHandle {
270 fn eq(&self, other: &Self) -> bool {
271 self.0 == other.0
272 }
273}
274
275impl Default for MenuBounds {
276 fn default() -> Self {
277 Self {
278 origin: PxPosition::new(Px::ZERO, Px::ZERO),
279 size: ComputedData::ZERO,
280 }
281 }
282}
283
284#[derive(Clone, Copy, PartialEq)]
285enum MenuAnchorSource {
286 Local(Option<tessera_ui::FloatRect>),
287 Window {
288 anchor: State<tessera_ui::LayoutCoordinates>,
289 origin: State<tessera_ui::LayoutCoordinates>,
290 },
291}
292
293impl MenuAnchorSource {
294 fn resolve(self, main: ComputedData) -> tessera_ui::FloatRect {
295 match self {
296 Self::Local(anchor) => anchor.unwrap_or_else(|| {
297 tessera_ui::FloatRect::from_position_size(PxPosition::ZERO, main.into())
298 }),
299 Self::Window { anchor, origin } => origin
300 .get()
301 .local_to_window
302 .inverse_rect(anchor.get().window_bounds()),
303 }
304 }
305}
306
307#[derive(Clone)]
308struct MenuLayout {
309 is_open: bool,
310 has_background: bool,
311 placement: MenuPlacement,
312 offset: [Dp; 2],
313 anchor: MenuAnchorSource,
314 bounds: State<Option<MenuBounds>>,
315}
316
317struct MenuScrollClip {
318 controller: State<ScrollableController>,
319}
320
321impl DrawModifierNode for MenuScrollClip {
322 fn draw(&self, ctx: &mut DrawModifierContext<'_, '_>, content: &mut dyn DrawModifierContent) {
323 let mut metadata = ctx.render_input.metadata_mut();
324 let size = metadata.computed_data().expect("measured menu viewport");
325 let overflow = self
326 .controller
327 .with(|scroll| scroll.child_size().height > size.height);
328 let margin = Dp(16.0).to_pixels_f32();
331 let vertical_margin = if overflow { 0.0 } else { margin };
332 metadata.set_clip_region(ClipRegion {
333 position: PxPosition::from_f32_xy(-margin, -vertical_margin),
334 size: [
335 size.width.to_f32() + 2.0 * margin,
336 size.height.to_f32() + 2.0 * vertical_margin,
337 ],
338 shape: ClipShape::RoundedRectangle {
339 radii: [0.0; 4],
340 exponents: [2.0; 4],
341 },
342 });
343 content.draw(ctx.render_input);
344 }
345}
346
347impl PartialEq for MenuLayout {
348 fn eq(&self, other: &Self) -> bool {
349 self.is_open == other.is_open
350 && self.has_background == other.has_background
351 && self.placement == other.placement
352 && self.offset == other.offset
353 && self.anchor == other.anchor
354 && self.bounds == other.bounds
355 }
356}
357
358impl MenuLayout {
359 fn place<T: LayoutPlacementTarget>(
360 &self,
361 children: &[T],
362 sizes: &[ComputedData],
363 fallback: ComputedData,
364 ) -> LayoutResult {
365 let main = sizes[0];
366 let mut result = LayoutResult::new(main);
367 result.place_child(children[0], PxPosition::ZERO);
368 if !self.is_open {
369 self.bounds.set_if_changed(None);
370 return result;
371 }
372 let (menu_index, available) = if self.has_background {
373 result.place_child(children[1], PxPosition::ZERO);
374 let background = sizes[1];
375 (
376 2,
377 if background.width > Px::ZERO && background.height > Px::ZERO {
378 background
379 } else {
380 fallback
381 },
382 )
383 } else {
384 (1, fallback)
385 };
386 let menu_size = sizes[menu_index];
387 let position = resolve_menu_position(
388 self.anchor.resolve(main),
389 self.placement,
390 menu_size,
391 available,
392 self.offset,
393 );
394 result.place_child(children[menu_index], position);
395 self.bounds.set_if_changed(Some(MenuBounds {
396 origin: position,
397 size: menu_size,
398 }));
399 result
400 }
401}
402
403impl tessera_ui::LayoutModifierNode for MenuLayout {
404 fn measure_eq(&self, other: &Self) -> bool {
405 self.is_open == other.is_open && self.has_background == other.has_background
406 }
407
408 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
409 let children = input.children();
410 let constraint = input.parent_constraint().without_min();
411 let sizes = children
412 .iter()
413 .map(|child| child.measure(input, &constraint).map(|child| child.size()))
414 .collect::<Result<Vec<_>, _>>()?;
415 let fallback = extract_available_size(input.parent_constraint());
416 Ok(input.placement(|| self.place(&children, &sizes, fallback)))
417 }
418
419 fn place_children(
420 &self,
421 scope: &PlacementScope<'_>,
422 _inner: &PlacementResult,
423 ) -> Option<PlacementResult> {
424 let children = scope.children();
425 let sizes = children
426 .iter()
427 .map(|child| child.size())
428 .collect::<Vec<_>>();
429 Some(
430 self.place(
431 &children,
432 &sizes,
433 extract_available_size(scope.parent_constraint()),
434 )
435 .into_placement_result(),
436 )
437 }
438}
439
440fn resolve_menu_position(
441 anchor: tessera_ui::FloatRect,
442 placement: MenuPlacement,
443 menu_size: ComputedData,
444 available: ComputedData,
445 offset: [Dp; 2],
446) -> PxPosition {
447 let anchor_x = anchor.x;
448 let anchor_y = anchor.y;
449 let anchor_end_x = anchor_x + anchor.width;
450 let anchor_end_y = anchor_y + anchor.height;
451
452 let width = menu_size.width.to_f32();
453 let height = menu_size.height.to_f32();
454 let dx = offset[0].to_pixels_f32();
455 let dy = offset[1].to_pixels_f32();
456 let start_x = anchor_x + dx;
457 let end_x = anchor_end_x - width + dx;
458 let above = anchor_y - height - dy;
459 let below = anchor_end_y + dy;
460 let right = anchor_end_x + dx;
461 let left = anchor_x - width - dx;
462 let start_y = anchor_y + dy;
463 let end_y = anchor_end_y - height + dy;
464 let (x, alternate_x, y, alternate_y) = match placement {
465 MenuPlacement::BelowStart => (start_x, end_x, below, above),
466 MenuPlacement::BelowEnd => (end_x, start_x, below, above),
467 MenuPlacement::AboveStart => (start_x, end_x, above, below),
468 MenuPlacement::AboveEnd => (end_x, start_x, above, below),
469 MenuPlacement::RightStart => (right, left, start_y, end_y),
470 MenuPlacement::LeftStart => (left, right, start_y, end_y),
471 };
472 PxPosition::from_f32_xy(
473 fit_menu_axis(x, alternate_x, width, available.width.to_f32()),
474 fit_menu_axis(y, alternate_y, height, available.height.to_f32()),
475 )
476}
477
478fn fit_menu_axis(preferred: f32, alternate: f32, extent: f32, available: f32) -> f32 {
479 let max = (available - extent).max(0.0);
480 if preferred >= 0.0 && preferred <= max {
481 preferred
482 } else if alternate >= 0.0 && alternate <= max {
483 alternate
484 } else {
485 preferred.clamp(0.0, max)
486 }
487}
488
489fn extract_available_size(constraint: ParentConstraint<'_>) -> ComputedData {
490 let width = constraint.width().resolve_max().unwrap_or(Px::MAX);
491 let height = constraint.height().resolve_max().unwrap_or(Px::MAX);
492
493 ComputedData { width, height }
494}
495
496fn is_click_outside_menu(cursor_position: Option<PxPosition>, bounds: Option<MenuBounds>) -> bool {
497 let Some(bounds) = bounds else {
498 return false;
499 };
500
501 cursor_position
502 .map(|pos| !is_position_in_rect(pos, bounds.origin, bounds.size.width, bounds.size.height))
503 .unwrap_or(false)
504}
505
506fn apply_close_action(controller: State<MenuController>, on_dismiss: &Option<Callback>) {
507 if let Some(callback) = on_dismiss {
508 callback.call();
509 }
510 controller.with_mut(|c| c.close());
511}
512
513fn close_for_traversal(
514 controller: State<MenuController>,
515 on_dismiss: &Option<Callback>,
516 reverse: bool,
517) {
518 apply_close_action(controller, on_dismiss);
519 controller.with_mut(|controller| {
520 controller.exit_direction = Some(if reverse {
521 FocusDirection::Previous
522 } else {
523 FocusDirection::Next
524 });
525 });
526}
527
528struct MenuKeyboardBoundary {
529 controller: State<MenuController>,
530 rendered_open: bool,
531}
532
533impl MenuKeyboardBoundary {
534 fn check(&self) {
535 if self.controller.with(MenuController::is_open) != self.rendered_open {
536 FocusManager::current().defer_keyboard_until_layout();
537 }
538 }
539}
540
541impl KeyboardInputModifierNode for MenuKeyboardBoundary {
542 fn on_keyboard_input(&self, _input: KeyboardInput<'_>) {
543 self.check();
544 }
545}
546
547impl PointerInputModifierNode for MenuKeyboardBoundary {
548 fn on_pointer_input(&self, _input: PointerInput<'_>) {
549 self.check();
550 }
551}
552
553#[tessera]
626pub fn menu_provider(
627 anchor: Option<MenuAnchor>,
628 window_anchor: Option<State<tessera_ui::LayoutCoordinates>>,
629 grouped: Option<bool>,
630 color_style: Option<MenuColorStyle>,
631 placement: Option<MenuPlacement>,
632 offset: Option<[Dp; 2]>,
633 modifier: Option<Modifier>,
634 max_height: Option<Px>,
635 shape: Option<Shape>,
636 elevation: Option<Dp>,
637 container_color: Option<Color>,
638 scrim_color: Option<Color>,
639 close_on_background: Option<bool>,
640 close_on_escape: Option<bool>,
641 on_dismiss: Option<Callback>,
642 is_open: Option<bool>,
643 controller: Option<State<MenuController>>,
644 focus_restorer_fallback: Option<FocusRequester>,
645 main_content: Option<RenderSlot>,
646 menu_content: Option<RenderSlotWith<MenuScope>>,
647) {
648 let controlled_is_open = is_open;
649 let is_open = is_open.unwrap_or(false);
650 let grouped = grouped.unwrap_or(false);
651 let inherited_style = use_context::<MenuColorStyle>().map(|style| style.get());
652 let color_style = color_style.or(inherited_style).unwrap_or_default();
653 let scheme = use_context::<MaterialTheme>()
654 .expect("MaterialTheme must be provided")
655 .get()
656 .color_scheme;
657 let mut provider_args = MenuProviderConfig {
658 host: None,
659 anchor,
660 window_anchor,
661 bounds: None,
662 focus_scope: None,
663 parent_request: None,
664 grouped,
665 color_style,
666 placement: placement.unwrap_or_default(),
667 offset: offset.unwrap_or([Dp(0.0), MENU_VERTICAL_GAP]),
668 modifier: modifier.unwrap_or_else(default_menu_modifier),
669 max_height: max_height.or_else(default_max_height),
670 animate_shape: shape.is_none(),
671 shape: shape.unwrap_or_else(default_menu_shape),
672 elevation: elevation.unwrap_or(Dp(3.0)),
673 container_color: container_color.unwrap_or_else(|| {
674 if grouped {
675 Color::TRANSPARENT
676 } else {
677 color_style.container(&scheme)
678 }
679 }),
680 scrim_color: scrim_color.unwrap_or_else(default_scrim_color),
681 close_on_background: close_on_background.unwrap_or(true),
682 close_on_escape: close_on_escape.unwrap_or(true),
683 on_dismiss,
684 is_open,
685 controller,
686 focus_restorer_fallback,
687 main_content,
688 menu_content,
689 };
690 let controller = if let Some(controller) = provider_args.controller {
691 controller
692 } else {
693 remember(MenuController::new)
694 };
695
696 if let Some(target) = controlled_is_open
700 && controller.with(|c| c.is_open()) != target
701 {
702 controller.with_mut(|c| {
703 if target {
704 c.open();
705 } else {
706 c.close();
707 }
708 });
709 }
710
711 provider_args.controller = Some(controller);
712 menu_host().provider(provider_args);
713}
714
715#[tessera]
716fn menu_provider_content(provider: Option<MenuProviderConfig>) {
717 let provider_args = provider.unwrap_or_default();
718 let controller = provider_args.controller.expect("menu controller");
719 let host = provider_args.host.expect("menu host");
720
721 let main_content = provider_args.main_content.unwrap_or_else(RenderSlot::empty);
722 let menu_content = if let Some(content) = provider_args.menu_content {
723 content
724 } else {
725 RenderSlotWith::new(|_| {})
726 };
727 let menu_open_state = remember(|| false);
728 let main_focus_scope = remember(FocusScopeNode::new).get();
729 let internal_focus_scope = remember(FocusScopeNode::new).get();
730 let menu_focus_scope = provider_args.focus_scope.unwrap_or(internal_focus_scope);
731 let pending_focus_restore = remember(|| None::<Option<FocusRequester>>);
732
733 let (is_open, anchor) = controller.with(|c| c.snapshot());
734 let anchor =
735 if let Some(anchor) = provider_args.window_anchor {
736 MenuAnchorSource::Window {
737 anchor,
738 origin: host.origin,
739 }
740 } else {
741 MenuAnchorSource::Local(provider_args.anchor.or(anchor).map(|anchor| {
742 tessera_ui::FloatRect::from_position_size(anchor.origin, anchor.size)
743 }))
744 };
745 let mut just_opened = false;
746 let mut just_closed = false;
747 menu_open_state.with_mut(|was_open| {
748 just_opened = !*was_open && is_open;
749 just_closed = *was_open && !is_open;
750 *was_open = is_open;
751 });
752 if just_opened || just_closed {
753 FocusManager::current().defer_keyboard_until_layout();
754 }
755 let root_restore = just_closed
756 && provider_args.parent_request.is_none()
757 && (host.take_root_restore() || host.popup_has_focus());
758 let can_restore =
759 provider_args.parent_request.is_none() || host.root.with(MenuController::is_open);
760 if just_closed && can_restore && (menu_focus_scope.has_focus() || root_restore) {
761 pending_focus_restore.set(Some(provider_args.focus_restorer_fallback));
762 }
763 let internal_bounds = remember(|| None);
764 let bounds = provider_args
765 .bounds
766 .clone()
767 .map(|bounds| bounds.0)
768 .unwrap_or(internal_bounds);
769
770 let bounds = bounds;
772 let on_dismiss = provider_args.on_dismiss;
773 let close_on_escape = provider_args.close_on_escape;
774 let close_on_background = provider_args.close_on_background;
775 let parent_request = provider_args.parent_request;
776 let mut modifier = Modifier::new();
777 if pending_focus_restore.get().is_some() {
778 modifier = modifier.on_globally_positioned(move |_| {
781 if let Some(fallback) = pending_focus_restore.with_mut(Option::take)
782 && !controller.with(MenuController::is_open)
783 {
784 if let Some(fallback) = fallback {
785 fallback.request_focus();
786 } else {
787 main_focus_scope.restore_focus();
788 }
789 if let Some(direction) = controller.with_mut(|menu| menu.exit_direction.take()) {
790 FocusManager::current().request_move_focus(direction);
791 }
792 }
793 });
794 }
795 if is_open && parent_request.is_none() {
796 modifier = with_pointer_input(modifier, {
797 let bounds = bounds;
798 move |mut input| {
799 let cursor_position = input.cursor_position_rel;
800 let menu_bounds = bounds.get();
801 let should_close_click = close_on_background
802 && input.has_unconsumed_release()
803 && is_click_outside_menu(cursor_position, menu_bounds);
804
805 input.block_all();
806
807 if should_close_click {
808 apply_close_action(controller, &on_dismiss);
809 }
810 }
811 });
812 }
813
814 if is_open && close_on_escape {
815 let on_dismiss = provider_args.on_dismiss;
816 modifier = with_keyboard_input(modifier, move |mut input| {
817 let should_close_escape = input.keyboard_events.iter().any(|event| {
818 event.state == winit::event::ElementState::Pressed
819 && matches!(
820 event.physical_key,
821 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape)
822 )
823 });
824 if should_close_escape {
825 apply_close_action(controller, &on_dismiss);
826 input.block_keyboard();
827 }
828 });
829 }
830
831 if parent_request.is_none() {
832 modifier = modifier.push_pointer_input(MenuKeyboardBoundary {
833 controller,
834 rendered_open: is_open,
835 });
836 }
837 modifier = modifier.push_keyboard_input(MenuKeyboardBoundary {
838 controller,
839 rendered_open: is_open,
840 });
841 layout()
842 .modifier(modifier)
843 .policy(MenuLayout {
844 is_open,
845 has_background: parent_request.is_none(),
846 placement: provider_args.placement,
847 offset: provider_args.offset,
848 anchor,
849 bounds: bounds,
850 })
851 .child(move || {
852 layout()
853 .modifier(Modifier::new().focus_scope_with(main_focus_scope))
854 .child_shared(main_content);
855 if is_open {
856 let bounds = bounds;
857 if parent_request.is_none() {
858 surface()
859 .style(SurfaceStyle::Filled {
860 color: provider_args.scrim_color,
861 })
862 .modifier(with_pointer_input(
863 Modifier::new().fill_max_size(),
864 move |mut input| {
865 let should_close = close_on_background
866 && input.has_unconsumed_release()
867 && is_click_outside_menu(
868 input.cursor_position_rel,
869 bounds.get(),
870 );
871 input.block_all();
872 if should_close {
873 apply_close_action(controller, &on_dismiss);
874 }
875 },
876 ))
877 .child(|| {});
878 }
879
880 menu_panel()
881 .provider(provider_args.clone())
882 .controller(controller)
883 .focus_scope(menu_focus_scope)
884 .menu_content_shared(menu_content)
885 .just_opened(just_opened);
886 }
887 });
888}
889
890fn closes_submenu(
891 is_submenu: bool,
892 placement: MenuPlacement,
893 key: winit::keyboard::NamedKey,
894) -> bool {
895 is_submenu
896 && matches!(
897 (placement, key),
898 (
899 MenuPlacement::RightStart,
900 winit::keyboard::NamedKey::ArrowLeft
901 ) | (
902 MenuPlacement::LeftStart,
903 winit::keyboard::NamedKey::ArrowRight
904 )
905 )
906}
907
908#[tessera]
909fn menu_panel(
910 provider: Option<MenuProviderConfig>,
911 controller: Option<State<MenuController>>,
912 focus_scope: Option<FocusScopeNode>,
913 menu_content: Option<RenderSlotWith<MenuScope>>,
914 just_opened: Option<bool>,
915) {
916 let provider = provider.unwrap_or_default();
917 let just_opened = just_opened.unwrap_or(false);
918 let controller = controller.expect("menu_panel requires controller");
919 let menu_content = menu_content.expect("menu_panel requires menu content");
920 let focus_scope = focus_scope.expect("menu_panel requires focus scope");
921 let on_dismiss = provider.on_dismiss;
922 let placement = provider.placement;
923 let is_submenu = provider.parent_request.is_some();
924 let typeahead = remember(typeahead::Registry::default);
925 let scroll = remember(ScrollableController::new);
926 let color_style = provider.color_style;
927 let grouped = provider.grouped;
928 let host = provider.host.expect("menu host");
929 let frame = current_frame_nanos();
930 let motion = remember(|| MenuShapeMotion::new(MenuPosition::Standalone, frame));
931 let focused = focus_scope.has_focus();
932 if !grouped && provider.animate_shape {
933 if motion.with(|motion| motion.needs_update(MenuPosition::Standalone, focused)) {
934 motion.with_mut(|motion| motion.update(MenuPosition::Standalone, focused, frame));
935 }
936 if !motion.with(MenuShapeMotion::is_idle) {
937 receive_frame_nanos(move |frame| motion::advance(motion, frame));
938 }
939 }
940 let hover = MenuShapeHover {
941 motion,
942 host: Some(host.clone()),
943 controller: Some(controller),
944 };
945
946 let mut modifier = with_keyboard_input(
947 Modifier::new()
948 .layout_coordinate_space()
949 .focus_scope_with(focus_scope)
950 .focus_traversal_policy(FocusTraversalPolicy::vertical().wrap(true)),
951 move |mut input| {
952 if input.key_modifiers.control_key()
953 || input.key_modifiers.alt_key()
954 || input.key_modifiers.super_key()
955 {
956 return;
957 }
958
959 let mut handled = false;
960 for event in input.keyboard_events.iter() {
961 if event.state != winit::event::ElementState::Pressed {
962 continue;
963 }
964
965 handled = match &event.logical_key {
966 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab) => {
967 host.exit(input.key_modifiers.shift_key());
968 true
969 }
970 winit::keyboard::Key::Named(key)
971 if closes_submenu(is_submenu, placement, *key) =>
972 {
973 apply_close_action(controller, &on_dismiss);
974 true
975 }
976 winit::keyboard::Key::Character(text) => {
977 typeahead::type_text(typeahead, text.as_str(), current_frame_nanos())
978 }
979 _ => false,
980 };
981
982 if handled {
983 break;
984 }
985 }
986
987 if handled {
988 input.block_keyboard();
989 }
990 },
991 );
992 if just_opened {
993 modifier = modifier.on_globally_positioned(move |_| {
994 if controller.with(MenuController::is_open) {
995 focus_scope.request_focus();
996 }
997 });
998 }
999 if !grouped && provider.animate_shape {
1000 modifier = modifier.push_pointer_preview_input(hover);
1001 }
1002
1003 layout().modifier(modifier).child(move || {
1004 let menu_content = menu_content;
1005 surface()
1006 .style(SurfaceStyle::Filled {
1007 color: provider.container_color,
1008 })
1009 .shape(if grouped {
1010 Shape::RECTANGLE
1011 } else if provider.animate_shape {
1012 motion.with(MenuShapeMotion::shape)
1013 } else {
1014 provider.shape
1015 })
1016 .modifier(provider.modifier.clone().constrain(
1017 None,
1018 Some(AxisConstraint::new(Px::ZERO, provider.max_height)),
1019 ))
1020 .accessibility_role(Role::Menu)
1021 .block_input(true)
1022 .elevation(if grouped {
1023 Dp::ZERO
1024 } else {
1025 provider.elevation
1026 })
1027 .child(move || {
1028 let menu_content = menu_content;
1029 provide_context(
1030 || color_style,
1031 move || {
1032 let inherited = use_context::<MenuColorStyle>().expect("menu colors");
1033 if inherited.get() != color_style {
1034 inherited.set(color_style);
1035 }
1036 let modifier = Modifier::new().fill_max_width();
1037 let modifier = if grouped {
1038 modifier.push_draw(MenuScrollClip { controller: scroll })
1039 } else {
1040 modifier.clip_shape(if provider.animate_shape {
1041 motion.with(MenuShapeMotion::shape)
1042 } else {
1043 provider.shape
1044 })
1045 };
1046 column()
1047 .modifier(modifier.vertical_scroll(scroll).padding_symmetric(
1048 Dp::ZERO,
1049 if grouped { Dp::ZERO } else { Dp(2.0) },
1050 ))
1051 .cross_axis_alignment(CrossAxisAlignment::Start)
1052 .children(move || {
1053 menu_content.render(MenuScope {
1054 host,
1055 controller,
1056 typeahead: typeahead::MenuTypeahead(typeahead),
1057 });
1058 });
1059 },
1060 );
1061 });
1062 });
1063}
1064
1065#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1067pub enum MenuItemSelectionMode {
1068 #[default]
1070 Radio,
1071 Checkbox,
1073}
1074
1075#[derive(Clone, PartialEq)]
1076struct MenuItemConfig {
1077 pub menu: Option<MenuScope>,
1078 pub label: String,
1079 pub supporting_text: Option<String>,
1080 pub trailing_text: Option<String>,
1081 pub leading_icon: Option<Painter>,
1082 pub trailing_icon: Option<Painter>,
1083 pub submenu_content: Option<RenderSlotWith<MenuScope>>,
1084 pub submenu_placement: MenuPlacement,
1085 pub selected: bool,
1086 pub selection: Option<Toggled>,
1087 pub selection_mode: Option<MenuItemSelectionMode>,
1088 pub shape: Shape,
1089 pub container_color: Color,
1090 pub enabled: bool,
1091 pub close_on_click: bool,
1092 pub height: Dp,
1093 pub label_color: Color,
1094 pub supporting_color: Color,
1095 pub disabled_color: Color,
1096 pub on_click: Option<Callback>,
1097}
1098
1099impl MenuItemConfig {
1100 pub fn new(label: impl Into<String>) -> Self {
1101 Self {
1102 menu: None,
1103 label: label.into(),
1104 supporting_text: None,
1105 trailing_text: None,
1106 leading_icon: None,
1107 trailing_icon: None,
1108 submenu_content: None,
1109 submenu_placement: MenuPlacement::RightStart,
1110 selected: false,
1111 selection: None,
1112 selection_mode: None,
1113 shape: MenuDefaults::item_shape(MenuPosition::Standalone, false),
1114 container_color: Color::TRANSPARENT,
1115 enabled: true,
1116 close_on_click: true,
1117 height: MENU_ITEM_HEIGHT,
1118 label_color: Color::new(0.1, 0.1, 0.1, 1.0),
1119 supporting_color: Color::new(0.4, 0.4, 0.4, 1.0),
1120 disabled_color: Color::new(0.1, 0.1, 0.1, 1.0)
1121 .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1122 on_click: None,
1123 }
1124 }
1125}
1126
1127impl Default for MenuItemConfig {
1128 fn default() -> Self {
1129 Self::new("")
1130 }
1131}
1132
1133#[tessera]
1199pub fn menu_item(
1200 menu: Option<MenuScope>,
1201 #[prop(into)] label: Option<String>,
1202 #[prop(into)] supporting_text: Option<String>,
1203 #[prop(into)] trailing_text: Option<String>,
1204 #[prop(into)] leading_icon: Option<Painter>,
1205 #[prop(into)] trailing_icon: Option<Painter>,
1206 submenu_content: Option<RenderSlotWith<MenuScope>>,
1207 submenu_placement: Option<MenuPlacement>,
1208 selected: Option<bool>,
1209 selection_mode: Option<MenuItemSelectionMode>,
1210 position: Option<MenuPosition>,
1211 shape: Option<Shape>,
1212 container_color: Option<Color>,
1213 color_style: Option<MenuColorStyle>,
1214 enabled: Option<bool>,
1215 close_on_click: Option<bool>,
1216 height: Option<Dp>,
1217 label_color: Option<Color>,
1218 supporting_color: Option<Color>,
1219 disabled_color: Option<Color>,
1220 on_click: Option<Callback>,
1221) {
1222 let label = label.unwrap_or_default();
1223 let selected = selected.unwrap_or(false);
1224 let selection = selection_mode.map(|_| {
1225 if selected {
1226 Toggled::True
1227 } else {
1228 Toggled::False
1229 }
1230 });
1231 let position = position.unwrap_or_default();
1232 let enabled = enabled.unwrap_or(true) && (on_click.is_some() || submenu_content.is_some());
1233 let inherited_style = use_context::<MenuColorStyle>().map(|style| style.get());
1234 let color_style = color_style.or(inherited_style).unwrap_or_default();
1235 let scheme = use_context::<MaterialTheme>()
1236 .expect("MaterialTheme must be provided")
1237 .get()
1238 .color_scheme;
1239
1240 let mut args = MenuItemConfig {
1241 menu,
1242 label,
1243 supporting_text,
1244 trailing_text,
1245 leading_icon,
1246 trailing_icon,
1247 submenu_content,
1248 submenu_placement: submenu_placement.unwrap_or(MenuPlacement::RightStart),
1249 selected,
1250 selection_mode,
1251 selection,
1252 shape: shape.unwrap_or_else(|| MenuDefaults::item_shape(position, selected)),
1253 container_color: container_color.unwrap_or_else(|| {
1254 if selected && enabled {
1255 color_style.selected_container(&scheme)
1256 } else {
1257 Color::TRANSPARENT
1258 }
1259 }),
1260 enabled,
1261 close_on_click: close_on_click.unwrap_or(true),
1262 height: height.unwrap_or(MENU_ITEM_HEIGHT),
1263 label_color: label_color.unwrap_or_else(|| color_style.content(&scheme, selected)),
1264 supporting_color: supporting_color
1265 .unwrap_or_else(|| color_style.supporting(&scheme, selected)),
1266 disabled_color: disabled_color.unwrap_or(
1267 color_style
1268 .content(&scheme, false)
1269 .with_alpha(MaterialAlpha::DISABLED_CONTENT),
1270 ),
1271 on_click,
1272 };
1273
1274 if args.close_on_click
1275 && args.on_click.is_some()
1276 && args.submenu_content.is_none()
1277 && let Some(menu) = args.menu
1278 {
1279 let host = menu.host;
1280 let previous = args.on_click;
1281 args.on_click = Some(Callback::new(move || {
1282 if let Some(callback) = previous {
1283 callback.call();
1284 }
1285 host.close_all();
1286 }));
1287 }
1288
1289 menu_item_inner().args(args);
1290}
1291
1292#[tessera]
1293fn menu_item_surface(
1294 item: Option<MenuItemConfig>,
1295 submenu_controller: Option<State<MenuController>>,
1296 submenu_request: Option<State<MenuPopupRequest>>,
1297 focus_requester: Option<FocusRequester>,
1298) {
1299 let item = item.unwrap_or_default();
1300 let enabled = item.enabled && (item.on_click.is_some() || item.submenu_content.is_some());
1301 let has_submenu = item.submenu_content.is_some();
1302 let focus_requester = if let Some(requester) = focus_requester {
1303 requester
1304 } else {
1305 remember(FocusRequester::new).get()
1306 };
1307 let typeahead = item.menu.map(|menu| menu.typeahead);
1308 let _typeahead_lease =
1309 remember(|| typeahead.map(|context| typeahead::ItemLease::new(context.0, focus_requester)));
1310 if let Some(context) = typeahead {
1311 typeahead::update(context.0, focus_requester, &item.label, enabled);
1312 }
1313 let hover = remember(MenuHoverIntent::default);
1314 let host = item.menu.map(|menu| menu.host);
1315 let parent = item.menu.map(|menu| menu.controller);
1316 if enabled && hover.with(|hover| hover.deadline.is_some()) {
1317 let host = host.clone();
1318 receive_frame_nanos(move |frame| {
1319 let Some(deadline) = hover.with(|hover| hover.deadline) else {
1320 return FrameNanosControl::Stop;
1321 };
1322 if !hover.with(|hover| hover.inside)
1323 || !focus_requester.is_focused()
1324 || parent.is_none_or(|parent| !parent.with(MenuController::is_open))
1325 {
1326 hover.with_mut(|hover| hover.deadline = None);
1327 return FrameNanosControl::Stop;
1328 }
1329 if frame < deadline {
1330 return FrameNanosControl::Continue;
1331 }
1332 hover.with_mut(|hover| hover.deadline = None);
1333 if let Some((host, parent)) = host.as_ref().zip(parent) {
1334 host.apply_hover(parent, submenu_request);
1335 }
1336 FrameNanosControl::Stop
1337 });
1338 }
1339 let modifier = if let Some(request) = submenu_request {
1340 with_keyboard_input(Modifier::new(), move |mut input| {
1341 if input.key_modifiers.control_key()
1342 || input.key_modifiers.alt_key()
1343 || input.key_modifiers.super_key()
1344 {
1345 return;
1346 }
1347 let value = request.get();
1348 if !value.enabled {
1349 return;
1350 }
1351 let opening = if value.placement == MenuPlacement::LeftStart {
1352 winit::keyboard::NamedKey::ArrowLeft
1353 } else {
1354 winit::keyboard::NamedKey::ArrowRight
1355 };
1356 let should_open = input.keyboard_events.iter().any(|event| {
1357 event.state == winit::event::ElementState::Pressed
1358 && event.logical_key == winit::keyboard::Key::Named(opening)
1359 });
1360 if should_open {
1361 MenuPopupRequest::open(request);
1362 input.block_keyboard();
1363 }
1364 })
1365 .on_layout_coordinates(move |bounds| {
1366 let anchor = request.get().anchor;
1367 if anchor.get() != bounds {
1368 anchor.set(bounds);
1369 }
1370 })
1371 } else {
1372 Modifier::new()
1373 };
1374
1375 let modifier = if let Some(context) = typeahead {
1376 modifier.on_layout_rect_in_space(move |bounds| {
1377 typeahead::position(context.0, focus_requester, bounds);
1378 })
1379 } else {
1380 modifier
1381 };
1382 layout().modifier(modifier).child(move || {
1383 let on_click = if has_submenu {
1384 let request = submenu_request.expect("submenu request");
1385 Some(Callback::new(move || {
1386 MenuPopupRequest::open(request);
1387 }))
1388 } else {
1389 item.on_click
1390 };
1391 let theme = use_context::<MaterialTheme>()
1392 .expect("MaterialTheme must be provided")
1393 .get();
1394 let label_color = if enabled {
1395 item.label_color
1396 } else {
1397 item.disabled_color
1398 };
1399 let supporting_color = if enabled {
1400 item.supporting_color
1401 } else {
1402 item.disabled_color
1403 };
1404 let selection = item.selection;
1405 let expanded =
1406 submenu_controller.map(|controller| controller.with(MenuController::is_open));
1407 surface()
1408 .style(SurfaceStyle::Filled {
1409 color: item.container_color,
1410 })
1411 .shape(item.shape)
1412 .enabled(enabled)
1413 .modifier(
1414 Modifier::new()
1415 .fill_max_width()
1416 .padding_symmetric(Dp(4.0), Dp::ZERO)
1417 .minimum_interactive_component_size()
1418 .push_pointer_preview_input(MenuItemHoverFocus {
1419 enabled,
1420 requester: focus_requester,
1421 host: host.clone(),
1422 controller: parent,
1423 request: submenu_request,
1424 hover,
1425 })
1426 .push_semantics(MenuItemDisclosure(expanded)),
1427 )
1428 .accessibility_role(match item.selection_mode {
1429 Some(MenuItemSelectionMode::Radio) => Role::MenuItemRadio,
1430 Some(MenuItemSelectionMode::Checkbox) => Role::MenuItemCheckBox,
1431 None => Role::MenuItem,
1432 })
1433 .accessibility_toggled_optional(selection)
1434 .accessibility_label(item.label.clone())
1435 .block_input(true)
1436 .ripple_color(label_color)
1437 .focus_requester(focus_requester)
1438 .on_click_optional(on_click)
1439 .accessibility_description_optional(item.supporting_text.clone())
1440 .child({
1441 let item = item.clone();
1442 move || {
1443 let item = item.clone();
1444 row()
1445 .modifier(
1446 Modifier::new()
1447 .fill_max_width()
1448 .constrain(
1449 None,
1450 Some(AxisConstraint::at_least(Px::from(item.height))),
1451 )
1452 .padding_symmetric(MENU_HORIZONTAL_PADDING, Dp(8.0)),
1453 )
1454 .cross_axis_alignment(CrossAxisAlignment::Center)
1455 .children(move || {
1456 if item.selected {
1457 checkmark()
1458 .color(label_color)
1459 .size(MENU_LEADING_SIZE)
1460 .padding([2.0, 2.0]);
1461 spacer().modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1462 } else if let Some(painter) = item.leading_icon.clone() {
1463 icon()
1464 .painter(painter)
1465 .size(MENU_LEADING_SIZE)
1466 .tint(supporting_color);
1467 spacer().modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1468 }
1469 let label = item.label.clone();
1470 let supporting = item.supporting_text.clone();
1471 column()
1472 .modifier(Modifier::new().weight(1.0))
1473 .cross_axis_alignment(CrossAxisAlignment::Start)
1474 .children(move || {
1475 text(label.clone())
1476 .style(theme.typography.body_large)
1477 .color(label_color);
1478 if let Some(supporting) = supporting.as_ref() {
1479 text(supporting.clone())
1480 .style(theme.typography.body_medium)
1481 .color(supporting_color);
1482 }
1483 });
1484 if item.trailing_icon.is_some()
1485 || item.trailing_text.is_some()
1486 || has_submenu
1487 {
1488 spacer().modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1489 if let Some(painter) = item.trailing_icon.clone() {
1490 icon()
1491 .painter(painter)
1492 .size(MENU_LEADING_SIZE)
1493 .tint(supporting_color);
1494 } else if let Some(trailing) = item.trailing_text.clone() {
1495 text(trailing)
1496 .style(theme.typography.label_small)
1497 .color(supporting_color);
1498 }
1499 if has_submenu {
1500 if item.trailing_icon.is_some() || item.trailing_text.is_some()
1501 {
1502 spacer()
1503 .modifier(Modifier::new().width(MENU_TRAILING_SPACING));
1504 }
1505 icon()
1506 .painter(
1507 if item.submenu_placement == MenuPlacement::LeftStart {
1508 filled::CHEVRON_LEFT_SVG
1509 } else {
1510 filled::CHEVRON_RIGHT_SVG
1511 },
1512 )
1513 .size(MENU_LEADING_SIZE)
1514 .tint(supporting_color);
1515 }
1516 }
1517 });
1518 }
1519 });
1520 });
1521}
1522
1523struct MenuItemHoverFocus {
1524 enabled: bool,
1525 requester: FocusRequester,
1526 host: Option<MenuHost>,
1527 controller: Option<State<MenuController>>,
1528 request: Option<State<MenuPopupRequest>>,
1529 hover: State<MenuHoverIntent>,
1530}
1531
1532#[derive(Default)]
1533struct MenuHoverIntent {
1534 inside: bool,
1535 mouse: bool,
1536 deadline: Option<u64>,
1537}
1538
1539impl PointerInputModifierNode for MenuItemHoverFocus {
1540 fn on_pointer_input(&self, input: PointerInput<'_>) {
1541 let moved = input.pointer_changes.iter().any(|change| {
1542 change.pointer_id == MOUSE_POINTER_ID
1543 && matches!(change.content, CursorEventContent::Moved(_))
1544 });
1545 let mouse = input
1546 .pointer_changes
1547 .last()
1548 .map(|change| change.pointer_id == MOUSE_POINTER_ID)
1549 .unwrap_or_else(|| self.hover.with(|hover| hover.mouse));
1550 let inside = self.enabled
1551 && mouse
1552 && input.cursor_position_rel.is_some_and(|point| {
1553 point.x >= 0.0
1554 && point.y >= 0.0
1555 && point.x < input.computed_data.width.to_f32()
1556 && point.y < input.computed_data.height.to_f32()
1557 })
1558 && self
1559 .host
1560 .as_ref()
1561 .zip(self.controller)
1562 .is_none_or(|(host, controller)| {
1563 host.allows_hover(controller, input.cursor_position_abs())
1564 });
1565 let schedule = inside
1566 && moved
1567 && self.hover.with(|hover| hover.deadline.is_none())
1568 && self
1569 .host
1570 .as_ref()
1571 .zip(self.controller)
1572 .is_some_and(|(host, parent)| {
1573 if let Some(request) = self.request {
1574 !request.get().controller.with(MenuController::is_open)
1575 } else {
1576 host.has_descendants(parent)
1577 }
1578 });
1579 if schedule
1580 || self
1581 .hover
1582 .with(|hover| hover.inside != inside || hover.mouse != mouse)
1583 {
1584 self.hover.with_mut(|hover| {
1585 hover.inside = inside;
1586 hover.mouse = mouse;
1587 if !inside {
1588 hover.deadline = None;
1589 } else if schedule {
1590 hover.deadline =
1591 Some(current_frame_nanos().saturating_add(MENU_HOVER_DELAY_NANOS));
1592 }
1593 });
1594 }
1595 if inside && moved {
1596 self.requester.request_focus();
1597 }
1598 }
1599}
1600
1601struct MenuItemDisclosure(Option<bool>);
1602
1603impl tessera_ui::SemanticsModifierNode for MenuItemDisclosure {
1604 fn apply(
1605 &self,
1606 node: &mut tessera_ui::AccessibilityNode,
1607 _handler: &mut Option<tessera_ui::AccessibilityActionHandler>,
1608 ) {
1609 node.expanded = self.0;
1610 node.has_popup = self.0.map(|_| HasPopup::Menu);
1611 }
1612}
1613
1614#[tessera]
1615fn menu_item_inner(args: Option<MenuItemConfig>) {
1616 let args = args.unwrap_or_default();
1617 let submenu_content = args.submenu_content;
1618
1619 if let Some(submenu_content) = submenu_content {
1620 let submenu_controller = remember(MenuController::new);
1621 let focus_requester = remember(FocusRequester::new).get();
1622 let menu = args.menu.expect("submenu items require a menu scope");
1623 let host = menu.host;
1624 let parent = menu.controller;
1625 let value = MenuPopupRequest {
1626 host: host.clone(),
1627 parent,
1628 controller: submenu_controller,
1629 anchor: remember(tessera_ui::LayoutCoordinates::default),
1630 scope: remember(FocusScopeNode::new).get(),
1631 fallback: focus_requester,
1632 content: submenu_content,
1633 placement: args.submenu_placement,
1634 color_style: use_context::<MenuColorStyle>()
1635 .map(|style| style.get())
1636 .unwrap_or_default(),
1637 enabled: args.enabled,
1638 bounds: remember(|| None),
1639 };
1640 let request = remember(|| value.clone());
1641 if request.get() != value {
1642 request.set(value);
1643 }
1644 let scope = request.get().scope;
1645 let _lease = remember(|| MenuPopupLease {
1646 host,
1647 request,
1648 parent,
1649 scope,
1650 });
1651 if !args.enabled {
1652 request.get().host.close_disabled_branch(request);
1653 }
1654 menu_item_surface()
1655 .item(args)
1656 .submenu_controller(submenu_controller)
1657 .submenu_request(request)
1658 .focus_requester(focus_requester);
1659 } else {
1660 menu_item_surface().item(args);
1661 }
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666 use tessera_ui::testing::{
1667 layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
1668 start_layout_test_session,
1669 };
1670 use tessera_ui::{
1671 CursorEventContent, Dp, FocusModifierExt as _, GestureState, LayoutCoordinates, Modifier,
1672 PointerChange, PressKeyEventType, Px, PxPosition, State, remember, scaled_box, tessera,
1673 testing::LayoutSnapshot,
1674 };
1675
1676 use crate::{
1677 button::button,
1678 column::column,
1679 modifier::{ModifierExt as _, SemanticsArgs},
1680 spacer::spacer,
1681 theme::material_theme,
1682 };
1683
1684 use super::{MenuAnchor, MenuController, menu_item, menu_provider};
1685
1686 #[derive(Clone, Copy, Default)]
1687 struct FixtureHandles {
1688 typeahead: Option<State<super::typeahead::Registry>>,
1689 child_typeahead: Option<State<super::typeahead::Registry>>,
1690 remove_delete: Option<State<bool>>,
1691 controller: Option<State<MenuController>>,
1692 main_state: Option<State<u32>>,
1693 }
1694
1695 #[tessera]
1696 fn typeahead_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1697 let controller = remember(|| {
1698 let mut controller = MenuController::new();
1699 controller.open();
1700 controller
1701 });
1702 let remove_delete = remember(|| false);
1703 observed.with_mut(|handles| handles.controller = Some(controller));
1704 observed.with_mut(|handles| handles.remove_delete = Some(remove_delete));
1705 material_theme().child(move || {
1706 let observed = observed.clone();
1707 menu_provider()
1708 .controller(controller)
1709 .main_content(|| {
1710 crate::spacer::spacer().modifier(Modifier::new().fill_max_size());
1711 })
1712 .menu_content(move |menu| {
1713 let registry = menu.typeahead.0;
1714 observed.with_mut(|handles| handles.typeahead = Some(registry));
1715 menu_item().menu(menu).label("Preview").on_click(|| {});
1716 menu_item().menu(menu).label("Share").submenu_content({
1717 let observed = observed.clone();
1718 move |menu| {
1719 let registry = menu.typeahead.0;
1720 observed.with_mut(|handles| handles.child_typeahead = Some(registry));
1721 menu_item().menu(menu).label("Copy link").on_click(|| {});
1722 menu_item().menu(menu).label("Send").on_click(|| {});
1723 }
1724 });
1725 if !remove_delete.get() {
1726 menu_item().menu(menu).label("Delete").on_click(|| {});
1727 }
1728 menu_item().menu(menu).label("Duplicate").on_click(|| {});
1729 menu_item()
1730 .menu(menu)
1731 .label("Download")
1732 .enabled(false)
1733 .on_click(|| {});
1734 });
1735 });
1736 }
1737
1738 #[cfg(not(target_family = "wasm"))]
1739 #[test]
1740 #[ignore = "requires a native GPU adapter"]
1741 fn gpu_menu_prefix_matching_moves_focus_skips_disabled_items_and_cleans_removed_items() {
1742 use tessera_ui::{
1743 HeadlessConfig, HeadlessRenderer,
1744 accesskit::{Action, ActionRequest, Role, TreeId},
1745 };
1746
1747 let observed = tessera_ui::testing::TestProbe::default();
1748 let mut renderer = HeadlessRenderer::new(
1749 Box::new({
1750 let observed = observed.clone();
1751 move || {
1752 typeahead_fixture(observed.clone());
1753 }
1754 }),
1755 vec![Box::new(crate::TesseraComponents)],
1756 HeadlessConfig {
1757 width: 600,
1758 height: 400,
1759 sample_count: 1,
1760 ..Default::default()
1761 },
1762 )
1763 .unwrap();
1764 renderer.render_frames(6);
1765 let registry = observed.get().typeahead.unwrap();
1766 let focused = |renderer: &HeadlessRenderer| {
1767 let tree = renderer.accessibility_snapshot().unwrap();
1768 tree.nodes
1769 .iter()
1770 .find(|(id, _)| *id == tree.focus)
1771 .and_then(|(_, node)| node.label())
1772 .map(str::to_owned)
1773 };
1774 assert_eq!(focused(&renderer).as_deref(), Some("Preview"));
1775 for (text, time, expected) in [
1776 ("d", 0, "Delete"),
1777 ("D", 100_000_000, "Duplicate"),
1778 ("e", 200_000_000, "Delete"),
1779 ("du", 2_000_000_000, "Duplicate"),
1780 ("do", 4_000_000_000, "Duplicate"),
1781 ("d", 6_000_000_000, "Delete"),
1782 ] {
1783 assert!(super::typeahead::type_text(registry, text, time));
1784 renderer.render_frames(2);
1785 assert_eq!(
1786 focused(&renderer).as_deref(),
1787 Some(expected),
1788 "prefix {text}"
1789 );
1790 }
1791 observed.get().remove_delete.unwrap().set(true);
1792 renderer.render_frames(3);
1793 super::typeahead::type_text(registry, "d", 8_000_000_000);
1794 renderer.render_frames(2);
1795 assert_eq!(focused(&renderer).as_deref(), Some("Duplicate"));
1796 let tree = renderer.accessibility_snapshot().unwrap();
1797 let share = tree
1798 .nodes
1799 .iter()
1800 .find(|(_, node)| node.role() == Role::MenuItem && node.label() == Some("Share"))
1801 .unwrap()
1802 .0;
1803 assert!(renderer.dispatch_accessibility_action(ActionRequest {
1804 action: Action::Click,
1805 target_node: share,
1806 target_tree: TreeId::ROOT,
1807 data: None,
1808 }));
1809 renderer.render_frames(6);
1810 let child = observed.get().child_typeahead.unwrap();
1811 super::typeahead::type_text(child, "s", 0);
1812 renderer.render_frames(2);
1813 assert_eq!(focused(&renderer).as_deref(), Some("Send"));
1814 observed
1815 .get()
1816 .controller
1817 .unwrap()
1818 .with_mut(MenuController::close);
1819 renderer.render_frames(4);
1820 assert!(!registry.is_alive());
1821 assert!(!child.is_alive());
1822 }
1823
1824 #[tessera]
1825 fn menu_controller_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1826 let controller = remember(MenuController::new);
1827 observed.with_mut(|handles| handles.controller = Some(controller));
1828 material_theme().child(move || {
1829 menu_provider()
1830 .controller(controller)
1831 .main_content({
1832 let observed = observed.clone();
1833 move || {
1834 let state = remember(|| 0_u32);
1835 observed.with_mut(|handles| handles.main_state = Some(state));
1836 column().children(|| {});
1837 }
1838 })
1839 .menu_content(|menu| {
1840 column()
1841 .modifier(Modifier::new().semantics(SemanticsArgs {
1842 test_tag: Some("menu_content_tag".to_string()),
1843 ..Default::default()
1844 }))
1845 .children(move || {
1846 menu_item().menu(menu).label("Edit");
1847 });
1848 });
1849 });
1850 }
1851
1852 #[tessera]
1853 fn menu_background_fixture(
1854 close_on_background: bool,
1855 observed: tessera_ui::testing::TestProbe<FixtureHandles>,
1856 ) {
1857 let controller = remember(MenuController::new);
1858 let state = remember(|| 0_u32);
1859 observed.with_mut(|handles| handles.controller = Some(controller));
1860 observed.with_mut(|handles| handles.main_state = Some(state));
1861 material_theme().child(move || {
1862 menu_provider()
1863 .controller(controller)
1864 .close_on_background(close_on_background)
1865 .main_content(move || {
1866 button()
1867 .modifier(Modifier::new().fill_max_size())
1868 .on_click(move || state.with_mut(|count| *count += 1));
1869 })
1870 .menu_content(|menu| {
1871 column()
1872 .modifier(Modifier::new().semantics(SemanticsArgs {
1873 test_tag: Some("menu_background_content".to_string()),
1874 ..Default::default()
1875 }))
1876 .children(move || {
1877 menu_item().menu(menu).label("Edit").enabled(false);
1878 });
1879 });
1880 });
1881 }
1882
1883 #[test]
1884 fn menu_background_dismisses_without_activating_underlying_content() {
1885 for (close_on_background, click_inside, expected_open) in [
1886 (true, false, false),
1887 (false, false, true),
1888 (true, true, true),
1889 ] {
1890 let observed = tessera_ui::testing::TestProbe::default();
1891 let mut session = start_layout_test_session(
1892 layout_test({
1893 let observed = observed.clone();
1894 move || {
1895 menu_background_fixture(close_on_background, observed.clone());
1896 }
1897 })
1898 .viewport_px(400, 300),
1899 );
1900 let _ = pump_layout_test_session(&mut session, 0);
1901 let controller = observed.get().controller.expect("controller");
1902 controller.with_mut(|menu| {
1903 menu.open_at(MenuAnchor::at(PxPosition::new(Px(10), Px(10))));
1904 });
1905 let opened = pump_layout_test_session(&mut session, 16_666_667);
1906 let bounds = opened.node("menu_background_content").bounds();
1907 let now = tessera_ui::time::Instant::now();
1908 let cursor = Some(if click_inside {
1909 PxPosition::new(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
1910 } else {
1911 PxPosition::new(Px(350), Px(250))
1912 });
1913 for (index, content) in [
1914 CursorEventContent::Pressed(PressKeyEventType::Left),
1915 CursorEventContent::Released(PressKeyEventType::Left),
1916 ]
1917 .into_iter()
1918 .enumerate()
1919 {
1920 let change = PointerChange::new(now, 1, content, GestureState::TapCandidate);
1921 let _ = pump_layout_test_session_with_input(
1922 &mut session,
1923 33_333_334 + index as u64 * 16_666_667,
1924 vec![change],
1925 cursor,
1926 );
1927 }
1928 assert_eq!(
1929 controller.with(MenuController::is_open),
1930 expected_open,
1931 "close_on_background={close_on_background}, click_inside={click_inside}"
1932 );
1933 assert_eq!(observed.get().main_state.unwrap().get(), 0);
1934 }
1935 }
1936
1937 #[test]
1938 fn menu_provider_renders_menu_items_after_controller_opens() {
1939 let observed = tessera_ui::testing::TestProbe::default();
1940 let mut session = start_layout_test_session(
1941 layout_test({
1942 let observed = observed.clone();
1943 move || menu_controller_fixture(observed.clone())
1944 })
1945 .viewport_px(400, 300),
1946 );
1947 let _ = pump_layout_test_session(&mut session, 0);
1948
1949 let controller = observed.get().controller.expect("controller");
1950 controller.with_mut(|menu| {
1951 menu.open_at(MenuAnchor::at(PxPosition::new(Px(10), Px(10))));
1952 });
1953
1954 let opened: LayoutSnapshot = pump_layout_test_session(&mut session, 16_666_667);
1955 opened.node("menu_content_tag").exists();
1956 }
1957
1958 #[tessera]
1959 fn window_anchor_fixture(observed: tessera_ui::testing::TestProbe<Option<State<Dp>>>) {
1960 let anchor = remember(LayoutCoordinates::default);
1961 let x = remember(|| Dp(40.0));
1962 observed.set(Some(x));
1963 material_theme().child(move || {
1964 tessera_ui::layout::layout()
1965 .modifier(Modifier::new().offset(Dp(12.0), Dp(18.0)))
1966 .child(move || {
1967 scaled_box(0.5).child(move || {
1968 menu_provider()
1969 .window_anchor(anchor)
1970 .anchor(MenuAnchor::at(PxPosition::new(Px(999), Px(999))))
1971 .is_open(true)
1972 .placement(super::MenuPlacement::BelowStart)
1973 .offset([Dp::ZERO; 2])
1974 .modifier(Modifier::new().width(Dp(80.0)).semantics(SemanticsArgs {
1975 test_tag: Some("anchored-menu".into()),
1976 ..Default::default()
1977 }))
1978 .main_content(move || {
1979 spacer().modifier(
1980 Modifier::new()
1981 .size(Dp(80.0), Dp(40.0))
1982 .offset(x.get(), Dp(30.0))
1983 .on_layout_coordinates(move |coordinates| {
1984 anchor.set_if_changed(coordinates);
1985 }),
1986 );
1987 })
1988 .menu_content(|_| {
1989 spacer().modifier(Modifier::new().height(Dp(20.0)));
1990 });
1991 });
1992 });
1993 });
1994 }
1995
1996 #[test]
1997 fn window_anchor_follows_trigger_geometry_through_ancestor_transforms() {
1998 let observed = tessera_ui::testing::TestProbe::default();
1999 let mut session = start_layout_test_session(
2000 layout_test({
2001 let observed = observed.clone();
2002 move || {
2003 window_anchor_fixture(observed.clone());
2004 }
2005 })
2006 .viewport_px(400, 300),
2007 );
2008 pump_layout_test_session(&mut session, 0)
2009 .node("anchored-menu")
2010 .position(32, 53);
2011 observed.get().expect("trigger position").set(Dp(100.0));
2012 pump_layout_test_session(&mut session, 16_666_667)
2013 .node("anchored-menu")
2014 .position(62, 53);
2015 }
2016
2017 #[test]
2018 fn menu_visibility_preserves_main_content_state() {
2019 let observed = tessera_ui::testing::TestProbe::default();
2020 let mut session = start_layout_test_session(
2021 layout_test({
2022 let observed = observed.clone();
2023 move || menu_controller_fixture(observed.clone())
2024 })
2025 .viewport_px(400, 300),
2026 );
2027 let _ = pump_layout_test_session(&mut session, 0);
2028 let main_state = observed.get().main_state.expect("main_state");
2029 main_state.set(7);
2030 let controller = observed.get().controller.expect("controller");
2031 controller.with_mut(|menu| {
2032 menu.open_at(MenuAnchor::at(PxPosition::new(Px(10), Px(10))));
2033 });
2034 let _ = pump_layout_test_session(&mut session, 16_666_667);
2035 assert!(observed.get().main_state.unwrap() == main_state);
2036 assert_eq!(main_state.get(), 7);
2037
2038 controller.with_mut(MenuController::close);
2039 let _ = pump_layout_test_session(&mut session, 33_333_334);
2040 assert!(observed.get().main_state.unwrap() == main_state);
2041 assert_eq!(main_state.get(), 7);
2042 }
2043}
2044
2045#[cfg(test)]
2046#[path = "menus/focus_tests.rs"]
2047mod focus_tests;
2048
2049#[cfg(test)]
2050#[path = "menus/appearance_tests.rs"]
2051mod appearance_tests;
2052
2053#[cfg(test)]
2054#[path = "menus/selection_tests.rs"]
2055mod selection_tests;
2056
2057#[cfg(test)]
2058#[path = "menus/submenu_tests.rs"]
2059mod submenu_tests;
2060
2061#[cfg(test)]
2062#[path = "menus/shadow_tests.rs"]
2063mod shadow_tests;
2064
2065#[cfg(test)]
2066#[path = "menus/placement_tests.rs"]
2067mod placement_tests;
2068
2069#[cfg(test)]
2070#[path = "menus/motion_tests.rs"]
2071mod motion_tests;
2072
2073#[cfg(test)]
2074mod navigation_tests {
2075 use tessera_ui::winit::keyboard::NamedKey;
2076
2077 use super::{MenuPlacement, closes_submenu};
2078
2079 #[test]
2080 fn horizontal_close_key_applies_only_to_submenus_and_their_return_direction() {
2081 for (placement, close, open) in [
2082 (
2083 MenuPlacement::RightStart,
2084 NamedKey::ArrowLeft,
2085 NamedKey::ArrowRight,
2086 ),
2087 (
2088 MenuPlacement::LeftStart,
2089 NamedKey::ArrowRight,
2090 NamedKey::ArrowLeft,
2091 ),
2092 ] {
2093 assert!(!closes_submenu(false, placement, close));
2094 assert!(!closes_submenu(false, placement, open));
2095 assert!(closes_submenu(true, placement, close));
2096 assert!(!closes_submenu(true, placement, open));
2097 assert!(!closes_submenu(true, placement, NamedKey::ArrowDown));
2098 }
2099 assert!(!closes_submenu(
2100 true,
2101 MenuPlacement::BelowStart,
2102 NamedKey::ArrowLeft
2103 ));
2104 }
2105}