1use frust::authoring::{
71 Action, Toggled,
72 text::{TextContext, TextLayout, TextOverflow, TextStyle},
73};
74use frust::authoring::{
75 Affine, AnyView, BezPath, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color,
76 CursorIcon, ErasedArgCallback, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
77 PaintScene, Point, PointerEvent, PointerPhase, Rect, Role, ScrollDelta, SemanticsCtx, Size,
78 TypedArgCallback, View, Widget, any, build_child, erase_callback_arg, rebuild_children,
79 route_event_single, teardown_child, visit_children,
80};
81use frust::input::WHEEL_LINE_PX;
82use frust::{IconData, Theme};
83
84use super::items::{MenuAction, MenuNode, MenuSelection, partition_surfaces};
85use super::{
86 MENU_DIVIDER_THICKNESS, MENU_DIVIDER_V_PADDING, MENU_GROUP_LABEL_H_PADDING,
87 MENU_GROUP_LABEL_V_PADDING, MENU_ICON_GAP, MENU_ICON_SIZE, MENU_ITEM_GAP, MENU_MAX_HEIGHT,
88 MENU_MAX_WIDTH, MENU_MIN_WIDTH, MENU_ROW_H_PADDING, MENU_ROW_MIN_HEIGHT, MENU_SECTION_GAP,
89 MENU_SUBMENU_GAP, MENU_SUPPORTING_V_PADDING, MENU_SURFACE_H_PADDING, MENU_SURFACE_V_PADDING,
90 MenuColorStyle, MenuColors, SELECTED_CHECK_SCALE, container_radius, item_radius,
91};
92use crate::icons;
93use crate::interaction::InteractionState;
94use crate::overlay::{OverlayElevation, shadow};
95use crate::press::presses;
96
97const SHAPING_INK: Color = Color::BLACK;
102
103struct Run {
108 content: String,
109 layout: Option<TextLayout>,
110 shaped_for: Option<(TextStyle, Option<f64>)>,
112 natural: Option<(TextStyle, f64)>,
114}
115
116impl Run {
117 fn new(content: impl Into<String>) -> Self {
119 Run {
120 content: content.into(),
121 layout: None,
122 shaped_for: None,
123 natural: None,
124 }
125 }
126
127 fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
129 if let Some((cached, width)) = &self.natural
130 && cached == style
131 {
132 return *width;
133 }
134 let text_ctx = ctx.text_context::<TextContext>();
135 let width = text_ctx.layout(&self.content, style, None).size().width;
136 self.natural = Some((style.clone(), width));
137 width
138 }
139
140 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
146 let key = (style.clone(), Some(max_width));
147 if let Some(cached) = &self.layout
148 && self.shaped_for.as_ref() == Some(&key)
149 {
150 return cached.size();
151 }
152 let text_ctx = ctx.text_context::<TextContext>();
153 let laid = text_ctx.layout_bounded(
154 &self.content,
155 style,
156 Some(max_width.max(0.0) as f32),
157 Some(1),
158 TextOverflow::Ellipsis,
159 );
160 let size = laid.size();
161 self.layout = Some(laid);
162 self.shaped_for = Some(key);
163 size
164 }
165
166 fn size(&self) -> Size {
168 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
169 }
170
171 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
174 let Some(layout) = &self.layout else {
175 return;
176 };
177 for mut run in layout.to_scene_runs(origin) {
178 run.brush = Brush::Solid(color);
179 scene.draw_glyph_run(run);
180 }
181 }
182}
183
184struct Glyph {
186 path: BezPath,
187 design: f64,
188 extent: f64,
191}
192
193impl Glyph {
194 fn new(icon: frust::IconSource, extent: f64) -> Self {
196 let (path, design) = IconData::from(icon).resolve();
197 Glyph {
198 path,
199 design,
200 extent,
201 }
202 }
203
204 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
206 let scale = if self.design > 0.0 {
207 self.extent / self.design
208 } else {
209 1.0
210 };
211 let scaled = Affine::scale(scale) * self.path.clone();
212 scene.fill_path(origin, &scaled, &Brush::Solid(color));
213 }
214}
215
216enum RowKind {
218 Entry,
220 Selectable(String),
222 Toggleable(bool),
224 Divider,
226 Submenu,
228}
229
230impl RowKind {
231 fn is_item(&self) -> bool {
234 !matches!(self, RowKind::Divider)
235 }
236}
237
238struct Row {
241 kind: RowKind,
242 index: usize,
244 surface: usize,
246 enabled: bool,
247 destructive: bool,
248 selected: bool,
251 label: Run,
252 supporting: Option<Run>,
253 shortcut: Option<Run>,
254 leading: Option<Glyph>,
255 trailing: Option<Glyph>,
256 sub: Option<usize>,
258 top: f64,
260 height: f64,
262}
263
264struct Surface {
267 label: Option<Run>,
268 top: f64,
270 height: f64,
271}
272
273struct Plan {
277 surfaces: Vec<PlannedSurface>,
278 rows: Vec<PlannedRow>,
279}
280
281struct PlannedSurface {
283 label: Option<String>,
284}
285
286struct PlannedRow {
289 node: MenuNode,
290 index: usize,
291 surface: usize,
292 submenu: Option<(usize, Vec<MenuNode>)>,
294}
295
296fn plan_tree(nodes: &[MenuNode], base: usize) -> Plan {
298 let mut surfaces = Vec::new();
299 let mut rows = Vec::new();
300 let mut index = base;
301 for group in partition_surfaces(nodes) {
302 let surface = surfaces.len();
303 surfaces.push(PlannedSurface {
304 label: group.section_label().map(str::to_string),
305 });
306 for node in group.children() {
307 let submenu = match node {
308 MenuNode::Submenu(item) => Some((index + 1, item.children().to_vec())),
309 _ => None,
310 };
311 index += node.span();
312 match node {
316 MenuNode::Group(inner) => {
317 let nested = plan_tree(inner.children(), index - node.span());
318 for mut row in nested.rows {
319 row.surface = surface;
320 rows.push(row);
321 }
322 }
323 _ => rows.push(PlannedRow {
324 node: node.clone(),
325 index: index - node.span(),
326 surface,
327 submenu,
328 }),
329 }
330 }
331 }
332 Plan { surfaces, rows }
333}
334
335pub struct MenuPanelView<State: 'static> {
337 nodes: Vec<MenuNode>,
338 style: MenuColorStyle,
339 selected: Option<String>,
340 open: bool,
341 base: usize,
342 on_select: TypedArgCallback<State, MenuSelection>,
343}
344
345pub fn menu_panel<State: 'static, F: Fn(&mut State, MenuSelection) + 'static>(
356 nodes: Vec<MenuNode>,
357 on_select: F,
358) -> MenuPanelView<State> {
359 MenuPanelView {
360 nodes,
361 style: MenuColorStyle::default(),
362 selected: None,
363 open: true,
364 base: 0,
365 on_select: std::rc::Rc::new(on_select),
366 }
367}
368
369impl<State: 'static> MenuPanelView<State> {
370 pub fn color_style(mut self, style: MenuColorStyle) -> Self {
372 self.style = style;
373 self
374 }
375
376 pub fn selected(mut self, value: Option<String>) -> Self {
379 self.selected = value;
380 self
381 }
382
383 pub fn open(mut self, open: bool) -> Self {
386 self.open = open;
387 self
388 }
389
390 pub fn base(mut self, base: usize) -> Self {
393 self.base = base;
394 self
395 }
396
397 fn sub_views(&self, plan: &Plan) -> Vec<AnyView<State>> {
399 plan.rows
400 .iter()
401 .filter_map(|row| row.submenu.as_ref())
402 .map(|(base, children)| {
403 any(MenuPanelView {
404 nodes: children.clone(),
405 style: self.style,
406 selected: self.selected.clone(),
407 open: self.open,
408 base: *base,
409 on_select: self.on_select.clone(),
410 })
411 })
412 .collect()
413 }
414
415 fn build_rows(&self, plan: &Plan) -> Vec<Row> {
418 let mut pod = 0usize;
419 let mut rows = Vec::with_capacity(plan.rows.len());
420 for planned in &plan.rows {
421 let sub = planned.submenu.as_ref().map(|_| {
422 let index = pod;
423 pod += 1;
424 index
425 });
426 rows.push(self.build_row(planned, sub));
427 }
428 rows
429 }
430
431 fn build_row(&self, planned: &PlannedRow, sub: Option<usize>) -> Row {
433 let icon = |slot: &Option<super::MenuIcon>| slot.map(|i| Glyph::new(i.0, MENU_ICON_SIZE));
434 let (kind, label, supporting, shortcut, leading, trailing, enabled, destructive, selected) =
435 match &planned.node {
436 MenuNode::Entry(e) => (
437 RowKind::Entry,
438 Run::new(e.label.clone()),
439 e.supporting.clone().map(Run::new),
440 e.shortcut.clone().map(Run::new),
441 icon(&e.leading),
442 icon(&e.trailing),
443 e.enabled,
444 e.destructive,
445 false,
446 ),
447 MenuNode::Selectable(e) => {
448 let selected =
449 e.selected || self.selected.as_deref().is_some_and(|v| v == e.value);
450 let leading = icon(&e.leading).or_else(|| {
453 selected.then(|| {
454 Glyph::new(icons::CHECK_ROUNDED, MENU_ICON_SIZE * SELECTED_CHECK_SCALE)
455 })
456 });
457 (
458 RowKind::Selectable(e.value.clone()),
459 Run::new(e.label.clone()),
460 e.supporting.clone().map(Run::new),
461 e.shortcut.clone().map(Run::new),
462 leading,
463 icon(&e.trailing),
464 e.enabled,
465 false,
466 selected,
467 )
468 }
469 MenuNode::Toggleable(e) => {
470 let leading = icon(&e.leading).or_else(|| {
473 let source = if e.checked {
474 icons::CHECK_BOX_ROUNDED
475 } else {
476 icons::CHECK_BOX_OUTLINE_BLANK_ROUNDED
477 };
478 Some(Glyph::new(source, MENU_ICON_SIZE))
479 });
480 (
481 RowKind::Toggleable(e.checked),
482 Run::new(e.label.clone()),
483 e.supporting.clone().map(Run::new),
484 e.shortcut.clone().map(Run::new),
485 leading,
486 icon(&e.trailing),
487 e.enabled,
488 false,
489 e.checked,
490 )
491 }
492 MenuNode::Submenu(e) => (
493 RowKind::Submenu,
494 Run::new(e.label.clone()),
495 None,
496 None,
497 icon(&e.leading),
498 Some(Glyph::new(icons::ARROW_RIGHT_ROUNDED, MENU_ICON_SIZE)),
501 e.enabled,
502 false,
503 false,
504 ),
505 MenuNode::Divider | MenuNode::Group(_) => (
510 RowKind::Divider,
511 Run::new(String::new()),
512 None,
513 None,
514 None,
515 None,
516 false,
517 false,
518 false,
519 ),
520 };
521 Row {
522 kind,
523 index: planned.index,
524 surface: planned.surface,
525 enabled,
526 destructive,
527 selected,
528 label,
529 supporting,
530 shortcut,
531 leading,
532 trailing,
533 sub,
534 top: 0.0,
535 height: 0.0,
536 }
537 }
538
539 fn build_surfaces(&self, plan: &Plan) -> Vec<Surface> {
541 plan.surfaces
542 .iter()
543 .map(|planned| Surface {
544 label: planned.label.clone().map(Run::new),
545 top: 0.0,
546 height: 0.0,
547 })
548 .collect()
549 }
550}
551
552pub struct MenuPanelWidget {
554 surfaces: Vec<Surface>,
555 rows: Vec<Row>,
556 pods: Vec<ChildPod>,
558 style: MenuColorStyle,
559 open: bool,
560 on_select: ErasedArgCallback<MenuSelection>,
561 hovered: Option<usize>,
564 pressed: Option<usize>,
566 press_inside: bool,
570 captured: bool,
571 open_sub: Option<usize>,
573 scroll: f64,
574 viewport: f64,
575 content_height: f64,
576 width: f64,
577}
578
579impl MenuPanelWidget {
580 pub fn width(&self) -> f64 {
582 self.width
583 }
584
585 pub fn open_submenu(&self) -> Option<usize> {
587 self.open_sub
588 }
589
590 pub fn scroll(&self) -> f64 {
592 self.scroll
593 }
594
595 #[cfg(test)]
597 fn band(&self, row: usize) -> (f64, f64) {
598 let row = &self.rows[row];
599 (row.top, row.height)
600 }
601
602 fn close_chain(&mut self) {
609 self.open_sub = None;
610 }
611
612 fn open_submenu_at(&mut self, row: usize) -> bool {
614 let opens = self
615 .rows
616 .get(row)
617 .is_some_and(|r| r.enabled && r.sub.is_some());
618 if opens {
619 self.open_sub = Some(row);
620 }
621 opens
622 }
623
624 fn open_sub_pod(&self) -> Option<usize> {
626 let row = self.open_sub?;
627 self.rows.get(row)?.sub
628 }
629
630 fn open_sub_rect(&self) -> Option<Rect> {
632 let pod = self.open_sub_pod()?;
633 let pod = self.pods.get(pod)?;
634 Some(Rect::from_origin_size(pod.origin(), pod.size()))
635 }
636
637 fn clamp_scroll(&mut self) {
639 let max = (self.content_height - self.viewport).max(0.0);
640 self.scroll = self.scroll.clamp(0.0, max);
641 }
642
643 fn apply_scroll(&mut self) {
649 for row in self.rows.iter() {
650 let Some(pod) = row.sub.and_then(|i| self.pods.get_mut(i)) else {
651 continue;
652 };
653 pod.set_origin(Point::new(
658 self.width + MENU_SUBMENU_GAP,
659 row.top - self.scroll - MENU_SURFACE_V_PADDING,
660 ));
661 }
662 }
663
664 fn row_at(&self, pos: Point) -> Option<usize> {
666 if pos.x < 0.0 || pos.x >= self.width || pos.y < 0.0 || pos.y >= self.viewport {
667 return None;
668 }
669 let y = pos.y + self.scroll;
670 self.rows
671 .iter()
672 .position(|row| y >= row.top && y < row.top + row.height)
673 }
674
675 fn activatable(&self, i: usize) -> bool {
677 self.rows
678 .get(i)
679 .is_some_and(|row| row.enabled && row.kind.is_item())
680 }
681
682 fn activate(&mut self, ctx: &mut EventCtx, i: usize) {
684 let Some(row) = self.rows.get(i) else {
685 return;
686 };
687 let action = match &row.kind {
688 RowKind::Entry => MenuAction::Press,
689 RowKind::Selectable(value) => MenuAction::Select(value.clone()),
690 RowKind::Toggleable(checked) => MenuAction::Toggle(!checked),
693 RowKind::Submenu => {
694 self.open_submenu_at(i);
695 ctx.request_redraw();
696 return;
697 }
698 RowKind::Divider => return,
699 };
700 let selection = MenuSelection {
701 index: row.index,
702 label: row.label.content.clone(),
703 action,
704 };
705 (self.on_select)(ctx, selection);
706 }
707
708 fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
711 match p.phase {
712 PointerPhase::Move if !self.captured => {
713 if self.open_sub_rect().is_some_and(|r| r.contains(p.position)) {
715 return EventResult::Ignored;
716 }
717 let row = self.row_at(p.position);
718 if row.is_some() {
721 ctx.claim_hover();
722 }
723 let hovered = row.filter(|i| self.activatable(*i));
724 if hovered.is_some() {
725 ctx.set_cursor(CursorIcon::Pointer);
726 }
727 if self.hovered != hovered {
728 self.hovered = hovered;
729 match hovered {
730 Some(i) => {
733 if !self.open_submenu_at(i) {
734 self.open_sub = None;
735 }
736 }
737 None => self.open_sub = None,
741 }
742 ctx.request_redraw();
743 }
744 EventResult::Ignored
745 }
746 PointerPhase::Move => {
747 let inside = self.pressed.is_some() && self.row_at(p.position) == self.pressed;
750 if self.press_inside != inside {
751 self.press_inside = inside;
752 ctx.request_redraw();
753 }
754 EventResult::Handled
755 }
756 PointerPhase::Down => {
757 if !presses(p) {
758 return EventResult::Ignored;
759 }
760 let Some(row) = self.row_at(p.position).filter(|i| self.activatable(*i)) else {
761 return EventResult::Ignored;
764 };
765 self.pressed = Some(row);
766 self.press_inside = true;
767 self.captured = true;
768 ctx.capture_pointer();
769 ctx.request_redraw();
770 EventResult::Handled
771 }
772 PointerPhase::Up => {
773 if !self.captured {
774 return EventResult::Ignored;
775 }
776 let released = self.row_at(p.position);
777 let armed = self.pressed;
778 self.pressed = None;
779 self.press_inside = false;
780 self.captured = false;
781 if let Some(row) = armed.filter(|row| released == Some(*row)) {
783 self.activate(ctx, row);
784 }
785 ctx.request_redraw();
786 EventResult::Handled
787 }
788 PointerPhase::Cancel => {
791 if !self.captured {
792 return EventResult::Ignored;
793 }
794 self.pressed = None;
795 self.press_inside = false;
796 self.captured = false;
797 ctx.request_redraw();
798 EventResult::Handled
799 }
800 }
801 }
802
803 fn handle_scroll(
806 &mut self,
807 ctx: &mut EventCtx,
808 position: Point,
809 delta: &ScrollDelta,
810 ) -> EventResult {
811 if position.y < 0.0 || position.y >= self.viewport {
812 return EventResult::Ignored;
813 }
814 let dy = match delta {
815 ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
816 ScrollDelta::Pixels(_, y) => *y,
817 };
818 let before = self.scroll;
819 self.scroll += dy;
820 self.clamp_scroll();
821 if (self.scroll - before).abs() <= f64::EPSILON {
822 return EventResult::Ignored;
823 }
824 self.apply_scroll();
825 ctx.request_redraw();
826 EventResult::Handled
827 }
828}
829
830impl<State: 'static> View<State> for MenuPanelView<State> {
831 type Element = MenuPanelWidget;
832
833 fn build(&self, ctx: &mut BuildCtx<'_>) -> MenuPanelWidget {
834 let plan = plan_tree(&self.nodes, self.base);
835 let pods = self
836 .sub_views(&plan)
837 .iter()
838 .map(|view| build_child(view, ctx))
839 .collect();
840 MenuPanelWidget {
841 surfaces: self.build_surfaces(&plan),
842 rows: self.build_rows(&plan),
843 pods,
844 style: self.style,
845 open: self.open,
846 on_select: erase_callback_arg(&self.on_select),
847 hovered: None,
848 pressed: None,
849 press_inside: false,
850 captured: false,
851 open_sub: None,
852 scroll: 0.0,
853 viewport: 0.0,
854 content_height: 0.0,
855 width: 0.0,
856 }
857 }
858
859 fn rebuild(
860 &self,
861 prev: &Self,
862 element: &mut MenuPanelWidget,
863 ctx: &mut BuildCtx<'_>,
864 ) -> ChangeFlags {
865 let plan = plan_tree(&self.nodes, self.base);
866 let mut flags = rebuild_children(
867 &prev.sub_views(&plan_tree(&prev.nodes, prev.base)),
868 &self.sub_views(&plan),
869 &mut element.pods,
870 ctx,
871 |view: &AnyView<State>| view,
872 |_| None,
873 );
874 if prev.nodes != self.nodes
878 || prev.selected != self.selected
879 || prev.style != self.style
880 || prev.base != self.base
881 {
882 element.surfaces = self.build_surfaces(&plan);
883 element.rows = self.build_rows(&plan);
884 element.style = self.style;
885 element.hovered = None;
886 element.pressed = None;
887 element.press_inside = false;
888 element.captured = false;
889 element.close_chain();
890 element.scroll = 0.0;
891 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
892 }
893 if element.open != self.open {
894 element.open = self.open;
895 if !self.open {
896 element.close_chain();
898 flags |= ChangeFlags::PAINT;
899 }
900 }
901 element.on_select = erase_callback_arg(&self.on_select);
902 flags
903 }
904
905 fn teardown(&self, element: &mut MenuPanelWidget, ctx: &mut BuildCtx<'_>) {
906 let plan = plan_tree(&self.nodes, self.base);
907 for (view, pod) in self.sub_views(&plan).iter().zip(element.pods.iter_mut()) {
908 teardown_child(view, pod, ctx);
909 }
910 }
911}
912
913struct Chrome {
917 colors: MenuColors,
918 shadow: Option<(f64, f64, Color)>,
920 container_radius: f64,
921 item_radius: f64,
922}
923
924struct Styles {
928 label: TextStyle,
929 supporting: TextStyle,
930}
931
932impl Styles {
933 fn resolve(theme: Option<&Theme>) -> Self {
936 let (mut label, mut supporting) = match theme {
937 Some(theme) => (
938 theme.type_scale.label_large.clone(),
939 theme.type_scale.label_medium.clone(),
940 ),
941 None => {
942 let mut large = TextStyle::new(14.0, SHAPING_INK);
943 large.letter_spacing = 0.1;
944 let mut medium = TextStyle::new(12.0, SHAPING_INK);
945 medium.letter_spacing = 0.5;
946 (large, medium)
947 }
948 };
949 label.color = SHAPING_INK;
950 supporting.color = SHAPING_INK;
951 Styles { label, supporting }
952 }
953}
954
955impl Widget for MenuPanelWidget {
956 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
957 let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
958
959 let mut natural: f64 = 0.0;
963 for i in 0..self.rows.len() {
964 natural = natural.max(self.row_natural_width(ctx, i, &styles));
965 }
966 for surface in self.surfaces.iter_mut() {
967 if let Some(label) = surface.label.as_mut() {
968 let width = label.natural_width(ctx, &styles.label);
969 natural = natural.max(width + 2.0 * MENU_GROUP_LABEL_H_PADDING);
970 }
971 }
972 let cap = if bc.max().width.is_finite() {
973 bc.max().width.min(MENU_MAX_WIDTH)
974 } else {
975 MENU_MAX_WIDTH
976 };
977 self.width = (natural + 2.0 * MENU_SURFACE_H_PADDING)
978 .max(MENU_MIN_WIDTH.min(cap))
979 .min(cap);
980
981 let mut y = 0.0;
984 for surface in 0..self.surfaces.len() {
985 if surface > 0 {
986 y += MENU_SECTION_GAP;
987 }
988 let top = y;
989 y += MENU_SURFACE_V_PADDING;
990 if let Some(label) = self.surfaces[surface].label.as_mut() {
991 let width = (self.width - 2.0 * MENU_GROUP_LABEL_H_PADDING).max(0.0);
992 let height = label.shape(ctx, &styles.label, width).height;
993 y += height + 2.0 * MENU_GROUP_LABEL_V_PADDING;
994 }
995 for i in 0..self.rows.len() {
996 if self.rows[i].surface != surface {
997 continue;
998 }
999 let height = self.row_layout(ctx, i, &styles);
1000 self.rows[i].top = y;
1001 self.rows[i].height = height;
1002 y += height;
1003 }
1004 y += MENU_SURFACE_V_PADDING;
1005 self.surfaces[surface].top = top;
1006 self.surfaces[surface].height = y - top;
1007 }
1008 self.content_height = y;
1009
1010 let height_cap = if bc.max().height.is_finite() {
1011 bc.max().height.min(MENU_MAX_HEIGHT)
1012 } else {
1013 MENU_MAX_HEIGHT
1014 };
1015 self.viewport = self.content_height.min(height_cap);
1016 self.clamp_scroll();
1017
1018 let loose = BoxConstraints::loose(Size::new(MENU_MAX_WIDTH, MENU_MAX_HEIGHT));
1020 for pod in self.pods.iter_mut() {
1021 pod.layout_child(ctx, &loose);
1022 }
1023 self.apply_scroll();
1024 bc.constrain(Size::new(self.width, self.viewport))
1025 }
1026
1027 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1028 if !ctx.is_hovered() {
1031 self.hovered = None;
1032 }
1033 let theme = Theme::from_paint_ctx(ctx);
1034 let chrome = Chrome {
1035 colors: MenuColors::resolve(theme, self.style),
1036 shadow: shadow(theme, OverlayElevation::Level2),
1038 container_radius: container_radius(theme),
1039 item_radius: item_radius(theme),
1040 };
1041 let origin = ctx.origin();
1042
1043 let clipped = self.content_height > self.viewport + f64::EPSILON;
1048 if clipped {
1049 scene.push_clip(origin, Size::new(self.width, self.viewport));
1050 }
1051
1052 for surface in 0..self.surfaces.len() {
1053 self.paint_surface(surface, origin, &chrome, scene);
1054 }
1055
1056 if clipped {
1057 scene.pop_clip();
1058 }
1059
1060 if let Some(pod) = self.open_sub_pod()
1063 && let Some(pod) = self.pods.get_mut(pod)
1064 {
1065 pod.paint_child(ctx, scene);
1066 }
1067 }
1068
1069 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1070 if event.is_broadcast() {
1071 for pod in self.pods.iter_mut() {
1072 pod.event_child(ctx, event);
1073 }
1074 return EventResult::Ignored;
1075 }
1076 if let Some(pod) = self.open_sub_pod()
1079 && route_event_single(&mut self.pods[pod], ctx, event) == EventResult::Handled
1080 {
1081 return EventResult::Handled;
1082 }
1083 match event {
1084 InputEvent::Scroll { position, delta } => self.handle_scroll(ctx, *position, delta),
1085 InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
1086 _ => EventResult::Ignored,
1089 }
1090 }
1091
1092 fn semantics(&self, ctx: &mut SemanticsCtx) {
1093 let rows = &self.rows;
1094 let pods = &self.pods;
1095 let open_sub = self.open_sub_pod();
1096 ctx.push_container(
1097 Role::Menu,
1098 |_node| {},
1099 |ctx| {
1100 for row in rows.iter() {
1101 let role = match row.kind {
1102 RowKind::Divider => continue,
1103 RowKind::Selectable(_) => Role::MenuItemRadio,
1104 RowKind::Toggleable(_) => Role::MenuItemCheckBox,
1105 RowKind::Entry | RowKind::Submenu => Role::MenuItem,
1106 };
1107 ctx.push_node(role, |node| {
1112 node.set_label(row.label.content.as_str());
1113 if row.enabled {
1114 node.add_action(Action::Click);
1115 }
1116 if matches!(row.kind, RowKind::Selectable(_) | RowKind::Toggleable(_)) {
1117 node.set_toggled(if row.selected {
1118 Toggled::True
1119 } else {
1120 Toggled::False
1121 });
1122 }
1123 });
1124 }
1125 if let Some(pod) = open_sub.and_then(|i| pods.get(i)) {
1129 pod.semantics_child(ctx);
1130 }
1131 },
1132 );
1133 }
1134
1135 visit_children!(pods);
1136}
1137
1138impl MenuPanelWidget {
1139 fn row_natural_width(&mut self, ctx: &mut LayoutCtx, i: usize, styles: &Styles) -> f64 {
1142 if !self.rows[i].kind.is_item() {
1143 return 0.0;
1144 }
1145 let mut text = {
1146 let row = &mut self.rows[i];
1147 row.label.natural_width(ctx, &styles.label)
1148 };
1149 if let Some(supporting) = self.rows[i].supporting.as_mut() {
1150 text = text.max(supporting.natural_width(ctx, &styles.supporting));
1151 }
1152 let mut width = 2.0 * MENU_ROW_H_PADDING + text;
1153 if self.rows[i].leading.is_some() {
1154 width += MENU_ICON_SIZE + MENU_ICON_GAP;
1155 }
1156 if let Some(shortcut) = self.rows[i].shortcut.as_mut() {
1157 width += MENU_ICON_GAP + shortcut.natural_width(ctx, &styles.supporting);
1158 }
1159 if self.rows[i].trailing.is_some() {
1160 width += MENU_ICON_GAP + MENU_ICON_SIZE;
1161 }
1162 width
1163 }
1164
1165 fn row_text_width(&self, i: usize) -> f64 {
1168 let row = &self.rows[i];
1169 let mut width = self.width - 2.0 * MENU_SURFACE_H_PADDING - 2.0 * MENU_ROW_H_PADDING;
1170 if row.leading.is_some() {
1171 width -= MENU_ICON_SIZE + MENU_ICON_GAP;
1172 }
1173 if row.trailing.is_some() {
1174 width -= MENU_ICON_GAP + MENU_ICON_SIZE;
1175 }
1176 if let Some(shortcut) = &row.shortcut {
1177 width -= MENU_ICON_GAP + shortcut.size().width;
1178 }
1179 width.max(0.0)
1180 }
1181
1182 fn row_layout(&mut self, ctx: &mut LayoutCtx, i: usize, styles: &Styles) -> f64 {
1184 if !self.rows[i].kind.is_item() {
1185 return 2.0 * MENU_DIVIDER_V_PADDING + MENU_DIVIDER_THICKNESS;
1186 }
1187 if let Some(shortcut) = self.rows[i].shortcut.as_mut() {
1190 let natural = shortcut.natural_width(ctx, &styles.supporting);
1191 shortcut.shape(ctx, &styles.supporting, natural);
1192 }
1193 let text_width = self.row_text_width(i);
1194 let mut content = {
1195 let label = &mut self.rows[i].label;
1196 label.shape(ctx, &styles.label, text_width).height
1197 };
1198 let has_supporting = self.rows[i].supporting.is_some();
1199 if let Some(supporting) = self.rows[i].supporting.as_mut() {
1200 content += supporting.shape(ctx, &styles.supporting, text_width).height;
1201 }
1202 let padding = if has_supporting {
1203 2.0 * MENU_SUPPORTING_V_PADDING
1204 } else {
1205 0.0
1206 };
1207 MENU_ITEM_GAP + (content + padding).max(MENU_ROW_MIN_HEIGHT)
1208 }
1209
1210 fn paint_surface(&self, s: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1212 let top = origin.y + self.surfaces[s].top - self.scroll;
1213 let box_origin = Point::new(origin.x, top);
1214 let box_size = Size::new(self.width, self.surfaces[s].height);
1215 if let Some((blur, y_offset, color)) = chrome.shadow {
1216 scene.draw_shadow(
1217 Point::new(box_origin.x, box_origin.y + y_offset),
1218 box_size,
1219 chrome.container_radius,
1220 blur,
1221 color,
1222 );
1223 }
1224 scene.fill_rounded_rect(
1225 box_origin,
1226 box_size,
1227 chrome.container_radius,
1228 chrome.colors.container,
1229 );
1230 if let Some(label) = &self.surfaces[s].label {
1231 label.paint(
1232 Point::new(
1233 box_origin.x + MENU_GROUP_LABEL_H_PADDING,
1234 box_origin.y + MENU_SURFACE_V_PADDING + MENU_GROUP_LABEL_V_PADDING,
1235 ),
1236 chrome.colors.supporting_content,
1237 scene,
1238 );
1239 }
1240 for i in 0..self.rows.len() {
1241 if self.rows[i].surface == s {
1242 self.paint_row(i, origin, chrome, scene);
1243 }
1244 }
1245 }
1246
1247 fn paint_row(&self, i: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1249 let colors = &chrome.colors;
1250 let row = &self.rows[i];
1251 let top = origin.y + row.top - self.scroll;
1252 if !row.kind.is_item() {
1253 scene.fill_rect(
1255 Point::new(
1256 origin.x + MENU_SURFACE_H_PADDING + MENU_ROW_H_PADDING,
1257 top + MENU_DIVIDER_V_PADDING,
1258 ),
1259 Size::new(
1260 (self.width - 2.0 * MENU_SURFACE_H_PADDING - 2.0 * MENU_ROW_H_PADDING).max(0.0),
1261 MENU_DIVIDER_THICKNESS,
1262 ),
1263 colors.divider,
1264 );
1265 return;
1266 }
1267 let item_origin = Point::new(origin.x + MENU_SURFACE_H_PADDING, top + MENU_ITEM_GAP / 2.0);
1268 let item_size = Size::new(
1269 (self.width - 2.0 * MENU_SURFACE_H_PADDING).max(0.0),
1270 row.height - MENU_ITEM_GAP,
1271 );
1272 if row.selected {
1273 scene.fill_rounded_rect(
1274 item_origin,
1275 item_size,
1276 chrome.item_radius,
1277 colors.selected_container,
1278 );
1279 }
1280 let mut state = InteractionState::new();
1281 state.hovered = self.hovered == Some(i) || self.open_sub == Some(i);
1284 state.pressed = self.pressed == Some(i) && self.press_inside;
1285 let opacity = if row.enabled {
1286 state.resolve_opacity()
1287 } else {
1288 0.0
1289 };
1290 if opacity > 0.0 {
1291 scene.fill_rounded_rect(
1292 item_origin,
1293 item_size,
1294 chrome.item_radius,
1295 crate::overlay::with_alpha(colors.state_layer, opacity),
1296 );
1297 }
1298 let ink = colors.entry_foreground(row.enabled, row.destructive, row.selected);
1299 let icon_ink = colors.icon_foreground(row.enabled, row.destructive, row.selected);
1300
1301 let mut x = item_origin.x + MENU_ROW_H_PADDING;
1302 let middle = item_origin.y + item_size.height / 2.0;
1303 if let Some(leading) = &row.leading {
1304 leading.paint(
1305 Point::new(x, middle - leading.extent / 2.0),
1306 icon_ink,
1307 scene,
1308 );
1309 x += MENU_ICON_SIZE + MENU_ICON_GAP;
1310 }
1311 let label_size = row.label.size();
1312 let supporting_size = row.supporting.as_ref().map_or(Size::ZERO, Run::size);
1313 let text_top = middle - (label_size.height + supporting_size.height) / 2.0;
1314 row.label.paint(Point::new(x, text_top), ink, scene);
1315 if let Some(supporting) = &row.supporting {
1316 supporting.paint(
1317 Point::new(x, text_top + label_size.height),
1318 colors.supporting_foreground(row.enabled, row.selected),
1319 scene,
1320 );
1321 }
1322 let mut right = item_origin.x + item_size.width - MENU_ROW_H_PADDING;
1323 if let Some(trailing) = &row.trailing {
1324 right -= MENU_ICON_SIZE;
1325 trailing.paint(
1326 Point::new(right, middle - trailing.extent / 2.0),
1327 icon_ink,
1328 scene,
1329 );
1330 right -= MENU_ICON_GAP;
1331 }
1332 if let Some(shortcut) = &row.shortcut {
1333 let size = shortcut.size();
1334 shortcut.paint(
1335 Point::new(right - size.width, middle - size.height / 2.0),
1336 colors.supporting_foreground(row.enabled, row.selected),
1337 scene,
1338 );
1339 }
1340 }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use crate::menu::items::{
1347 menu_entry, menu_group, menu_selectable, menu_submenu, menu_toggleable,
1348 };
1349 use crate::menu::{MENU_CONTAINER_RADIUS, MENU_ITEM_RADIUS, MENU_SECTION_GAP};
1350 use frust::authoring::scene::GlyphRun;
1351 use frust::authoring::{BuildCtx, PointerButton, Rect as KRect, text::TextContext};
1352 use frust::{Brightness, FrameTime};
1353 use std::any::Any;
1354
1355 const AREA: Size = Size::new(600.0, 800.0);
1356
1357 #[derive(Default)]
1359 struct AppState {
1360 selections: Vec<MenuSelection>,
1361 }
1362
1363 #[derive(Default)]
1364 struct Recorder {
1365 rects: Vec<(Point, Size, Color)>,
1366 rrects: Vec<(Point, Size, f64, Color)>,
1367 paths: Vec<Point>,
1369 runs: usize,
1370 shadows: usize,
1371 clips: usize,
1372 }
1373
1374 impl PaintScene for Recorder {
1375 fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
1376 self.rects.push((origin, size, color));
1377 }
1378 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1379 self.rrects.push((origin, size, radius, color));
1380 }
1381 fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1382 self.paths.push(origin);
1383 }
1384 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1385 fn draw_glyph_run(&mut self, _run: GlyphRun) {
1386 self.runs += 1;
1387 }
1388 fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _std: f64, _c: Color) {
1389 self.shadows += 1;
1390 }
1391 fn push_clip(&mut self, _origin: Point, _size: Size) {
1392 self.clips += 1;
1393 }
1394 fn pop_clip(&mut self) {}
1395 }
1396
1397 fn every_kind() -> Vec<MenuNode> {
1399 vec![
1400 menu_entry("Cut").shortcut("Ctrl X").into(),
1401 MenuNode::Divider,
1402 menu_group(vec![
1403 menu_selectable("Grid", "grid").selected(true).into(),
1404 menu_selectable("List", "list").into(),
1405 ])
1406 .label("View")
1407 .into(),
1408 menu_toggleable("Word wrap", true).into(),
1409 menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
1410 ]
1411 }
1412
1413 fn view(nodes: Vec<MenuNode>) -> MenuPanelView<AppState> {
1414 menu_panel(nodes, |state: &mut AppState, selection| {
1415 state.selections.push(selection)
1416 })
1417 }
1418
1419 fn build(view: &MenuPanelView<AppState>) -> MenuPanelWidget {
1420 let mut counter = 0u64;
1421 View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1422 }
1423
1424 fn layout(w: &mut MenuPanelWidget, area: Size) -> Size {
1425 let mut tcx = TextContext::new();
1426 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1427 w.layout(&mut lctx, &BoxConstraints::loose(area))
1428 }
1429
1430 fn ready(nodes: Vec<MenuNode>) -> MenuPanelWidget {
1432 let mut w = build(&view(nodes));
1433 layout(&mut w, AREA);
1434 w
1435 }
1436
1437 fn rebuild(
1439 w: &mut MenuPanelWidget,
1440 prev: &MenuPanelView<AppState>,
1441 next: &MenuPanelView<AppState>,
1442 ) {
1443 let mut counter = 0u64;
1444 View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1445 }
1446
1447 fn paint(w: &mut MenuPanelWidget) -> Recorder {
1448 let theme = crate::baseline().with_brightness(Brightness::Light);
1449 let mut rec = Recorder::default();
1450 let size = Size::new(w.width, w.viewport);
1451 let mut ctx =
1452 PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1453 w.paint(&mut ctx, &mut rec);
1454 rec
1455 }
1456
1457 fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1458 InputEvent::Pointer(PointerEvent {
1459 phase,
1460 position: Point::new(x, y),
1461 button: PointerButton::Primary,
1462 })
1463 }
1464
1465 fn dispatch(w: &mut MenuPanelWidget, state: &mut AppState, event: &InputEvent) -> EventResult {
1466 let any: &mut dyn Any = state;
1467 let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.viewport));
1468 w.event(&mut ctx, event)
1469 }
1470
1471 fn row_center(w: &MenuPanelWidget, row: usize) -> Point {
1473 let (top, height) = w.band(row);
1474 Point::new(w.width / 2.0, top + height / 2.0 - w.scroll)
1475 }
1476
1477 fn click(w: &mut MenuPanelWidget, state: &mut AppState, row: usize) {
1479 let c = row_center(w, row);
1480 dispatch(w, state, &pointer(PointerPhase::Down, c.x, c.y));
1481 dispatch(w, state, &pointer(PointerPhase::Up, c.x, c.y));
1482 }
1483
1484 fn click_submenu_row(w: &mut MenuPanelWidget, state: &mut AppState) {
1486 let rect = w.open_sub_rect().expect("an open submenu is placed");
1487 let p = Point::new(
1488 rect.x0 + rect.width() / 2.0,
1489 rect.y0 + MENU_SURFACE_V_PADDING + MENU_ROW_MIN_HEIGHT / 2.0,
1490 );
1491 dispatch(w, state, &pointer(PointerPhase::Down, p.x, p.y));
1492 dispatch(w, state, &pointer(PointerPhase::Up, p.x, p.y));
1493 }
1494
1495 #[test]
1498 fn all_six_kinds_render_across_the_surfaces_they_partition_into() {
1499 let mut w = ready(every_kind());
1500 assert_eq!(w.rows.len(), 6);
1502 assert_eq!(
1503 w.surfaces.len(),
1504 3,
1505 "flat run, the labelled group, the tail"
1506 );
1507 assert!(w.surfaces[1].label.is_some(), "the group's section label");
1508
1509 let rec = paint(&mut w);
1510 let containers = rec
1512 .rrects
1513 .iter()
1514 .filter(|(_, _, radius, _)| *radius == MENU_CONTAINER_RADIUS)
1515 .count();
1516 assert_eq!(containers, 3);
1517 assert_eq!(rec.shadows, 3, "every surface takes the level-2 rung");
1518 assert_eq!(rec.rects.len(), 1);
1520 assert_eq!(rec.rects[0].1.height, MENU_DIVIDER_THICKNESS);
1521 assert_eq!(rec.paths.len(), 3);
1523 assert!(rec.runs >= 7, "runs: {}", rec.runs);
1525 }
1526
1527 #[test]
1528 fn the_surfaces_stack_with_the_section_gap_between_them() {
1529 let w = ready(every_kind());
1530 for pair in w.surfaces.windows(2) {
1531 assert_eq!(
1532 pair[1].top - (pair[0].top + pair[0].height),
1533 MENU_SECTION_GAP
1534 );
1535 }
1536 assert_eq!(w.surfaces[0].top, 0.0);
1537 }
1538
1539 #[test]
1540 fn a_group_label_reserves_its_own_band_above_the_group_s_rows() {
1541 let labelled = ready(vec![
1542 menu_group(vec![menu_entry("a").into()])
1543 .label("Section")
1544 .into(),
1545 ]);
1546 let bare = ready(vec![menu_group(vec![menu_entry("a").into()]).into()]);
1547 assert!(
1548 labelled.surfaces[0].height > bare.surfaces[0].height,
1549 "{} vs {}",
1550 labelled.surfaces[0].height,
1551 bare.surfaces[0].height
1552 );
1553 assert!(labelled.rows[0].top > bare.rows[0].top);
1554 }
1555
1556 #[test]
1557 fn a_row_is_at_least_the_reference_s_entry_height() {
1558 let w = ready(vec![menu_entry("Cut").into()]);
1559 assert_eq!(w.rows[0].height, MENU_ITEM_GAP + MENU_ROW_MIN_HEIGHT);
1560 let tall = ready(vec![
1562 menu_entry("Cut").supporting("Removes the selection").into(),
1563 ]);
1564 assert!(tall.rows[0].height >= w.rows[0].height);
1565 }
1566
1567 #[test]
1568 fn the_panel_width_clamps_into_the_reference_s_min_max_band() {
1569 let narrow = ready(vec![menu_entry("a").into()]);
1570 assert_eq!(
1571 narrow.width(),
1572 MENU_MIN_WIDTH,
1573 "short labels take the floor"
1574 );
1575 let wide = ready(vec![
1576 menu_entry("a label far longer than any menu should ever try to show inline")
1577 .shortcut("Ctrl Shift X")
1578 .into(),
1579 ]);
1580 assert_eq!(wide.width(), MENU_MAX_WIDTH, "long ones take the ceiling");
1581 }
1582
1583 #[test]
1584 fn a_narrow_area_wins_over_the_reference_s_own_minimum() {
1585 let mut w = build(&view(vec![menu_entry("a").into()]));
1586 let size = layout(&mut w, Size::new(80.0, 800.0));
1587 assert_eq!(size.width, 80.0, "the panel never overflows its area");
1588 }
1589
1590 #[test]
1591 fn a_tall_tree_caps_at_the_max_height_and_clips_while_scrolling() {
1592 let nodes: Vec<MenuNode> = (0..20)
1593 .map(|i| menu_entry(format!("row {i}")).into())
1594 .collect();
1595 let mut w = ready(nodes);
1596 assert_eq!(w.viewport, MENU_MAX_HEIGHT);
1597 assert!(w.content_height > MENU_MAX_HEIGHT);
1598 assert_eq!(paint(&mut w).clips, 1, "a scrolling panel clips");
1599 assert_eq!(
1600 paint(&mut ready(vec![menu_entry("a").into()])).clips,
1601 0,
1602 "…and one that fits never clips its own shadow out"
1603 );
1604
1605 let mut state = AppState::default();
1606 let scrolled = dispatch(
1607 &mut w,
1608 &mut state,
1609 &InputEvent::Scroll {
1610 position: Point::new(10.0, 10.0),
1611 delta: ScrollDelta::Pixels(0.0, 120.0),
1612 },
1613 );
1614 assert_eq!(scrolled, EventResult::Handled);
1615 assert_eq!(w.scroll(), 120.0);
1616 dispatch(
1618 &mut w,
1619 &mut state,
1620 &InputEvent::Scroll {
1621 position: Point::new(10.0, 10.0),
1622 delta: ScrollDelta::Pixels(0.0, 10_000.0),
1623 },
1624 );
1625 assert_eq!(w.scroll(), w.content_height - w.viewport);
1626 }
1627
1628 #[test]
1631 fn an_entry_reports_a_press_at_its_depth_first_index() {
1632 let mut w = ready(every_kind());
1633 let mut state = AppState::default();
1634 click(&mut w, &mut state, 0);
1635 assert_eq!(state.selections.len(), 1);
1636 assert_eq!(state.selections[0].index, 0);
1637 assert_eq!(state.selections[0].label, "Cut");
1638 assert_eq!(state.selections[0].action, MenuAction::Press);
1639 }
1640
1641 #[test]
1642 fn a_selectable_reports_its_value_and_never_flips_its_own_state() {
1643 let nodes = vec![
1644 menu_selectable("Grid", "grid").into(),
1645 menu_selectable("List", "list").into(),
1646 ];
1647 let mut w = build(&view(nodes.clone()).selected(Some("grid".into())));
1648 layout(&mut w, AREA);
1649 assert!(w.rows[0].selected, "the panel's value checks the row");
1650 assert!(!w.rows[1].selected);
1651
1652 let mut state = AppState::default();
1653 click(&mut w, &mut state, 1);
1654 assert_eq!(state.selections[0].value(), Some("list"));
1655 assert!(
1656 w.rows[0].selected && !w.rows[1].selected,
1657 "controlled: the panel does not move the check itself"
1658 );
1659
1660 rebuild(
1662 &mut w,
1663 &view(nodes.clone()).selected(Some("grid".into())),
1664 &view(nodes).selected(Some("list".into())),
1665 );
1666 layout(&mut w, AREA);
1667 assert!(!w.rows[0].selected && w.rows[1].selected);
1668 }
1669
1670 #[test]
1671 fn a_toggleable_requests_the_opposite_of_its_current_state() {
1672 let mut w = ready(vec![
1673 menu_toggleable("Wrap", false).into(),
1674 menu_toggleable("Minimap", true).into(),
1675 ]);
1676 let mut state = AppState::default();
1677 click(&mut w, &mut state, 0);
1678 click(&mut w, &mut state, 1);
1679 assert_eq!(state.selections[0].checked(), Some(true));
1680 assert_eq!(state.selections[1].checked(), Some(false));
1681 assert!(
1682 !w.rows[0].selected && w.rows[1].selected,
1683 "controlled: the boxes stay as the caller last drew them"
1684 );
1685 }
1686
1687 #[test]
1688 fn a_disabled_row_and_a_divider_report_nothing() {
1689 let mut w = ready(vec![
1690 menu_entry("Paste").enabled(false).into(),
1691 MenuNode::Divider,
1692 ]);
1693 let mut state = AppState::default();
1694 let c = row_center(&w, 0);
1695 assert_eq!(
1696 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
1697 EventResult::Ignored
1698 );
1699 click(&mut w, &mut state, 1);
1700 assert!(state.selections.is_empty());
1701 }
1702
1703 #[test]
1704 fn a_release_outside_the_armed_row_reports_nothing() {
1705 let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
1706 let mut state = AppState::default();
1707 let first = row_center(&w, 0);
1708 let second = row_center(&w, 1);
1709 dispatch(
1710 &mut w,
1711 &mut state,
1712 &pointer(PointerPhase::Down, first.x, first.y),
1713 );
1714 dispatch(
1715 &mut w,
1716 &mut state,
1717 &pointer(PointerPhase::Up, second.x, second.y),
1718 );
1719 assert!(state.selections.is_empty(), "fires on up-inside only");
1720 }
1721
1722 #[test]
1723 fn only_a_primary_press_arms_a_row() {
1724 let mut w = ready(vec![menu_entry("a").into()]);
1725 let mut state = AppState::default();
1726 let c = row_center(&w, 0);
1727 let secondary = InputEvent::Pointer(PointerEvent {
1728 phase: PointerPhase::Down,
1729 position: c,
1730 button: PointerButton::Secondary,
1731 });
1732 assert_eq!(
1733 dispatch(&mut w, &mut state, &secondary),
1734 EventResult::Ignored
1735 );
1736 assert_eq!(
1737 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y)),
1738 EventResult::Ignored
1739 );
1740 assert!(state.selections.is_empty());
1741 }
1742
1743 #[test]
1744 fn a_cancel_clears_the_press_without_reporting() {
1745 let mut w = ready(vec![menu_entry("a").into()]);
1746 let mut state = AppState::default();
1747 let c = row_center(&w, 0);
1748 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1749 assert_eq!(
1750 dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y)),
1751 EventResult::Handled
1752 );
1753 assert!(state.selections.is_empty());
1754 assert_eq!(
1755 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y)),
1756 EventResult::Ignored,
1757 "the gesture is over"
1758 );
1759 }
1760
1761 #[test]
1762 fn a_press_on_the_panel_s_own_background_is_left_for_the_host() {
1763 let mut w = ready(vec![menu_entry("a").into()]);
1764 let mut state = AppState::default();
1765 assert_eq!(
1766 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 4.0, 1.0)),
1767 EventResult::Ignored,
1768 "the surface padding is not a row"
1769 );
1770 }
1771
1772 #[test]
1775 fn a_tap_on_a_submenu_row_opens_its_panel_and_reports_nothing() {
1776 let mut w = ready(vec![
1777 menu_entry("Cut").into(),
1778 menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
1779 ]);
1780 let mut state = AppState::default();
1781 assert_eq!(w.open_submenu(), None);
1782 click(&mut w, &mut state, 1);
1783 assert_eq!(w.open_submenu(), Some(1), "the reference's tap-open rule");
1784 assert!(state.selections.is_empty(), "opening is not selecting");
1785
1786 let rect = w.open_sub_rect().expect("an open submenu is placed");
1788 assert_eq!(rect.x0, w.width() + MENU_SUBMENU_GAP);
1789 assert_eq!(rect.y0, w.band(1).0 - MENU_SURFACE_V_PADDING);
1790 assert!(rect.width() > 0.0 && rect.height() > 0.0);
1791 }
1792
1793 #[test]
1794 fn hovering_a_submenu_row_opens_it_and_hovering_elsewhere_closes_it() {
1795 let mut w = ready(vec![
1796 menu_entry("Cut").into(),
1797 menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
1798 ]);
1799 let mut state = AppState::default();
1800 let sub = row_center(&w, 1);
1801 assert_eq!(
1802 dispatch(
1803 &mut w,
1804 &mut state,
1805 &pointer(PointerPhase::Move, sub.x, sub.y)
1806 ),
1807 EventResult::Ignored,
1808 "watching a move is not consuming it"
1809 );
1810 assert_eq!(w.open_submenu(), Some(1));
1811
1812 let inside = w.open_sub_rect().expect("placed").center();
1814 dispatch(
1815 &mut w,
1816 &mut state,
1817 &pointer(PointerPhase::Move, inside.x, inside.y),
1818 );
1819 assert_eq!(
1820 w.open_submenu(),
1821 Some(1),
1822 "the pointer has to be able to get there"
1823 );
1824
1825 let other = row_center(&w, 0);
1827 dispatch(
1828 &mut w,
1829 &mut state,
1830 &pointer(PointerPhase::Move, other.x, other.y),
1831 );
1832 assert_eq!(w.open_submenu(), None);
1833 }
1834
1835 #[test]
1836 fn a_submenu_row_reports_its_children_at_the_indices_that_follow_it() {
1837 let mut w = ready(vec![
1838 menu_entry("Cut").into(),
1839 menu_submenu(
1840 "Share",
1841 vec![menu_entry("Copy link").into(), menu_entry("Email").into()],
1842 )
1843 .into(),
1844 menu_entry("Paste").into(),
1845 ]);
1846 let mut state = AppState::default();
1847 click(&mut w, &mut state, 1);
1848 click_submenu_row(&mut w, &mut state);
1850 assert_eq!(state.selections.len(), 1, "the nested row reported");
1851 assert_eq!(state.selections[0].index, 2);
1852 assert_eq!(state.selections[0].label, "Copy link");
1853 assert_eq!(
1854 w.rows[2].index, 4,
1855 "the tail keeps counting past the subtree"
1856 );
1857 }
1858
1859 #[test]
1860 fn a_disabled_submenu_row_opens_nothing() {
1861 let mut w = ready(vec![
1862 menu_submenu("Share", vec![menu_entry("Copy link").into()])
1863 .enabled(false)
1864 .into(),
1865 ]);
1866 let mut state = AppState::default();
1867 let c = row_center(&w, 0);
1868 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1869 click(&mut w, &mut state, 0);
1870 assert_eq!(w.open_submenu(), None);
1871 }
1872
1873 #[test]
1874 fn closing_the_panel_closes_the_whole_chain() {
1875 let nodes = vec![
1876 menu_submenu(
1877 "Share",
1878 vec![menu_submenu("More", vec![menu_entry("Deep").into()]).into()],
1879 )
1880 .into(),
1881 ];
1882 let mut w = ready(nodes.clone());
1883 let mut state = AppState::default();
1884 click(&mut w, &mut state, 0);
1885 assert_eq!(w.open_submenu(), Some(0));
1886 click_submenu_row(&mut w, &mut state);
1888 assert_eq!(nested(&mut w, 0), Some(0), "the cascade is two deep");
1889
1890 rebuild(
1892 &mut w,
1893 &view(nodes.clone()).open(true),
1894 &view(nodes).open(false),
1895 );
1896 assert_eq!(w.open_submenu(), None);
1897 assert_eq!(
1898 nested(&mut w, 0),
1899 None,
1900 "every depth closed, not just the first"
1901 );
1902 }
1903
1904 fn nested(w: &mut MenuPanelWidget, pod: usize) -> Option<usize> {
1909 let boxed = w.pods[pod]
1910 .widget_mut()
1911 .downcast_mut::<Box<dyn Widget>>()
1912 .expect("an erased pod");
1913 (**boxed)
1914 .downcast_mut::<MenuPanelWidget>()
1915 .expect("the submenu panel")
1916 .open_submenu()
1917 }
1918
1919 #[test]
1922 fn a_pressed_row_takes_a_state_layer_and_a_selected_one_its_own_fill() {
1923 let mut w = ready(vec![menu_selectable("Grid", "grid").selected(true).into()]);
1924 let colors = MenuColors::resolve(
1925 Some(&crate::baseline().with_brightness(Brightness::Light)),
1926 MenuColorStyle::Standard,
1927 );
1928 let washes = |rec: &Recorder| -> Vec<Color> {
1929 rec.rrects
1930 .iter()
1931 .filter(|(_, _, radius, _)| *radius == MENU_ITEM_RADIUS)
1932 .map(|(_, _, _, color)| *color)
1933 .collect()
1934 };
1935 let rec = paint(&mut w);
1936 assert_eq!(
1937 washes(&rec),
1938 vec![colors.selected_container],
1939 "the selected fill, and no wash yet"
1940 );
1941
1942 let mut state = AppState::default();
1946 let c = row_center(&w, 0);
1947 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1948 let rec = paint(&mut w);
1949 let painted = washes(&rec);
1950 assert_eq!(painted.len(), 2, "the fill plus the press wash over it");
1951 assert_eq!(painted[0], colors.selected_container);
1952 assert_eq!(
1953 painted[1].components[3],
1954 crate::interaction::PRESSED_OPACITY
1955 );
1956 }
1957
1958 #[test]
1959 fn a_drag_off_the_armed_row_drops_its_wash_and_a_drag_back_re_arms_it() {
1960 let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
1961 let mut state = AppState::default();
1962 let first = row_center(&w, 0);
1963 let second = row_center(&w, 1);
1964 dispatch(
1965 &mut w,
1966 &mut state,
1967 &pointer(PointerPhase::Down, first.x, first.y),
1968 );
1969 assert!(w.press_inside);
1970 dispatch(
1971 &mut w,
1972 &mut state,
1973 &pointer(PointerPhase::Move, second.x, second.y),
1974 );
1975 assert!(!w.press_inside, "the wash follows the pointer out");
1976 assert_eq!(w.pressed, Some(0), "…but the gesture is still armed");
1977 dispatch(
1978 &mut w,
1979 &mut state,
1980 &pointer(PointerPhase::Move, first.x, first.y),
1981 );
1982 assert!(w.press_inside, "and back in");
1983 dispatch(
1984 &mut w,
1985 &mut state,
1986 &pointer(PointerPhase::Up, first.x, first.y),
1987 );
1988 assert_eq!(state.selections.len(), 1);
1989 }
1990
1991 #[test]
1992 fn a_move_latches_the_hovered_row_for_the_next_paint() {
1993 let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
1994 let mut state = AppState::default();
1995 let second = row_center(&w, 1);
1996 dispatch(
1997 &mut w,
1998 &mut state,
1999 &pointer(PointerPhase::Move, second.x, second.y),
2000 );
2001 assert_eq!(w.hovered, Some(1));
2002 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 2.0, 1.0));
2004 assert_eq!(w.hovered, None);
2005 }
2006
2007 #[test]
2008 fn a_disabled_row_never_washes() {
2009 let mut w = ready(vec![menu_entry("Paste").enabled(false).into()]);
2010 let mut state = AppState::default();
2011 let c = row_center(&w, 0);
2012 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
2013 let rec = paint(&mut w);
2014 assert!(
2015 !rec.rrects
2016 .iter()
2017 .any(|(_, _, radius, _)| *radius == MENU_ITEM_RADIUS),
2018 "no fill and no wash"
2019 );
2020 }
2021
2022 #[test]
2023 fn the_radii_resolve_off_the_theme_s_shape_scale() {
2024 let theme = crate::baseline();
2029 assert_eq!(container_radius(Some(&theme)), theme.shape.large);
2030 assert_eq!(item_radius(Some(&theme)), theme.shape.medium);
2031 assert_eq!(container_radius(None), MENU_CONTAINER_RADIUS);
2032 assert_eq!(item_radius(None), MENU_ITEM_RADIUS);
2033 assert_eq!(theme.shape.large, MENU_CONTAINER_RADIUS);
2034 assert_eq!(theme.shape.medium, MENU_ITEM_RADIUS);
2035 }
2036
2037 #[test]
2038 fn the_vibrant_style_paints_its_own_container() {
2039 let mut standard = ready(vec![menu_entry("a").into()]);
2040 let mut vibrant =
2041 build(&view(vec![menu_entry("a").into()]).color_style(MenuColorStyle::Vibrant));
2042 layout(&mut vibrant, AREA);
2043 let a = paint(&mut standard).rrects[0].3;
2044 let b = paint(&mut vibrant).rrects[0].3;
2045 assert_ne!(a, b);
2046 let theme = crate::baseline().with_brightness(Brightness::Light);
2047 assert_eq!(a, theme.scheme().surface_container_low);
2048 assert_eq!(b, theme.scheme().tertiary_container);
2049 }
2050
2051 #[test]
2052 fn an_unthemed_pass_paints_the_baseline_light_roles() {
2053 let mut w = ready(vec![menu_entry("a").into()]);
2056 let mut rec = Recorder::default();
2057 let size = Size::new(w.width, w.viewport);
2058 let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO);
2059 w.paint(&mut ctx, &mut rec);
2060 assert_eq!(
2061 rec.rrects[0].3,
2062 MenuColors::resolve(None, MenuColorStyle::Standard).container
2063 );
2064 }
2065
2066 #[test]
2067 fn the_semantics_tree_is_a_menu_of_role_typed_rows() {
2068 use frust_core::RenderRoot;
2069 let mut root: RenderRoot<AppState, MenuPanelView<AppState>> = RenderRoot::new();
2070 let mut state = AppState::default();
2071 let mut tcx = TextContext::new();
2072 let mut logic = |_s: &mut AppState| {
2073 view(vec![
2074 menu_entry("Cut").into(),
2075 MenuNode::Divider,
2076 menu_selectable("Grid", "grid").selected(true).into(),
2077 menu_toggleable("Wrap", false).into(),
2078 ])
2079 };
2080 root.rebuild(&mut logic, &mut state);
2081 root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
2082 let nodes = root.semantics().nodes;
2083 let roles: Vec<_> = nodes.iter().map(|(_, n)| n.role()).collect();
2084 assert!(roles.contains(&Role::Menu));
2085 assert_eq!(
2086 roles.iter().filter(|r| **r == Role::MenuItem).count(),
2087 1,
2088 "the divider contributes nothing"
2089 );
2090 assert!(roles.contains(&Role::MenuItemRadio));
2091 assert!(roles.contains(&Role::MenuItemCheckBox));
2092 let radio = nodes
2093 .iter()
2094 .find(|(_, n)| n.role() == Role::MenuItemRadio)
2095 .expect("a radio row");
2096 assert_eq!(radio.1.label(), Some("Grid"));
2097 assert_eq!(radio.1.toggled(), Some(Toggled::True));
2098 }
2099
2100 #[test]
2101 fn a_rebuild_that_re_supplies_the_same_tree_keeps_its_shaped_runs() {
2102 let nodes = every_kind();
2103 let mut w = ready(nodes.clone());
2104 let before = w.rows[0].label.size();
2105 assert!(before.width > 0.0);
2106 rebuild(&mut w, &view(nodes.clone()), &view(nodes));
2107 assert_eq!(
2108 w.rows[0].label.size(),
2109 before,
2110 "no re-shape, so the cached layout survives"
2111 );
2112 }
2113
2114 #[test]
2115 fn a_rebuild_with_a_new_tree_replaces_the_rows_and_drops_the_chain() {
2116 let nodes = vec![menu_submenu("Share", vec![menu_entry("Copy link").into()]).into()];
2117 let mut w = ready(nodes.clone());
2118 let mut state = AppState::default();
2119 click(&mut w, &mut state, 0);
2120 assert_eq!(w.open_submenu(), Some(0));
2121
2122 rebuild(
2123 &mut w,
2124 &view(nodes),
2125 &view(vec![menu_entry("Only this").into()]),
2126 );
2127 layout(&mut w, AREA);
2128 assert_eq!(w.rows.len(), 1);
2129 assert_eq!(w.open_submenu(), None);
2130 assert!(w.pods.is_empty(), "the submenu pod went with its row");
2131 }
2132
2133 #[test]
2134 fn a_broadcast_reaches_every_submenu_pod_and_is_never_consumed() {
2135 let mut w = ready(vec![
2136 menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
2137 ]);
2138 let mut state = AppState::default();
2139 let result = dispatch(&mut w, &mut state, &InputEvent::Housekeeping);
2140 assert_eq!(result, EventResult::Ignored);
2141 }
2142
2143 #[test]
2144 fn a_key_event_is_left_for_the_host_to_dismiss_on() {
2145 use frust::authoring::{Key, KeyEvent, Modifiers, NamedKey};
2146 let mut w = ready(vec![menu_entry("a").into()]);
2147 let mut state = AppState::default();
2148 let escape = InputEvent::Key(KeyEvent {
2149 key: Key::Named(NamedKey::Escape),
2150 modifiers: Modifiers::default(),
2151 repeat: false,
2152 });
2153 assert_eq!(dispatch(&mut w, &mut state, &escape), EventResult::Ignored);
2154 }
2155
2156 #[test]
2157 fn a_paint_with_no_hover_link_clears_the_latched_row() {
2158 let mut w = ready(vec![menu_entry("a").into()]);
2159 let mut state = AppState::default();
2160 let c = row_center(&w, 0);
2161 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
2162 assert_eq!(w.hovered, Some(0));
2163 paint(&mut w);
2165 assert_eq!(w.hovered, None);
2166 }
2167
2168 #[test]
2169 fn every_painted_rect_stays_inside_the_panel_s_own_width() {
2170 let mut w = ready(every_kind());
2171 let rec = paint(&mut w);
2172 let panel = KRect::from_origin_size(Point::ORIGIN, Size::new(w.width, w.viewport));
2173 for (origin, size, _, _) in rec.rrects.iter() {
2174 assert!(origin.x >= panel.x0 - 0.001, "{origin:?}");
2175 assert!(origin.x + size.width <= panel.x1 + 0.001, "{origin:?}");
2176 }
2177 }
2178}