Another project
1use crate::a11y::{AccessNode, Role};
2use crate::frame::{FrameCtx, InteractDeclaration};
3use crate::hit_test::{Sense, ZLayer};
4use crate::input::{KeyCode, NamedKey};
5use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
6use crate::strings::StringKey;
7use crate::theme::{Border, Color, Step12, StrokeWidth};
8use crate::widget_id::{WidgetId, WidgetKey};
9
10use super::keys::{TakeKey, take_key};
11use bone_text::{ShapeRequest, ShapedLine};
12
13use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint};
14use super::visuals::push_focus_ring;
15
16#[derive(Clone, Debug, PartialEq)]
17pub enum MenuItem {
18 Action {
19 id: WidgetId,
20 label: StringKey,
21 shortcut: Option<super::paint::LabelText>,
22 disabled: bool,
23 },
24 Submenu {
25 id: WidgetId,
26 label: StringKey,
27 items: Vec<MenuItem>,
28 },
29 Separator,
30}
31
32impl MenuItem {
33 #[must_use]
34 pub fn id(&self) -> Option<WidgetId> {
35 match self {
36 Self::Action { id, .. } | Self::Submenu { id, .. } => Some(*id),
37 Self::Separator => None,
38 }
39 }
40
41 #[must_use]
42 pub fn is_focusable(&self) -> bool {
43 match self {
44 Self::Action { disabled, .. } => !*disabled,
45 Self::Submenu { .. } => true,
46 Self::Separator => false,
47 }
48 }
49}
50
51#[derive(Clone, Debug, Default, PartialEq, Eq)]
52pub struct MenuState {
53 pub open_submenu: Option<WidgetId>,
54 pub highlighted: Option<usize>,
55 pub submenu: Option<Box<MenuState>>,
56}
57
58#[derive(Copy, Clone, Debug, PartialEq)]
59pub struct MenuMetrics {
60 pub item_height: LayoutPx,
61 pub separator_height: LayoutPx,
62 pub padding_x: LayoutPx,
63 pub min_width: LayoutPx,
64 pub shortcut_gap: LayoutPx,
65}
66
67impl MenuMetrics {
68 #[must_use]
69 pub const fn standard() -> Self {
70 Self {
71 item_height: LayoutPx::new(24.0),
72 separator_height: LayoutPx::new(7.0),
73 padding_x: LayoutPx::new(10.0),
74 min_width: LayoutPx::new(180.0),
75 shortcut_gap: LayoutPx::new(24.0),
76 }
77 }
78}
79
80#[derive(Debug, PartialEq)]
81pub struct Menu<'a, 'state> {
82 pub id: WidgetId,
83 pub origin: LayoutPos,
84 pub label: StringKey,
85 pub items: &'a [MenuItem],
86 pub metrics: MenuMetrics,
87 pub state: &'state mut MenuState,
88}
89
90impl<'a, 'state> Menu<'a, 'state> {
91 #[must_use]
92 pub fn new(
93 id: WidgetId,
94 origin: LayoutPos,
95 label: StringKey,
96 items: &'a [MenuItem],
97 state: &'state mut MenuState,
98 ) -> Self {
99 Self {
100 id,
101 origin,
102 label,
103 items,
104 metrics: MenuMetrics::standard(),
105 state,
106 }
107 }
108
109 #[must_use]
110 pub fn metrics(self, metrics: MenuMetrics) -> Self {
111 Self { metrics, ..self }
112 }
113}
114
115#[derive(Clone, Debug, PartialEq)]
116pub struct MenuResponse {
117 pub activated: Option<WidgetId>,
118 pub close: bool,
119 pub paint: Vec<WidgetPaint>,
120 pub rect: LayoutRect,
121}
122
123#[must_use]
124pub fn show_menu(ctx: &mut FrameCtx<'_>, menu: Menu<'_, '_>) -> MenuResponse {
125 let Menu {
126 id,
127 origin,
128 label,
129 items,
130 metrics,
131 state,
132 } = menu;
133 let rect = menu_rect(origin, items, metrics);
134 ctx.a11y
135 .push(id, rect, AccessNode::new(Role::Menu).with_label(label));
136 let mut paint = vec![WidgetPaint::Surface {
137 rect,
138 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1),
139 border: Some(Border {
140 width: StrokeWidth::px(1.5),
141 color: ctx.theme().colors.neutral.step(Step12::BORDER),
142 }),
143 radius: ctx.theme().radius.sm,
144 elevation: Some(ctx.theme().elevation.level1),
145 }];
146 let layouts = item_rects(rect, items, metrics);
147 let mut activated: Option<WidgetId> = None;
148 let mut close = false;
149 let prev_open = state.open_submenu;
150 let snap_open = state.open_submenu;
151 items
152 .iter()
153 .zip(layouts.iter())
154 .enumerate()
155 .for_each(|(idx, (item, item_rect))| {
156 let is_highlighted = state.highlighted == Some(idx);
157 let item_result = draw_item(
158 ctx,
159 ItemDrawArgs {
160 item,
161 rect: *item_rect,
162 metrics,
163 is_highlighted,
164 is_open_submenu: matches!(item, MenuItem::Submenu { id: sid, .. } if snap_open == Some(*sid)),
165 },
166 );
167 paint.extend(item_result.paint);
168 if item_result.hovered {
169 state.highlighted = Some(idx);
170 }
171 if let Some(activate) = item_result.activated {
172 if activated.is_none() {
173 activated = Some(activate);
174 }
175 close = true;
176 }
177 if let Some(sid) = item_result.opened_submenu {
178 state.open_submenu = Some(sid);
179 }
180 });
181 if state.open_submenu != prev_open {
182 state.submenu = None;
183 }
184 render_open_submenu(
185 ctx,
186 SubmenuArgs {
187 parent_id: id,
188 items,
189 layouts: &layouts,
190 metrics,
191 state,
192 },
193 &mut paint,
194 &mut activated,
195 &mut close,
196 );
197 handle_keyboard(ctx, items, state, &mut activated, &mut close);
198 MenuResponse {
199 activated,
200 close,
201 paint,
202 rect,
203 }
204}
205
206struct SubmenuArgs<'a, 'state> {
207 parent_id: WidgetId,
208 items: &'a [MenuItem],
209 layouts: &'a [LayoutRect],
210 metrics: MenuMetrics,
211 state: &'state mut MenuState,
212}
213
214fn render_open_submenu(
215 ctx: &mut FrameCtx<'_>,
216 args: SubmenuArgs<'_, '_>,
217 paint: &mut Vec<WidgetPaint>,
218 activated: &mut Option<WidgetId>,
219 close: &mut bool,
220) {
221 let SubmenuArgs {
222 parent_id,
223 items,
224 layouts,
225 metrics,
226 state,
227 } = args;
228 let Some(sid) = state.open_submenu else {
229 return;
230 };
231 let active = items.iter().zip(layouts.iter()).find_map(|(item, rect)| {
232 matches!(item, MenuItem::Submenu { id: iid, .. } if *iid == sid).then(|| match item {
233 MenuItem::Submenu {
234 items: sub, label, ..
235 } => (*rect, sub.as_slice(), *label),
236 _ => unreachable!(),
237 })
238 });
239 let Some((item_rect, subitems, sub_label)) = active else {
240 state.open_submenu = None;
241 state.submenu = None;
242 return;
243 };
244 let sub_origin = LayoutPos::new(
245 LayoutPx::new(item_rect.origin.x.value() + item_rect.size.width.value()),
246 item_rect.origin.y,
247 );
248 let sub_id = parent_id.child(WidgetKey::new("submenu"));
249 let sub_state = state
250 .submenu
251 .get_or_insert_with(|| Box::new(MenuState::default()));
252 let sub_response = show_menu(
253 ctx,
254 Menu::new(sub_id, sub_origin, sub_label, subitems, sub_state).metrics(metrics),
255 );
256 paint.extend(sub_response.paint);
257 if let Some(a) = sub_response.activated {
258 if activated.is_none() {
259 *activated = Some(a);
260 }
261 *close = true;
262 } else if sub_response.close {
263 state.open_submenu = None;
264 state.submenu = None;
265 }
266}
267
268#[derive(Copy, Clone)]
269struct ItemDrawArgs<'a> {
270 item: &'a MenuItem,
271 rect: LayoutRect,
272 metrics: MenuMetrics,
273 is_highlighted: bool,
274 is_open_submenu: bool,
275}
276
277struct ItemDrawResult {
278 paint: Vec<WidgetPaint>,
279 hovered: bool,
280 activated: Option<WidgetId>,
281 opened_submenu: Option<WidgetId>,
282}
283
284fn draw_item(ctx: &mut FrameCtx<'_>, args: ItemDrawArgs<'_>) -> ItemDrawResult {
285 match args.item {
286 MenuItem::Separator => ItemDrawResult {
287 paint: separator_paint(ctx, args.rect, args.metrics),
288 hovered: false,
289 activated: None,
290 opened_submenu: None,
291 },
292 MenuItem::Action {
293 id,
294 label,
295 shortcut,
296 disabled,
297 } => {
298 let interaction = ctx.interact(
299 InteractDeclaration::new(*id, args.rect, Sense::INTERACTIVE)
300 .at_z(ZLayer::POPUP)
301 .focusable(false)
302 .disabled(*disabled)
303 .a11y(
304 AccessNode::new(Role::MenuItem)
305 .with_label(*label)
306 .with_disabled(*disabled),
307 ),
308 );
309 let activated = (!*disabled && interaction.click()).then_some(*id);
310 let mut paint =
311 item_surface(ctx, args.rect, args.is_highlighted || interaction.hover());
312 paint.push(WidgetPaint::AlignedLabel {
313 rect: label_only_rect(args.rect, args.metrics),
314 text: LabelText::Key(*label),
315 color: if *disabled {
316 ctx.theme().colors.text_disabled()
317 } else {
318 ctx.theme().colors.text_primary()
319 },
320 role: ctx.theme().typography.body,
321 align: HorizontalAlign::Start,
322 });
323 if let Some(sc) = shortcut {
324 paint.push(WidgetPaint::AlignedLabel {
325 rect: shortcut_rect(args.rect, args.metrics),
326 text: sc.clone(),
327 color: ctx.theme().colors.text_secondary(),
328 role: ctx.theme().typography.caption,
329 align: HorizontalAlign::End,
330 });
331 }
332 ItemDrawResult {
333 paint,
334 hovered: interaction.hover(),
335 activated,
336 opened_submenu: None,
337 }
338 }
339 MenuItem::Submenu { id, label, .. } => {
340 let interaction = ctx.interact(
341 InteractDeclaration::new(*id, args.rect, Sense::INTERACTIVE)
342 .at_z(ZLayer::POPUP)
343 .focusable(false)
344 .active(args.is_open_submenu)
345 .a11y(
346 AccessNode::new(Role::MenuItem)
347 .with_label(*label)
348 .with_expanded(args.is_open_submenu),
349 ),
350 );
351 let opened = (interaction.click() || (interaction.hover() && !args.is_open_submenu))
352 .then_some(*id);
353 let mut paint = item_surface(
354 ctx,
355 args.rect,
356 args.is_highlighted || interaction.hover() || args.is_open_submenu,
357 );
358 paint.push(WidgetPaint::AlignedLabel {
359 rect: label_only_rect(args.rect, args.metrics),
360 text: LabelText::Key(*label),
361 color: ctx.theme().colors.text_primary(),
362 role: ctx.theme().typography.body,
363 align: HorizontalAlign::Start,
364 });
365 paint.push(WidgetPaint::Mark {
366 rect: arrow_rect(args.rect, args.metrics),
367 kind: GlyphMark::SubmenuArrow,
368 color: ctx.theme().colors.text_secondary(),
369 });
370 ItemDrawResult {
371 paint,
372 hovered: interaction.hover(),
373 activated: None,
374 opened_submenu: opened,
375 }
376 }
377 }
378}
379
380fn item_surface(ctx: &FrameCtx<'_>, rect: LayoutRect, highlighted: bool) -> Vec<WidgetPaint> {
381 if !highlighted {
382 return Vec::new();
383 }
384 vec![WidgetPaint::Surface {
385 rect,
386 fill: ctx.theme().colors.accent.step(Step12::HOVER_BG),
387 border: None,
388 radius: ctx.theme().radius.sm,
389 elevation: None,
390 }]
391}
392
393fn separator_paint(ctx: &FrameCtx<'_>, rect: LayoutRect, metrics: MenuMetrics) -> Vec<WidgetPaint> {
394 let line_y = rect.origin.y.value() + metrics.separator_height.value() / 2.0 - 0.5;
395 let line_rect = LayoutRect::new(
396 LayoutPos::new(
397 LayoutPx::new(rect.origin.x.value() + metrics.padding_x.value()),
398 LayoutPx::new(line_y),
399 ),
400 LayoutSize::new(
401 LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * metrics.padding_x.value()),
402 LayoutPx::new(1.0),
403 ),
404 );
405 vec![WidgetPaint::Surface {
406 rect: line_rect,
407 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
408 border: None,
409 radius: ctx.theme().radius.none,
410 elevation: None,
411 }]
412}
413
414fn label_only_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect {
415 LayoutRect::new(
416 LayoutPos::new(
417 LayoutPx::new(item.origin.x.value() + metrics.padding_x.value()),
418 item.origin.y,
419 ),
420 LayoutSize::new(
421 LayoutPx::saturating_nonneg(item.size.width.value() - 2.0 * metrics.padding_x.value()),
422 item.size.height,
423 ),
424 )
425}
426
427fn shortcut_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect {
428 let half = item.size.width.value() / 2.0;
429 LayoutRect::new(
430 LayoutPos::new(LayoutPx::new(item.origin.x.value() + half), item.origin.y),
431 LayoutSize::new(
432 LayoutPx::saturating_nonneg(half - metrics.padding_x.value()),
433 item.size.height,
434 ),
435 )
436}
437
438fn arrow_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect {
439 let arrow_px = 12.0;
440 let pad = (item.size.height.value() - arrow_px).max(0.0) / 2.0;
441 LayoutRect::new(
442 LayoutPos::new(
443 LayoutPx::new(
444 item.origin.x.value() + item.size.width.value()
445 - arrow_px
446 - metrics.padding_x.value(),
447 ),
448 LayoutPx::new(item.origin.y.value() + pad),
449 ),
450 LayoutSize::new(LayoutPx::new(arrow_px), LayoutPx::new(arrow_px)),
451 )
452}
453
454fn menu_rect(origin: LayoutPos, items: &[MenuItem], metrics: MenuMetrics) -> LayoutRect {
455 let height: f32 = items
456 .iter()
457 .map(|i| match i {
458 MenuItem::Separator => metrics.separator_height.value(),
459 _ => metrics.item_height.value(),
460 })
461 .sum();
462 LayoutRect::new(
463 origin,
464 LayoutSize::new(metrics.min_width, LayoutPx::new(height)),
465 )
466}
467
468fn item_rects(menu: LayoutRect, items: &[MenuItem], metrics: MenuMetrics) -> Vec<LayoutRect> {
469 items
470 .iter()
471 .scan(menu.origin.y.value(), |y, item| {
472 let h = match item {
473 MenuItem::Separator => metrics.separator_height.value(),
474 _ => metrics.item_height.value(),
475 };
476 let rect = LayoutRect::new(
477 LayoutPos::new(menu.origin.x, LayoutPx::new(*y)),
478 LayoutSize::new(menu.size.width, LayoutPx::new(h)),
479 );
480 *y += h;
481 Some(rect)
482 })
483 .collect()
484}
485
486fn handle_keyboard(
487 ctx: &mut FrameCtx<'_>,
488 items: &[MenuItem],
489 state: &mut MenuState,
490 activated: &mut Option<WidgetId>,
491 close: &mut bool,
492) {
493 let event = take_key(
494 ctx.input,
495 &[
496 TakeKey::named(NamedKey::ArrowUp),
497 TakeKey::named(NamedKey::ArrowDown),
498 TakeKey::named(NamedKey::Enter),
499 TakeKey::named(NamedKey::Space),
500 TakeKey::named(NamedKey::Escape),
501 TakeKey::named(NamedKey::Home),
502 TakeKey::named(NamedKey::End),
503 ],
504 );
505 let Some(event) = event else { return };
506 match event.code {
507 KeyCode::Named(NamedKey::Escape) => {
508 *close = true;
509 }
510 KeyCode::Named(NamedKey::ArrowDown) => {
511 state.highlighted = next_focusable(items, state.highlighted, false);
512 }
513 KeyCode::Named(NamedKey::ArrowUp) => {
514 state.highlighted = next_focusable(items, state.highlighted, true);
515 }
516 KeyCode::Named(NamedKey::Home) => {
517 state.highlighted = first_focusable(items);
518 }
519 KeyCode::Named(NamedKey::End) => {
520 state.highlighted = last_focusable(items);
521 }
522 KeyCode::Named(NamedKey::Enter | NamedKey::Space) => {
523 if let Some(idx) = state.highlighted
524 && let Some(MenuItem::Action { id, disabled, .. }) = items.get(idx)
525 && !*disabled
526 {
527 *activated = Some(*id);
528 *close = true;
529 }
530 }
531 KeyCode::Named(_) | KeyCode::Char(_) => {}
532 }
533}
534
535fn first_focusable(items: &[MenuItem]) -> Option<usize> {
536 items.iter().position(MenuItem::is_focusable)
537}
538
539fn last_focusable(items: &[MenuItem]) -> Option<usize> {
540 items
541 .iter()
542 .enumerate()
543 .rev()
544 .find(|(_, i)| i.is_focusable())
545 .map(|(idx, _)| idx)
546}
547
548fn next_focusable(items: &[MenuItem], from: Option<usize>, reverse: bool) -> Option<usize> {
549 if items.is_empty() {
550 return None;
551 }
552 let len = items.len();
553 let start = from.unwrap_or(if reverse { 0 } else { len - 1 });
554 (1..=len)
555 .find_map(|delta| {
556 let idx = if reverse {
557 (start + len - delta) % len
558 } else {
559 (start + delta) % len
560 };
561 items[idx].is_focusable().then_some(idx)
562 })
563 .or(from)
564}
565
566#[derive(Debug, PartialEq)]
567pub struct ContextMenu<'a, 'state> {
568 pub id: WidgetId,
569 pub anchor: LayoutPos,
570 pub label: StringKey,
571 pub items: &'a [MenuItem],
572 pub state: &'state mut MenuState,
573 pub metrics: MenuMetrics,
574}
575
576impl<'a, 'state> ContextMenu<'a, 'state> {
577 #[must_use]
578 pub fn at_cursor(
579 id: WidgetId,
580 anchor: LayoutPos,
581 label: StringKey,
582 items: &'a [MenuItem],
583 state: &'state mut MenuState,
584 ) -> Self {
585 Self {
586 id,
587 anchor,
588 label,
589 items,
590 state,
591 metrics: MenuMetrics::standard(),
592 }
593 }
594}
595
596#[must_use]
597pub fn show_context_menu(ctx: &mut FrameCtx<'_>, menu: ContextMenu<'_, '_>) -> MenuResponse {
598 let ContextMenu {
599 id,
600 anchor,
601 label,
602 items,
603 state,
604 metrics,
605 } = menu;
606 show_menu(
607 ctx,
608 Menu {
609 id,
610 origin: anchor,
611 label,
612 items,
613 metrics,
614 state,
615 },
616 )
617}
618
619#[derive(Clone, Debug, PartialEq)]
620pub struct MenuBarEntry {
621 pub id: WidgetId,
622 pub label: StringKey,
623 pub items: Vec<MenuItem>,
624}
625
626#[derive(Clone, Debug, Default, PartialEq, Eq)]
627pub struct MenuBarState {
628 pub open: Option<WidgetId>,
629 pub menu: MenuState,
630}
631
632#[derive(Debug, PartialEq)]
633pub struct MenuBar<'a, 'state> {
634 pub id: WidgetId,
635 pub rect: LayoutRect,
636 pub label: StringKey,
637 pub entries: &'a [MenuBarEntry],
638 pub state: &'state mut MenuBarState,
639 pub min_item_width: LayoutPx,
640 pub item_padding: LayoutPx,
641 pub document_label: Option<LabelText>,
642}
643
644impl<'a, 'state> MenuBar<'a, 'state> {
645 #[must_use]
646 pub const fn new(
647 id: WidgetId,
648 rect: LayoutRect,
649 label: StringKey,
650 entries: &'a [MenuBarEntry],
651 state: &'state mut MenuBarState,
652 ) -> Self {
653 Self {
654 id,
655 rect,
656 label,
657 entries,
658 state,
659 min_item_width: LayoutPx::new(36.0),
660 item_padding: LayoutPx::new(10.0),
661 document_label: None,
662 }
663 }
664
665 #[must_use]
666 pub fn with_document_label(mut self, label: LabelText) -> Self {
667 self.document_label = Some(label);
668 self
669 }
670}
671
672#[derive(Clone, Debug, PartialEq)]
673pub struct MenuBarResponse {
674 pub activated: Option<WidgetId>,
675 pub paint: Vec<WidgetPaint>,
676 pub popover_paint: Vec<WidgetPaint>,
677}
678
679#[must_use]
680pub fn show_menu_bar(ctx: &mut FrameCtx<'_>, bar: MenuBar<'_, '_>) -> MenuBarResponse {
681 let MenuBar {
682 id,
683 rect,
684 label,
685 entries,
686 state,
687 min_item_width,
688 item_padding,
689 document_label,
690 } = bar;
691 ctx.a11y
692 .push(id, rect, AccessNode::new(Role::MenuBar).with_label(label));
693 let mut paint = vec![WidgetPaint::Surface {
694 rect,
695 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
696 border: Some(Border {
697 width: StrokeWidth::HAIRLINE,
698 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
699 }),
700 radius: ctx.theme().radius.none,
701 elevation: None,
702 }];
703 let role = ctx.theme().typography.label;
704 let request = ShapeRequest {
705 face: role.face,
706 size_px: role.size.as_px_f32(),
707 weight: role.weight,
708 line_height_px: 0.0,
709 letter_spacing_px: 0.0,
710 max_width: None,
711 };
712 let widths: Vec<LayoutPx> = entries
713 .iter()
714 .map(|e| {
715 let resolved = ctx.strings.resolve(e.label);
716 let advance = ctx
717 .shaper
718 .shape(resolved, request)
719 .lines
720 .first()
721 .map_or(0.0, ShapedLine::visible_advance_px);
722 let total = advance + 2.0 * item_padding.value();
723 LayoutPx::new(total.max(min_item_width.value()))
724 })
725 .collect();
726 let direction = ctx.direction();
727 let raw_entry_layouts = entry_rects(rect, &widths);
728 let entry_layouts: Vec<LayoutRect> = raw_entry_layouts
729 .iter()
730 .map(|r| r.mirror_horizontally_within(rect, direction))
731 .collect();
732 entries
733 .iter()
734 .zip(entry_layouts.iter())
735 .for_each(|(entry, entry_rect)| {
736 paint.extend(draw_menu_bar_entry(
737 ctx,
738 entry,
739 *entry_rect,
740 item_padding,
741 state,
742 ));
743 });
744 if let Some(label_text) = document_label {
745 paint.push(document_label_paint(
746 ctx,
747 label_text,
748 rect,
749 request,
750 raw_entry_layouts.as_slice(),
751 direction,
752 ));
753 }
754 let mut popover_paint = Vec::new();
755 let activated = open_menu_bar_dropdown(ctx, entries, &entry_layouts, state, &mut popover_paint);
756 MenuBarResponse {
757 activated,
758 paint,
759 popover_paint,
760 }
761}
762
763fn draw_menu_bar_entry(
764 ctx: &mut FrameCtx<'_>,
765 entry: &MenuBarEntry,
766 entry_rect: LayoutRect,
767 item_padding: LayoutPx,
768 state: &mut MenuBarState,
769) -> Vec<WidgetPaint> {
770 let is_open = state.open == Some(entry.id);
771 let interaction = ctx.interact(
772 InteractDeclaration::new(entry.id, entry_rect, Sense::INTERACTIVE)
773 .focusable(true)
774 .active(is_open)
775 .a11y(
776 AccessNode::new(Role::MenuItem)
777 .with_label(entry.label)
778 .with_expanded(is_open),
779 ),
780 );
781 let live_focused = ctx.is_focused(entry.id);
782 let pointer_toggled = interaction.click();
783 let key_toggled = live_focused
784 && take_key(
785 ctx.input,
786 &[
787 TakeKey::named(NamedKey::Enter),
788 TakeKey::named(NamedKey::Space),
789 TakeKey::named(NamedKey::ArrowDown),
790 ],
791 )
792 .is_some();
793 if pointer_toggled || key_toggled {
794 state.open = if is_open { None } else { Some(entry.id) };
795 state.menu = MenuState::default();
796 } else if state.open.is_some() && !is_open && interaction.hover() {
797 state.open = Some(entry.id);
798 state.menu = MenuState::default();
799 }
800 let mut paint = vec![
801 WidgetPaint::Surface {
802 rect: entry_rect,
803 fill: if is_open {
804 ctx.theme().colors.neutral.step(Step12::SELECTED_BG)
805 } else if interaction.hover() {
806 ctx.theme().colors.neutral.step(Step12::HOVER_BG)
807 } else {
808 Color::TRANSPARENT
809 },
810 border: None,
811 radius: ctx.theme().radius.sm,
812 elevation: None,
813 },
814 WidgetPaint::AlignedLabel {
815 rect: LayoutRect::new(
816 LayoutPos::new(
817 LayoutPx::new(entry_rect.origin.x.value() + item_padding.value()),
818 entry_rect.origin.y,
819 ),
820 LayoutSize::new(
821 LayoutPx::saturating_nonneg(
822 entry_rect.size.width.value() - 2.0 * item_padding.value(),
823 ),
824 entry_rect.size.height,
825 ),
826 ),
827 text: LabelText::Key(entry.label),
828 color: ctx.theme().colors.text_primary(),
829 role: ctx.theme().typography.label,
830 align: HorizontalAlign::Start,
831 },
832 ];
833 push_focus_ring(
834 ctx,
835 &mut paint,
836 entry_rect,
837 ctx.theme().radius.sm,
838 live_focused,
839 );
840 paint
841}
842
843fn open_menu_bar_dropdown(
844 ctx: &mut FrameCtx<'_>,
845 entries: &[MenuBarEntry],
846 entry_layouts: &[LayoutRect],
847 state: &mut MenuBarState,
848 paint: &mut Vec<WidgetPaint>,
849) -> Option<WidgetId> {
850 let open_id = state.open?;
851 let (entry, entry_rect) = entries
852 .iter()
853 .zip(entry_layouts.iter())
854 .find(|(e, _)| e.id == open_id)?;
855 let menu_origin = LayoutPos::new(
856 entry_rect.origin.x,
857 LayoutPx::new(entry_rect.origin.y.value() + entry_rect.size.height.value()),
858 );
859 let response = show_menu(
860 ctx,
861 Menu::new(
862 entry.id.child(WidgetKey::new("menu")),
863 menu_origin,
864 entry.label,
865 &entry.items,
866 &mut state.menu,
867 ),
868 );
869 paint.extend(response.paint);
870 if response.close {
871 state.open = None;
872 state.menu = MenuState::default();
873 }
874 response.activated
875}
876
877fn document_label_paint(
878 ctx: &mut FrameCtx<'_>,
879 label_text: LabelText,
880 bar_rect: LayoutRect,
881 request: ShapeRequest,
882 entry_layouts: &[LayoutRect],
883 direction: LayoutDirection,
884) -> WidgetPaint {
885 let resolved = label_text.resolve(ctx.strings);
886 let advance = ctx
887 .shaper
888 .shape(resolved, request)
889 .lines
890 .first()
891 .map_or(0.0, ShapedLine::visible_advance_px);
892 let bar_min_x = bar_rect.origin.x.value();
893 let bar_max_x = bar_min_x + bar_rect.size.width.value();
894 let width = advance.min(bar_rect.size.width.value()).max(0.0);
895 let entries_end = entry_layouts
896 .last()
897 .map_or(bar_min_x, |r| r.origin.x.value() + r.size.width.value());
898 let centered_x = bar_min_x + (bar_rect.size.width.value() - width) * 0.5;
899 let max_x = (bar_max_x - width).max(bar_min_x);
900 let label_x = centered_x.clamp(entries_end.min(max_x), max_x);
901 let label_rect = LayoutRect::new(
902 LayoutPos::new(LayoutPx::saturating(label_x), bar_rect.origin.y),
903 LayoutSize::new(LayoutPx::saturating_nonneg(width), bar_rect.size.height),
904 );
905 WidgetPaint::AlignedLabel {
906 rect: label_rect.mirror_horizontally_within(bar_rect, direction),
907 text: label_text,
908 color: ctx.theme().colors.text_primary(),
909 role: ctx.theme().typography.label,
910 align: HorizontalAlign::Center,
911 }
912}
913
914fn entry_rects(bar: LayoutRect, widths: &[LayoutPx]) -> Vec<LayoutRect> {
915 widths
916 .iter()
917 .scan(bar.origin.x.value(), |x, w| {
918 let rect = LayoutRect::new(
919 LayoutPos::new(LayoutPx::new(*x), bar.origin.y),
920 LayoutSize::new(*w, bar.size.height),
921 );
922 *x += w.value();
923 Some(rect)
924 })
925 .collect()
926}
927
928#[cfg(test)]
929mod tests {
930 use std::sync::Arc;
931
932 use super::{
933 Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, show_menu, show_menu_bar,
934 };
935 use crate::focus::FocusManager;
936 use crate::frame::FrameCtx;
937 use crate::hit_test::{HitFrame, HitState, resolve};
938 use crate::hotkey::HotkeyTable;
939 use crate::input::{
940 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
941 PointerButtonMask, PointerSample,
942 };
943 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
944 use crate::strings::{StringKey, StringTable};
945 use crate::theme::Theme;
946 use crate::widget_id::{WidgetId, WidgetKey};
947
948 fn menu_root() -> WidgetId {
949 WidgetId::ROOT.child(WidgetKey::new("menu"))
950 }
951
952 fn menu_bar_id() -> WidgetId {
953 WidgetId::ROOT.child(WidgetKey::new("menu_bar"))
954 }
955
956 fn action(name: &'static str) -> MenuItem {
957 MenuItem::Action {
958 id: menu_root().child(WidgetKey::new(name)),
959 label: StringKey::new("menu.action"),
960 shortcut: Some(super::super::paint::LabelText::Key(StringKey::new(
961 "menu.shortcut",
962 ))),
963 disabled: false,
964 }
965 }
966
967 fn disabled_action(name: &'static str) -> MenuItem {
968 match action(name) {
969 MenuItem::Action {
970 id,
971 label,
972 shortcut,
973 ..
974 } => MenuItem::Action {
975 id,
976 label,
977 shortcut,
978 disabled: true,
979 },
980 _ => unreachable!(),
981 }
982 }
983
984 fn render(
985 items: &[MenuItem],
986 state: &mut MenuState,
987 focus: &mut FocusManager,
988 snap: &mut InputSnapshot,
989 prev: &HitState,
990 ) -> (super::MenuResponse, HitState) {
991 let theme = Arc::new(Theme::light());
992 let table = HotkeyTable::new();
993 let mut hits = HitFrame::new();
994 let response = {
995 let mut shaper = bone_text::Shaper::new();
996 let mut a11y = crate::a11y::AccessTreeBuilder::new();
997 let mut ctx = FrameCtx::new(
998 theme,
999 snap,
1000 focus,
1001 &table,
1002 StringTable::empty(),
1003 &mut hits,
1004 prev,
1005 &mut a11y,
1006 &mut shaper,
1007 );
1008 show_menu(
1009 &mut ctx,
1010 Menu::new(
1011 menu_root(),
1012 LayoutPos::ORIGIN,
1013 StringKey::new("test.menu"),
1014 items,
1015 state,
1016 ),
1017 )
1018 };
1019 let next = resolve(prev, &hits, snap, focus.focused());
1020 (response, next)
1021 }
1022
1023 fn press(pos: LayoutPos) -> InputSnapshot {
1024 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
1025 s.pointer = Some(PointerSample::new(pos));
1026 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
1027 s
1028 }
1029
1030 fn release(pos: LayoutPos) -> InputSnapshot {
1031 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
1032 s.pointer = Some(PointerSample::new(pos));
1033 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
1034 s
1035 }
1036
1037 fn idle(pos: LayoutPos) -> InputSnapshot {
1038 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
1039 s.pointer = Some(PointerSample::new(pos));
1040 s
1041 }
1042
1043 #[test]
1044 fn click_action_item_activates() {
1045 let items = vec![action("save"), action("save_as")];
1046 let mut state = MenuState::default();
1047 let mut focus = FocusManager::new();
1048 let mut prev = HitState::new();
1049 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(36.0));
1050 let mut last_response: Option<super::MenuResponse> = None;
1051 [press(click_pos), release(click_pos), idle(click_pos)]
1052 .into_iter()
1053 .for_each(|mut snap| {
1054 let (response, next) = render(&items, &mut state, &mut focus, &mut snap, &prev);
1055 last_response = Some(response);
1056 prev = next;
1057 });
1058 let Some(r) = last_response else {
1059 panic!("response missing")
1060 };
1061 let MenuItem::Action { id: target_id, .. } = &items[1] else {
1062 panic!("expected action");
1063 };
1064 assert_eq!(r.activated, Some(*target_id));
1065 assert!(r.close);
1066 }
1067
1068 #[test]
1069 fn disabled_action_does_not_activate() {
1070 let items = vec![disabled_action("save")];
1071 let mut state = MenuState::default();
1072 let mut focus = FocusManager::new();
1073 let mut prev = HitState::new();
1074 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(12.0));
1075 [press(click_pos), release(click_pos), idle(click_pos)]
1076 .into_iter()
1077 .for_each(|mut snap| {
1078 let (response, next) = render(&items, &mut state, &mut focus, &mut snap, &prev);
1079 assert!(response.activated.is_none());
1080 assert!(!response.close);
1081 prev = next;
1082 });
1083 }
1084
1085 #[test]
1086 fn arrow_down_skips_separator_and_disabled() {
1087 let items = vec![
1088 disabled_action("a"),
1089 MenuItem::Separator,
1090 action("b"),
1091 action("c"),
1092 ];
1093 let mut state = MenuState::default();
1094 let mut focus = FocusManager::new();
1095 let prev = HitState::new();
1096 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1097 snap.keys_pressed.push(KeyEvent::new(
1098 KeyCode::Named(NamedKey::ArrowDown),
1099 ModifierMask::NONE,
1100 ));
1101 let _ = render(&items, &mut state, &mut focus, &mut snap, &prev);
1102 assert_eq!(state.highlighted, Some(2));
1103 }
1104
1105 #[test]
1106 fn enter_with_highlighted_action_activates_it() {
1107 let items = vec![action("a"), action("b")];
1108 let mut state = MenuState {
1109 highlighted: Some(1),
1110 ..MenuState::default()
1111 };
1112 let mut focus = FocusManager::new();
1113 let prev = HitState::new();
1114 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1115 snap.keys_pressed.push(KeyEvent::new(
1116 KeyCode::Named(NamedKey::Enter),
1117 ModifierMask::NONE,
1118 ));
1119 let (response, _) = render(&items, &mut state, &mut focus, &mut snap, &prev);
1120 let MenuItem::Action { id, .. } = &items[1] else {
1121 panic!()
1122 };
1123 assert_eq!(response.activated, Some(*id));
1124 assert!(response.close);
1125 }
1126
1127 #[test]
1128 fn escape_closes_without_activating() {
1129 let items = vec![action("a")];
1130 let mut state = MenuState::default();
1131 let mut focus = FocusManager::new();
1132 let prev = HitState::new();
1133 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1134 snap.keys_pressed.push(KeyEvent::new(
1135 KeyCode::Named(NamedKey::Escape),
1136 ModifierMask::NONE,
1137 ));
1138 let (response, _) = render(&items, &mut state, &mut focus, &mut snap, &prev);
1139 assert!(response.activated.is_none());
1140 assert!(response.close);
1141 }
1142
1143 fn bar_state() -> MenuBarState {
1144 MenuBarState::default()
1145 }
1146
1147 fn entry(name: &'static str) -> MenuBarEntry {
1148 let entry_id = menu_root().child(WidgetKey::new(name));
1149 MenuBarEntry {
1150 id: entry_id,
1151 label: StringKey::new("menubar.entry"),
1152 items: vec![MenuItem::Action {
1153 id: entry_id.child(WidgetKey::new("first")),
1154 label: StringKey::new("menu.action"),
1155 shortcut: None,
1156 disabled: false,
1157 }],
1158 }
1159 }
1160
1161 #[test]
1162 fn click_menubar_entry_opens_its_menu() {
1163 let entries = vec![entry("file"), entry("edit")];
1164 let mut state = bar_state();
1165 let theme = Arc::new(Theme::light());
1166 let table = HotkeyTable::new();
1167 let mut focus = FocusManager::new();
1168 let mut prev = HitState::new();
1169 let click_pos = LayoutPos::new(LayoutPx::new(200.0), LayoutPx::new(10.0));
1170 let bar_rect = LayoutRect::new(
1171 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1172 LayoutSize::new(LayoutPx::new(600.0), LayoutPx::new(24.0)),
1173 );
1174 [press(click_pos), release(click_pos), idle(click_pos)]
1175 .into_iter()
1176 .for_each(|mut snap| {
1177 let mut hits = HitFrame::new();
1178 {
1179 let mut shaper = bone_text::Shaper::new();
1180 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1181 let mut ctx = FrameCtx::new(
1182 theme.clone(),
1183 &mut snap,
1184 &mut focus,
1185 &table,
1186 StringTable::empty(),
1187 &mut hits,
1188 &prev,
1189 &mut a11y,
1190 &mut shaper,
1191 );
1192 let _ = show_menu_bar(
1193 &mut ctx,
1194 MenuBar::new(
1195 menu_bar_id(),
1196 bar_rect,
1197 StringKey::new("test.menu_bar"),
1198 &entries,
1199 &mut state,
1200 ),
1201 );
1202 }
1203 prev = resolve(&prev, &hits, &snap, focus.focused());
1204 });
1205 assert_eq!(state.open, Some(entries[1].id));
1206 }
1207
1208 #[test]
1209 fn arrow_down_on_focused_entry_opens_its_menu() {
1210 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
1211
1212 let entries = vec![entry("file"), entry("edit")];
1213 let mut state = bar_state();
1214 let theme = Arc::new(Theme::light());
1215 let table = HotkeyTable::new();
1216 let mut focus = FocusManager::new();
1217 let bar_rect = LayoutRect::new(
1218 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1219 LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)),
1220 );
1221 focus.request_focus(entries[0].id);
1222 let prev = HitState::new();
1223
1224 [InputSnapshot::idle(FrameInstant::ZERO), {
1225 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
1226 s.keys_pressed.push(KeyEvent::new(
1227 KeyCode::Named(NamedKey::ArrowDown),
1228 ModifierMask::NONE,
1229 ));
1230 s
1231 }]
1232 .into_iter()
1233 .for_each(|mut snap| {
1234 let mut hits = HitFrame::new();
1235 let mut shaper = bone_text::Shaper::new();
1236 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1237 let mut ctx = FrameCtx::new(
1238 theme.clone(),
1239 &mut snap,
1240 &mut focus,
1241 &table,
1242 StringTable::empty(),
1243 &mut hits,
1244 &prev,
1245 &mut a11y,
1246 &mut shaper,
1247 );
1248 let _ = show_menu_bar(
1249 &mut ctx,
1250 MenuBar::new(
1251 menu_bar_id(),
1252 bar_rect,
1253 StringKey::new("test.menu_bar"),
1254 &entries,
1255 &mut state,
1256 ),
1257 );
1258 });
1259 assert_eq!(state.open, Some(entries[0].id));
1260 }
1261
1262 #[test]
1263 fn document_label_centers_in_the_bar() {
1264 use super::super::paint::{HorizontalAlign, LabelText, WidgetPaint};
1265
1266 let entries = vec![entry("file"), entry("edit"), entry("view")];
1267 let mut state = bar_state();
1268 let theme = Arc::new(Theme::light());
1269 let table = HotkeyTable::new();
1270 let mut focus = FocusManager::new();
1271 let bar_rect = LayoutRect::new(
1272 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1273 LayoutSize::new(LayoutPx::new(1920.0), LayoutPx::new(24.0)),
1274 );
1275 let doc = "Part117 *";
1276 let prev = HitState::new();
1277 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1278 let mut hits = HitFrame::new();
1279 let mut shaper = bone_text::Shaper::new();
1280 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1281 let response = {
1282 let mut ctx = FrameCtx::new(
1283 theme.clone(),
1284 &mut snap,
1285 &mut focus,
1286 &table,
1287 StringTable::empty(),
1288 &mut hits,
1289 &prev,
1290 &mut a11y,
1291 &mut shaper,
1292 );
1293 show_menu_bar(
1294 &mut ctx,
1295 MenuBar::new(
1296 menu_bar_id(),
1297 bar_rect,
1298 StringKey::new("test.menu_bar"),
1299 &entries,
1300 &mut state,
1301 )
1302 .with_document_label(LabelText::Owned(doc.to_owned())),
1303 )
1304 };
1305 let found = response.paint.iter().find_map(|p| match p {
1306 WidgetPaint::AlignedLabel {
1307 rect,
1308 text: LabelText::Owned(t),
1309 align,
1310 ..
1311 } if t.as_str() == doc => Some((*rect, *align)),
1312 _ => None,
1313 });
1314 let Some((rect, align)) = found else {
1315 panic!("document label is painted");
1316 };
1317 assert_eq!(align, HorizontalAlign::Center);
1318 let label_center = rect.origin.x.value() + rect.size.width.value() * 0.5;
1319 let bar_center = bar_rect.origin.x.value() + bar_rect.size.width.value() * 0.5;
1320 assert!(
1321 (label_center - bar_center).abs() <= 0.5,
1322 "document label center {label_center} expected bar center {bar_center}",
1323 );
1324 }
1325}