1use std::cell::{Cell, RefCell};
50use std::collections::HashMap;
51use std::rc::Rc;
52
53use teksilo_canvas::{Point, Rect, Size, SizeProposal};
54use teksilo_core::accessibility::AccessNodeBuilder;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
57use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
58use teksilo_core::signal::Signal;
59use teksilo_core::widget::{
60 CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
61};
62use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
63use teksilo_core::widget_id::WidgetId;
64use teksilo_core::window::{
65 MenubarAction, MenubarDispatcher, MenubarGuard, MenubarKeyEvent, MenubarReveal,
66};
67use teksilo_tokens::{SurfaceRole, TextStyleRole};
68
69use crate::animations::Unroll;
70use crate::icon_button::{IconButton, IconButtonSize};
71use crate::menu_context::MenuContext;
72use crate::menu_item::MenuLabel;
73use crate::menu_item::ParsedMnemonic;
74use crate::menu_item::parse_mnemonic;
75use crate::primitives::{HStack, Padding, RectWidget, Spacer, ZStack};
76use teksilo_i18n::LocalizedString;
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum CollapsePolicy {
82 #[default]
86 Responsive,
87 Always,
90}
91
92struct MenuBarEntry {
97 label: LocalizedString,
98 factory: Box<dyn Fn() -> Box<dyn Widget>>,
99}
100
101pub struct MenuBar {
111 entries: Vec<MenuBarEntry>,
112 leading_slot: Vec<PendingChild>,
125 trailing_slot: Vec<PendingChild>,
126 leading_slot_ids: Vec<WidgetId>,
130 trailing_slot_ids: Vec<WidgetId>,
131 root_child_id: Option<WidgetId>,
132 menubar_guard: RefCell<Option<MenubarGuard>>,
136 install_dispatcher: bool,
144 collapse_policy: Option<CollapsePolicy>,
147 collapsed: Signal<bool>,
151 revealed: Signal<bool>,
153 reveal_progress: Signal<f32>,
159 last_collapsed: Cell<bool>,
161 bar_id: Option<WidgetId>,
164 hamburger_id: Option<WidgetId>,
166 hamburger_size: IconButtonSize,
169 model: Option<crate::menu::MenuModel>,
172 native_mode: crate::menu::NativeMenuMode,
174 native_binding: RefCell<Option<crate::menu::native::NativeMenuBinding>>,
177}
178
179impl MenuBar {
180 pub fn new() -> Self {
182 Self {
183 entries: Vec::new(),
184 leading_slot: Vec::new(),
185 trailing_slot: Vec::new(),
186 leading_slot_ids: Vec::new(),
187 trailing_slot_ids: Vec::new(),
188 root_child_id: None,
189 menubar_guard: RefCell::new(None),
190 install_dispatcher: true,
191 collapse_policy: None,
192 collapsed: Signal::new(false),
193 revealed: Signal::new(false),
194 reveal_progress: Signal::new_animated(1.0),
195 last_collapsed: Cell::new(false),
196 bar_id: None,
197 hamburger_id: None,
198 hamburger_size: IconButtonSize::Default,
199 model: None,
200 native_mode: crate::menu::NativeMenuMode::Off,
201 native_binding: RefCell::new(None),
202 }
203 }
204
205 pub fn from_model(model: crate::menu::MenuModel) -> Self {
211 let mut bar = Self::new();
212 bar.model = Some(model);
217 bar
218 }
219
220 fn model_entries(model: &crate::menu::MenuModel) -> Vec<MenuBarEntry> {
225 model
226 .nodes()
227 .iter()
228 .filter_map(|node| match node {
229 crate::menu::MenuNode::Submenu {
230 title, children, ..
231 } => {
232 let children = children.clone();
233 Some(MenuBarEntry {
234 label: title.clone(),
235 factory: Box::new(move || {
236 Box::new(crate::menu::model::build_menu_list(&children))
237 }),
238 })
239 }
240 _ => None,
241 })
242 .collect()
243 }
244
245 fn add_slot(
256 ctx: &mut BuildContext,
257 mut row: HStack,
258 pending: &mut Vec<PendingChild>,
259 cache: &mut Vec<WidgetId>,
260 ) -> HStack {
261 if cache.is_empty() && !pending.is_empty() {
262 *cache = pending
263 .drain(..)
264 .map(|p| match p {
265 PendingChild::Id(id) => id,
266 PendingChild::Deferred(w) => ctx.add_boxed(w),
267 })
268 .collect();
269 }
270 for &id in cache.iter() {
271 row = row.add_child(id);
272 }
273 row
274 }
275
276 pub fn native_on_macos(mut self, mode: crate::menu::NativeMenuMode) -> Self {
282 self.native_mode = mode;
283 self
284 }
285
286 pub fn collapsible(mut self) -> Self {
297 self.collapse_policy
298 .get_or_insert(CollapsePolicy::Responsive);
299 self
300 }
301
302 pub fn collapsed_signal(mut self, collapsed: Signal<bool>) -> Self {
309 self.collapse_policy
310 .get_or_insert(CollapsePolicy::Responsive);
311 self.last_collapsed.set(collapsed.get());
312 self.collapsed = collapsed;
313 self
314 }
315
316 pub fn collapse_policy(mut self, policy: CollapsePolicy) -> Self {
320 self.collapse_policy = Some(policy);
321 if policy == CollapsePolicy::Always {
325 self.collapsed.set(true);
326 self.last_collapsed.set(true);
327 }
328 self
329 }
330
331 pub fn hamburger_size(mut self, size: IconButtonSize) -> Self {
337 self.hamburger_size = size;
338 self
339 }
340
341 pub fn is_collapsed(&self) -> Signal<bool> {
344 self.collapsed.clone()
345 }
346
347 pub fn no_dispatcher_install(mut self) -> Self {
355 self.install_dispatcher = false;
356 self
357 }
358
359 pub fn menu(
363 mut self,
364 label: impl Into<LocalizedString>,
365 factory: impl Fn() -> Box<dyn Widget> + 'static,
366 ) -> Self {
367 let ls: LocalizedString = label.into();
368 self.entries.push(MenuBarEntry {
369 label: ls,
370 factory: Box::new(factory),
371 });
372 self
373 }
374
375 pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
384 self.leading_slot
385 .push(PendingChild::Deferred(Box::new(widget)));
386 self
387 }
388
389 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
393 self.trailing_slot
394 .push(PendingChild::Deferred(Box::new(widget)));
395 self
396 }
397
398 fn build_suppressed(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
402 let mut row = HStack::new().spacing(2.0);
403 row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
404 row = row.child(Spacer::new());
405 row = Self::add_slot(
406 ctx,
407 row,
408 &mut self.trailing_slot,
409 &mut self.trailing_slot_ids,
410 );
411 let row_id = ctx.add(row);
412 self.root_child_id = Some(row_id);
413 self.bar_id = Some(row_id);
414 vec![row_id]
415 }
416}
417
418impl Default for MenuBar {
419 fn default() -> Self {
420 Self::new()
421 }
422}
423
424struct MenuBarDispatcher {
431 trigger_ids: Vec<WidgetId>,
433 mnemonic_table: HashMap<char, usize>,
435}
436
437impl MenubarDispatcher for MenuBarDispatcher {
438 fn try_handle(&self, event: &MenubarKeyEvent) -> Option<MenubarAction> {
439 if event.modifiers == Modifiers::NONE && matches!(event.key, Key::F10) {
444 return self
445 .trigger_ids
446 .first()
447 .map(|&id| MenubarAction::FocusTrigger {
448 trigger_id: id,
449 reveal: None,
450 });
451 }
452 #[cfg(not(target_os = "macos"))]
463 if event.modifiers == Modifiers::ALT {
464 let lookup_char = match event.key {
469 Key::Character(c) => Some(c.to_ascii_lowercase()),
470 _ => {
471 let c = event.key.to_char()?;
472 Some(c.to_ascii_lowercase())
473 }
474 };
475 if let Some(c) = lookup_char {
476 if let Some(&idx) = self.mnemonic_table.get(&c) {
477 if let Some(&tid) = self.trigger_ids.get(idx) {
478 return Some(MenubarAction::OpenMenu {
479 trigger_id: tid,
480 reveal: None,
481 });
482 }
483 }
484 return Some(MenubarAction::Intercept);
488 }
489 }
490 let _ = &self.mnemonic_table;
493 None
494 }
495
496 fn on_alt_tap(&self) -> Option<MenubarAction> {
497 self.trigger_ids
501 .first()
502 .map(|&id| MenubarAction::FocusTrigger {
503 trigger_id: id,
504 reveal: None,
505 })
506 }
507}
508
509struct CollapsibleMenuBarDispatcher {
520 inner: MenuBarDispatcher,
521 collapsed: Signal<bool>,
522 reveal: MenubarReveal,
523}
524
525impl CollapsibleMenuBarDispatcher {
526 fn with_reveal(&self, action: MenubarAction) -> MenubarAction {
527 if !self.collapsed.get() {
528 return action;
529 }
530 let reveal = Some(self.reveal.clone());
531 match action {
532 MenubarAction::OpenMenu { trigger_id, .. } => {
533 MenubarAction::OpenMenu { trigger_id, reveal }
534 }
535 MenubarAction::FocusTrigger { trigger_id, .. } => {
536 MenubarAction::FocusTrigger { trigger_id, reveal }
537 }
538 MenubarAction::Intercept => MenubarAction::Intercept,
539 }
540 }
541}
542
543impl MenubarDispatcher for CollapsibleMenuBarDispatcher {
544 fn try_handle(&self, event: &MenubarKeyEvent) -> Option<MenubarAction> {
545 self.inner.try_handle(event).map(|a| self.with_reveal(a))
546 }
547
548 fn on_alt_tap(&self) -> Option<MenubarAction> {
549 self.inner.on_alt_tap().map(|a| self.with_reveal(a))
550 }
551}
552
553impl std::fmt::Debug for MenuBar {
554 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555 f.debug_struct("MenuBar")
556 .field("entries", &self.entries.len())
557 .finish()
558 }
559}
560
561#[derive(Debug)]
566struct MenuBarTrigger {
567 label: LocalizedString,
568 stripped_name: String,
572 mnemonic_key: Option<char>,
575 index: usize,
576 menu_ctx: MenuContext,
577 root_child_id: Option<WidgetId>,
578}
579
580impl Widget for MenuBarTrigger {
581 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
582 let theme = ctx.theme();
583 let radius_control = theme.shape.radius_control;
584 use crate::styles::recipe_menu_item_style as menu;
585 let index = self.index;
586 let menu_ctx = self.menu_ctx.clone();
587
588 let bg_role = menu_ctx.open_index.map(move |open| {
592 if *open == Some(index) {
593 SurfaceRole::AccentSubtle
594 } else {
595 SurfaceRole::Transparent
596 }
597 });
598
599 let theme_signal = ctx.theme_signal();
604 let text_color = menu_ctx
605 .open_index
606 .zip(&theme_signal)
607 .map(move |(open, t)| {
608 if *open == Some(index) {
609 t.colors.text_primary
610 } else {
611 t.colors.text_primary.with_alpha(0.8)
612 }
613 });
614
615 let alt_down = ctx
620 .window()
621 .map(|w| w.alt_down().clone())
622 .unwrap_or_else(|| Signal::new(false));
623 let label_source: teksilo_core::signal::Prop<String> = self.label.clone().into();
624 let label_id = ctx.add(MenuLabel::new(
625 label_source,
626 alt_down,
627 text_color,
628 TextStyleRole::Small,
629 ));
630
631 let padding =
632 Padding::symmetric(4.0, menu::MENU_ITEM_PADDING_HORIZONTAL).child_id(label_id);
633 let padding_id = ctx.add(padding);
634
635 let bg = RectWidget::new()
636 .background(bg_role)
637 .corner_radius(teksilo_tokens::CornerRadius::uniform(radius_control));
638 let bg_id = ctx.add(bg);
639
640 let zstack = ZStack::new().add_child(bg_id).add_child(padding_id);
641 let root_id = ctx.add(zstack);
642 self.root_child_id = Some(root_id);
643
644 let handler_set = HandlerSet::new()
645 .on_tap({
646 let menu_ctx = menu_ctx.clone();
647 move |_pos, ctx: &mut EventContext| {
648 if menu_ctx.open_index.get() == Some(index) {
649 menu_ctx.close(ctx);
650 } else {
651 menu_ctx.open_at(index, ctx);
652 }
653 }
654 })
655 .on_hover({
656 let menu_ctx = menu_ctx.clone();
657 move |entered: bool, ctx: &mut EventContext| {
658 if entered {
659 let current = menu_ctx.open_index.get();
661 if current.is_some() && current != Some(index) {
662 menu_ctx.open_at(index, ctx);
663 }
664 }
665 }
666 })
667 .on_key({
668 let menu_ctx = menu_ctx.clone();
669 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
670 let (left_delta, right_delta) = if ctx.is_rtl() { (1, -1) } else { (-1, 1) };
675 match event {
676 WidgetEvent::KeyDown {
677 key: Key::ArrowDown | Key::Enter | Key::Space,
678 ..
679 } => {
680 menu_ctx.open_at(index, ctx);
681 EventResponse::Handled
682 }
683 WidgetEvent::KeyDown {
684 key: Key::ArrowLeft,
685 ..
686 } => {
687 menu_ctx.navigate(left_delta, ctx);
688 EventResponse::Handled
689 }
690 WidgetEvent::KeyDown {
691 key: Key::ArrowRight,
692 ..
693 } => {
694 menu_ctx.navigate(right_delta, ctx);
695 EventResponse::Handled
696 }
697 _ => EventResponse::Ignored,
698 }
699 }
700 })
701 .on_access_action({
702 let menu_ctx = menu_ctx.clone();
706 move |action, ctx: &mut EventContext| -> EventResponse {
707 use teksilo_core::accesskit::Action;
708 match action {
709 Action::Click => {
710 if menu_ctx.open_index.get() == Some(index) {
711 menu_ctx.close(ctx);
712 } else {
713 menu_ctx.open_at(index, ctx);
714 }
715 EventResponse::Handled
716 }
717 Action::Expand => {
718 menu_ctx.open_at(index, ctx);
719 EventResponse::Handled
720 }
721 Action::Collapse => {
722 menu_ctx.close(ctx);
723 EventResponse::Handled
724 }
725 _ => EventResponse::Ignored,
726 }
727 }
728 })
729 .focusable(true)
730 .cursor(CursorIcon::Pointer);
731
732 ctx.apply_self_handlers(handler_set);
733
734 let self_id = ctx.self_id();
737 let registry = ctx.binding_registry();
738 self.menu_ctx.open_index.bind_to(
739 self_id,
740 registry,
741 teksilo_core::binding::BindingLevel::RepaintOnly,
742 );
743
744 vec![root_id]
745 }
746
747 fn layout_response(
748 &self,
749 proposal: SizeProposal,
750 ctx: &LayoutContext,
751 ) -> teksilo_core::widget::LayoutResponse {
752 match self.root_child_id {
753 Some(id) => ctx
754 .child_size(id, proposal)
755 .unwrap_or_else(|| proposal.resolve(0.0, 28.0)),
756 None => proposal.resolve(60.0, 28.0),
757 }
758 .into()
759 }
760
761 fn place_children(
762 &self,
763 bounds: Rect,
764 _proposal: SizeProposal,
765 children: &mut [WidgetPlacement],
766 _ctx: &LayoutContext,
767 ) {
768 for child in children.iter_mut() {
769 child.origin = bounds.origin();
770 child.size = bounds.size();
771 }
772 }
773
774 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
775 builder.set_role(teksilo_core::accesskit::Role::MenuItem);
776 if !self.stripped_name.is_empty() {
780 builder.set_name(self.stripped_name.clone());
781 } else {
782 builder.set_name(parse_mnemonic(&self.label.resolve_now()).stripped);
783 }
784 builder.set_has_popup(teksilo_core::accesskit::HasPopup::Menu);
786 let is_open = self.menu_ctx.open_index.get() == Some(self.index);
787 builder.set_expanded(is_open);
788 builder.add_action(teksilo_core::accesskit::Action::Click);
793 if is_open {
794 builder.add_action(teksilo_core::accesskit::Action::Collapse);
795 } else {
796 builder.add_action(teksilo_core::accesskit::Action::Expand);
797 }
798 if let Some(k) = self.mnemonic_key {
800 builder
801 .inner_mut()
802 .set_access_key(k.to_ascii_uppercase().to_string());
803 }
804 }
805
806 fn children(&self) -> Vec<WidgetId> {
807 self.root_child_id.into_iter().collect()
808 }
809}
810
811#[derive(Debug)]
819struct MenuOverlayHost {
820 inner: Option<Box<dyn Widget>>,
821 menu_ctx: MenuContext,
822 menu_index: usize,
823 inner_id: Option<WidgetId>,
824}
825
826impl Widget for MenuOverlayHost {
827 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
828 let inner_widget = self.inner.take().expect("MenuOverlayHost built twice");
829 let id = ctx.add_boxed(inner_widget);
830 self.inner_id = Some(id);
831
832 self.menu_ctx.set_focus_id(self.menu_index, id);
834
835 let menu_ctx = self.menu_ctx.clone();
836 let menu_index = self.menu_index;
837 let handler_set = HandlerSet::new()
838 .on_focus({
839 let menu_ctx = menu_ctx.clone();
840 move |gained: bool, _ctx: &mut EventContext| {
841 if !gained && menu_ctx.open_index.get() == Some(menu_index) {
858 menu_ctx.open_index.set(None);
859 }
860 }
861 })
862 .on_key({
863 let menu_ctx = menu_ctx.clone();
864 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
865 let (left_delta, right_delta) = if ctx.is_rtl() { (1, -1) } else { (-1, 1) };
869 match event {
870 WidgetEvent::KeyDown {
871 key: Key::ArrowLeft,
872 ..
873 } => {
874 menu_ctx.navigate(left_delta, ctx);
875 EventResponse::Handled
876 }
877 WidgetEvent::KeyDown {
878 key: Key::ArrowRight,
879 ..
880 } => {
881 menu_ctx.navigate(right_delta, ctx);
882 EventResponse::Handled
883 }
884 WidgetEvent::KeyDown {
885 key: Key::Escape, ..
886 } => {
887 menu_ctx.close(ctx);
888 EventResponse::Handled
889 }
890 _ => EventResponse::Ignored,
891 }
892 }
893 });
894 ctx.apply_self_handlers(handler_set);
897
898 vec![id]
899 }
900
901 fn layout_response(
902 &self,
903 proposal: SizeProposal,
904 ctx: &LayoutContext,
905 ) -> teksilo_core::widget::LayoutResponse {
906 self.inner_id
907 .and_then(|id| ctx.child_size(id, proposal))
908 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
909 .into()
910 }
911
912 fn place_children(
913 &self,
914 bounds: Rect,
915 _proposal: SizeProposal,
916 children: &mut [WidgetPlacement],
917 _ctx: &LayoutContext,
918 ) {
919 for child in children.iter_mut() {
920 child.origin = bounds.origin();
921 child.size = bounds.size();
922 }
923 }
924
925 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
926 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
933 }
934
935 fn children(&self) -> Vec<WidgetId> {
936 self.inner_id.into_iter().collect()
937 }
938}
939
940impl Widget for MenuBar {
945 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
946 if self.native_mode.installs_native()
949 && cfg!(target_os = "macos")
950 && let Some(model) = &self.model
951 {
952 *self.native_binding.borrow_mut() = crate::menu::native::install(model, ctx);
953 }
954
955 if let Some(model) = &self.model {
959 model.version().bind_to(
960 ctx.self_id(),
961 ctx.binding_registry(),
962 teksilo_core::BindingLevel::Rebuild,
963 );
964 }
965
966 if self.native_mode.suppresses_in_window() {
970 return self.build_suppressed(ctx);
971 }
972
973 let theme_signal = ctx.theme_signal();
974
975 let open_index: Signal<Option<usize>> = ctx.signal(None);
976 let menu_ctx = MenuContext::new(open_index);
977
978 let mut row = HStack::new().spacing(2.0);
980
981 row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
983
984 let mut trigger_ids = Vec::new();
986 let mut content_ids = Vec::new();
987 let mut mnemonic_table: HashMap<char, usize> = HashMap::new();
991
992 let model_entries = self.model.as_ref().map(Self::model_entries);
999 let entries: &[MenuBarEntry] = match &model_entries {
1000 Some(derived) => derived,
1001 None => &self.entries,
1002 };
1003 for (i, entry) in entries.iter().enumerate() {
1004 let parsed: ParsedMnemonic = parse_mnemonic(&entry.label.resolve_now());
1005
1006 let host = MenuOverlayHost {
1008 inner: Some((entry.factory)()),
1009 menu_ctx: menu_ctx.clone(),
1010 menu_index: i,
1011 inner_id: None,
1012 };
1013 let opened_here = menu_ctx.open_index.map(move |open| *open == Some(i));
1022 let content_id = ctx.add_detached_deferred(opened_here, host);
1023 ctx.set_dormant(content_id);
1024
1025 let trigger = MenuBarTrigger {
1026 label: entry.label.clone(),
1027 stripped_name: parsed.stripped.clone(),
1028 mnemonic_key: parsed.key_lower,
1029 index: i,
1030 menu_ctx: menu_ctx.clone(),
1031 root_child_id: None,
1032 };
1033 let trigger_id = ctx.add(trigger);
1034 row = row.add_child(trigger_id);
1035
1036 if let Some(k) = parsed.key_lower {
1037 if let Some(prev) = mnemonic_table.insert(k, i) {
1038 debug_assert!(
1039 false,
1040 "MenuBar: duplicate mnemonic {:?} (triggers {} and {})",
1041 k, prev, i
1042 );
1043 }
1044 }
1045
1046 trigger_ids.push(trigger_id);
1047 content_ids.push(content_id);
1048 }
1049
1050 for (i, (&tid, &cid)) in trigger_ids.iter().zip(content_ids.iter()).enumerate() {
1054 menu_ctx.register(i, tid, cid, cid);
1055 }
1056
1057 row = row.child(Spacer::new());
1059
1060 row = Self::add_slot(
1062 ctx,
1063 row,
1064 &mut self.trailing_slot,
1065 &mut self.trailing_slot_ids,
1066 );
1067
1068 let row_id = ctx.add(row);
1069
1070 let bg = RectWidget::new()
1071 .background(SurfaceRole::Main)
1072 .border_color(theme_signal.map(|t| t.colors.border.with_alpha(0.2)))
1073 .border_width(0.0_f32);
1074 let bg_id = ctx.add(bg);
1075
1076 let padding = Padding::symmetric(0.0, 2.0).child_id(row_id);
1077 let padding_id = ctx.add(padding);
1078
1079 let zstack_id = ctx.add(ZStack::new().add_child(bg_id).add_child(padding_id));
1080 let ham_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
1084 let root_id = if self.collapse_policy.is_some() {
1098 let height_box = ctx.add(RevealHeightBox {
1099 child_id: None,
1100 pending_child: Some(PendingChild::Id(zstack_id)),
1101 revealed: self.revealed.clone(),
1102 hamburger_id: ham_cell.clone(),
1103 });
1104 ctx.add(
1107 Unroll::from_progress(self.reveal_progress.clone())
1108 .child_id(height_box)
1109 .access_role(teksilo_core::accesskit::Role::MenuBar),
1110 )
1111 } else {
1112 zstack_id
1113 };
1114 self.root_child_id = Some(root_id);
1115 self.bar_id = Some(root_id);
1116
1117 let mut children = vec![root_id];
1121 let collapsible_reveal: Option<MenubarReveal> = if self.collapse_policy.is_some() {
1122 let bar_id = root_id;
1123 let revealed = self.revealed.clone();
1124 let collapsed = self.collapsed.clone();
1125 let reveal_progress = self.reveal_progress.clone();
1129 let reveal_duration = ctx.theme().motion.duration_collapse;
1130 let reduced_motion = ctx.prefers_reduced_motion();
1131
1132 let anchor_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
1137 let first_trigger = trigger_ids.first().copied();
1140
1141 let reveal: MenubarReveal = {
1142 let revealed = revealed.clone();
1143 let anchor_cell = anchor_cell.clone();
1144 let reveal_progress = reveal_progress.clone();
1145 Rc::new(move |ctx: &mut EventContext| {
1146 if revealed.get() {
1147 return; }
1149 revealed.set(true);
1150 ctx.activate(bar_id);
1151 let anchor = anchor_cell.get().unwrap_or(bar_id);
1152 let on_dismiss: Rc<dyn Fn()> = {
1153 let revealed = revealed.clone();
1154 Rc::new(move || revealed.set(false))
1155 };
1156 let request = OverlayRequest {
1157 content_id: bar_id,
1158 anchor,
1159 placement: OverlayPlacement::TrailingEdge,
1160 dismiss: DismissBehavior::EscapeOrClickOutside,
1161 layer: OverlayLayer::InTree,
1162 parent_overlay: None,
1163 on_dismiss: Some(on_dismiss),
1164 fade_duration: None,
1165 };
1166 if reduced_motion {
1167 reveal_progress.set(1.0);
1169 ctx.show_overlay(request);
1170 } else {
1171 reveal_progress.set(0.0);
1175 ctx.show_overlay_with_reveal(
1176 request,
1177 reveal_progress.clone(),
1178 reveal_duration,
1179 );
1180 }
1181 if let Some(trigger) = first_trigger {
1182 ctx.request_focus(trigger);
1183 }
1184 })
1185 };
1186
1187 let hamburger = IconButton::menu()
1192 .size(self.hamburger_size)
1193 .expanded_when(revealed.clone())
1194 .on_activate_fn({
1195 let reveal = reveal.clone();
1196 move |ctx| reveal(ctx)
1197 });
1198 let hamburger_id = ctx.add(hamburger);
1199 anchor_cell.set(Some(hamburger_id));
1200 ham_cell.set(Some(hamburger_id));
1203 self.hamburger_id = Some(hamburger_id);
1204
1205 ctx.visible_when(hamburger_id, collapsed.clone());
1207 let bar_active = collapsed.zip(&revealed).map(|(c, r)| !*c || *r);
1212 ctx.visible_when(bar_id, bar_active);
1213
1214 children.push(hamburger_id);
1215 Some(reveal)
1216 } else {
1217 None
1218 };
1219
1220 if self.install_dispatcher
1239 && let Some(window) = ctx.window()
1240 {
1241 *self.menubar_guard.borrow_mut() = None;
1242 let inner = MenuBarDispatcher {
1243 trigger_ids: trigger_ids.clone(),
1244 mnemonic_table,
1245 };
1246 let dispatcher: Rc<dyn MenubarDispatcher> = match collapsible_reveal {
1247 Some(reveal) => Rc::new(CollapsibleMenuBarDispatcher {
1248 inner,
1249 collapsed: self.collapsed.clone(),
1250 reveal,
1251 }),
1252 None => Rc::new(inner),
1253 };
1254 let guard = window.install_menubar_dispatcher(dispatcher);
1255 *self.menubar_guard.borrow_mut() = Some(guard);
1256 }
1257
1258 children
1259 }
1260
1261 fn layout_response(
1262 &self,
1263 proposal: SizeProposal,
1264 ctx: &LayoutContext,
1265 ) -> teksilo_core::widget::LayoutResponse {
1266 if self.collapse_policy.is_some() && self.collapsed.get() {
1269 return match self.hamburger_id {
1270 Some(id) => ctx
1271 .child_size(id, SizeProposal::unspecified())
1272 .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1273 None => proposal.resolve(0.0, 0.0),
1274 }
1275 .into();
1276 }
1277 match self.root_child_id {
1278 Some(id) => {
1279 let content_proposal = SizeProposal {
1280 width: proposal.width,
1281 height: None,
1282 };
1283 let size = ctx
1284 .child_size(id, content_proposal)
1285 .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1286 Size::new(proposal.width.unwrap_or(size.width), size.height)
1287 }
1288 None => proposal.resolve(0.0, 0.0),
1289 }
1290 .into()
1291 }
1292
1293 fn place_children(
1294 &self,
1295 bounds: Rect,
1296 proposal: SizeProposal,
1297 children: &mut [WidgetPlacement],
1298 ctx: &LayoutContext,
1299 ) {
1300 if let Some(policy) = self.collapse_policy {
1304 let should_collapse = match policy {
1305 CollapsePolicy::Always => true,
1306 CollapsePolicy::Responsive => {
1307 if self.revealed.get() {
1308 self.collapsed.get()
1311 } else if let (Some(bar_id), Some(avail)) = (self.bar_id, proposal.width) {
1312 ctx.measure_intrinsic(bar_id, SizeProposal::unspecified())
1313 .map(|s| s.width)
1314 .unwrap_or(0.0)
1315 > avail + 0.5
1316 } else {
1317 false
1319 }
1320 }
1321 };
1322 if self.last_collapsed.get() != should_collapse {
1323 self.last_collapsed.set(should_collapse);
1324 self.collapsed.set(should_collapse);
1325 }
1326 }
1327
1328 let collapsed = self.collapse_policy.is_some() && self.collapsed.get();
1333 for child in children.iter_mut() {
1334 if collapsed && Some(child.id) == self.hamburger_id {
1335 let size = ctx
1336 .measure_intrinsic(child.id, SizeProposal::unspecified())
1337 .unwrap_or_else(|| bounds.size());
1338 let x = if ctx.is_rtl() {
1339 bounds.right() - size.width
1340 } else {
1341 bounds.x
1342 };
1343 child.origin = Point::new(x, bounds.y);
1344 child.size = size;
1345 } else {
1346 child.origin = bounds.origin();
1347 child.size = bounds.size();
1348 }
1349 }
1350 }
1351
1352 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1353 if self.collapse_policy.is_none() {
1357 builder.set_role(teksilo_core::accesskit::Role::MenuBar);
1358 }
1359 }
1360
1361 fn children(&self) -> Vec<WidgetId> {
1362 let mut v: Vec<WidgetId> = self.root_child_id.into_iter().collect();
1363 if let Some(h) = self.hamburger_id {
1364 v.push(h);
1365 }
1366 v
1367 }
1368
1369 fn preserves_children_on_rebuild(&self) -> bool {
1377 true
1378 }
1379}
1380
1381#[derive(Debug)]
1393struct RevealHeightBox {
1394 child_id: Option<WidgetId>,
1395 pending_child: Option<PendingChild>,
1396 revealed: Signal<bool>,
1397 hamburger_id: Rc<Cell<Option<WidgetId>>>,
1401}
1402
1403impl Widget for RevealHeightBox {
1404 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1405 if let Some(pending) = self.pending_child.take() {
1406 self.child_id = Some(match pending {
1407 PendingChild::Id(id) => id,
1408 PendingChild::Deferred(w) => ctx.add_boxed(w),
1409 });
1410 }
1411 self.revealed.bind_to(
1414 ctx.self_id(),
1415 ctx.binding_registry(),
1416 teksilo_core::binding::BindingLevel::Relayout,
1417 );
1418 self.child_id.into_iter().collect()
1419 }
1420
1421 fn layout_response(
1422 &self,
1423 proposal: SizeProposal,
1424 ctx: &LayoutContext,
1425 ) -> teksilo_core::widget::LayoutResponse {
1426 let child = self.child_id;
1427 if self.revealed.get() {
1428 if let Some(ham) = self.hamburger_id.get() {
1429 if let Some(h) = ctx
1430 .measure_intrinsic(ham, SizeProposal::unspecified())
1431 .map(|s| s.height)
1432 {
1433 let child_w = child
1434 .and_then(|id| {
1435 ctx.child_size(
1436 id,
1437 SizeProposal {
1438 width: proposal.width,
1439 height: Some(h),
1440 },
1441 )
1442 })
1443 .map(|s| s.width)
1444 .unwrap_or(0.0);
1445 let w = proposal.width.unwrap_or(child_w);
1446 return Size::new(w, h).into();
1447 }
1448 }
1449 }
1450 child
1451 .and_then(|id| ctx.child_size(id, proposal))
1452 .unwrap_or(Size::ZERO)
1453 .into()
1454 }
1455
1456 fn place_children(
1457 &self,
1458 bounds: Rect,
1459 _proposal: SizeProposal,
1460 children: &mut [WidgetPlacement],
1461 _ctx: &LayoutContext,
1462 ) {
1463 for child in children.iter_mut() {
1464 child.origin = bounds.origin();
1465 child.size = bounds.size();
1466 }
1467 }
1468
1469 fn children(&self) -> Vec<WidgetId> {
1470 self.child_id.into_iter().collect()
1471 }
1472}
1473
1474#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 use crate::MenuItem;
1482 use crate::menu_list::MenuList;
1483 use teksilo_core::accesskit::Role;
1484 use teksilo_core::widget_id::WidgetId;
1485 use teksilo_core::widget_tree::WidgetTree;
1486 use teksilo_core::window::state::WindowStateInit;
1487 use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
1488 use teksilo_i18n::lit;
1489
1490 fn tree_with_window() -> WidgetTree {
1491 let mut t = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1492 t.set_window_state(WindowState::new(WindowStateInit {
1493 id: TeksiloWindowId::new(1),
1494 string_id: Some("test".to_string()),
1495 placement: WindowPlacement::Floating,
1496 title: "Test".to_string(),
1497 size: (800, 600),
1498 position: (0, 0),
1499 focused: false,
1500 resizable: true,
1501 always_on_top: false,
1502 }));
1503 t
1504 }
1505
1506 fn count_by_type(t: &WidgetTree, needle: &str) -> u32 {
1508 t.widget_type_histogram()
1509 .iter()
1510 .filter(|(name, _)| name.contains(needle))
1511 .map(|(_, n)| *n)
1512 .sum()
1513 }
1514
1515 #[derive(Debug)]
1519 struct SlotMarker;
1520 impl Widget for SlotMarker {
1521 fn layout_response(
1522 &self,
1523 proposal: SizeProposal,
1524 _ctx: &LayoutContext,
1525 ) -> teksilo_core::widget::LayoutResponse {
1526 proposal.resolve(12.0, 12.0).into()
1527 }
1528 }
1529
1530 #[derive(Debug)]
1534 struct CountingSlot {
1535 builds: std::rc::Rc<std::cell::Cell<u32>>,
1536 id_out: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
1537 }
1538 impl Widget for CountingSlot {
1539 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1540 self.builds.set(self.builds.get() + 1);
1541 self.id_out.set(Some(ctx.self_id()));
1542 vec![]
1543 }
1544 fn layout_response(
1545 &self,
1546 proposal: SizeProposal,
1547 _ctx: &LayoutContext,
1548 ) -> teksilo_core::widget::LayoutResponse {
1549 proposal.resolve(12.0, 12.0).into()
1550 }
1551 }
1552
1553 fn first_descendant_with_role(t: &WidgetTree, from: WidgetId, role: Role) -> Option<WidgetId> {
1554 let mut queue = std::collections::VecDeque::new();
1555 queue.push_back(from);
1556 while let Some(id) = queue.pop_front() {
1557 if t.accessibility_node(id).role() == role {
1558 return Some(id);
1559 }
1560 for child in t.children(id) {
1561 queue.push_back(child);
1562 }
1563 }
1564 None
1565 }
1566
1567 fn collect_descendants_with_role(t: &WidgetTree, from: WidgetId, role: Role) -> Vec<WidgetId> {
1568 let mut queue = std::collections::VecDeque::new();
1569 let mut out = Vec::new();
1570 queue.push_back(from);
1571 while let Some(id) = queue.pop_front() {
1572 if t.accessibility_node(id).role() == role {
1573 out.push(id);
1574 }
1575 for child in t.children(id) {
1576 queue.push_back(child);
1577 }
1578 }
1579 out
1580 }
1581
1582 fn light_menubar_trigger_glyph_rgb(use_model: bool) -> Vec<[u32; 3]> {
1588 let mut t = WidgetTree::new()
1589 .with_theme(teksilo_core::presets::intui::light())
1590 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1591 teksilo_canvas::MockTextBackend::new(),
1592 )));
1593 t.set_window_state(WindowState::new(WindowStateInit {
1594 id: TeksiloWindowId::new(1),
1595 string_id: Some("test".to_string()),
1596 placement: WindowPlacement::Floating,
1597 title: "Test".to_string(),
1598 size: (800, 600),
1599 position: (0, 0),
1600 focused: false,
1601 resizable: true,
1602 always_on_top: false,
1603 }));
1604 if use_model {
1605 let model = crate::menu::MenuModel::new()
1606 .menu(lit!("&File"), |m| m)
1607 .menu(lit!("&Edit"), |m| m);
1608 t.add(MenuBar::from_model(model));
1609 } else {
1610 t.add(
1611 MenuBar::new()
1612 .menu(lit!("&File"), || Box::new(MenuList::new()))
1613 .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1614 );
1615 }
1616 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1617 let frame = t.render();
1618 frame
1619 .glyphs
1620 .iter()
1621 .map(|g| {
1622 [
1623 (g.color[0] * 255.0).round() as u32,
1624 (g.color[1] * 255.0).round() as u32,
1625 (g.color[2] * 255.0).round() as u32,
1626 ]
1627 })
1628 .collect()
1629 }
1630
1631 #[test]
1646 fn trigger_labels_paint_in_active_theme_color() {
1647 let rgb_of = |c: teksilo_tokens::Color| {
1648 let a = c.to_array();
1649 [
1650 (a[0] * 255.0).round() as u32,
1651 (a[1] * 255.0).round() as u32,
1652 (a[2] * 255.0).round() as u32,
1653 ]
1654 };
1655 let light_rgb = rgb_of(teksilo_core::presets::intui::light().colors.text_primary);
1656 let dark_rgb = rgb_of(teksilo_core::presets::intui::dark().colors.text_primary);
1657 assert_ne!(
1658 light_rgb, dark_rgb,
1659 "presets must differ for this test to mean anything"
1660 );
1661
1662 for use_model in [false, true] {
1663 let glyphs = light_menubar_trigger_glyph_rgb(use_model);
1664 assert!(
1665 !glyphs.is_empty(),
1666 "expected trigger label glyphs (use_model={use_model})"
1667 );
1668 for rgb in &glyphs {
1669 assert_eq!(
1670 *rgb, light_rgb,
1671 "trigger label glyph must use the active (light) theme's text_primary, \
1672 not a stale constructor-default theme (use_model={use_model})"
1673 );
1674 }
1675 }
1676 }
1677
1678 struct EvictingTextBackend {
1685 inner: teksilo_canvas::MockTextBackend,
1686 evicted: std::rc::Rc<std::cell::Cell<bool>>,
1687 }
1688
1689 impl teksilo_canvas::TextBackend for EvictingTextBackend {
1690 fn layout_single_line(
1691 &mut self,
1692 text: &str,
1693 style: &teksilo_tokens::TextStyle,
1694 max_width: Option<f32>,
1695 ) -> teksilo_canvas::TextLayout {
1696 self.evicted.set(false);
1698 self.inner.layout_single_line(text, style, max_width)
1699 }
1700
1701 fn ensure_glyphs(
1702 &mut self,
1703 layout: &teksilo_canvas::TextLayout,
1704 ) -> Vec<teksilo_canvas::GlyphQuad> {
1705 if self.evicted.get() {
1706 Vec::new()
1707 } else {
1708 self.inner.ensure_glyphs(layout)
1709 }
1710 }
1711 }
1712
1713 #[test]
1722 fn trigger_labels_survive_glyph_cache_eviction() {
1723 let evicted = std::rc::Rc::new(std::cell::Cell::new(false));
1724 let backend = EvictingTextBackend {
1725 inner: teksilo_canvas::MockTextBackend::new(),
1726 evicted: evicted.clone(),
1727 };
1728 let mut t = WidgetTree::new()
1729 .with_theme(teksilo_core::presets::intui::light())
1730 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
1731 t.set_window_state(WindowState::new(WindowStateInit {
1732 id: TeksiloWindowId::new(1),
1733 string_id: Some("test".to_string()),
1734 placement: WindowPlacement::Floating,
1735 title: "Test".to_string(),
1736 size: (800, 600),
1737 position: (0, 0),
1738 focused: false,
1739 resizable: true,
1740 always_on_top: false,
1741 }));
1742 t.add(
1743 MenuBar::new()
1744 .menu(lit!("&File"), || Box::new(MenuList::new()))
1745 .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1746 );
1747 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1748 let glyphs_initial = t.render().glyphs.len();
1749 assert!(glyphs_initial > 0, "trigger labels must render initially");
1750
1751 evicted.set(true);
1755 t.invalidate_all_paints();
1756 let glyphs_after = t.render().glyphs.len();
1757 assert!(
1758 glyphs_after > 0,
1759 "trigger labels must survive glyph-cache eviction (re-shape fallback); \
1760 got {glyphs_after} glyphs after eviction"
1761 );
1762 }
1763
1764 #[test]
1765 fn menubar_emits_role_menubar() {
1766 let mut t = tree_with_window();
1767 let mb = t.add(
1768 MenuBar::new()
1769 .menu(lit!("&File"), || Box::new(MenuList::new()))
1770 .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1771 );
1772 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1773 assert_eq!(t.accessibility_node(mb).role(), Role::MenuBar);
1774 }
1775
1776 #[test]
1777 fn trigger_uses_stripped_name_in_at() {
1778 let mut t = tree_with_window();
1779 let mb = t.add(
1780 MenuBar::new()
1781 .menu(lit!("&File"), || Box::new(MenuList::new()))
1782 .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1783 );
1784 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1785 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
1786 assert_eq!(triggers.len(), 2);
1787 let info0 = t.accessibility_node(triggers[0]);
1789 let info1 = t.accessibility_node(triggers[1]);
1790 assert_eq!(info0.name(), Some("File"));
1791 assert_eq!(info1.name(), Some("Edit"));
1792 }
1793
1794 #[test]
1795 fn trigger_arrow_navigation_ltr_right_goes_to_next() {
1796 let mut t = tree_with_window();
1797 let mb = t.add(
1798 MenuBar::new()
1799 .menu(lit!("&File"), || Box::new(MenuList::new()))
1800 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
1801 .menu(lit!("&View"), || Box::new(MenuList::new())),
1802 );
1803 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1804 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
1805 assert_eq!(triggers.len(), 3);
1806
1807 t.focus(triggers[0]);
1809 t.press_key(Key::ArrowRight, Modifiers::NONE);
1810 assert!(t.accessibility_node(triggers[1]).is_expanded());
1811 assert!(!t.accessibility_node(triggers[0]).is_expanded());
1812 }
1813
1814 #[test]
1815 fn trigger_arrow_navigation_rtl_right_goes_to_previous() {
1816 let mut t = tree_with_window();
1817 t.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1818 let mb = t.add(
1819 MenuBar::new()
1820 .menu(lit!("&File"), || Box::new(MenuList::new()))
1821 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
1822 .menu(lit!("&View"), || Box::new(MenuList::new())),
1823 );
1824 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1825 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
1826 assert_eq!(triggers.len(), 3);
1827
1828 t.focus(triggers[0]);
1831 t.press_key(Key::ArrowRight, Modifiers::NONE);
1832 assert!(t.accessibility_node(triggers[2]).is_expanded());
1833 assert!(!t.accessibility_node(triggers[0]).is_expanded());
1834 }
1835
1836 #[test]
1837 fn dispatcher_installed_on_every_platform() {
1838 let mut t = tree_with_window();
1839 t.add(
1840 MenuBar::new()
1841 .menu(lit!("&File"), || Box::new(MenuList::new()))
1842 .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1843 );
1844 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1845 let window = t.window_state().expect("window state attached");
1846 assert!(
1847 window.menubar_dispatcher().is_some(),
1848 "MenuBar should install the window-level dispatcher on every \
1849 platform — framework menus aren't the OS system menu and need \
1850 keyboard accelerators wired regardless of host OS"
1851 );
1852 }
1853
1854 #[test]
1855 fn rebuilding_menubar_does_not_double_install_dispatcher() {
1856 let mut t = tree_with_window();
1863 let mb = t.add(
1864 MenuBar::new()
1865 .menu(lit!("&File"), || Box::new(MenuList::new()))
1866 .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1867 );
1868 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1869 assert!(t.window_state().unwrap().menubar_dispatcher().is_some());
1870 assert_eq!(
1874 count_by_type(&t, "MenuBarTrigger"),
1875 2,
1876 "two menus before rebuild"
1877 );
1878 t.arena_mark_needs_rebuild_for_testing(mb);
1882 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1883 assert!(
1884 t.window_state().unwrap().menubar_dispatcher().is_some(),
1885 "after rebuild the dispatcher slot must still point at \
1886 the most-recently-installed dispatcher"
1887 );
1888 assert_eq!(
1889 count_by_type(&t, "MenuBarTrigger"),
1890 2,
1891 "classic .menu() bar must keep its menus across a rebuild \
1892 (regression: build() used to mem::take the entries, leaving \
1893 an empty bar on the next theme/locale rebuild)"
1894 );
1895 }
1896
1897 #[test]
1898 fn menubar_slots_survive_rebuild() {
1899 let mut t = tree_with_window();
1905 let mb = t.add(
1906 MenuBar::new()
1907 .menu(lit!("&File"), || Box::new(MenuList::new()))
1908 .leading_slot(SlotMarker)
1909 .trailing_slot(SlotMarker),
1910 );
1911 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1912 assert_eq!(
1913 count_by_type(&t, "SlotMarker"),
1914 2,
1915 "both slots before rebuild"
1916 );
1917 assert_eq!(count_by_type(&t, "MenuBarTrigger"), 1);
1918
1919 t.arena_mark_needs_rebuild_for_testing(mb);
1920 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1921 assert_eq!(
1922 count_by_type(&t, "SlotMarker"),
1923 2,
1924 "leading + trailing slots must survive a rebuild"
1925 );
1926 assert_eq!(count_by_type(&t, "MenuBarTrigger"), 1, "menu survives too");
1927 }
1928
1929 #[test]
1930 fn model_menubar_slots_survive_first_layout_self_rebuild() {
1931 let file = teksilo_core::MenuItemId::next();
1937 let model = crate::menu::MenuModel::new().menu_with_id(file, lit!("File"), |m| {
1938 m.item(crate::menu::MenuEntry::new(lit!("New")))
1939 });
1940 let mut t = tree_with_window();
1941 let _mb = t.add(
1942 MenuBar::from_model(model.clone())
1943 .leading_slot(SlotMarker)
1944 .trailing_slot(SlotMarker),
1945 );
1946 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1947 assert_eq!(
1948 count_by_type(&t, "SlotMarker"),
1949 2,
1950 "model bar's slots must survive the self-rebuild on first layout"
1951 );
1952
1953 model.push_item(file, crate::menu::MenuEntry::new(lit!("Open")));
1955 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1956 assert_eq!(
1957 count_by_type(&t, "SlotMarker"),
1958 2,
1959 "slots survive a model-mutation rebuild too"
1960 );
1961 }
1962
1963 #[test]
1964 fn model_menubar_preserves_stateful_slot_across_rebuild() {
1965 let builds = std::rc::Rc::new(std::cell::Cell::new(0u32));
1972 let id_out = std::rc::Rc::new(std::cell::Cell::new(None));
1973 let file = teksilo_core::MenuItemId::next();
1974 let model = crate::menu::MenuModel::new().menu_with_id(file, lit!("File"), |m| {
1975 m.item(crate::menu::MenuEntry::new(lit!("New")))
1976 });
1977 let mut t = tree_with_window();
1978 t.add(
1979 MenuBar::from_model(model.clone()).leading_slot(CountingSlot {
1980 builds: builds.clone(),
1981 id_out: id_out.clone(),
1982 }),
1983 );
1984 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1985 let first_id = id_out.get().expect("slot built");
1986 assert_eq!(builds.get(), 1, "slot built exactly once initially");
1987 assert_eq!(count_by_type(&t, "MenuBarTrigger"), 1);
1988
1989 model.push_menu(lit!("Edit"), |m| {
1991 m.item(crate::menu::MenuEntry::new(lit!("Undo")))
1992 });
1993 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1994
1995 assert_eq!(
1996 count_by_type(&t, "MenuBarTrigger"),
1997 2,
1998 "the bar rebuilt (a second menu trigger appeared)"
1999 );
2000 assert_eq!(
2001 builds.get(),
2002 1,
2003 "the stateful slot was preserved, not rebuilt, across the rebuild"
2004 );
2005 assert_eq!(
2006 id_out.get(),
2007 Some(first_id),
2008 "the slot kept its identity (same widget instance)"
2009 );
2010 }
2011
2012 #[test]
2013 fn windowstate_dispatcher_slot_reinstall_after_guard_drop() {
2014 use teksilo_core::window::{MenubarAction, MenubarDispatcher, MenubarKeyEvent};
2018
2019 struct Noop;
2020 impl MenubarDispatcher for Noop {
2021 fn try_handle(&self, _ev: &MenubarKeyEvent) -> Option<MenubarAction> {
2022 None
2023 }
2024 }
2025
2026 let mut t = tree_with_window();
2027 let window = t.window_state().unwrap().clone();
2028 let guard_a = window.install_menubar_dispatcher(Rc::new(Noop));
2029 assert!(window.menubar_dispatcher().is_some());
2030 drop(guard_a);
2031 assert!(
2032 window.menubar_dispatcher().is_none(),
2033 "dropping the guard must clear the slot"
2034 );
2035 let _guard_b = window.install_menubar_dispatcher(Rc::new(Noop));
2036 assert!(
2037 window.menubar_dispatcher().is_some(),
2038 "second install after first guard's drop must succeed without an assert"
2039 );
2040 let _ = &mut t;
2041 }
2042
2043 fn fake_id(n: u64) -> WidgetId {
2049 slotmap::KeyData::from_ffi(n).into()
2050 }
2051
2052 fn make_dispatcher() -> MenuBarDispatcher {
2053 let mut mnemonic_table = HashMap::new();
2054 mnemonic_table.insert('f', 0);
2055 mnemonic_table.insert('e', 1);
2056 mnemonic_table.insert('v', 2);
2057 MenuBarDispatcher {
2058 trigger_ids: vec![fake_id(10), fake_id(11), fake_id(12)],
2059 mnemonic_table,
2060 }
2061 }
2062
2063 #[test]
2064 fn dispatcher_f10_focuses_first_trigger() {
2065 let d = make_dispatcher();
2066 let action = d.try_handle(&MenubarKeyEvent {
2067 key: Key::F10,
2068 modifiers: Modifiers::NONE,
2069 });
2070 assert!(matches!(
2071 action,
2072 Some(MenubarAction::FocusTrigger { trigger_id, .. }) if trigger_id == fake_id(10)
2073 ));
2074 }
2075
2076 #[test]
2077 fn dispatcher_f10_with_modifier_ignored() {
2078 let d = make_dispatcher();
2079 let action = d.try_handle(&MenubarKeyEvent {
2080 key: Key::F10,
2081 modifiers: Modifiers::CTRL,
2082 });
2083 assert!(action.is_none());
2084 }
2085
2086 #[cfg(not(target_os = "macos"))]
2091 #[test]
2092 fn dispatcher_alt_letter_opens_matching_menu() {
2093 let d = make_dispatcher();
2094 let action = d.try_handle(&MenubarKeyEvent {
2095 key: Key::F,
2096 modifiers: Modifiers::ALT,
2097 });
2098 assert!(matches!(
2099 action,
2100 Some(MenubarAction::OpenMenu { trigger_id, .. }) if trigger_id == fake_id(10)
2101 ));
2102 }
2103
2104 #[cfg(not(target_os = "macos"))]
2105 #[test]
2106 fn dispatcher_alt_letter_no_match_intercepts() {
2107 let d = make_dispatcher();
2108 let action = d.try_handle(&MenubarKeyEvent {
2109 key: Key::Q,
2110 modifiers: Modifiers::ALT,
2111 });
2112 assert!(matches!(action, Some(MenubarAction::Intercept)));
2113 }
2114
2115 #[test]
2116 fn dispatcher_alt_unrelated_key_ignored() {
2117 let d = make_dispatcher();
2122 let action = d.try_handle(&MenubarKeyEvent {
2123 key: Key::F,
2124 modifiers: Modifiers::CTRL,
2125 });
2126 assert!(action.is_none());
2127 }
2128
2129 #[cfg(not(target_os = "macos"))]
2130 #[test]
2131 fn dispatcher_case_insensitive_alt_letter() {
2132 let d = make_dispatcher();
2133 let lower = d.try_handle(&MenubarKeyEvent {
2135 key: Key::Character('f'),
2136 modifiers: Modifiers::ALT,
2137 });
2138 let upper = d.try_handle(&MenubarKeyEvent {
2139 key: Key::Character('F'),
2140 modifiers: Modifiers::ALT,
2141 });
2142 assert!(matches!(lower, Some(MenubarAction::OpenMenu { .. })));
2143 assert!(matches!(upper, Some(MenubarAction::OpenMenu { .. })));
2144 }
2145
2146 #[cfg(target_os = "macos")]
2147 #[test]
2148 fn dispatcher_alt_letter_does_not_intercept_on_macos() {
2149 let d = make_dispatcher();
2153 let action = d.try_handle(&MenubarKeyEvent {
2154 key: Key::F,
2155 modifiers: Modifiers::ALT,
2156 });
2157 assert!(
2158 action.is_none(),
2159 "macOS: Alt+letter must fall through to focus dispatch \
2160 so accented character input still works in text fields"
2161 );
2162 }
2163
2164 #[test]
2165 fn dispatcher_alt_tap_focuses_first_trigger() {
2166 let d = make_dispatcher();
2167 let action = d.on_alt_tap();
2168 assert!(matches!(
2169 action,
2170 Some(MenubarAction::FocusTrigger { trigger_id, .. }) if trigger_id == fake_id(10)
2171 ));
2172 }
2173
2174 #[test]
2175 fn dispatcher_alt_tap_with_no_triggers_is_none() {
2176 let d = MenuBarDispatcher {
2177 trigger_ids: Vec::new(),
2178 mnemonic_table: HashMap::new(),
2179 };
2180 assert!(d.on_alt_tap().is_none());
2181 assert!(
2182 d.try_handle(&MenubarKeyEvent {
2183 key: Key::F10,
2184 modifiers: Modifiers::NONE,
2185 })
2186 .is_none()
2187 );
2188 }
2189
2190 fn collapsible_tree() -> WidgetTree {
2193 let mut t = WidgetTree::new()
2194 .with_theme(teksilo_core::presets::intui::light())
2195 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
2196 teksilo_canvas::MockTextBackend::new(),
2197 )));
2198 t.set_window_state(WindowState::new(WindowStateInit {
2199 id: TeksiloWindowId::new(1),
2200 string_id: Some("test".to_string()),
2201 placement: WindowPlacement::Floating,
2202 title: "Test".to_string(),
2203 size: (800, 600),
2204 position: (0, 0),
2205 focused: false,
2206 resizable: true,
2207 always_on_top: false,
2208 }));
2209 t
2210 }
2211
2212 #[test]
2213 fn collapsible_always_shows_hamburger() {
2214 let mut t = collapsible_tree();
2215 let mb_widget = MenuBar::new()
2216 .menu(lit!("&File"), || Box::new(MenuList::new()))
2217 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2218 .collapse_policy(CollapsePolicy::Always);
2219 let collapsed = mb_widget.is_collapsed();
2220 let mb = t.add(mb_widget);
2221 t.layout(SizeProposal::exact(800.0, 100.0));
2223 t.layout(SizeProposal::exact(800.0, 100.0));
2224 assert!(collapsed.get(), "Always policy must collapse to hamburger");
2225 let children = t.children(mb);
2226 assert_eq!(children.len(), 2, "[bar, hamburger]");
2227 let (bar, hamburger) = (children[0], children[1]);
2228 assert!(t.is_active(hamburger), "hamburger active when collapsed");
2229 assert!(
2230 !t.is_active(bar),
2231 "bar dormant when collapsed and not revealed"
2232 );
2233 }
2234
2235 #[test]
2238 fn collapsible_hamburger_keeps_constant_width_in_wide_slot() {
2239 use crate::primitives::FixedSize;
2240 let mut t = collapsible_tree();
2241 let mb = t.add(
2242 MenuBar::new()
2243 .menu(lit!("&File"), || Box::new(MenuList::new()))
2244 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2245 .collapse_policy(CollapsePolicy::Always),
2246 );
2247 let _slot = t.add(FixedSize::new().width(600.0_f32).child_id(mb));
2249 t.layout(SizeProposal::exact(800.0, 100.0));
2250 t.layout(SizeProposal::exact(800.0, 100.0));
2251
2252 let hamburger = t.children(mb)[1];
2253 let hw = t.bounds(hamburger).width;
2254 assert!(
2255 hw > 0.0 && hw < 200.0,
2256 "hamburger width {hw} must stay compact, not fill the 600px slot"
2257 );
2258 }
2259
2260 #[test]
2261 fn collapsible_responsive_collapses_when_narrow() {
2262 let mut t = collapsible_tree();
2263 let mb_widget = MenuBar::new()
2264 .menu(lit!("&File"), || Box::new(MenuList::new()))
2265 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2266 .menu(lit!("&View"), || Box::new(MenuList::new()))
2267 .collapsible();
2268 let collapsed = mb_widget.is_collapsed();
2269 let _mb = t.add(mb_widget);
2270 t.layout(SizeProposal::exact(40.0, 100.0));
2271 t.layout(SizeProposal::exact(40.0, 100.0));
2272 assert!(collapsed.get(), "narrow width must collapse to hamburger");
2273 }
2274
2275 #[test]
2276 fn collapsible_responsive_expands_when_wide() {
2277 let mut t = collapsible_tree();
2278 let mb_widget = MenuBar::new()
2279 .menu(lit!("&File"), || Box::new(MenuList::new()))
2280 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2281 .collapsible();
2282 let collapsed = mb_widget.is_collapsed();
2283 let mb = t.add(mb_widget);
2284 t.layout(SizeProposal::exact(800.0, 100.0));
2285 t.layout(SizeProposal::exact(800.0, 100.0));
2286 assert!(!collapsed.get(), "wide width must show the inline bar");
2287 let children = t.children(mb);
2288 assert!(t.is_active(children[0]), "bar active inline when wide");
2289 assert!(
2290 !t.is_active(children[1]),
2291 "hamburger dormant when bar is inline"
2292 );
2293 }
2294
2295 #[test]
2298 fn collapsible_responsive_toggles_with_width() {
2299 let mut t = collapsible_tree();
2300 let mb_widget = MenuBar::new()
2301 .menu(lit!("&File"), || Box::new(MenuList::new()))
2302 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2303 .menu(lit!("&View"), || Box::new(MenuList::new()))
2304 .collapsible();
2305 let collapsed = mb_widget.is_collapsed();
2306 let _mb = t.add(mb_widget);
2307
2308 t.layout(SizeProposal::exact(800.0, 100.0));
2309 t.layout(SizeProposal::exact(800.0, 100.0));
2310 assert!(!collapsed.get(), "wide → inline");
2311
2312 t.layout(SizeProposal::exact(30.0, 100.0));
2313 t.layout(SizeProposal::exact(30.0, 100.0));
2314 assert!(collapsed.get(), "narrow → hamburger");
2315
2316 t.layout(SizeProposal::exact(800.0, 100.0));
2317 t.layout(SizeProposal::exact(800.0, 100.0));
2318 assert!(!collapsed.get(), "wide again → inline");
2319 }
2320
2321 #[test]
2322 fn collapsible_click_hamburger_reveals_bar_overlay() {
2323 let mut t = collapsible_tree();
2324 let mb = t.add(
2325 MenuBar::new()
2326 .menu(lit!("&File"), || Box::new(MenuList::new()))
2327 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2328 .collapse_policy(CollapsePolicy::Always),
2329 );
2330 t.layout(SizeProposal::exact(800.0, 100.0));
2331 t.layout(SizeProposal::exact(800.0, 100.0));
2332 let children = t.children(mb);
2333 let (bar, hamburger) = (children[0], children[1]);
2334 assert!(!t.is_active(bar), "bar hidden before reveal");
2335
2336 t.click(hamburger);
2337 t.layout(SizeProposal::exact(800.0, 100.0));
2338 assert!(t.is_active(bar), "clicking the hamburger reveals the bar");
2339 }
2340
2341 #[test]
2342 fn collapsible_reveal_focuses_first_trigger() {
2343 let mut t = collapsible_tree();
2344 let mb = t.add(
2345 MenuBar::new()
2346 .menu(lit!("&File"), || Box::new(MenuList::new()))
2347 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2348 .collapse_policy(CollapsePolicy::Always),
2349 );
2350 t.layout(SizeProposal::exact(800.0, 100.0));
2351 t.layout(SizeProposal::exact(800.0, 100.0));
2352 let hamburger = t.children(mb)[1];
2353
2354 t.click(hamburger);
2355 t.layout(SizeProposal::exact(800.0, 100.0));
2356
2357 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2358 assert_eq!(triggers.len(), 2);
2359 assert_eq!(
2360 t.focused(),
2361 Some(triggers[0]),
2362 "revealing the bar focuses the first menu trigger"
2363 );
2364 }
2365
2366 #[test]
2371 fn collapsible_revealed_bar_left_navigates_not_closes() {
2372 let menu = |label: &'static str| {
2373 move || -> Box<dyn Widget> {
2374 Box::new(MenuList::new().item(crate::menu_item::MenuItem::new(lit!(label))))
2375 }
2376 };
2377 let mut t = collapsible_tree();
2378 let mb = t.add(
2379 MenuBar::new()
2380 .menu(lit!("&File"), menu("New"))
2381 .menu(lit!("&Edit"), menu("Undo"))
2382 .menu(lit!("&View"), menu("Zoom"))
2383 .collapse_policy(CollapsePolicy::Always),
2384 );
2385 t.layout(SizeProposal::exact(800.0, 100.0));
2386 t.layout(SizeProposal::exact(800.0, 100.0));
2387 let hamburger = t.children(mb)[1];
2388 t.click(hamburger);
2389 t.layout(SizeProposal::exact(800.0, 100.0));
2390 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2391 let expanded = |t: &WidgetTree| -> Vec<bool> {
2392 triggers
2393 .iter()
2394 .map(|&id| t.accessibility_node(id).is_expanded())
2395 .collect()
2396 };
2397
2398 t.press_key(Key::ArrowRight, Modifiers::NONE);
2400 t.layout(SizeProposal::exact(800.0, 100.0));
2401 t.press_key(Key::ArrowRight, Modifiers::NONE);
2402 t.layout(SizeProposal::exact(800.0, 100.0));
2403 assert_eq!(expanded(&t), vec![false, false, true], "RIGHT reached View");
2404
2405 t.press_key(Key::ArrowLeft, Modifiers::NONE);
2407 t.layout(SizeProposal::exact(800.0, 100.0));
2408 assert_eq!(
2409 expanded(&t),
2410 vec![false, true, false],
2411 "LEFT navigates to the previous menu (Edit), not closes"
2412 );
2413
2414 t.press_key(Key::ArrowLeft, Modifiers::NONE);
2416 t.layout(SizeProposal::exact(800.0, 100.0));
2417 assert_eq!(
2418 expanded(&t),
2419 vec![true, false, false],
2420 "LEFT again reaches File"
2421 );
2422 }
2423
2424 #[test]
2428 fn collapsible_hamburger_accessibility() {
2429 let mut t = collapsible_tree();
2430 let mb = t.add(
2431 MenuBar::new()
2432 .menu(lit!("&File"), || Box::new(MenuList::new()))
2433 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2434 .collapse_policy(CollapsePolicy::Always),
2435 );
2436 t.layout(SizeProposal::exact(800.0, 100.0));
2437 t.layout(SizeProposal::exact(800.0, 100.0));
2438 let hamburger = t.children(mb)[1];
2439
2440 let info = t.accessibility_node(hamburger);
2442 assert_eq!(info.role(), Role::Button);
2443 assert!(
2444 !info.is_expanded(),
2445 "collapsed hamburger reports expanded=false"
2446 );
2447
2448 t.click(hamburger);
2450 t.layout(SizeProposal::exact(800.0, 100.0));
2451 assert!(
2452 t.accessibility_node(hamburger).is_expanded(),
2453 "revealed hamburger reports expanded=true"
2454 );
2455 assert!(first_descendant_with_role(&t, mb, Role::MenuBar).is_some());
2456
2457 t.press_key(Key::Escape, Modifiers::NONE);
2461 t.advance_time(std::time::Duration::from_secs(1));
2462 t.layout(SizeProposal::exact(800.0, 100.0));
2463 assert!(
2464 !t.accessibility_node(hamburger).is_expanded(),
2465 "collapsed again after Escape"
2466 );
2467 assert_eq!(
2468 t.focused(),
2469 Some(hamburger),
2470 "focus returns to the hamburger after the bar is dismissed"
2471 );
2472 }
2473
2474 #[test]
2480 fn collapsible_revealed_bar_survives_arrow_navigation() {
2481 let mut t = collapsible_tree();
2482 let mb = t.add(
2483 MenuBar::new()
2484 .menu(lit!("&File"), || Box::new(MenuList::new()))
2485 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2486 .menu(lit!("&View"), || Box::new(MenuList::new()))
2487 .collapse_policy(CollapsePolicy::Always),
2488 );
2489 t.layout(SizeProposal::exact(800.0, 100.0));
2490 t.layout(SizeProposal::exact(800.0, 100.0));
2491 let (bar, hamburger) = (t.children(mb)[0], t.children(mb)[1]);
2492
2493 t.click(hamburger);
2494 t.layout(SizeProposal::exact(800.0, 100.0));
2495 assert!(t.is_active(bar), "bar revealed");
2496 t.tick_animations(std::time::Duration::from_millis(500));
2499 t.layout(SizeProposal::exact(800.0, 100.0));
2500
2501 t.press_key(Key::ArrowRight, Modifiers::NONE);
2503 t.layout(SizeProposal::exact(800.0, 100.0));
2504
2505 assert!(
2506 t.is_active(bar),
2507 "bar must stay visible while navigating between menus"
2508 );
2509 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2512 let b = t.bounds(triggers[1]);
2513 assert!(
2514 b.width > 0.0 && (b.x > 0.0 || b.y > 0.0),
2515 "trigger stays laid out in the floating bar, not collapsed to the origin: {b:?}"
2516 );
2517 }
2518
2519 #[test]
2520 fn revealed_bar_height_matches_hamburger() {
2521 let mut t = collapsible_tree();
2522 let mb = t.add(
2523 MenuBar::new()
2524 .menu(lit!("&File"), || Box::new(MenuList::new()))
2525 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2526 .collapse_policy(CollapsePolicy::Always)
2527 .hamburger_size(IconButtonSize::Toolbar),
2528 );
2529 t.layout(SizeProposal::exact(800.0, 100.0));
2530 t.layout(SizeProposal::exact(800.0, 100.0));
2531 let (bar, hamburger) = (t.children(mb)[0], t.children(mb)[1]);
2532
2533 t.click(hamburger);
2534 t.layout(SizeProposal::exact(800.0, 100.0));
2535 assert!(t.is_active(bar), "bar revealed");
2536
2537 let ham_h = t.bounds(hamburger).height;
2538 let bar_h = t.bounds(bar).height;
2539 assert!(ham_h > 0.0, "hamburger laid out: {ham_h}");
2540 assert!(
2541 (bar_h - ham_h).abs() < 0.5,
2542 "floating bar height ({bar_h}) matches the hamburger ({ham_h})"
2543 );
2544 }
2545
2546 #[test]
2547 fn revealed_bar_unrolls_open_and_defers_close() {
2548 let mut t = collapsible_tree();
2549 let mb = t.add(
2550 MenuBar::new()
2551 .menu(lit!("&File"), || Box::new(MenuList::new()))
2552 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2553 .menu(lit!("&View"), || Box::new(MenuList::new()))
2554 .collapse_policy(CollapsePolicy::Always),
2555 );
2556 t.layout(SizeProposal::exact(800.0, 100.0));
2557 t.layout(SizeProposal::exact(800.0, 100.0));
2558 let (bar, hamburger) = (t.children(mb)[0], t.children(mb)[1]);
2559
2560 t.click(hamburger);
2562 t.layout(SizeProposal::exact(800.0, 100.0));
2563 let just_opened = t.bounds(bar).width;
2564 t.tick_animations(std::time::Duration::from_millis(500));
2565 t.layout(SizeProposal::exact(800.0, 100.0));
2566 let unrolled = t.bounds(bar).width;
2567 assert!(
2568 unrolled > just_opened + 1.0,
2569 "bar unrolls wider after the tween: {just_opened} -> {unrolled}"
2570 );
2571
2572 t.press_key(Key::Escape, Modifiers::NONE);
2575 t.layout(SizeProposal::exact(800.0, 100.0));
2576 assert!(
2577 t.is_active(bar),
2578 "bar stays active while rolling back on close"
2579 );
2580 t.advance_time(std::time::Duration::from_secs(1));
2581 t.layout(SizeProposal::exact(800.0, 100.0));
2582 assert!(
2583 !t.is_active(bar),
2584 "bar dormant after the roll-back finishes"
2585 );
2586 }
2587
2588 #[test]
2589 fn collapsible_escape_hides_revealed_bar() {
2590 let mut t = collapsible_tree();
2591 let mb = t.add(
2592 MenuBar::new()
2593 .menu(lit!("&File"), || Box::new(MenuList::new()))
2594 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2595 .collapse_policy(CollapsePolicy::Always),
2596 );
2597 t.layout(SizeProposal::exact(800.0, 100.0));
2598 t.layout(SizeProposal::exact(800.0, 100.0));
2599 let children = t.children(mb);
2600 let (bar, hamburger) = (children[0], children[1]);
2601
2602 t.click(hamburger);
2603 t.layout(SizeProposal::exact(800.0, 100.0));
2604 assert!(t.is_active(bar));
2605
2606 t.press_key(Key::Escape, Modifiers::NONE);
2607 t.advance_time(std::time::Duration::from_secs(1));
2610 t.layout(SizeProposal::exact(800.0, 100.0));
2611 assert!(!t.is_active(bar), "Escape hides the revealed bar");
2612 }
2613
2614 #[test]
2615 fn collapsible_click_outside_hides_revealed_bar() {
2616 let mut t = collapsible_tree();
2617 let mb = t.add(
2618 MenuBar::new()
2619 .menu(lit!("&File"), || Box::new(MenuList::new()))
2620 .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2621 .collapse_policy(CollapsePolicy::Always),
2622 );
2623 t.layout(SizeProposal::exact(800.0, 100.0));
2624 t.layout(SizeProposal::exact(800.0, 100.0));
2625 let children = t.children(mb);
2626 let (bar, hamburger) = (children[0], children[1]);
2627
2628 t.click(hamburger);
2629 t.layout(SizeProposal::exact(800.0, 100.0));
2630 assert!(t.is_active(bar));
2631
2632 t.pointer_down_button(
2634 teksilo_canvas::Point::new(400.0, 400.0),
2635 teksilo_core::event::PointerButton::Primary,
2636 );
2637 t.advance_time(std::time::Duration::from_secs(1));
2639 t.layout(SizeProposal::exact(800.0, 100.0));
2640 assert!(!t.is_active(bar), "click outside hides the revealed bar");
2641 }
2642
2643 #[test]
2644 fn collapsible_bar_carries_menubar_role() {
2645 let mut t = collapsible_tree();
2646 let mb = t.add(
2647 MenuBar::new()
2648 .menu(lit!("&File"), || Box::new(MenuList::new()))
2649 .collapsible(),
2650 );
2651 t.layout(SizeProposal::exact(800.0, 100.0));
2652 t.layout(SizeProposal::exact(800.0, 100.0));
2653 assert!(
2657 first_descendant_with_role(&t, mb, Role::MenuBar).is_some(),
2658 "the bar content node carries Role::MenuBar"
2659 );
2660 }
2661
2662 #[test]
2663 fn collapsible_dispatcher_injects_reveal_only_when_collapsed() {
2664 let collapsed = Signal::new(true);
2665 let reveal: MenubarReveal = std::rc::Rc::new(|_| {});
2666 let d = CollapsibleMenuBarDispatcher {
2667 inner: MenuBarDispatcher {
2668 trigger_ids: vec![fake_id(10)],
2669 mnemonic_table: HashMap::new(),
2670 },
2671 collapsed: collapsed.clone(),
2672 reveal,
2673 };
2674
2675 let action = d.try_handle(&MenubarKeyEvent {
2676 key: Key::F10,
2677 modifiers: Modifiers::NONE,
2678 });
2679 assert!(
2680 matches!(
2681 action,
2682 Some(MenubarAction::FocusTrigger {
2683 reveal: Some(_),
2684 ..
2685 })
2686 ),
2687 "collapsed → reveal attached"
2688 );
2689
2690 collapsed.set(false);
2691 let action = d.try_handle(&MenubarKeyEvent {
2692 key: Key::F10,
2693 modifiers: Modifiers::NONE,
2694 });
2695 assert!(
2696 matches!(
2697 action,
2698 Some(MenubarAction::FocusTrigger { reveal: None, .. })
2699 ),
2700 "expanded → no reveal (classic inline behaviour)"
2701 );
2702 }
2703
2704 #[test]
2705 fn from_model_builds_in_window_triggers() {
2706 use crate::menu::{MenuEntry, MenuModel};
2707 let model = MenuModel::new()
2708 .menu(lit!("&File"), |m| {
2709 m.item(MenuEntry::new(lit!("&New")).intent("app.new"))
2710 .separator()
2711 .item(MenuEntry::new(lit!("&Quit")).intent("app.quit"))
2712 })
2713 .menu(lit!("&Edit"), |m| {
2714 m.item(MenuEntry::new(lit!("Cu&t")).intent("app.cut"))
2715 });
2716
2717 let mut t = tree_with_window();
2718 let mb = t.add(MenuBar::from_model(model));
2719 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2720
2721 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2723 assert_eq!(triggers.len(), 2);
2724 assert_eq!(t.accessibility_node(triggers[0]).name(), Some("File"));
2725 assert_eq!(t.accessibility_node(triggers[1]).name(), Some("Edit"));
2726 }
2727
2728 #[test]
2729 fn runtime_model_mutation_rebuilds_in_window_bar() {
2730 use crate::menu::{MenuEntry, MenuModel};
2731 let model = MenuModel::new().menu(lit!("&File"), |m| m.item(MenuEntry::new(lit!("&New"))));
2732 let model_handle = model.clone();
2733
2734 let mut t = tree_with_window();
2735 let mb = t.add(MenuBar::from_model(model));
2736 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2737 assert_eq!(
2738 collect_descendants_with_role(&t, mb, Role::MenuItem).len(),
2739 1
2740 );
2741
2742 model_handle.push_menu(lit!("&Edit"), |m| m.item(MenuEntry::new(lit!("Cu&t"))));
2745 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2746 assert_eq!(
2747 collect_descendants_with_role(&t, mb, Role::MenuItem).len(),
2748 2
2749 );
2750
2751 let nodes_ids: Vec<_> = {
2753 model_handle
2754 .nodes()
2755 .iter()
2756 .filter_map(|n| match n {
2757 crate::menu::MenuNode::Submenu { id, title, .. }
2758 if title.resolve_now().contains("Edit") =>
2759 {
2760 Some(*id)
2761 }
2762 _ => None,
2763 })
2764 .collect()
2765 };
2766 assert!(model_handle.remove(nodes_ids[0]));
2767 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2768 assert_eq!(
2769 collect_descendants_with_role(&t, mb, Role::MenuItem).len(),
2770 1
2771 );
2772 }
2773
2774 #[test]
2775 fn native_suppress_hides_in_window_bar_on_macos() {
2776 use crate::menu::{MenuEntry, MenuModel, NativeMenuMode};
2777 let model = MenuModel::new().menu(lit!("&File"), |m| {
2778 m.item(MenuEntry::new(lit!("&New")).intent("app.new"))
2779 });
2780 let mut t = tree_with_window();
2781 let mb = t.add(MenuBar::from_model(model).native_on_macos(NativeMenuMode::Suppress));
2782 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2783
2784 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2785 if cfg!(target_os = "macos") {
2786 assert!(
2788 triggers.is_empty(),
2789 "macOS Suppress renders no in-window triggers"
2790 );
2791 } else {
2792 assert_eq!(triggers.len(), 1);
2794 }
2795 }
2796
2797 #[cfg(target_os = "macos")]
2803 #[test]
2804 fn native_install_records_localized_snapshot() {
2805 use crate::menu::{MenuEntry, MenuModel, NativeMenuMode, StandardMenu};
2806 use std::any::{Any, TypeId};
2807 use std::collections::HashMap;
2808 use std::sync::Arc;
2809 use teksilo_core::AppEventPoster;
2810 use teksilo_platform::native_menu::{
2811 MemoryNativeMenuBackend, NativeCheck, NativeMenuHandle, NativeMenuNode,
2812 StandardMenuRole,
2813 };
2814
2815 struct NullPoster;
2816 impl AppEventPoster for NullPoster {
2817 fn post_subscription_event(
2818 &self,
2819 _: teksilo_core::SubscriptionId,
2820 _: Box<dyn Any + Send>,
2821 ) {
2822 }
2823 fn post_external(&self, _: Box<dyn Any + Send>) {}
2824 }
2825
2826 let grid = Signal::new(true);
2827 let model = MenuModel::new()
2828 .standard_menu(StandardMenu::app().quit(lit!("Quitter")))
2830 .menu(lit!("&File"), |m| {
2831 m.item(MenuEntry::new(lit!("&New")).intent("app.new"))
2832 .item(MenuEntry::new(lit!("Show &Grid")).checkable(grid.clone()))
2833 });
2834
2835 let backend = MemoryNativeMenuBackend::new();
2836 let handle = NativeMenuHandle::new(backend.clone());
2837 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
2838 app_state.insert(TypeId::of::<NativeMenuHandle>(), Box::new(handle));
2839 let poster: Arc<dyn AppEventPoster> = Arc::new(NullPoster);
2840
2841 let mut t = tree_with_window();
2842 t.set_app_context(Rc::new(
2843 teksilo_core::event_source::TreeAppContext::empty()
2844 .with_app_state(app_state)
2845 .with_poster(poster),
2846 ));
2847 let _mb = t.add(MenuBar::from_model(model).native_on_macos(NativeMenuMode::Coexist));
2848 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2849
2850 let snap = backend
2851 .menu_for(TeksiloWindowId::new(1))
2852 .expect("native snapshot recorded for the window");
2853
2854 match &snap.roots[0] {
2858 NativeMenuNode::Standard {
2859 role: StandardMenuRole::App,
2860 labels,
2861 quit_item,
2862 ..
2863 } => {
2864 assert_eq!(labels.quit, "Quitter", "Quit label routes through i18n");
2865 assert_eq!(labels.about, "About", "default About label resolved");
2866 assert!(quit_item.is_none(), "no quit intent declared, no routing");
2867 }
2868 other => panic!("expected leading App menu, got {other:?}"),
2869 }
2870
2871 let file = snap
2873 .roots
2874 .iter()
2875 .find_map(|n| match n {
2876 NativeMenuNode::Submenu { title, children } if title == "File" => Some(children),
2877 _ => None,
2878 })
2879 .expect("File submenu in snapshot");
2880 assert!(
2881 file.iter()
2882 .any(|n| matches!(n, NativeMenuNode::Item { title, .. } if title == "New")),
2883 "New item present, '&' stripped"
2884 );
2885 assert!(
2886 file.iter().any(|n| matches!(
2887 n,
2888 NativeMenuNode::Item { title, check: NativeCheck::On, .. } if title == "Show Grid"
2889 )),
2890 "checkable item reflects the bound signal (On) with stripped title"
2891 );
2892 }
2893
2894 #[derive(Debug)]
2898 struct FocusableLeaf;
2899 impl Widget for FocusableLeaf {
2900 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2901 ctx.apply_self_handlers(HandlerSet::new().focusable(true));
2902 vec![]
2903 }
2904 fn layout_response(
2905 &self,
2906 proposal: SizeProposal,
2907 _ctx: &LayoutContext,
2908 ) -> teksilo_core::widget::LayoutResponse {
2909 proposal.resolve(12.0, 12.0).into()
2910 }
2911 }
2912
2913 #[test]
2923 fn arrow_down_then_tab_closes_dropdown_and_lands_past_trigger() {
2924 let mut t = tree_with_window();
2925 let mb = t.add(
2926 MenuBar::new()
2927 .menu(lit!("&File"), || {
2928 Box::new(
2929 MenuList::new()
2930 .item(MenuItem::new(lit!("New")))
2931 .item(MenuItem::new(lit!("Open"))),
2932 )
2933 })
2934 .menu(lit!("&Edit"), || {
2935 Box::new(MenuList::new().item(MenuItem::new(lit!("Cut"))))
2936 }),
2937 );
2938 let after = t.add(FocusableLeaf);
2940 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
2941
2942 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2943 assert_eq!(triggers.len(), 2, "two top-level triggers");
2944
2945 t.focus(triggers[0]);
2947 t.press_key(Key::ArrowDown, Modifiers::NONE);
2948 assert!(
2949 t.accessibility_node(triggers[0]).is_expanded(),
2950 "precondition: ArrowDown opens the File dropdown"
2951 );
2952 assert_ne!(
2953 t.focused(),
2954 Some(triggers[0]),
2955 "precondition: opening moves focus off the trigger, into the menu"
2956 );
2957
2958 t.press_key(Key::Tab, Modifiers::NONE);
2959
2960 assert!(
2968 t.active_overlays().is_empty(),
2969 "the dropdown panel itself must not survive Tab"
2970 );
2971 assert!(
2972 !t.accessibility_node(triggers[0]).is_expanded(),
2973 "and the trigger must stop announcing itself as expanded"
2974 );
2975 assert_eq!(
2976 t.focused(),
2977 Some(after),
2978 "Tab must land on the first stop past the trigger"
2979 );
2980 }
2981
2982 #[test]
2999 fn sideways_navigate_does_not_orphan_focus() {
3000 let mut t = tree_with_window();
3001 let mb = t.add(
3002 MenuBar::new()
3003 .menu(lit!("&File"), || {
3004 Box::new(MenuList::new().item(MenuItem::new(lit!("New"))))
3005 })
3006 .menu(lit!("&Edit"), || {
3007 Box::new(MenuList::new().item(MenuItem::new(lit!("Cut"))))
3008 }),
3009 );
3010 t.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
3011 let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
3012
3013 t.focus(triggers[0]);
3014 t.press_key(Key::ArrowDown, Modifiers::NONE);
3015 assert!(t.accessibility_node(triggers[0]).is_expanded());
3016
3017 t.press_key(Key::ArrowRight, Modifiers::NONE);
3018 assert!(
3019 t.accessibility_node(triggers[1]).is_expanded(),
3020 "ArrowRight must leave the Edit menu open, not dismissed by the focus-out rule"
3021 );
3022 assert!(
3023 !t.accessibility_node(triggers[0]).is_expanded(),
3024 "and must close the File menu it navigated away from"
3025 );
3026 assert_eq!(
3027 t.active_overlays().len(),
3028 1,
3029 "exactly one menu overlay stays up across sideways navigation"
3030 );
3031 assert!(
3033 t.focused().is_some_and(|f| f != triggers[0]),
3034 "focus must land in the menu that was just opened, not be left behind"
3035 );
3036 }
3037}