1use std::collections::HashMap;
10use std::sync::Arc;
11use std::sync::Mutex;
12use std::time::Duration;
13
14use fret_core::{Modifiers, Point, PointerType, Px, Rect, Size, Transform2D};
15use fret_runtime::{CommandId, Effect, FrameId, Model, TimerToken};
16use fret_ui::action::{ActionCx, OnDismissiblePointerMove, UiActionHost};
17use fret_ui::element::{AnyElement, LayoutStyle};
18use fret_ui::elements::ContinuousFrames;
19use fret_ui::elements::GlobalElementId;
20use fret_ui::overlay_placement::Side;
21use fret_ui::theme::CubicBezier;
22use fret_ui::{ElementContext, UiHost};
23
24use crate::declarative::model_watch::ModelWatchExt;
25use crate::headless::transition::TransitionTimeline;
26use crate::overlay;
27use crate::primitives::popper;
28use crate::primitives::portal_inherited;
29use crate::{OverlayController, OverlayPresence, OverlayRequest};
30
31pub const DEFAULT_DELAY_DURATION_MS: u64 = 200;
33pub const DEFAULT_SKIP_DELAY_DURATION_MS: u64 = 300;
35pub const DEFAULT_CLOSE_DELAY_MS: u64 = 150;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct NavigationMenuConfig {
40 pub delay_duration: Duration,
41 pub skip_delay_duration: Duration,
42 pub close_delay_duration: Duration,
43}
44
45impl Default for NavigationMenuConfig {
46 fn default() -> Self {
47 Self {
48 delay_duration: Duration::from_millis(DEFAULT_DELAY_DURATION_MS),
49 skip_delay_duration: Duration::from_millis(DEFAULT_SKIP_DELAY_DURATION_MS),
50 close_delay_duration: Duration::from_millis(DEFAULT_CLOSE_DELAY_MS),
51 }
52 }
53}
54
55impl NavigationMenuConfig {
56 pub fn new(
57 delay_duration: Duration,
58 skip_delay_duration: Duration,
59 close_delay_duration: Duration,
60 ) -> Self {
61 Self {
62 delay_duration,
63 skip_delay_duration,
64 close_delay_duration,
65 }
66 }
67}
68
69pub fn navigation_menu_use_value_model<H: UiHost>(
75 cx: &mut ElementContext<'_, H>,
76 controlled: Option<Model<Option<Arc<str>>>>,
77 default_value: impl FnOnce() -> Option<Arc<str>>,
78) -> crate::primitives::controllable_state::ControllableModel<Option<Arc<str>>> {
79 crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_value)
80}
81
82#[derive(Default)]
83struct TriggerIdRegistry {
84 ids: HashMap<Arc<str>, GlobalElementId>,
85}
86
87#[derive(Default)]
88struct TriggerStateRegistry {
89 states: HashMap<Arc<str>, NavigationMenuTriggerState>,
90}
91
92#[derive(Default)]
93struct EntryFocusRegistry {
94 frame_id: Option<FrameId>,
95 first_link_ids: HashMap<Arc<str>, GlobalElementId>,
96}
97
98fn navigation_menu_entry_focus_registry<H: UiHost>(
99 cx: &mut ElementContext<'_, H>,
100 root_id: GlobalElementId,
101) -> Arc<Mutex<EntryFocusRegistry>> {
102 cx.state_for(
103 root_id,
104 || Arc::new(Mutex::new(EntryFocusRegistry::default())),
105 |s| s.clone(),
106 )
107}
108
109fn find_first_focus_target(elements: &[AnyElement]) -> Option<GlobalElementId> {
110 let mut stack: Vec<&AnyElement> = elements.iter().rev().collect();
111 while let Some(el) = stack.pop() {
112 match &el.kind {
113 fret_ui::element::ElementKind::Pressable(props) if props.enabled => return Some(el.id),
114 fret_ui::element::ElementKind::TextInput(props) if props.enabled => return Some(el.id),
115 fret_ui::element::ElementKind::TextArea(props) if props.enabled => return Some(el.id),
116 fret_ui::element::ElementKind::TextInputRegion(props) if props.enabled => {
117 return Some(el.id);
118 }
119 _ => {}
120 }
121 for child in el.children.iter().rev() {
122 stack.push(child);
123 }
124 }
125 None
126}
127
128pub fn navigation_menu_register_trigger_id<H: UiHost>(
132 cx: &mut ElementContext<'_, H>,
133 root_id: GlobalElementId,
134 value: Arc<str>,
135 trigger_id: GlobalElementId,
136) {
137 cx.state_for(root_id, TriggerIdRegistry::default, |st| {
138 st.ids.insert(value, trigger_id);
139 });
140}
141
142pub fn navigation_menu_trigger_id<H: UiHost>(
144 cx: &mut ElementContext<'_, H>,
145 root_id: GlobalElementId,
146 value: &str,
147) -> Option<GlobalElementId> {
148 cx.state_for(root_id, TriggerIdRegistry::default, |st| {
149 st.ids.get(value).copied()
150 })
151}
152
153#[derive(Default)]
154struct ViewportContentIdRegistry {
155 ids: HashMap<Arc<str>, GlobalElementId>,
156}
157
158#[derive(Default)]
159struct ViewportPanelIdRegistry {
160 id: Option<GlobalElementId>,
161}
162
163#[derive(Default)]
164struct IndicatorTrackIdRegistry {
165 id: Option<GlobalElementId>,
166}
167
168#[derive(Default)]
169struct IndicatorDiamondIdRegistry {
170 id: Option<GlobalElementId>,
171}
172pub fn navigation_menu_register_viewport_content_id<H: UiHost>(
178 cx: &mut ElementContext<'_, H>,
179 root_id: GlobalElementId,
180 value: Arc<str>,
181 content_id: GlobalElementId,
182) {
183 cx.state_for(root_id, ViewportContentIdRegistry::default, |st| {
184 st.ids.insert(value, content_id);
185 });
186}
187
188pub fn navigation_menu_viewport_content_id<H: UiHost>(
190 cx: &mut ElementContext<'_, H>,
191 root_id: GlobalElementId,
192 value: &str,
193) -> Option<GlobalElementId> {
194 cx.state_for(root_id, ViewportContentIdRegistry::default, |st| {
195 st.ids.get(value).copied()
196 })
197}
198
199pub fn navigation_menu_register_viewport_panel_id<H: UiHost>(
204 cx: &mut ElementContext<'_, H>,
205 root_id: GlobalElementId,
206 viewport_panel_id: GlobalElementId,
207) {
208 cx.state_for(root_id, ViewportPanelIdRegistry::default, |st| {
209 st.id = Some(viewport_panel_id);
210 });
211}
212
213pub fn navigation_menu_viewport_panel_id<H: UiHost>(
215 cx: &mut ElementContext<'_, H>,
216 root_id: GlobalElementId,
217) -> Option<GlobalElementId> {
218 cx.state_for(root_id, ViewportPanelIdRegistry::default, |st| st.id)
219}
220
221pub fn navigation_menu_register_indicator_track_id<H: UiHost>(
225 cx: &mut ElementContext<'_, H>,
226 root_id: GlobalElementId,
227 indicator_track_id: GlobalElementId,
228) {
229 cx.state_for(root_id, IndicatorTrackIdRegistry::default, |st| {
230 st.id = Some(indicator_track_id);
231 });
232}
233
234pub fn navigation_menu_indicator_track_id<H: UiHost>(
236 cx: &mut ElementContext<'_, H>,
237 root_id: GlobalElementId,
238) -> Option<GlobalElementId> {
239 cx.state_for(root_id, IndicatorTrackIdRegistry::default, |st| st.id)
240}
241
242pub fn navigation_menu_register_indicator_diamond_id<H: UiHost>(
244 cx: &mut ElementContext<'_, H>,
245 root_id: GlobalElementId,
246 indicator_diamond_id: GlobalElementId,
247) {
248 cx.state_for(root_id, IndicatorDiamondIdRegistry::default, |st| {
249 st.id = Some(indicator_diamond_id);
250 });
251}
252
253pub fn navigation_menu_indicator_diamond_id<H: UiHost>(
255 cx: &mut ElementContext<'_, H>,
256 root_id: GlobalElementId,
257) -> Option<GlobalElementId> {
258 cx.state_for(root_id, IndicatorDiamondIdRegistry::default, |st| st.id)
259}
260
261fn navigation_menu_viewport_content_semantics_id_in_scope<H: UiHost>(
262 cx: &mut ElementContext<'_, H>,
263 root_id: GlobalElementId,
264 value: &str,
265) -> GlobalElementId {
266 navigation_menu_viewport_content_pressable_with_id_props::<H>(
267 cx,
268 root_id,
269 value,
270 |_cx, _st, _id| {
271 (
272 fret_ui::element::PressableProps {
273 layout: LayoutStyle::default(),
274 enabled: true,
275 focusable: false,
276 ..Default::default()
277 },
278 Vec::new(),
279 )
280 },
281 )
282 .id
283}
284
285pub fn navigation_menu_viewport_content_semantics_id<H: UiHost>(
295 cx: &mut ElementContext<'_, H>,
296 root_id: GlobalElementId,
297 overlay_root_name: &str,
298 value: &str,
299) -> GlobalElementId {
300 let inherited = portal_inherited::PortalInherited::capture(cx);
301 portal_inherited::with_root_name_inheriting(cx, overlay_root_name, inherited, |cx| {
302 navigation_menu_viewport_content_semantics_id_in_scope::<H>(cx, root_id, value)
303 })
304}
305
306pub fn navigation_menu_viewport_content_pressable_with_id_props<H: UiHost>(
311 cx: &mut ElementContext<'_, H>,
312 root_id: GlobalElementId,
313 value: &str,
314 f: impl FnOnce(
315 &mut ElementContext<'_, H>,
316 fret_ui::element::PressableState,
317 GlobalElementId,
318 ) -> (fret_ui::element::PressableProps, Vec<AnyElement>),
319) -> AnyElement {
320 let value: Arc<str> = Arc::from(value);
321 cx.keyed(value.as_ref(), |cx| {
322 let value_for_registry = value.clone();
323 cx.pressable_with_id_props(move |cx, st, id| {
324 let (props, children) = f(cx, st, id);
325
326 if let Some(target) = find_first_focus_target(&children) {
327 let registry = navigation_menu_entry_focus_registry(cx, root_id);
328 let mut st = registry.lock().unwrap_or_else(|e| e.into_inner());
329 st.first_link_ids
330 .entry(value_for_registry.clone())
331 .or_insert(target);
332 }
333
334 (props, children)
335 })
336 })
337}
338
339#[derive(Default)]
340struct ViewportPresentSelectionState {
341 last_present_selected: Option<Arc<str>>,
342}
343
344pub fn navigation_menu_viewport_selected_value<H: UiHost>(
350 cx: &mut ElementContext<'_, H>,
351 root_id: GlobalElementId,
352 selected: Option<Arc<str>>,
353 present: bool,
354) -> Option<Arc<str>> {
355 cx.state_for(root_id, ViewportPresentSelectionState::default, |st| {
356 if selected.is_some() {
357 st.last_present_selected = selected.clone();
358 return selected;
359 }
360
361 if present {
362 return st.last_present_selected.clone();
363 }
364
365 None
366 })
367}
368
369#[derive(Default)]
370struct ViewportSizeRegistry {
371 sizes: HashMap<Arc<str>, Size>,
372 last_size: Option<Size>,
373}
374
375pub fn navigation_menu_register_viewport_size<H: UiHost>(
381 cx: &mut ElementContext<'_, H>,
382 root_id: GlobalElementId,
383 value: Arc<str>,
384 size: Size,
385) {
386 cx.state_for(root_id, ViewportSizeRegistry::default, |st| {
387 st.sizes.insert(value, size);
388 st.last_size = Some(size);
389 });
390}
391
392fn lerp_px(a: fret_core::Px, b: fret_core::Px, t: f32) -> fret_core::Px {
393 let t = t.clamp(0.0, 1.0);
394 fret_core::Px(a.0 + (b.0 - a.0) * t)
395}
396
397fn lerp_size(a: Size, b: Size, t: f32) -> Size {
398 Size::new(lerp_px(a.width, b.width, t), lerp_px(a.height, b.height, t))
399}
400
401#[derive(Debug, Clone, Copy, PartialEq)]
402pub struct NavigationMenuViewportSizeOutput {
403 pub size: Size,
404 pub from_size: Option<Size>,
405 pub to_size: Option<Size>,
406 pub progress: f32,
407 pub animating: bool,
408}
409
410impl Default for NavigationMenuViewportSizeOutput {
411 fn default() -> Self {
412 Self {
413 size: Size::default(),
414 from_size: None,
415 to_size: None,
416 progress: 1.0,
417 animating: false,
418 }
419 }
420}
421
422pub fn navigation_menu_viewport_size_for_transition<H: UiHost>(
428 cx: &mut ElementContext<'_, H>,
429 root_id: GlobalElementId,
430 selected: Option<Arc<str>>,
431 values: &[Arc<str>],
432 transition: NavigationMenuContentTransitionOutput,
433 fallback: Size,
434) -> NavigationMenuViewportSizeOutput {
435 let (active_size, last_size, from_size, to_size) =
436 cx.state_for(root_id, ViewportSizeRegistry::default, |st| {
437 let active_size = selected
438 .as_ref()
439 .and_then(|v| st.sizes.get(v).copied())
440 .or(st.last_size)
441 .unwrap_or(fallback);
442
443 let from_size = transition
444 .from_idx
445 .and_then(|idx| values.get(idx))
446 .and_then(|v| st.sizes.get(v).copied());
447 let to_size = transition
448 .to_idx
449 .and_then(|idx| values.get(idx))
450 .and_then(|v| st.sizes.get(v).copied());
451
452 (active_size, st.last_size, from_size, to_size)
453 });
454
455 if !transition.switching {
456 return NavigationMenuViewportSizeOutput {
457 size: active_size,
458 from_size: None,
459 to_size: None,
460 progress: 1.0,
461 animating: false,
462 };
463 }
464
465 let Some(from_idx) = transition.from_idx else {
466 return NavigationMenuViewportSizeOutput {
467 size: active_size,
468 from_size: None,
469 to_size: None,
470 progress: 1.0,
471 animating: false,
472 };
473 };
474 let Some(to_idx) = transition.to_idx else {
475 return NavigationMenuViewportSizeOutput {
476 size: active_size,
477 from_size: None,
478 to_size: None,
479 progress: 1.0,
480 animating: false,
481 };
482 };
483 if from_idx == to_idx {
484 return NavigationMenuViewportSizeOutput {
485 size: active_size,
486 from_size: None,
487 to_size: None,
488 progress: 1.0,
489 animating: false,
490 };
491 }
492
493 let from_size = from_size.or(to_size).or(last_size).unwrap_or(fallback);
494 let to_size = to_size
495 .or(Some(from_size))
496 .or(last_size)
497 .unwrap_or(fallback);
498
499 let progress = transition.progress.clamp(0.0, 1.0);
500 let size = if transition.animating {
501 lerp_size(from_size, to_size, progress)
502 } else {
503 to_size
504 };
505
506 NavigationMenuViewportSizeOutput {
507 size,
508 from_size: Some(from_size),
509 to_size: Some(to_size),
510 progress,
511 animating: transition.animating,
512 }
513}
514
515pub fn navigation_menu_indicator_rect(
521 anchor: Rect,
522 viewport_rect: Rect,
523 side: Side,
524 indicator_thickness: Px,
525) -> Rect {
526 let thickness = indicator_thickness.0.max(0.0);
527
528 match side {
529 Side::Bottom => Rect::new(
534 Point::new(anchor.origin.x, Px(viewport_rect.origin.y.0 - thickness)),
535 Size::new(anchor.size.width, Px(thickness)),
536 ),
537 Side::Top => Rect::new(
538 Point::new(
539 anchor.origin.x,
540 Px(viewport_rect.origin.y.0 + viewport_rect.size.height.0),
541 ),
542 Size::new(anchor.size.width, Px(thickness)),
543 ),
544 Side::Right => Rect::new(
547 Point::new(Px(viewport_rect.origin.x.0 - thickness), anchor.origin.y),
548 Size::new(Px(thickness), anchor.size.height),
549 ),
550 Side::Left => Rect::new(
551 Point::new(
552 Px(viewport_rect.origin.x.0 + viewport_rect.size.width.0),
553 anchor.origin.y,
554 ),
555 Size::new(Px(thickness), anchor.size.height),
556 ),
557 }
558}
559
560#[derive(Debug, Clone, Copy, PartialEq)]
561pub struct NavigationMenuViewportOverlayLayout {
562 pub anchor: Rect,
563 pub placed: Rect,
564 pub side: Side,
565 pub transform_origin: Point,
566 pub indicator_rect: Rect,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq)]
570pub struct NavigationMenuViewportOverlayRequestArgs {
571 pub window_margin: Px,
572 pub placement: popper::PopperContentPlacement,
573 pub placement_anchor_override: Option<GlobalElementId>,
577 pub content_size: Size,
578 pub indicator_size: Px,
579 pub width_tracks_anchor: bool,
583}
584
585#[derive(Debug)]
586pub struct NavigationMenuViewportOverlayRenderOutput {
587 pub opacity: f32,
588 pub transform: Transform2D,
589 pub children: Vec<AnyElement>,
590}
591
592pub fn navigation_menu_request_viewport_overlay<H: UiHost>(
598 cx: &mut ElementContext<'_, H>,
599 root_id: GlobalElementId,
600 cfg: NavigationMenuConfig,
601 value_model: Model<Option<Arc<str>>>,
602 open_model: Model<bool>,
603 presence: OverlayPresence,
604 selected_value: Option<&str>,
605 args: NavigationMenuViewportOverlayRequestArgs,
606 on_pointer_move: Option<OnDismissiblePointerMove>,
607 render: impl FnOnce(
608 &mut ElementContext<'_, H>,
609 NavigationMenuViewportOverlayLayout,
610 ) -> NavigationMenuViewportOverlayRenderOutput,
611) -> Option<NavigationMenuViewportOverlayLayout> {
612 if !presence.present {
613 return None;
614 }
615
616 let overlay_root_name = OverlayController::popover_root_name(root_id);
617 let mut computed_layout: Option<NavigationMenuViewportOverlayLayout> = None;
618 let root_state: Arc<Mutex<NavigationMenuRootState>> = cx.state_for(
619 root_id,
620 || Arc::new(Mutex::new(NavigationMenuRootState::default())),
621 |s| s.clone(),
622 );
623 let trigger_states: Arc<Mutex<TriggerStateRegistry>> = cx.state_for(
624 root_id,
625 || Arc::new(Mutex::new(TriggerStateRegistry::default())),
626 |s| s.clone(),
627 );
628
629 let inherited = portal_inherited::PortalInherited::capture(cx);
630 let overlay_children = portal_inherited::with_root_name_inheriting(
631 cx,
632 &overlay_root_name,
633 inherited,
634 |cx| {
635 let Some(value) = selected_value else {
636 return Vec::new();
637 };
638 let trigger_anchor_id = navigation_menu_trigger_id(cx, root_id, value);
639 let trigger_anchor = trigger_anchor_id.and_then(|id| {
640 cx.last_visual_bounds_for_element(id)
641 .or_else(|| cx.last_bounds_for_element(id))
642 });
643 let Some(trigger_anchor) = trigger_anchor else {
644 return Vec::new();
645 };
646
647 let placement_anchor = args
648 .placement_anchor_override
649 .and_then(|id| {
650 cx.last_visual_bounds_for_element(id)
651 .or_else(|| cx.last_bounds_for_element(id))
652 })
653 .map(|override_anchor| match args.placement.side {
654 Side::Top | Side::Bottom => Rect::new(
655 Point::new(override_anchor.origin.x, trigger_anchor.origin.y),
656 Size::new(override_anchor.size.width, trigger_anchor.size.height),
657 ),
658 Side::Left | Side::Right => Rect::new(
659 Point::new(trigger_anchor.origin.x, override_anchor.origin.y),
660 Size::new(trigger_anchor.size.width, override_anchor.size.height),
661 ),
662 })
663 .unwrap_or(trigger_anchor);
664
665 let content_size = if args.width_tracks_anchor {
666 Size::new(placement_anchor.size.width, args.content_size.height)
667 } else {
668 args.content_size
669 };
670
671 if std::env::var("FRET_DEBUG_NAV_MENU_OVERLAY").ok().as_deref() == Some("1") {
672 eprintln!(
673 "nav-menu overlay root={:?} selected={:?} trigger_anchor={:?} override={:?} placement_anchor={:?} content_size={:?} width_tracks_anchor={}",
674 root_id,
675 selected_value,
676 trigger_anchor,
677 args.placement_anchor_override
678 .and_then(|id| cx.last_bounds_for_element(id)),
679 placement_anchor,
680 content_size,
681 args.width_tracks_anchor
682 );
683 }
684
685 let outer = overlay::outer_bounds_with_window_margin_for_environment(
686 cx,
687 fret_ui::Invalidation::Layout,
688 args.window_margin,
689 );
690 let popper_layout = popper::popper_content_layout_unclamped(
691 outer,
692 placement_anchor,
693 content_size,
694 args.placement,
695 );
696 let placed = popper_layout.rect;
697
698 if std::env::var("FRET_DEBUG_NAV_MENU_OVERLAY").ok().as_deref() == Some("1") {
699 eprintln!(
700 "nav-menu overlay outer={:?} window_margin={:?} placed={:?}",
701 outer, args.window_margin, placed
702 );
703 }
704
705 let transform_origin =
706 popper::popper_content_transform_origin(&popper_layout, placement_anchor, None);
707 let indicator_rect = navigation_menu_indicator_rect(
708 trigger_anchor,
709 placed,
710 popper_layout.side,
711 args.indicator_size,
712 );
713
714 let layout = NavigationMenuViewportOverlayLayout {
715 anchor: trigger_anchor,
716 placed,
717 side: popper_layout.side,
718 transform_origin,
719 indicator_rect,
720 };
721 computed_layout = Some(layout);
722
723 let out = render(cx, layout);
724
725 let overlay_content =
726 crate::declarative::overlay_motion::wrap_opacity_and_render_transform(
727 cx,
728 out.opacity,
729 out.transform,
730 out.children,
731 );
732
733 vec![overlay_content]
734 },
735 );
736
737 let open_model_for_request = open_model.clone();
738 let mut request = OverlayRequest::dismissible_popover(
739 root_id,
740 root_id,
741 open_model_for_request,
742 presence,
743 overlay_children,
744 );
745 request.root_name = Some(overlay_root_name);
746 request.dismissible_on_pointer_move = on_pointer_move;
747 let on_dismiss_request: fret_ui::action::OnDismissRequest = Arc::new({
748 let value_model = value_model.clone();
749 let root_state = root_state.clone();
750 let trigger_states = trigger_states.clone();
751 move |host: &mut dyn fret_ui::action::UiActionHost,
752 action_cx: fret_ui::action::ActionCx,
753 req: &mut fret_ui::action::DismissRequestCx| {
754 if req.default_prevented() {
755 return;
756 }
757
758 if req.reason == fret_ui::action::DismissReason::Escape {
759 let selected = host
760 .models_mut()
761 .read(&value_model, |v| v.clone())
762 .ok()
763 .flatten();
764 if let Some(value) = selected {
765 let mut states = trigger_states.lock().unwrap_or_else(|e| e.into_inner());
766 let entry = states.states.entry(value).or_default();
767 entry.was_escape_close = true;
768 entry.was_click_close = false;
769 entry.has_pointer_move_opened = false;
770 }
771 }
772
773 let mut st = root_state.lock().unwrap_or_else(|e| e.into_inner());
774 st.on_item_dismiss(host, action_cx, &value_model, cfg);
775 }
776 });
777 let policy = crate::primitives::popover::PopoverCloseAutoFocusGuardPolicy::for_variant(
778 crate::primitives::popover::PopoverVariant::NonModal,
779 false,
780 );
781 let (on_dismiss_request, on_close_auto_focus) =
782 crate::primitives::popover::popover_close_auto_focus_guard_hooks(
783 cx,
784 policy,
785 open_model.clone(),
786 Some(on_dismiss_request),
787 None,
788 );
789 request.dismissible_on_dismiss_request = on_dismiss_request;
790 request.on_open_auto_focus = Some(Arc::new(
794 |_host: &mut dyn fret_ui::action::UiFocusActionHost,
795 _action_cx: fret_ui::action::ActionCx,
796 req: &mut fret_ui::action::AutoFocusRequestCx| {
797 req.prevent_default();
798 },
799 ));
800 request.on_close_auto_focus = on_close_auto_focus;
801 OverlayController::request(cx, request);
802
803 computed_layout
804}
805
806#[derive(Debug, Clone)]
811pub struct NavigationMenuRoot {
812 model: Model<Option<Arc<str>>>,
813 config: NavigationMenuConfig,
814 disabled: bool,
815}
816
817impl NavigationMenuRoot {
818 pub fn new(model: Model<Option<Arc<str>>>) -> Self {
819 Self {
820 model,
821 config: NavigationMenuConfig::default(),
822 disabled: false,
823 }
824 }
825
826 pub fn new_controllable<H: UiHost>(
829 cx: &mut ElementContext<'_, H>,
830 controlled: Option<Model<Option<Arc<str>>>>,
831 default_value: impl FnOnce() -> Option<Arc<str>>,
832 ) -> Self {
833 let model = navigation_menu_use_value_model(cx, controlled, default_value).model();
834 Self::new(model)
835 }
836
837 pub fn model(&self) -> Model<Option<Arc<str>>> {
838 self.model.clone()
839 }
840
841 pub fn config(mut self, config: NavigationMenuConfig) -> Self {
842 self.config = config;
843 self
844 }
845
846 pub fn disabled(mut self, disabled: bool) -> Self {
847 self.disabled = disabled;
848 self
849 }
850
851 pub fn context<H: UiHost>(
852 &self,
853 cx: &mut ElementContext<'_, H>,
854 root_id: GlobalElementId,
855 ) -> NavigationMenuContext {
856 let root_state: Arc<Mutex<NavigationMenuRootState>> = cx.state_for(
857 root_id,
858 || Arc::new(Mutex::new(NavigationMenuRootState::default())),
859 |s| s.clone(),
860 );
861 {
862 let entry_focus_registry = navigation_menu_entry_focus_registry(cx, root_id);
863 let mut st = entry_focus_registry
864 .lock()
865 .unwrap_or_else(|e| e.into_inner());
866 if st.frame_id != Some(cx.frame_id) {
867 st.frame_id = Some(cx.frame_id);
868 st.first_link_ids.clear();
869 }
870 }
871
872 let value_model_for_timer = self.model.clone();
873 let root_state_for_timer = root_state.clone();
874 let cfg = self.config;
875 cx.timer_on_timer_for(
876 root_id,
877 Arc::new(move |host, action_cx, token| {
878 let mut st = root_state_for_timer
879 .lock()
880 .unwrap_or_else(|e| e.into_inner());
881 st.on_timer(host, action_cx, token, &value_model_for_timer, cfg)
882 }),
883 );
884
885 NavigationMenuContext {
886 root_id,
887 model: self.model.clone(),
888 config: self.config,
889 disabled: self.disabled,
890 root_state,
891 }
892 }
893
894 pub fn trigger(&self, value: impl Into<Arc<str>>) -> NavigationMenuTrigger {
895 NavigationMenuTrigger::new(value)
896 }
897
898 pub fn content(&self, value: impl Into<Arc<str>>) -> NavigationMenuContent {
899 NavigationMenuContent::new(value)
900 }
901
902 pub fn link(&self) -> NavigationMenuLink {
903 NavigationMenuLink::new()
904 }
905}
906
907#[derive(Clone)]
908pub struct NavigationMenuContext {
909 pub root_id: GlobalElementId,
910 pub model: Model<Option<Arc<str>>>,
911 pub config: NavigationMenuConfig,
912 pub disabled: bool,
913 pub root_state: Arc<Mutex<NavigationMenuRootState>>,
914}
915
916impl NavigationMenuContext {
917 pub fn selected<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Option<Arc<str>> {
918 cx.watch_model(&self.model).layout().cloned().flatten()
919 }
920}
921
922#[derive(Debug, Clone)]
923pub struct NavigationMenuTrigger {
924 value: Arc<str>,
925 label: Option<Arc<str>>,
926 disabled: bool,
927}
928
929impl NavigationMenuTrigger {
930 pub fn new(value: impl Into<Arc<str>>) -> Self {
931 Self {
932 value: value.into(),
933 label: None,
934 disabled: false,
935 }
936 }
937
938 pub fn label(mut self, label: impl Into<Arc<str>>) -> Self {
939 self.label = Some(label.into());
940 self
941 }
942
943 pub fn disabled(mut self, disabled: bool) -> Self {
944 self.disabled = disabled;
945 self
946 }
947
948 #[track_caller]
952 pub fn into_element<H: UiHost>(
953 self,
954 cx: &mut ElementContext<'_, H>,
955 ctx: &NavigationMenuContext,
956 mut pressable: fret_ui::element::PressableProps,
957 pointer_region: fret_ui::element::PointerRegionProps,
958 f: impl FnOnce(
959 &mut ElementContext<'_, H>,
960 fret_ui::element::PressableState,
961 bool,
962 ) -> Vec<fret_ui::element::AnyElement>,
963 ) -> fret_ui::element::AnyElement {
964 let value_model = ctx.model.clone();
965 let item_value = self.value.clone();
966 let disabled = ctx.disabled || self.disabled;
967 let cfg = ctx.config;
968 let root_id = ctx.root_id;
969 let root_state = ctx.root_state.clone();
970 let entry_focus_registry = navigation_menu_entry_focus_registry(cx, root_id);
971 let trigger_states: Arc<Mutex<TriggerStateRegistry>> = cx.state_for(
972 root_id,
973 || Arc::new(Mutex::new(TriggerStateRegistry::default())),
974 |s| s.clone(),
975 );
976
977 let is_open = cx
978 .watch_model(&value_model)
979 .layout()
980 .cloned()
981 .flatten()
982 .as_deref()
983 .is_some_and(|v| v == item_value.as_ref());
984
985 pressable.enabled = !disabled;
986 pressable.focusable = !disabled;
987
988 if pressable.a11y.role.is_none() {
989 pressable.a11y.role = Some(fret_core::SemanticsRole::Button);
990 }
991 if pressable.a11y.label.is_none() {
992 pressable.a11y.label = self.label.clone();
993 }
994 pressable.a11y.expanded = Some(is_open);
995 if pressable.a11y.controls_element.is_none() {
996 let overlay_root_name = OverlayController::popover_root_name(root_id);
997 let content_id = navigation_menu_viewport_content_semantics_id::<H>(
998 cx,
999 root_id,
1000 overlay_root_name.as_str(),
1001 item_value.as_ref(),
1002 );
1003 pressable.a11y.controls_element = Some(content_id.0);
1004 }
1005
1006 cx.pointer_region(pointer_region, move |cx| {
1007 if !disabled {
1008 let trigger_states_for_pointer_move = trigger_states.clone();
1009 let root_state_for_pointer_move = root_state.clone();
1010 let value_for_pointer_move = value_model.clone();
1011 let item_value_for_pointer_move = item_value.clone();
1012 cx.pointer_region_on_pointer_move(Arc::new(move |host, action_cx, mv| {
1013 let mut states = trigger_states_for_pointer_move
1014 .lock()
1015 .unwrap_or_else(|e| e.into_inner());
1016 let st = *states
1017 .states
1018 .get(item_value_for_pointer_move.as_ref())
1019 .unwrap_or(&NavigationMenuTriggerState::default());
1020 match navigation_menu_trigger_pointer_move_action(mv.pointer_type, disabled, st)
1021 {
1022 NavigationMenuTriggerPointerMoveAction::Ignore => false,
1023 NavigationMenuTriggerPointerMoveAction::Open => {
1024 let mut root = root_state_for_pointer_move
1025 .lock()
1026 .unwrap_or_else(|e| e.into_inner());
1027 root.on_trigger_enter(
1028 host,
1029 action_cx,
1030 &value_for_pointer_move,
1031 item_value_for_pointer_move.clone(),
1032 cfg,
1033 );
1034 states.states.insert(
1035 item_value_for_pointer_move.clone(),
1036 NavigationMenuTriggerState {
1037 has_pointer_move_opened: true,
1038 was_click_close: false,
1039 was_escape_close: false,
1040 },
1041 );
1042 false
1043 }
1044 }
1045 }));
1046 }
1047
1048 let item_value_for_registry = item_value.clone();
1049 vec![cx.pressable_with_id(pressable, move |cx, st, trigger_id| {
1050 navigation_menu_register_trigger_id(
1051 cx,
1052 root_id,
1053 item_value_for_registry.clone(),
1054 trigger_id,
1055 );
1056
1057 if !disabled {
1058 use fret_core::KeyCode;
1059
1060 let element = trigger_id;
1061 let root_state_for_escape = root_state.clone();
1062 let value_for_escape = value_model.clone();
1063 let trigger_states_for_escape = trigger_states.clone();
1064 cx.key_on_key_down_for(
1065 element,
1066 Arc::new(move |host, action_cx, it| {
1067 if it.repeat || it.key != KeyCode::Escape {
1068 return false;
1069 }
1070
1071 let selected = host
1072 .models_mut()
1073 .read(&value_for_escape, |v| v.clone())
1074 .ok()
1075 .flatten();
1076 let Some(selected) = selected else {
1077 return false;
1078 };
1079
1080 let mut root = root_state_for_escape
1081 .lock()
1082 .unwrap_or_else(|e| e.into_inner());
1083 root.on_item_dismiss(host, action_cx, &value_for_escape, cfg);
1084
1085 let mut states = trigger_states_for_escape
1086 .lock()
1087 .unwrap_or_else(|e| e.into_inner());
1088 let entry = states.states.entry(selected).or_default();
1089 entry.was_escape_close = true;
1090 entry.was_click_close = false;
1091 entry.has_pointer_move_opened = false;
1092
1093 true
1094 }),
1095 );
1096
1097 let item_value_for_entry_key = item_value_for_registry.clone();
1098 let value_for_entry_key = value_model.clone();
1099 let entry_focus_registry_for_entry_key = entry_focus_registry.clone();
1100 cx.key_add_on_key_down_for(
1101 element,
1102 Arc::new(move |host, action_cx, it| {
1103 if it.repeat {
1104 return false;
1105 }
1106
1107 if it.key != KeyCode::ArrowDown {
1108 return false;
1109 }
1110
1111 let selected = host
1112 .models_mut()
1113 .read(&value_for_entry_key, |v| v.clone())
1114 .ok()
1115 .flatten();
1116 let open = selected
1117 .as_ref()
1118 .is_some_and(|v| v.as_ref() == item_value_for_entry_key.as_ref());
1119 if !open {
1120 return false;
1121 }
1122
1123 let target = entry_focus_registry_for_entry_key
1124 .lock()
1125 .unwrap_or_else(|e| e.into_inner())
1126 .first_link_ids
1127 .get(item_value_for_entry_key.as_ref())
1128 .copied();
1129 if let Some(target) = target {
1130 host.request_focus(target);
1131 } else {
1132 host.dispatch_command(
1133 Some(action_cx.window),
1134 CommandId::from("focus.next"),
1135 );
1136 }
1137 host.request_redraw(action_cx.window);
1138 true
1139 }),
1140 );
1141
1142 let item_value_for_focus_next = item_value_for_registry.clone();
1143 let value_for_focus_next = value_model.clone();
1144 let entry_focus_registry_for_focus_next = entry_focus_registry.clone();
1145 cx.command_add_on_command_for(
1146 element,
1147 Arc::new(move |host, action_cx, command| {
1148 if command.as_str() != "focus.next" {
1149 return false;
1150 }
1151
1152 let selected = host
1153 .models_mut()
1154 .read(&value_for_focus_next, |v| v.clone())
1155 .ok()
1156 .flatten();
1157 let open = selected
1158 .as_ref()
1159 .is_some_and(|v| v.as_ref() == item_value_for_focus_next.as_ref());
1160 if !open {
1161 return false;
1162 }
1163
1164 let target = entry_focus_registry_for_focus_next
1165 .lock()
1166 .unwrap_or_else(|e| e.into_inner())
1167 .first_link_ids
1168 .get(item_value_for_focus_next.as_ref())
1169 .copied();
1170 let Some(target) = target else {
1171 return false;
1172 };
1173 host.request_focus(target);
1174 host.request_redraw(action_cx.window);
1175 true
1176 }),
1177 );
1178
1179 let root_state_for_activate = root_state.clone();
1180 let value_for_activate = value_model.clone();
1181 let trigger_states_for_activate = trigger_states.clone();
1182 let item_value_for_activate = item_value.clone();
1183 cx.pressable_add_on_activate(crate::on_activate(
1184 move |host, action_cx, _reason| {
1185 let mut root = root_state_for_activate
1186 .lock()
1187 .unwrap_or_else(|e| e.into_inner());
1188 root.on_item_select(
1189 host,
1190 action_cx,
1191 &value_for_activate,
1192 item_value_for_activate.clone(),
1193 cfg,
1194 );
1195
1196 let now_open = host
1197 .models_mut()
1198 .read(&value_for_activate, |v| v.clone())
1199 .ok()
1200 .flatten()
1201 .is_some_and(|v| v.as_ref() == item_value_for_activate.as_ref());
1202
1203 let mut states = trigger_states_for_activate
1204 .lock()
1205 .unwrap_or_else(|e| e.into_inner());
1206 let entry = states
1207 .states
1208 .entry(item_value_for_activate.clone())
1209 .or_default();
1210 entry.was_click_close = !now_open;
1211 if now_open {
1212 entry.was_escape_close = false;
1213 }
1214 entry.has_pointer_move_opened = false;
1215 },
1216 ));
1217
1218 let trigger_states_for_hover = trigger_states.clone();
1219 let root_state_for_hover = root_state.clone();
1220 let value_for_trigger = value_model.clone();
1221 let item_value_for_hover = item_value.clone();
1222 cx.pressable_on_hover_change(Arc::new(move |host, action_cx, hovered| {
1223 if hovered {
1224 let mut states = trigger_states_for_hover
1229 .lock()
1230 .unwrap_or_else(|e| e.into_inner());
1231 if let Some(entry) =
1232 states.states.get_mut(item_value_for_hover.as_ref())
1233 {
1234 entry.was_escape_close = false;
1235 entry.was_click_close = false;
1236 entry.has_pointer_move_opened = false;
1237 }
1238 return;
1239 }
1240 let mut root = root_state_for_hover
1241 .lock()
1242 .unwrap_or_else(|e| e.into_inner());
1243 root.on_trigger_leave(host, action_cx, &value_for_trigger, cfg);
1244 let mut states = trigger_states_for_hover
1245 .lock()
1246 .unwrap_or_else(|e| e.into_inner());
1247 states.states.remove(item_value_for_hover.as_ref());
1248 }));
1249 }
1250
1251 f(cx, st, is_open)
1252 })]
1253 })
1254 }
1255}
1256
1257#[derive(Debug, Clone)]
1258pub struct NavigationMenuContent {
1259 value: Arc<str>,
1260 force_mount: bool,
1261}
1262
1263impl NavigationMenuContent {
1264 pub fn new(value: impl Into<Arc<str>>) -> Self {
1265 Self {
1266 value: value.into(),
1267 force_mount: false,
1268 }
1269 }
1270
1271 pub fn force_mount(mut self, force_mount: bool) -> Self {
1272 self.force_mount = force_mount;
1273 self
1274 }
1275
1276 #[track_caller]
1277 pub fn into_element<H: UiHost>(
1278 self,
1279 cx: &mut ElementContext<'_, H>,
1280 ctx: &NavigationMenuContext,
1281 f: impl FnOnce(&mut ElementContext<'_, H>) -> Vec<fret_ui::element::AnyElement>,
1282 ) -> Option<fret_ui::element::AnyElement> {
1283 let selected = ctx.selected(cx);
1284 let active = selected.as_deref() == Some(self.value.as_ref());
1285 if !active && !self.force_mount {
1286 return None;
1287 }
1288 let value = self.value.clone();
1289 let entry_focus_registry = navigation_menu_entry_focus_registry(cx, ctx.root_id);
1290 if self.force_mount {
1291 Some(cx.interactivity_gate(active, active, move |cx| {
1292 let children = f(cx);
1293 if active {
1294 let first = find_first_focus_target(&children);
1295 let mut st = entry_focus_registry
1296 .lock()
1297 .unwrap_or_else(|e| e.into_inner());
1298 if let Some(first) = first {
1299 st.first_link_ids.insert(value.clone(), first);
1300 } else {
1301 st.first_link_ids.remove(value.as_ref());
1302 }
1303 }
1304 children
1305 }))
1306 } else {
1307 Some(cx.interactivity_gate(true, true, move |cx| {
1308 let children = f(cx);
1309 if active {
1310 let first = find_first_focus_target(&children);
1311 let mut st = entry_focus_registry
1312 .lock()
1313 .unwrap_or_else(|e| e.into_inner());
1314 if let Some(first) = first {
1315 st.first_link_ids.insert(value.clone(), first);
1316 } else {
1317 st.first_link_ids.remove(value.as_ref());
1318 }
1319 }
1320 children
1321 }))
1322 }
1323 }
1324}
1325
1326#[derive(Debug, Clone)]
1327pub struct NavigationMenuLink {
1328 dismiss_on_select: bool,
1329 dismiss_on_ctrl_or_meta: bool,
1330}
1331
1332impl NavigationMenuLink {
1333 pub fn new() -> Self {
1334 Self {
1335 dismiss_on_select: true,
1336 dismiss_on_ctrl_or_meta: false,
1337 }
1338 }
1339
1340 pub fn dismiss_on_select(mut self, dismiss_on_select: bool) -> Self {
1341 self.dismiss_on_select = dismiss_on_select;
1342 self
1343 }
1344
1345 pub fn dismiss_on_ctrl_or_meta(mut self, dismiss_on_ctrl_or_meta: bool) -> Self {
1350 self.dismiss_on_ctrl_or_meta = dismiss_on_ctrl_or_meta;
1351 self
1352 }
1353
1354 #[track_caller]
1355 pub fn into_element<H: UiHost>(
1356 self,
1357 cx: &mut ElementContext<'_, H>,
1358 ctx: &NavigationMenuContext,
1359 mut pressable: fret_ui::element::PressableProps,
1360 f: impl FnOnce(
1361 &mut ElementContext<'_, H>,
1362 fret_ui::element::PressableState,
1363 ) -> Vec<fret_ui::element::AnyElement>,
1364 ) -> fret_ui::element::AnyElement {
1365 #[derive(Default)]
1366 struct LinkModifierState {
1367 suppress_dismiss_for_next_activate: bool,
1368 }
1369
1370 let disabled = ctx.disabled;
1371 pressable.enabled = pressable.enabled && !disabled;
1372 pressable.focusable = pressable.focusable && !disabled;
1373
1374 let root_state = ctx.root_state.clone();
1375 let value_model = ctx.model.clone();
1376 let cfg = ctx.config;
1377 let dismiss = self.dismiss_on_select;
1378 let dismiss_on_ctrl_or_meta = self.dismiss_on_ctrl_or_meta;
1379 cx.pressable(pressable, move |cx, st| {
1380 if dismiss && !disabled {
1381 let modifier_state: Arc<Mutex<LinkModifierState>> = cx.state_for(
1382 cx.root_id(),
1383 || Arc::new(Mutex::new(LinkModifierState::default())),
1384 |s| s.clone(),
1385 );
1386 let modifier_state_for_pointer = modifier_state.clone();
1387 cx.pressable_add_on_pointer_down(Arc::new(move |_host, _cx, down| {
1388 use fret_ui::action::PressablePointerDownResult as R;
1389
1390 let suppress = navigation_menu_link_suppresses_dismiss(
1391 down.modifiers,
1392 dismiss_on_ctrl_or_meta,
1393 );
1394 let mut st = modifier_state_for_pointer
1395 .lock()
1396 .unwrap_or_else(|e| e.into_inner());
1397 st.suppress_dismiss_for_next_activate = suppress;
1398 R::Continue
1399 }));
1400
1401 let root_state_for_dismiss = root_state.clone();
1402 let value_for_dismiss = value_model.clone();
1403 cx.pressable_add_on_activate(crate::on_activate(
1404 move |host, action_cx, _reason| {
1405 let mut st = modifier_state.lock().unwrap_or_else(|e| e.into_inner());
1406 let suppress = st.suppress_dismiss_for_next_activate;
1407 st.suppress_dismiss_for_next_activate = false;
1408 if suppress {
1409 return;
1410 }
1411
1412 let mut root = root_state_for_dismiss
1413 .lock()
1414 .unwrap_or_else(|e| e.into_inner());
1415 root.on_item_dismiss(host, action_cx, &value_for_dismiss, cfg);
1416 },
1417 ));
1418 }
1419 f(cx, st)
1420 })
1421 }
1422}
1423
1424fn navigation_menu_link_suppresses_dismiss(
1425 modifiers: Modifiers,
1426 dismiss_on_ctrl_or_meta: bool,
1427) -> bool {
1428 (modifiers.ctrl || modifiers.meta) && !dismiss_on_ctrl_or_meta
1429}
1430
1431fn cancel_timer(host: &mut dyn UiActionHost, token: &mut Option<TimerToken>) {
1432 if let Some(token) = token.take() {
1433 host.push_effect(Effect::CancelTimer { token });
1434 }
1435}
1436
1437fn arm_timer(
1438 host: &mut dyn UiActionHost,
1439 window: fret_core::AppWindowId,
1440 after: Duration,
1441 token_out: &mut Option<TimerToken>,
1442) -> TimerToken {
1443 cancel_timer(host, token_out);
1444 let token = host.next_timer_token();
1445 host.push_effect(Effect::SetTimer {
1446 window: Some(window),
1447 token,
1448 after,
1449 repeat: None,
1450 });
1451 *token_out = Some(token);
1452 token
1453}
1454
1455#[derive(Debug, Clone)]
1456pub struct NavigationMenuRootState {
1457 open_timer: Option<TimerToken>,
1458 close_timer: Option<TimerToken>,
1459 skip_delay_timer: Option<TimerToken>,
1460 pending_open_value: Option<Arc<str>>,
1461 is_open_delayed: bool,
1462}
1463
1464impl Default for NavigationMenuRootState {
1465 fn default() -> Self {
1466 Self {
1467 open_timer: None,
1468 close_timer: None,
1469 skip_delay_timer: None,
1470 pending_open_value: None,
1471 is_open_delayed: true,
1472 }
1473 }
1474}
1475
1476impl NavigationMenuRootState {
1477 pub fn is_open_delayed(&self) -> bool {
1478 self.is_open_delayed
1479 }
1480
1481 pub fn clear_timers(&mut self, host: &mut dyn UiActionHost) {
1482 cancel_timer(host, &mut self.open_timer);
1483 cancel_timer(host, &mut self.close_timer);
1484 cancel_timer(host, &mut self.skip_delay_timer);
1485 self.pending_open_value = None;
1486 }
1487
1488 fn note_opened(&mut self, host: &mut dyn UiActionHost, cfg: NavigationMenuConfig) {
1489 cancel_timer(host, &mut self.skip_delay_timer);
1490 self.is_open_delayed = cfg.skip_delay_duration.is_zero();
1492 }
1493
1494 fn note_closed(
1495 &mut self,
1496 host: &mut dyn UiActionHost,
1497 window: fret_core::AppWindowId,
1498 cfg: NavigationMenuConfig,
1499 ) {
1500 cancel_timer(host, &mut self.skip_delay_timer);
1501 self.is_open_delayed = true;
1502 if cfg.skip_delay_duration.is_zero() {
1503 return;
1504 }
1505 arm_timer(
1507 host,
1508 window,
1509 cfg.skip_delay_duration,
1510 &mut self.skip_delay_timer,
1511 );
1512 self.is_open_delayed = false;
1514 }
1515
1516 pub fn on_trigger_enter(
1517 &mut self,
1518 host: &mut dyn UiActionHost,
1519 acx: ActionCx,
1520 value_model: &Model<Option<Arc<str>>>,
1521 item_value: Arc<str>,
1522 cfg: NavigationMenuConfig,
1523 ) {
1524 cancel_timer(host, &mut self.open_timer);
1525
1526 let current = host
1527 .models_mut()
1528 .read(value_model, |v| v.clone())
1529 .ok()
1530 .flatten();
1531
1532 cancel_timer(host, &mut self.close_timer);
1534
1535 if !self.is_open_delayed {
1536 let _ = host
1537 .models_mut()
1538 .update(value_model, |v| *v = Some(item_value.clone()));
1539 self.note_opened(host, cfg);
1540 host.request_redraw(acx.window);
1541 return;
1542 }
1543
1544 if current.as_deref() == Some(item_value.as_ref()) {
1546 return;
1547 }
1548
1549 self.pending_open_value = Some(item_value);
1550 arm_timer(host, acx.window, cfg.delay_duration, &mut self.open_timer);
1551 host.request_redraw(acx.window);
1552 }
1553
1554 pub fn on_trigger_leave(
1555 &mut self,
1556 host: &mut dyn UiActionHost,
1557 acx: ActionCx,
1558 value_model: &Model<Option<Arc<str>>>,
1559 cfg: NavigationMenuConfig,
1560 ) {
1561 cancel_timer(host, &mut self.open_timer);
1562 self.pending_open_value = None;
1563 self.start_close_timer(host, acx, value_model, cfg);
1564 }
1565
1566 pub fn on_content_enter(&mut self, host: &mut dyn UiActionHost) {
1567 cancel_timer(host, &mut self.close_timer);
1568 }
1569
1570 pub fn on_content_leave(
1571 &mut self,
1572 host: &mut dyn UiActionHost,
1573 acx: ActionCx,
1574 value_model: &Model<Option<Arc<str>>>,
1575 cfg: NavigationMenuConfig,
1576 ) {
1577 self.start_close_timer(host, acx, value_model, cfg);
1578 }
1579
1580 fn start_close_timer(
1581 &mut self,
1582 host: &mut dyn UiActionHost,
1583 acx: ActionCx,
1584 value_model: &Model<Option<Arc<str>>>,
1585 cfg: NavigationMenuConfig,
1586 ) {
1587 if cfg.close_delay_duration.is_zero() {
1588 let _ = host.models_mut().update(value_model, |v| *v = None);
1589 self.note_closed(host, acx.window, cfg);
1590 host.request_redraw(acx.window);
1591 return;
1592 }
1593 arm_timer(
1594 host,
1595 acx.window,
1596 cfg.close_delay_duration,
1597 &mut self.close_timer,
1598 );
1599 host.request_redraw(acx.window);
1600 }
1601
1602 pub fn on_item_select(
1603 &mut self,
1604 host: &mut dyn UiActionHost,
1605 acx: ActionCx,
1606 value_model: &Model<Option<Arc<str>>>,
1607 item_value: Arc<str>,
1608 cfg: NavigationMenuConfig,
1609 ) {
1610 cancel_timer(host, &mut self.open_timer);
1611 cancel_timer(host, &mut self.close_timer);
1612 self.pending_open_value = None;
1613
1614 let current = host
1615 .models_mut()
1616 .read(value_model, |v| v.clone())
1617 .ok()
1618 .flatten();
1619 if current.as_deref() == Some(item_value.as_ref()) {
1620 let _ = host.models_mut().update(value_model, |v| *v = None);
1621 self.note_closed(host, acx.window, cfg);
1622 } else {
1623 let _ = host
1624 .models_mut()
1625 .update(value_model, |v| *v = Some(item_value.clone()));
1626 self.note_opened(host, cfg);
1627 }
1628
1629 host.request_redraw(acx.window);
1630 }
1631
1632 pub fn on_item_dismiss(
1633 &mut self,
1634 host: &mut dyn UiActionHost,
1635 acx: ActionCx,
1636 value_model: &Model<Option<Arc<str>>>,
1637 cfg: NavigationMenuConfig,
1638 ) {
1639 cancel_timer(host, &mut self.open_timer);
1640 cancel_timer(host, &mut self.close_timer);
1641 self.pending_open_value = None;
1642
1643 let _ = host.models_mut().update(value_model, |v| *v = None);
1644 self.note_closed(host, acx.window, cfg);
1645 host.request_redraw(acx.window);
1646 }
1647
1648 pub fn on_timer(
1652 &mut self,
1653 host: &mut dyn UiActionHost,
1654 acx: ActionCx,
1655 token: TimerToken,
1656 value_model: &Model<Option<Arc<str>>>,
1657 cfg: NavigationMenuConfig,
1658 ) -> bool {
1659 if self.open_timer == Some(token) {
1660 self.open_timer = None;
1661 let Some(value) = self.pending_open_value.take() else {
1662 return false;
1663 };
1664 cancel_timer(host, &mut self.close_timer);
1665 let _ = host.models_mut().update(value_model, |v| *v = Some(value));
1666 self.note_opened(host, cfg);
1667 host.request_redraw(acx.window);
1668 return true;
1669 }
1670
1671 if self.close_timer == Some(token) {
1672 self.close_timer = None;
1673 let _ = host.models_mut().update(value_model, |v| *v = None);
1674 self.note_closed(host, acx.window, cfg);
1675 host.request_redraw(acx.window);
1676 return true;
1677 }
1678
1679 if self.skip_delay_timer == Some(token) {
1680 self.skip_delay_timer = None;
1681 self.is_open_delayed = true;
1682 host.request_redraw(acx.window);
1683 return true;
1684 }
1685
1686 false
1687 }
1688}
1689
1690#[derive(Debug, Default, Clone, Copy)]
1691pub struct NavigationMenuTriggerState {
1692 pub has_pointer_move_opened: bool,
1693 pub was_click_close: bool,
1694 pub was_escape_close: bool,
1695}
1696
1697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1698pub enum NavigationMenuTriggerPointerMoveAction {
1699 Open,
1700 Ignore,
1701}
1702
1703pub fn navigation_menu_trigger_pointer_move_action(
1705 pointer_type: PointerType,
1706 disabled: bool,
1707 state: NavigationMenuTriggerState,
1708) -> NavigationMenuTriggerPointerMoveAction {
1709 match pointer_type {
1710 PointerType::Touch | PointerType::Pen => NavigationMenuTriggerPointerMoveAction::Ignore,
1711 PointerType::Mouse | PointerType::Unknown => {
1712 if disabled
1713 || state.was_click_close
1714 || state.was_escape_close
1715 || state.has_pointer_move_opened
1716 {
1717 NavigationMenuTriggerPointerMoveAction::Ignore
1718 } else {
1719 NavigationMenuTriggerPointerMoveAction::Open
1720 }
1721 }
1722 }
1723}
1724
1725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728pub enum NavigationMenuContentMotion {
1729 None,
1730 FromStart,
1731 FromEnd,
1732 ToStart,
1733 ToEnd,
1734}
1735
1736impl NavigationMenuContentMotion {
1737 pub fn as_str(self) -> &'static str {
1738 match self {
1739 NavigationMenuContentMotion::None => "none",
1740 NavigationMenuContentMotion::FromStart => "from-start",
1741 NavigationMenuContentMotion::FromEnd => "from-end",
1742 NavigationMenuContentMotion::ToStart => "to-start",
1743 NavigationMenuContentMotion::ToEnd => "to-end",
1744 }
1745 }
1746}
1747
1748#[derive(Debug, Clone, Copy, PartialEq)]
1749pub struct NavigationMenuContentTransitionOutput {
1750 pub from_idx: Option<usize>,
1751 pub to_idx: Option<usize>,
1752 pub switching: bool,
1753 pub progress: f32,
1755 pub animating: bool,
1756 pub from_motion: NavigationMenuContentMotion,
1757 pub to_motion: NavigationMenuContentMotion,
1758}
1759
1760#[derive(Debug, Clone, Copy, PartialEq)]
1766pub struct NavigationMenuContentSwitchOutput {
1767 pub from_idx: usize,
1768 pub to_idx: usize,
1769 pub progress: f32,
1770 pub animating: bool,
1771 pub forward: bool,
1772 pub from_motion: NavigationMenuContentMotion,
1773 pub to_motion: NavigationMenuContentMotion,
1774}
1775
1776pub fn navigation_menu_content_switch(
1778 transition: NavigationMenuContentTransitionOutput,
1779) -> Option<NavigationMenuContentSwitchOutput> {
1780 if !transition.switching || !transition.animating {
1781 return None;
1782 }
1783 let (Some(from_idx), Some(to_idx)) = (transition.from_idx, transition.to_idx) else {
1784 return None;
1785 };
1786 if from_idx == to_idx {
1787 return None;
1788 }
1789 Some(NavigationMenuContentSwitchOutput {
1790 from_idx,
1791 to_idx,
1792 progress: transition.progress.clamp(0.0, 1.0),
1793 animating: transition.animating,
1794 forward: to_idx > from_idx,
1795 from_motion: transition.from_motion,
1796 to_motion: transition.to_motion,
1797 })
1798}
1799
1800impl Default for NavigationMenuContentTransitionOutput {
1801 fn default() -> Self {
1802 Self {
1803 from_idx: None,
1804 to_idx: None,
1805 switching: false,
1806 progress: 1.0,
1807 animating: false,
1808 from_motion: NavigationMenuContentMotion::None,
1809 to_motion: NavigationMenuContentMotion::None,
1810 }
1811 }
1812}
1813
1814fn content_motion(
1815 from_idx: usize,
1816 to_idx: usize,
1817) -> (NavigationMenuContentMotion, NavigationMenuContentMotion) {
1818 if from_idx == to_idx {
1819 return (
1820 NavigationMenuContentMotion::None,
1821 NavigationMenuContentMotion::None,
1822 );
1823 }
1824
1825 if to_idx > from_idx {
1829 (
1830 NavigationMenuContentMotion::ToStart,
1831 NavigationMenuContentMotion::FromEnd,
1832 )
1833 } else {
1834 (
1835 NavigationMenuContentMotion::ToEnd,
1836 NavigationMenuContentMotion::FromStart,
1837 )
1838 }
1839}
1840
1841#[derive(Default)]
1842struct ContentTransitionState {
1843 last_selected: Option<Arc<str>>,
1844 last_selected_idx: Option<usize>,
1845 from_idx: Option<usize>,
1846 to_idx: Option<usize>,
1847 seq: u64,
1848}
1849
1850#[derive(Default)]
1851struct ContentTransitionMotionState {
1852 seq: u64,
1853 last_app_tick: u64,
1854 last_frame_tick: u64,
1855 tick: u64,
1856 timeline: TransitionTimeline,
1857 lease: Option<ContinuousFrames>,
1858 configured_open_ticks: u64,
1859 configured_close_ticks: u64,
1860}
1861
1862pub fn navigation_menu_content_transition_with_durations_and_easing<H: UiHost>(
1874 cx: &mut ElementContext<'_, H>,
1875 id: fret_ui::elements::GlobalElementId,
1876 open: bool,
1877 selected: Option<Arc<str>>,
1878 values: &[Arc<str>],
1879 open_ticks: u64,
1880 close_ticks: u64,
1881 ease: fn(f32) -> f32,
1882) -> NavigationMenuContentTransitionOutput {
1883 if !open {
1884 cx.state_for(id, ContentTransitionState::default, |st| {
1885 st.last_selected = None;
1886 st.last_selected_idx = None;
1887 st.from_idx = None;
1888 st.to_idx = None;
1889 });
1890 cx.state_for(id, ContentTransitionMotionState::default, |st| {
1891 st.seq = 0;
1892 st.tick = 0;
1893 st.timeline = TransitionTimeline::default();
1894 st.lease = None;
1895 });
1896 return NavigationMenuContentTransitionOutput::default();
1897 }
1898
1899 let selected_idx = selected
1900 .as_deref()
1901 .and_then(|v| values.iter().position(|it| it.as_ref() == v));
1902
1903 let (seq, from_idx, to_idx) = cx.state_for(id, ContentTransitionState::default, |st| {
1904 let changed = selected.is_some()
1905 && st.last_selected.is_some()
1906 && selected != st.last_selected
1907 && selected_idx.is_some()
1908 && st.last_selected_idx.is_some();
1909
1910 if changed {
1911 st.from_idx = st.last_selected_idx;
1912 st.to_idx = selected_idx;
1913 st.seq = st.seq.saturating_add(1);
1914 }
1915
1916 st.last_selected = selected.clone();
1917 st.last_selected_idx = selected_idx;
1918
1919 (st.seq, st.from_idx, st.to_idx)
1920 });
1921
1922 let (Some(from_idx), Some(to_idx)) = (from_idx, to_idx) else {
1923 return NavigationMenuContentTransitionOutput::default();
1924 };
1925
1926 let (open_ticks, close_ticks) =
1927 crate::declarative::transition::effective_transition_durations_for_cx(
1928 cx,
1929 open_ticks,
1930 close_ticks,
1931 );
1932
1933 let app_tick = cx.app.tick_id().0;
1934 let frame_tick = cx.frame_id.0;
1935
1936 let (out, start_lease, stop_lease) =
1937 cx.state_for(id, ContentTransitionMotionState::default, |st| {
1938 if st.configured_open_ticks != open_ticks || st.configured_close_ticks != close_ticks {
1939 st.configured_open_ticks = open_ticks;
1940 st.configured_close_ticks = close_ticks;
1941 st.timeline.set_durations(open_ticks, close_ticks);
1942 }
1943
1944 if st.seq != seq {
1945 st.seq = seq;
1946 st.last_app_tick = app_tick;
1947 st.last_frame_tick = frame_tick;
1948 st.tick = 0;
1949 st.timeline = TransitionTimeline::default();
1950 st.timeline.set_durations(open_ticks, close_ticks);
1951 }
1952
1953 if st.last_frame_tick != frame_tick {
1954 st.last_frame_tick = frame_tick;
1955 st.tick = st.tick.saturating_add(1);
1956 } else if st.last_app_tick != app_tick {
1957 st.last_app_tick = app_tick;
1958 st.tick = st.tick.saturating_add(1);
1959 } else {
1960 st.tick = st.tick.saturating_add(1);
1961 }
1962
1963 let out = st.timeline.update_with_easing(true, st.tick, ease);
1964 let start_lease = out.animating && st.lease.is_none();
1965 let stop_lease = !out.animating && st.lease.is_some();
1966 (out, start_lease, stop_lease)
1967 });
1968
1969 if start_lease {
1970 let lease = cx.begin_continuous_frames();
1971 cx.state_for(id, ContentTransitionMotionState::default, |st| {
1972 st.lease = Some(lease);
1973 });
1974 } else if stop_lease {
1975 cx.state_for(id, ContentTransitionMotionState::default, |st| {
1976 st.lease = None;
1977 });
1978
1979 cx.state_for(id, ContentTransitionState::default, |st| {
1980 st.from_idx = None;
1981 st.to_idx = None;
1982 });
1983 }
1984
1985 if out.animating {
1986 cx.request_frame();
1987 } else {
1988 cx.state_for(id, ContentTransitionState::default, |st| {
1991 st.from_idx = None;
1992 st.to_idx = None;
1993 });
1994 }
1995
1996 let (from_motion, to_motion) = content_motion(from_idx, to_idx);
1997 NavigationMenuContentTransitionOutput {
1998 from_idx: Some(from_idx),
1999 to_idx: Some(to_idx),
2000 switching: true,
2001 progress: out.progress,
2002 animating: out.animating,
2003 from_motion,
2004 to_motion,
2005 }
2006}
2007
2008pub fn navigation_menu_content_transition_with_durations_and_cubic_bezier<H: UiHost>(
2009 cx: &mut ElementContext<'_, H>,
2010 id: fret_ui::elements::GlobalElementId,
2011 open: bool,
2012 selected: Option<Arc<str>>,
2013 values: &[Arc<str>],
2014 open_ticks: u64,
2015 close_ticks: u64,
2016 bezier: CubicBezier,
2017) -> NavigationMenuContentTransitionOutput {
2018 if !open {
2019 cx.state_for(id, ContentTransitionState::default, |st| {
2020 st.last_selected = None;
2021 st.last_selected_idx = None;
2022 st.from_idx = None;
2023 st.to_idx = None;
2024 });
2025 cx.state_for(id, ContentTransitionMotionState::default, |st| {
2026 st.seq = 0;
2027 st.tick = 0;
2028 st.timeline = TransitionTimeline::default();
2029 st.lease = None;
2030 });
2031 return NavigationMenuContentTransitionOutput::default();
2032 }
2033
2034 let selected_idx = selected
2035 .as_deref()
2036 .and_then(|v| values.iter().position(|it| it.as_ref() == v));
2037
2038 let (seq, from_idx, to_idx) = cx.state_for(id, ContentTransitionState::default, |st| {
2039 let changed = selected.is_some()
2040 && st.last_selected.is_some()
2041 && selected != st.last_selected
2042 && selected_idx.is_some()
2043 && st.last_selected_idx.is_some();
2044
2045 if changed {
2046 st.from_idx = st.last_selected_idx;
2047 st.to_idx = selected_idx;
2048 st.seq = st.seq.saturating_add(1);
2049 }
2050
2051 st.last_selected = selected.clone();
2052 st.last_selected_idx = selected_idx;
2053
2054 (st.seq, st.from_idx, st.to_idx)
2055 });
2056
2057 let (Some(from_idx), Some(to_idx)) = (from_idx, to_idx) else {
2058 return NavigationMenuContentTransitionOutput::default();
2059 };
2060
2061 let (open_ticks, close_ticks) =
2062 crate::declarative::transition::effective_transition_durations_for_cx(
2063 cx,
2064 open_ticks,
2065 close_ticks,
2066 );
2067
2068 let app_tick = cx.app.tick_id().0;
2069 let frame_tick = cx.frame_id.0;
2070
2071 let (out, start_lease, stop_lease) =
2072 cx.state_for(id, ContentTransitionMotionState::default, |st| {
2073 if st.configured_open_ticks != open_ticks || st.configured_close_ticks != close_ticks {
2074 st.configured_open_ticks = open_ticks;
2075 st.configured_close_ticks = close_ticks;
2076 st.timeline.set_durations(open_ticks, close_ticks);
2077 }
2078
2079 if st.seq != seq {
2080 st.seq = seq;
2081 st.last_app_tick = app_tick;
2082 st.last_frame_tick = frame_tick;
2083 st.tick = 0;
2084 st.timeline = TransitionTimeline::default();
2085 st.timeline.set_durations(open_ticks, close_ticks);
2086 }
2087
2088 if st.last_frame_tick != frame_tick {
2089 st.last_frame_tick = frame_tick;
2090 st.tick = st.tick.saturating_add(1);
2091 } else if st.last_app_tick != app_tick {
2092 st.last_app_tick = app_tick;
2093 st.tick = st.tick.saturating_add(1);
2094 } else {
2095 st.tick = st.tick.saturating_add(1);
2096 }
2097
2098 let out = st.timeline.update_with_cubic_bezier(
2099 true, st.tick, bezier.x1, bezier.y1, bezier.x2, bezier.y2,
2100 );
2101 let start_lease = out.animating && st.lease.is_none();
2102 let stop_lease = !out.animating && st.lease.is_some();
2103 (out, start_lease, stop_lease)
2104 });
2105
2106 if start_lease {
2107 let lease = cx.begin_continuous_frames();
2108 cx.state_for(id, ContentTransitionMotionState::default, |st| {
2109 st.lease = Some(lease);
2110 });
2111 } else if stop_lease {
2112 cx.state_for(id, ContentTransitionMotionState::default, |st| {
2113 st.lease = None;
2114 });
2115
2116 cx.state_for(id, ContentTransitionState::default, |st| {
2117 st.from_idx = None;
2118 st.to_idx = None;
2119 });
2120 }
2121
2122 if out.animating {
2123 cx.request_frame();
2124 } else {
2125 cx.state_for(id, ContentTransitionState::default, |st| {
2128 st.from_idx = None;
2129 st.to_idx = None;
2130 });
2131 }
2132
2133 let (from_motion, to_motion) = content_motion(from_idx, to_idx);
2134 NavigationMenuContentTransitionOutput {
2135 from_idx: Some(from_idx),
2136 to_idx: Some(to_idx),
2137 switching: true,
2138 progress: out.progress,
2139 animating: out.animating,
2140 from_motion,
2141 to_motion,
2142 }
2143}
2144
2145pub fn navigation_menu_content_transition_with_durations_and_cubic_bezier_duration<H: UiHost>(
2146 cx: &mut ElementContext<'_, H>,
2147 id: fret_ui::elements::GlobalElementId,
2148 open: bool,
2149 selected: Option<Arc<str>>,
2150 values: &[Arc<str>],
2151 open_duration: Duration,
2152 close_duration: Duration,
2153 bezier: CubicBezier,
2154) -> NavigationMenuContentTransitionOutput {
2155 let open_ticks = crate::declarative::transition::ticks_60hz_for_duration(open_duration);
2156 let close_ticks = crate::declarative::transition::ticks_60hz_for_duration(close_duration);
2157 navigation_menu_content_transition_with_durations_and_cubic_bezier(
2158 cx,
2159 id,
2160 open,
2161 selected,
2162 values,
2163 open_ticks,
2164 close_ticks,
2165 bezier,
2166 )
2167}
2168
2169pub fn navigation_menu_content_transition<H: UiHost>(
2171 cx: &mut ElementContext<'_, H>,
2172 id: fret_ui::elements::GlobalElementId,
2173 open: bool,
2174 selected: Option<Arc<str>>,
2175 values: &[Arc<str>],
2176) -> NavigationMenuContentTransitionOutput {
2177 navigation_menu_content_transition_with_durations_and_cubic_bezier_duration(
2178 cx,
2179 id,
2180 open,
2181 selected,
2182 values,
2183 crate::declarative::overlay_motion::motion_layout_expand_duration(cx),
2184 crate::declarative::overlay_motion::motion_layout_expand_duration(cx),
2185 crate::declarative::overlay_motion::motion_layout_expand_ease_bezier(cx),
2186 )
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191 use super::*;
2192
2193 use fret_app::App;
2194 use fret_core::{AppWindowId, Point, Px, Rect, Size};
2195 use fret_ui::GlobalElementId;
2196 use fret_ui::action::UiActionHostAdapter;
2197 use fret_ui::element::{AnyElement, ElementKind, PressableProps};
2198
2199 fn acx(window: AppWindowId) -> ActionCx {
2200 ActionCx {
2201 window,
2202 target: GlobalElementId(0x1),
2203 }
2204 }
2205
2206 #[test]
2207 fn trigger_enter_is_delayed_by_default_and_opens_after_timer() {
2208 let window = AppWindowId::default();
2209 let mut app = App::new();
2210 let value = app.models_mut().insert(None::<Arc<str>>);
2211 let mut host = UiActionHostAdapter { app: &mut app };
2212
2213 let mut st = NavigationMenuRootState::default();
2214 let cfg = NavigationMenuConfig::default();
2215
2216 st.on_trigger_enter(&mut host, acx(window), &value, Arc::from("a"), cfg);
2217 assert_eq!(
2218 host.models_mut().read(&value, |v| v.clone()).ok().flatten(),
2219 None
2220 );
2221
2222 let effects = host.app.flush_effects();
2223 let token = effects
2224 .iter()
2225 .find_map(|e| match e {
2226 Effect::SetTimer { token, after, .. } if *after == cfg.delay_duration => {
2227 Some(*token)
2228 }
2229 _ => None,
2230 })
2231 .expect("expected open timer");
2232
2233 assert!(st.on_timer(&mut host, acx(window), token, &value, cfg));
2234 assert_eq!(
2235 host.models_mut()
2236 .read(&value, |v| v.clone())
2237 .ok()
2238 .flatten()
2239 .as_deref(),
2240 Some("a")
2241 );
2242 assert!(
2243 !st.is_open_delayed(),
2244 "expected skip-delay window to be active"
2245 );
2246 }
2247
2248 #[test]
2249 fn closing_enables_immediate_open_within_skip_delay_window() {
2250 let window = AppWindowId::default();
2251 let mut app = App::new();
2252 let value = app.models_mut().insert(Some(Arc::from("a")));
2253 let mut host = UiActionHostAdapter { app: &mut app };
2254
2255 let mut st = NavigationMenuRootState::default();
2256 let cfg = NavigationMenuConfig::default();
2257
2258 st.note_opened(&mut host, cfg);
2260 assert!(!st.is_open_delayed());
2261
2262 st.on_item_dismiss(&mut host, acx(window), &value, cfg);
2265 assert_eq!(
2266 host.models_mut().read(&value, |v| v.clone()).ok().flatten(),
2267 None
2268 );
2269 assert!(!st.is_open_delayed());
2270
2271 host.app.flush_effects();
2272
2273 st.on_trigger_enter(&mut host, acx(window), &value, Arc::from("b"), cfg);
2275 assert_eq!(
2276 host.models_mut()
2277 .read(&value, |v| v.clone())
2278 .ok()
2279 .flatten()
2280 .as_deref(),
2281 Some("b")
2282 );
2283 let effects = host.app.flush_effects();
2284 assert!(
2285 effects.iter().all(
2286 |e| !matches!(e, Effect::SetTimer { after, .. } if *after == cfg.delay_duration)
2287 ),
2288 "expected immediate open (no delayed-open timer)"
2289 );
2290 }
2291
2292 #[test]
2293 fn trigger_leave_starts_close_timer_and_content_enter_cancels_it() {
2294 let window = AppWindowId::default();
2295 let mut app = App::new();
2296 let value = app.models_mut().insert(Some(Arc::from("a")));
2297 let mut host = UiActionHostAdapter { app: &mut app };
2298
2299 let mut st = NavigationMenuRootState::default();
2300 let cfg = NavigationMenuConfig::default();
2301
2302 st.on_trigger_leave(&mut host, acx(window), &value, cfg);
2303 let effects = host.app.flush_effects();
2304 let close_token = effects
2305 .iter()
2306 .find_map(|e| match e {
2307 Effect::SetTimer { token, after, .. } if *after == cfg.close_delay_duration => {
2308 Some(*token)
2309 }
2310 _ => None,
2311 })
2312 .expect("expected close timer");
2313
2314 st.on_content_enter(&mut host);
2316 let effects = host.app.flush_effects();
2317 assert!(
2318 effects
2319 .iter()
2320 .any(|e| matches!(e, Effect::CancelTimer { token } if *token == close_token)),
2321 "expected close timer cancellation"
2322 );
2323 }
2324
2325 #[test]
2326 fn trigger_pointer_move_gate_matches_radix_outcomes() {
2327 let st = NavigationMenuTriggerState::default();
2328 assert_eq!(
2329 navigation_menu_trigger_pointer_move_action(PointerType::Mouse, false, st),
2330 NavigationMenuTriggerPointerMoveAction::Open
2331 );
2332 assert_eq!(
2333 navigation_menu_trigger_pointer_move_action(PointerType::Touch, false, st),
2334 NavigationMenuTriggerPointerMoveAction::Ignore
2335 );
2336
2337 let st = NavigationMenuTriggerState {
2338 has_pointer_move_opened: true,
2339 ..Default::default()
2340 };
2341 assert_eq!(
2342 navigation_menu_trigger_pointer_move_action(PointerType::Mouse, false, st),
2343 NavigationMenuTriggerPointerMoveAction::Ignore
2344 );
2345 }
2346
2347 #[test]
2348 fn content_motion_matches_forward_and_backward_semantics() {
2349 let (from, to) = content_motion(0, 1);
2350 assert_eq!(from, NavigationMenuContentMotion::ToStart);
2351 assert_eq!(to, NavigationMenuContentMotion::FromEnd);
2352
2353 let (from, to) = content_motion(2, 1);
2354 assert_eq!(from, NavigationMenuContentMotion::ToEnd);
2355 assert_eq!(to, NavigationMenuContentMotion::FromStart);
2356 }
2357
2358 #[test]
2359 fn content_switch_exposes_from_to_and_direction() {
2360 let transition = NavigationMenuContentTransitionOutput {
2361 from_idx: Some(0),
2362 to_idx: Some(2),
2363 switching: true,
2364 progress: 0.25,
2365 animating: true,
2366 from_motion: NavigationMenuContentMotion::ToStart,
2367 to_motion: NavigationMenuContentMotion::FromEnd,
2368 };
2369 let out = navigation_menu_content_switch(transition).expect("switch");
2370 assert_eq!(out.from_idx, 0);
2371 assert_eq!(out.to_idx, 2);
2372 assert!(out.forward);
2373 assert_eq!(out.progress, 0.25);
2374 assert_eq!(out.from_motion, NavigationMenuContentMotion::ToStart);
2375 assert_eq!(out.to_motion, NavigationMenuContentMotion::FromEnd);
2376 }
2377
2378 fn bounds() -> Rect {
2379 Rect::new(
2380 Point::new(Px(0.0), Px(0.0)),
2381 Size::new(Px(200.0), Px(120.0)),
2382 )
2383 }
2384
2385 #[test]
2386 fn viewport_size_interpolates_between_registered_sizes() {
2387 let window = AppWindowId::default();
2388 let mut app = App::new();
2389
2390 fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
2391 let root_id = cx.root_id();
2392 let a: Arc<str> = Arc::from("a");
2393 let b: Arc<str> = Arc::from("b");
2394 let values = vec![a.clone(), b.clone()];
2395
2396 navigation_menu_register_viewport_size(
2397 cx,
2398 root_id,
2399 a.clone(),
2400 Size::new(Px(100.0), Px(50.0)),
2401 );
2402 navigation_menu_register_viewport_size(
2403 cx,
2404 root_id,
2405 b.clone(),
2406 Size::new(Px(200.0), Px(150.0)),
2407 );
2408
2409 let transition = NavigationMenuContentTransitionOutput {
2410 from_idx: Some(0),
2411 to_idx: Some(1),
2412 switching: true,
2413 progress: 0.5,
2414 animating: true,
2415 from_motion: NavigationMenuContentMotion::ToStart,
2416 to_motion: NavigationMenuContentMotion::FromEnd,
2417 };
2418
2419 let out = navigation_menu_viewport_size_for_transition(
2420 cx,
2421 root_id,
2422 Some(b.clone()),
2423 &values,
2424 transition,
2425 Size::new(Px(10.0), Px(10.0)),
2426 );
2427 assert_eq!(out.size, Size::new(Px(150.0), Px(100.0)));
2428 });
2429 }
2430
2431 #[test]
2432 fn viewport_selected_value_is_stable_while_present() {
2433 let window = AppWindowId::default();
2434 let mut app = App::new();
2435
2436 fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
2437 let root_id = cx.root_id();
2438
2439 let a: Arc<str> = Arc::from("a");
2440 let b: Arc<str> = Arc::from("b");
2441
2442 assert_eq!(
2443 navigation_menu_viewport_selected_value(cx, root_id, Some(a.clone()), false)
2444 .as_deref(),
2445 Some("a")
2446 );
2447 assert_eq!(
2448 navigation_menu_viewport_selected_value(cx, root_id, None, true).as_deref(),
2449 Some("a")
2450 );
2451 assert_eq!(
2452 navigation_menu_viewport_selected_value(cx, root_id, Some(b.clone()), true)
2453 .as_deref(),
2454 Some("b")
2455 );
2456 assert_eq!(
2457 navigation_menu_viewport_selected_value(cx, root_id, None, false).as_deref(),
2458 None
2459 );
2460 });
2461 }
2462
2463 #[test]
2464 fn indicator_rect_tracks_anchor_center_and_viewport_edge() {
2465 let anchor = Rect::new(
2466 Point::new(Px(10.0), Px(20.0)),
2467 Size::new(Px(100.0), Px(40.0)),
2468 );
2469 let viewport = Rect::new(
2470 Point::new(Px(0.0), Px(100.0)),
2471 Size::new(Px(200.0), Px(80.0)),
2472 );
2473 let out = navigation_menu_indicator_rect(anchor, viewport, Side::Bottom, Px(6.0));
2474 assert_eq!(out.origin.x, Px(10.0));
2475 assert_eq!(out.origin.y, Px(100.0 - 6.0));
2476 assert_eq!(out.size, Size::new(Px(100.0), Px(6.0)));
2477 }
2478
2479 #[test]
2480 fn navigation_menu_link_suppresses_dismiss_on_ctrl_or_meta_by_default() {
2481 assert!(
2482 navigation_menu_link_suppresses_dismiss(
2483 Modifiers {
2484 ctrl: true,
2485 ..Default::default()
2486 },
2487 false
2488 ),
2489 "expected ctrl to suppress dismiss"
2490 );
2491 assert!(
2492 navigation_menu_link_suppresses_dismiss(
2493 Modifiers {
2494 meta: true,
2495 ..Default::default()
2496 },
2497 false
2498 ),
2499 "expected meta to suppress dismiss"
2500 );
2501 assert!(
2502 !navigation_menu_link_suppresses_dismiss(Modifiers::default(), false),
2503 "expected unmodified to not suppress dismiss"
2504 );
2505 assert!(
2506 !navigation_menu_link_suppresses_dismiss(
2507 Modifiers {
2508 meta: true,
2509 ..Default::default()
2510 },
2511 true
2512 ),
2513 "expected opt-in to allow dismiss on modified select"
2514 );
2515 }
2516
2517 #[test]
2518 fn viewport_content_semantics_id_matches_mounted_content_id() {
2519 let window = AppWindowId::default();
2520 let mut app = App::new();
2521
2522 fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
2523 let root_id = cx.root_id();
2524 let overlay_root_name = "nav-menu-overlay";
2525 let value = "alpha";
2526 let expected = navigation_menu_viewport_content_semantics_id::<App>(
2527 cx,
2528 root_id,
2529 overlay_root_name,
2530 value,
2531 );
2532 let actual = cx.with_root_name(overlay_root_name, |cx| {
2533 navigation_menu_viewport_content_pressable_with_id_props::<App>(
2534 cx,
2535 root_id,
2536 value,
2537 |_cx, _st, _id| {
2538 (
2539 fret_ui::element::PressableProps {
2540 layout: LayoutStyle::default(),
2541 enabled: true,
2542 focusable: false,
2543 ..Default::default()
2544 },
2545 Vec::new(),
2546 )
2547 },
2548 )
2549 .id
2550 });
2551 assert_eq!(expected, actual);
2552 });
2553 }
2554
2555 #[test]
2556 fn find_first_focus_target_returns_first_enabled_focusable_in_tree_order() {
2557 let first = GlobalElementId(0x10);
2558 let later = GlobalElementId(0x11);
2559 let elements = vec![
2560 AnyElement::new(
2561 GlobalElementId(0x1),
2562 ElementKind::Pressable(PressableProps {
2563 enabled: false,
2564 ..Default::default()
2565 }),
2566 vec![AnyElement::new(
2567 first,
2568 ElementKind::Pressable(PressableProps {
2569 enabled: true,
2570 ..Default::default()
2571 }),
2572 Vec::new(),
2573 )],
2574 ),
2575 AnyElement::new(
2576 later,
2577 ElementKind::Pressable(PressableProps {
2578 enabled: true,
2579 ..Default::default()
2580 }),
2581 Vec::new(),
2582 ),
2583 ];
2584
2585 assert_eq!(find_first_focus_target(&elements), Some(first));
2586 }
2587}