1use std::sync::Arc;
4
5use closure::closure;
6use derive_builder::Builder;
7use parking_lot::RwLock;
8use tessera_ui::{
9 Color, ComputedData, Constraint, CursorEvent, CursorEventContent, DimensionValue, Dp, Px,
10 PxPosition, PxSize, accesskit::Role, tessera, winit,
11};
12
13use crate::{
14 ShadowProps,
15 alignment::CrossAxisAlignment,
16 checkmark::{CheckmarkArgsBuilder, checkmark},
17 column::{ColumnArgsBuilder, column},
18 material_color::{blend_over, global_material_scheme},
19 pos_misc::is_position_in_rect,
20 ripple_state::RippleState,
21 row::{RowArgsBuilder, row},
22 shape_def::Shape,
23 spacer::{SpacerArgsBuilder, spacer},
24 surface::{SurfaceArgsBuilder, SurfaceStyle, surface},
25 text::{TextArgsBuilder, text},
26};
27
28const MENU_MIN_WIDTH: Dp = Dp(112.0);
29const MENU_MAX_WIDTH: Dp = Dp(280.0);
30const MENU_MAX_HEIGHT: Dp = Dp(320.0);
31const MENU_VERTICAL_GAP: Dp = Dp(4.0);
32const MENU_HORIZONTAL_PADDING: Dp = Dp(16.0);
33const MENU_LEADING_SIZE: Dp = Dp(20.0);
34const MENU_ITEM_HEIGHT: Dp = Dp(48.0);
35const MENU_TRAILING_SPACING: Dp = Dp(16.0);
36
37fn default_menu_width() -> DimensionValue {
38 DimensionValue::Wrap {
39 min: Some(Px::from(MENU_MIN_WIDTH)),
40 max: Some(Px::from(MENU_MAX_WIDTH)),
41 }
42}
43
44fn default_max_height() -> Option<Px> {
45 Some(Px::from(MENU_MAX_HEIGHT))
46}
47
48fn default_menu_shape() -> Shape {
49 Shape::rounded_rectangle(Dp(4.0))
50}
51
52fn default_menu_shadow() -> Option<ShadowProps> {
53 let scheme = global_material_scheme();
54 Some(ShadowProps {
55 color: scheme.shadow.with_alpha(0.12),
56 offset: [0.0, 3.0],
57 smoothness: 8.0,
58 })
59}
60
61fn default_menu_color() -> Color {
62 global_material_scheme().surface
63}
64
65fn default_hover_color() -> Color {
66 let scheme = global_material_scheme();
67 blend_over(scheme.surface, scheme.on_surface, 0.08)
68}
69
70fn default_scrim_color() -> Color {
71 Color::new(0.0, 0.0, 0.0, 0.0)
72}
73
74pub struct MenuScope<'a, 'b> {
76 scope: &'a mut crate::column::ColumnScope<'b>,
77}
78
79impl<'a, 'b> MenuScope<'a, 'b> {
80 pub fn item<F>(&mut self, child: F)
82 where
83 F: FnOnce() + Send + Sync + 'static,
84 {
85 self.scope.child(child);
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct MenuAnchor {
92 pub origin: PxPosition,
94 pub size: PxSize,
96}
97
98impl MenuAnchor {
99 pub fn new(origin: PxPosition, size: PxSize) -> Self {
101 Self { origin, size }
102 }
103
104 pub fn at(origin: PxPosition) -> Self {
106 Self::new(origin, PxSize::new(Px::ZERO, Px::ZERO))
107 }
108
109 pub fn from_dp(origin: (Dp, Dp), size: (Dp, Dp)) -> Self {
111 Self::new(
112 PxPosition::new(origin.0.into(), origin.1.into()),
113 PxSize::new(size.0.into(), size.1.into()),
114 )
115 }
116}
117
118impl Default for MenuAnchor {
119 fn default() -> Self {
120 Self::at(PxPosition::new(Px::ZERO, Px::ZERO))
121 }
122}
123
124#[derive(Default)]
125struct MenuStateInner {
126 is_open: bool,
127 anchor: MenuAnchor,
128}
129
130#[derive(Clone, Default)]
132pub struct MenuState {
133 inner: Arc<RwLock<MenuStateInner>>,
134}
135
136impl MenuState {
137 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn open(&self) {
144 self.inner.write().is_open = true;
145 }
146
147 pub fn open_at(&self, anchor: MenuAnchor) {
149 let mut inner = self.inner.write();
150 inner.anchor = anchor;
151 inner.is_open = true;
152 }
153
154 pub fn close(&self) {
156 self.inner.write().is_open = false;
157 }
158
159 pub fn toggle(&self) {
161 let mut inner = self.inner.write();
162 inner.is_open = !inner.is_open;
163 }
164
165 pub fn is_open(&self) -> bool {
167 self.inner.read().is_open
168 }
169
170 fn snapshot(&self) -> (bool, MenuAnchor) {
171 let inner = self.inner.read();
172 (inner.is_open, inner.anchor)
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub enum MenuPlacement {
179 #[default]
181 BelowStart,
182 BelowEnd,
184 AboveStart,
186 AboveEnd,
188}
189
190#[derive(Builder, Clone)]
192#[builder(pattern = "owned")]
193pub struct MenuProviderArgs {
194 #[builder(default)]
196 pub placement: MenuPlacement,
197 #[builder(default = "[Dp(0.0), MENU_VERTICAL_GAP]")]
199 pub offset: [Dp; 2],
200 #[builder(default = "default_menu_width()")]
202 pub width: DimensionValue,
203 #[builder(default = "default_max_height()")]
205 pub max_height: Option<Px>,
206 #[builder(default = "default_menu_shape()")]
208 pub shape: Shape,
209 #[builder(default = "default_menu_shadow()", setter(strip_option))]
211 pub shadow: Option<ShadowProps>,
212 #[builder(default = "default_menu_color()")]
214 pub container_color: Color,
215 #[builder(default = "default_scrim_color()")]
217 pub scrim_color: Color,
218 #[builder(default = "true")]
220 pub close_on_background: bool,
221 #[builder(default = "true")]
223 pub close_on_escape: bool,
224 #[builder(default, setter(strip_option))]
226 pub on_dismiss: Option<Arc<dyn Fn() + Send + Sync>>,
227}
228
229impl Default for MenuProviderArgs {
230 fn default() -> Self {
231 MenuProviderArgsBuilder::default()
232 .build()
233 .expect("MenuArgsBuilder default build should succeed")
234 }
235}
236
237pub type MenuArgs = MenuProviderArgs;
239pub type MenuArgsBuilder = MenuProviderArgsBuilder;
241
242#[derive(Clone, Copy)]
243struct MenuBounds {
244 origin: PxPosition,
245 size: ComputedData,
246}
247
248impl Default for MenuBounds {
249 fn default() -> Self {
250 Self {
251 origin: PxPosition::new(Px::ZERO, Px::ZERO),
252 size: ComputedData::ZERO,
253 }
254 }
255}
256
257fn resolve_menu_position(
258 anchor: MenuAnchor,
259 placement: MenuPlacement,
260 menu_size: ComputedData,
261 available: ComputedData,
262 offset: [Dp; 2],
263) -> PxPosition {
264 let anchor_end_x = anchor.origin.x + anchor.size.width;
265 let anchor_end_y = anchor.origin.y + anchor.size.height;
266
267 let mut x = match placement {
268 MenuPlacement::BelowStart | MenuPlacement::AboveStart => anchor.origin.x,
269 MenuPlacement::BelowEnd | MenuPlacement::AboveEnd => anchor_end_x - menu_size.width,
270 };
271
272 let mut y = match placement {
273 MenuPlacement::BelowStart | MenuPlacement::BelowEnd => anchor_end_y,
274 MenuPlacement::AboveStart | MenuPlacement::AboveEnd => anchor.origin.y - menu_size.height,
275 };
276
277 x += Px::from(offset[0]);
278 y += Px::from(offset[1]);
279
280 let max_x = available.width - menu_size.width;
281 let max_y = available.height - menu_size.height;
282 if x < Px::ZERO {
283 x = Px::ZERO;
284 }
285 if y < Px::ZERO {
286 y = Px::ZERO;
287 }
288 if max_x > Px::ZERO {
289 x = x.min(max_x);
290 }
291 if max_y > Px::ZERO {
292 y = y.min(max_y);
293 }
294
295 PxPosition::new(x, y)
296}
297
298fn extract_available_size(constraint: &Constraint) -> ComputedData {
299 let width = match constraint.width {
300 DimensionValue::Fixed(px) => px,
301 DimensionValue::Wrap { max, .. } | DimensionValue::Fill { max, .. } => {
302 max.unwrap_or(Px::MAX)
303 }
304 };
305 let height = match constraint.height {
306 DimensionValue::Fixed(px) => px,
307 DimensionValue::Wrap { max, .. } | DimensionValue::Fill { max, .. } => {
308 max.unwrap_or(Px::MAX)
309 }
310 };
311
312 ComputedData { width, height }
313}
314
315fn should_close_on_click(
316 cursor_events: &[CursorEvent],
317 cursor_position: Option<PxPosition>,
318 bounds: Option<MenuBounds>,
319) -> bool {
320 let Some(bounds) = bounds else {
321 return false;
322 };
323
324 cursor_events.iter().any(|event| {
325 matches!(event.content, CursorEventContent::Released(_))
326 && cursor_position
327 .map(|pos| {
328 !is_position_in_rect(pos, bounds.origin, bounds.size.width, bounds.size.height)
329 })
330 .unwrap_or(false)
331 })
332}
333
334fn apply_close_action(state: &MenuState, on_dismiss: &Option<Arc<dyn Fn() + Send + Sync>>) {
335 if let Some(callback) = on_dismiss {
336 callback();
337 }
338 state.close();
339}
340
341#[tessera]
406pub fn menu_provider(
407 args: impl Into<MenuProviderArgs>,
408 state: MenuState,
409 main_content: impl FnOnce() + Send + Sync + 'static,
410 menu_content: impl FnOnce(&mut MenuScope<'_, '_>) + Send + Sync + 'static,
411) {
412 let args: MenuProviderArgs = args.into();
413
414 main_content();
416
417 let (is_open, anchor) = state.snapshot();
418 if !is_open {
419 return;
420 }
421
422 let bounds: Arc<RwLock<Option<MenuBounds>>> = Arc::new(RwLock::new(None));
424
425 surface(
427 SurfaceArgsBuilder::default()
428 .style(SurfaceStyle::Filled {
429 color: args.scrim_color,
430 })
431 .width(DimensionValue::FILLED)
432 .height(DimensionValue::FILLED)
433 .block_input(true)
434 .build()
435 .expect("builder construction failed"),
436 None,
437 || {},
438 );
439
440 surface(
442 {
443 let mut builder = SurfaceArgsBuilder::default()
444 .style(SurfaceStyle::Filled {
445 color: args.container_color,
446 })
447 .shape(args.shape)
448 .padding(Dp(0.0))
449 .width(args.width)
450 .height(DimensionValue::Wrap {
451 min: None,
452 max: args.max_height,
453 })
454 .accessibility_role(Role::Menu)
455 .block_input(true);
456
457 if let Some(shadow) = args.shadow {
458 builder = builder.shadow(shadow);
459 }
460
461 builder.build().expect("builder construction failed")
462 },
463 None,
464 || {
465 column(
466 ColumnArgsBuilder::default()
467 .width(DimensionValue::FILLED)
468 .cross_axis_alignment(CrossAxisAlignment::Start)
469 .build()
470 .expect("builder construction failed"),
471 |scope| {
472 let mut menu_scope = MenuScope { scope };
473 menu_content(&mut menu_scope);
474 },
475 );
476 },
477 );
478
479 let bounds_for_handler = bounds.clone();
481 let on_dismiss_for_handler = args.on_dismiss.clone();
482 let close_on_escape = args.close_on_escape;
483 let close_on_background = args.close_on_background;
484 let state_for_handler = state.clone();
485 input_handler(Box::new(move |mut input| {
486 let mut cursor_events: Vec<_> = Vec::new();
487 std::mem::swap(&mut cursor_events, input.cursor_events);
488 let cursor_position = input.cursor_position_rel;
489
490 let mut keyboard_events: Vec<_> = Vec::new();
491 std::mem::swap(&mut keyboard_events, input.keyboard_events);
492
493 input.block_all();
495
496 let menu_bounds = *bounds_for_handler.read();
497 let should_close_click = close_on_background
498 && should_close_on_click(&cursor_events, cursor_position, menu_bounds);
499
500 let should_close_escape = close_on_escape
501 && keyboard_events.iter().any(|event| {
502 event.state == winit::event::ElementState::Pressed
503 && matches!(
504 event.physical_key,
505 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape)
506 )
507 });
508
509 if should_close_click || should_close_escape {
510 apply_close_action(&state_for_handler, &on_dismiss_for_handler);
511 }
512 }));
513
514 let bounds_for_measure = bounds;
516 let args_for_measure = args.clone();
517 measure(Box::new(move |input| {
518 let main_content_id = input
519 .children_ids
520 .first()
521 .copied()
522 .expect("main content should exist");
523 let main_size = input.measure_child(main_content_id, input.parent_constraint)?;
524 input.place_child(main_content_id, PxPosition::new(Px::ZERO, Px::ZERO));
525
526 let background_id = input
527 .children_ids
528 .get(1)
529 .copied()
530 .expect("menu background should exist");
531 let menu_id = input
532 .children_ids
533 .get(2)
534 .copied()
535 .expect("menu surface should exist");
536
537 let background_size = input.measure_child(background_id, input.parent_constraint)?;
538 input.place_child(background_id, PxPosition::new(Px::ZERO, Px::ZERO));
539
540 let menu_size = input.measure_child(menu_id, input.parent_constraint)?;
541 let available = if background_size.width > Px::ZERO && background_size.height > Px::ZERO {
542 background_size
543 } else {
544 extract_available_size(input.parent_constraint)
545 };
546 let menu_position = resolve_menu_position(
547 anchor,
548 args_for_measure.placement,
549 menu_size,
550 available,
551 args_for_measure.offset,
552 );
553 input.place_child(menu_id, menu_position);
554
555 if let Some(mut metadata) = input.metadatas.get_mut(&menu_id) {
556 metadata.clips_children = true;
557 }
558
559 *bounds_for_measure.write() = Some(MenuBounds {
560 origin: menu_position,
561 size: menu_size,
562 });
563
564 Ok(main_size)
565 }));
566}
567
568#[tessera]
570pub fn menu(
571 args: impl Into<MenuProviderArgs>,
572 state: MenuState,
573 content: impl FnOnce(&mut MenuScope<'_, '_>) + Send + Sync + 'static,
574) {
575 menu_provider(args, state, || {}, content);
576}
577
578#[derive(Builder, Clone)]
580#[builder(pattern = "owned")]
581pub struct MenuItemArgs {
582 #[builder(setter(into))]
584 pub label: String,
585 #[builder(default, setter(strip_option, into))]
587 pub supporting_text: Option<String>,
588 #[builder(default, setter(strip_option, into))]
590 pub trailing_text: Option<String>,
591 #[builder(default, setter(strip_option))]
593 pub leading_icon: Option<crate::icon::IconArgs>,
594 #[builder(default, setter(strip_option))]
596 pub trailing_icon: Option<crate::icon::IconArgs>,
597 #[builder(default)]
599 pub selected: bool,
600 #[builder(default = "true")]
602 pub enabled: bool,
603 #[builder(default = "true")]
605 pub close_on_click: bool,
606 #[builder(default = "MENU_ITEM_HEIGHT")]
608 pub height: Dp,
609 #[builder(default = "crate::material_color::global_material_scheme().on_surface")]
611 pub label_color: Color,
612 #[builder(default = "crate::material_color::global_material_scheme().on_surface_variant")]
614 pub supporting_color: Color,
615 #[builder(
617 default = "crate::material_color::global_material_scheme().on_surface.with_alpha(0.38)"
618 )]
619 pub disabled_color: Color,
620 #[builder(default, setter(strip_option))]
622 pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
623}
624
625impl Default for MenuItemArgs {
626 fn default() -> Self {
627 MenuItemArgsBuilder::default()
628 .label("")
629 .build()
630 .expect("MenuItemArgsBuilder default build should succeed")
631 }
632}
633
634fn render_leading(args: &MenuItemArgs, enabled: bool) {
635 if args.selected {
636 checkmark(
637 CheckmarkArgsBuilder::default()
638 .color(if enabled {
639 args.label_color
640 } else {
641 args.disabled_color
642 })
643 .size(MENU_LEADING_SIZE)
644 .padding([2.0, 2.0])
645 .build()
646 .expect("builder construction failed"),
647 );
648 } else if let Some(icon) = args.leading_icon.clone() {
649 crate::icon::icon(
650 crate::icon::IconArgsBuilder::default()
651 .content(icon.content)
652 .size(icon.size)
653 .width(
654 icon.width
655 .unwrap_or_else(|| DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE))),
656 )
657 .height(
658 icon.height
659 .unwrap_or_else(|| DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE))),
660 )
661 .tint(if enabled {
662 args.supporting_color
663 } else {
664 args.disabled_color
665 })
666 .build()
667 .expect("builder construction failed"),
668 );
669 } else {
670 spacer(
671 SpacerArgsBuilder::default()
672 .width(DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE)))
673 .height(DimensionValue::Fixed(Px::from(MENU_LEADING_SIZE)))
674 .build()
675 .expect("builder construction failed"),
676 );
677 }
678}
679
680fn render_labels(args: &MenuItemArgs, enabled: bool) {
681 let label_color = if enabled {
682 args.label_color
683 } else {
684 args.disabled_color
685 };
686 let supporting_color = if enabled {
687 args.supporting_color
688 } else {
689 args.disabled_color
690 };
691 let label_text = args.label.clone();
692 let supporting_text = args.supporting_text.clone();
693
694 column(
695 ColumnArgsBuilder::default()
696 .width(DimensionValue::WRAP)
697 .cross_axis_alignment(CrossAxisAlignment::Start)
698 .build()
699 .expect("builder construction failed"),
700 |scope| {
701 scope.child(move || {
702 let text_value = label_text.clone();
703 let color = label_color;
704 text(
705 TextArgsBuilder::default()
706 .text(text_value)
707 .size(Dp(16.0))
708 .color(color)
709 .build()
710 .expect("builder construction failed"),
711 );
712 });
713 if let Some(supporting) = supporting_text {
714 scope.child(move || {
715 let supporting_value = supporting.clone();
716 let color = supporting_color;
717 text(
718 TextArgsBuilder::default()
719 .text(supporting_value)
720 .size(Dp(14.0))
721 .color(color)
722 .build()
723 .expect("builder construction failed"),
724 );
725 });
726 }
727 },
728 );
729}
730
731fn render_trailing(args: &MenuItemArgs, enabled: bool) {
732 if let Some(trailing_icon) = args.trailing_icon.clone() {
733 crate::icon::icon(
734 crate::icon::IconArgsBuilder::default()
735 .content(trailing_icon.content)
736 .size(trailing_icon.size)
737 .width(trailing_icon.width.unwrap_or(DimensionValue::WRAP))
738 .height(trailing_icon.height.unwrap_or(DimensionValue::WRAP))
739 .tint(if enabled {
740 args.supporting_color
741 } else {
742 args.disabled_color
743 })
744 .build()
745 .expect("builder construction failed"),
746 );
747 } else if let Some(trailing_text) = args.trailing_text.clone() {
748 text(
749 TextArgsBuilder::default()
750 .text(trailing_text)
751 .size(Dp(14.0))
752 .color(if enabled {
753 args.supporting_color
754 } else {
755 args.disabled_color
756 })
757 .build()
758 .expect("builder construction failed"),
759 );
760 }
761}
762
763#[tessera]
800pub fn menu_item(args: MenuItemArgs, menu_state: Option<MenuState>, ripple_state: RippleState) {
801 let is_enabled = args.enabled && args.on_click.is_some();
802 let on_click = args.on_click.clone();
803 let close_on_click = args.close_on_click;
804
805 let interactive_click = if is_enabled {
806 Some(Arc::new(closure!(clone on_click, clone menu_state, || {
807 if let Some(handler) = &on_click {
808 handler();
809 }
810 if close_on_click && let Some(state) = &menu_state {
811 state.close();
812 }
813 })) as Arc<dyn Fn() + Send + Sync>)
814 } else {
815 None
816 };
817
818 let mut surface_builder = SurfaceArgsBuilder::default()
819 .style(SurfaceStyle::Filled {
820 color: Color::TRANSPARENT,
821 })
822 .hover_style(is_enabled.then(|| SurfaceStyle::Filled {
823 color: default_hover_color(),
824 }))
825 .padding(Dp(0.0))
826 .width(DimensionValue::FILLED)
827 .height(DimensionValue::Wrap {
828 min: Some(Px::from(args.height)),
829 max: None,
830 })
831 .accessibility_role(Role::MenuItem)
832 .accessibility_label(args.label.clone())
833 .block_input(true)
834 .ripple_color(
835 crate::material_color::global_material_scheme()
836 .on_surface
837 .with_alpha(0.12),
838 );
839
840 if let Some(click) = interactive_click {
841 surface_builder = surface_builder.on_click(click);
842 }
843
844 if let Some(description) = args.supporting_text.clone() {
845 surface_builder = surface_builder.accessibility_description(description);
846 }
847
848 surface(
849 surface_builder
850 .build()
851 .expect("builder construction failed"),
852 Some(ripple_state),
853 || {
854 row(
855 RowArgsBuilder::default()
856 .width(DimensionValue::FILLED)
857 .height(DimensionValue::Wrap {
858 min: Some(Px::from(args.height)),
859 max: None,
860 })
861 .cross_axis_alignment(CrossAxisAlignment::Center)
862 .build()
863 .expect("builder construction failed"),
864 |row_scope| {
865 row_scope.child(|| {
867 spacer(
868 SpacerArgsBuilder::default()
869 .width(DimensionValue::Fixed(Px::from(MENU_HORIZONTAL_PADDING)))
870 .build()
871 .expect("builder construction failed"),
872 );
873 });
874
875 let leading_args = args.clone();
877 row_scope.child(move || {
878 render_leading(&leading_args, is_enabled);
879 });
880
881 row_scope.child(|| {
883 spacer(
884 SpacerArgsBuilder::default()
885 .width(DimensionValue::Fixed(Px::from(MENU_HORIZONTAL_PADDING)))
886 .build()
887 .expect("builder construction failed"),
888 );
889 });
890
891 let label_args = args.clone();
893 row_scope.child(move || {
894 render_labels(&label_args, is_enabled);
895 });
896
897 row_scope.child_weighted(
899 || {
900 spacer(
901 SpacerArgsBuilder::default()
902 .width(DimensionValue::FILLED)
903 .build()
904 .expect("builder construction failed"),
905 );
906 },
907 1.0,
908 );
909
910 if args.trailing_icon.is_some() || args.trailing_text.is_some() {
912 let trailing_args = args.clone();
913 row_scope.child(move || {
914 render_trailing(&trailing_args, is_enabled);
915 });
916
917 row_scope.child(|| {
918 spacer(
919 SpacerArgsBuilder::default()
920 .width(DimensionValue::Fixed(Px::from(MENU_TRAILING_SPACING)))
921 .build()
922 .expect("builder construction failed"),
923 );
924 });
925 } else {
926 row_scope.child(|| {
927 spacer(
928 SpacerArgsBuilder::default()
929 .width(DimensionValue::Fixed(Px::from(MENU_HORIZONTAL_PADDING)))
930 .build()
931 .expect("builder construction failed"),
932 );
933 });
934 }
935 },
936 );
937 },
938 );
939}