1use std::rc::Rc;
59use std::time::Duration;
60
61use teksilo_canvas::{Point, Rect, Size, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::accesskit::HasPopup;
64use teksilo_core::build_context::BuildContext;
65use teksilo_core::event::{EventResponse, WidgetEvent};
66use teksilo_core::overlay::{
67 DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
68};
69use teksilo_core::signal::Signal;
70use teksilo_core::styles::{PopoverStyle, PopoverStyleConfig, PopoverVariant, SharedPopoverStyle};
71use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
72use teksilo_core::widget_builder::HandlerSet;
73use teksilo_core::widget_id::WidgetId;
74use teksilo_tokens::TextRole;
75
76use crate::button::{Button, InteractionState, resolve_text_role};
77use crate::common::range_nav::DisclosureChord;
78use crate::icon_button::{
79 IconButton, IconButtonSize, resolve_icon_role_embedded, resolve_icon_role_standalone,
80};
81use crate::overlay_trigger::OverlayTrigger;
82use crate::popover_caret::DisclosureCaret;
83use crate::primitives::ZStack;
84
85type OnVoid = Rc<dyn Fn()>;
86
87pub trait PopoverTrigger: Widget + Sized + 'static {
92 fn default_has_popup() -> HasPopup;
96
97 fn default_show_caret() -> bool;
101
102 fn suppress_caret(&self) -> bool {
106 false
107 }
108
109 fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole>;
114
115 fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self;
122
123 fn with_has_popup(self, kind: HasPopup) -> Self;
125
126 fn with_expanded_when(self, open: Signal<bool>) -> Self;
128
129 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self;
131
132 fn has_on_activate(&self) -> bool;
135}
136
137pub type PopoverCustom = PopoverWidget<OverlayTrigger>;
144
145impl PopoverTrigger for OverlayTrigger {
146 fn default_has_popup() -> HasPopup {
149 HasPopup::Dialog
150 }
151
152 fn default_show_caret() -> bool {
156 false
157 }
158
159 fn caret_role(&self, _interaction: &Signal<InteractionState>) -> Signal<TextRole> {
160 Signal::new(TextRole::Secondary)
163 }
164
165 fn with_shared_interaction(self, _signal: Signal<InteractionState>) -> Self {
166 self
169 }
170
171 fn with_has_popup(self, kind: HasPopup) -> Self {
172 self.has_popup(kind)
173 }
174
175 fn with_expanded_when(self, open: Signal<bool>) -> Self {
176 self.expanded_when(open)
177 }
178
179 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
180 self.on_activate(f)
181 }
182
183 fn has_on_activate(&self) -> bool {
184 self.has_on_activate()
185 }
186}
187
188impl PopoverTrigger for Button {
189 fn default_has_popup() -> HasPopup {
190 HasPopup::Dialog
191 }
192 fn default_show_caret() -> bool {
193 false
194 }
195 fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
196 let variant = self.current_variant();
197 interaction.map(move |s| resolve_text_role(variant, *s))
198 }
199 fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
200 self.share_interaction(signal)
201 }
202 fn with_has_popup(self, kind: HasPopup) -> Self {
203 self.has_popup(kind)
204 }
205 fn with_expanded_when(self, open: Signal<bool>) -> Self {
206 self.expanded_when(open)
207 }
208 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
209 self.on_activate_fn(f)
210 }
211 fn has_on_activate(&self) -> bool {
212 self.has_activate_handler()
213 }
214}
215
216impl PopoverTrigger for IconButton {
217 fn default_has_popup() -> HasPopup {
218 HasPopup::Menu
219 }
220 fn default_show_caret() -> bool {
221 true
222 }
223 fn suppress_caret(&self) -> bool {
224 matches!(self.size_variant(), IconButtonSize::Compact)
227 }
228 fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
229 if self.is_embedded() {
230 interaction.map(|s| resolve_icon_role_embedded(*s))
231 } else {
232 interaction.map(|s| resolve_icon_role_standalone(*s))
233 }
234 }
235 fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
236 self.share_interaction(signal)
237 }
238 fn with_has_popup(self, kind: HasPopup) -> Self {
239 self.has_popup(kind)
240 }
241 fn with_expanded_when(self, open: Signal<bool>) -> Self {
242 self.expanded_when(open)
243 }
244 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
245 self.on_activate_fn(f)
246 }
247 fn has_on_activate(&self) -> bool {
248 self.has_activate_handler()
249 }
250}
251
252fn warn_trigger_activate_discarded() {
257 thread_local! {
258 static WARNED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
259 }
260 WARNED.with(|w| {
261 if !w.get() {
262 eprintln!(
263 "[teksilo-widgets::popover] PopoverWidget overwrote the trigger's \
264 on_activate_fn — the caller-set handler was discarded. Use on_open / \
265 on_close, or observe open_signal, for trigger-side side effects."
266 );
267 w.set(true);
268 }
269 });
270}
271
272pub struct PopoverWidget<T: PopoverTrigger> {
277 trigger: Option<T>,
278 content: Option<Box<dyn Widget>>,
279
280 popover_open: Signal<bool>,
281 open_action: Option<&'static str>,
284 placement: OverlayPlacement,
285 dismiss_behavior: DismissBehavior,
286 fade_duration: Option<Duration>,
287 has_popup: HasPopup,
288 show_disclosure_caret: bool,
289
290 on_open: Option<OnVoid>,
291 on_close: Option<OnVoid>,
292
293 surface_variant: Option<PopoverVariant>,
301 surface_style: Option<SharedPopoverStyle>,
305 surface_name: String,
308
309 content_id: Option<WidgetId>,
310 root_child_id: Option<WidgetId>,
311
312 tooltip_text: Option<teksilo_i18n::LocalizedString>,
316 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
318 composite_tooltip_content: Option<Box<dyn Widget>>,
320}
321
322pub type PopoverButton = PopoverWidget<Button>;
325
326pub type PopoverIconButton = PopoverWidget<IconButton>;
330
331impl<T: PopoverTrigger> std::fmt::Debug for PopoverWidget<T> {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 f.debug_struct("PopoverWidget")
334 .field("placement", &self.placement)
335 .field("dismiss_behavior", &self.dismiss_behavior)
336 .field("has_popup", &self.has_popup)
337 .field("show_disclosure_caret", &self.show_disclosure_caret)
338 .field("popover_open", &self.popover_open.get())
339 .finish_non_exhaustive()
340 }
341}
342
343impl<T: PopoverTrigger> PopoverWidget<T> {
344 pub fn new(trigger: T) -> Self {
347 Self {
348 trigger: Some(trigger),
349 content: None,
350 popover_open: Signal::new(false),
351 open_action: None,
352 placement: OverlayPlacement::BelowPreferred,
353 dismiss_behavior: DismissBehavior::EscapeOrClickOutside,
354 fade_duration: None,
355 has_popup: T::default_has_popup(),
356 show_disclosure_caret: T::default_show_caret(),
357 on_open: None,
358 on_close: None,
359 surface_variant: Some(PopoverVariant::Default),
360 surface_style: None,
361 surface_name: String::new(),
362 content_id: None,
363 root_child_id: None,
364 tooltip_text: None,
365 rich_tooltip_source: None,
366 composite_tooltip_content: None,
367 }
368 }
369
370 pub fn content(mut self, content: impl Widget + 'static) -> Self {
375 self.content = Some(Box::new(content));
376 self
377 }
378
379 pub fn placement(mut self, p: OverlayPlacement) -> Self {
382 self.placement = p;
383 self
384 }
385
386 pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self {
389 self.dismiss_behavior = b;
390 self
391 }
392
393 pub fn fade_duration(mut self, d: Duration) -> Self {
396 self.fade_duration = Some(d);
397 self
398 }
399
400 pub fn has_popup_kind(mut self, k: HasPopup) -> Self {
403 self.has_popup = k;
404 self
405 }
406
407 pub fn show_disclosure_caret(mut self, on: bool) -> Self {
415 self.show_disclosure_caret = on;
416 self
417 }
418
419 pub fn on_open(mut self, f: impl Fn() + 'static) -> Self {
424 self.on_open = Some(Rc::new(f));
425 self
426 }
427
428 pub fn on_close(mut self, f: impl Fn() + 'static) -> Self {
431 self.on_close = Some(Rc::new(f));
432 self
433 }
434
435 pub fn open_signal(&self) -> Signal<bool> {
443 self.popover_open.clone()
444 }
445
446 pub fn open_action(mut self, intent: &'static str) -> Self {
471 self.open_action = Some(intent);
472 self
473 }
474
475 pub fn surface(mut self, variant: PopoverVariant) -> Self {
481 self.surface_variant = Some(variant);
482 self
483 }
484
485 pub fn bare(mut self) -> Self {
492 self.surface_variant = None;
493 self
494 }
495
496 pub fn surface_style(mut self, style: impl PopoverStyle) -> Self {
501 self.surface_style = Some(Rc::new(style));
502 self
503 }
504
505 pub fn surface_name(mut self, name: impl Into<String>) -> Self {
510 self.surface_name = name.into();
511 self
512 }
513
514 pub fn tooltip(mut self, text: impl Into<teksilo_i18n::LocalizedString>) -> Self {
521 self.tooltip_text = Some(text.into());
522 self.rich_tooltip_source = None;
523 self.composite_tooltip_content = None;
524 self
525 }
526
527 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
531 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
532 self.tooltip_text = None;
533 self.composite_tooltip_content = None;
534 self
535 }
536
537 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
543 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
544 self.tooltip_text = None;
545 self.composite_tooltip_content = None;
546 self
547 }
548
549 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
553 self.composite_tooltip_content = Some(Box::new(content));
554 self.tooltip_text = None;
555 self.rich_tooltip_source = None;
556 self
557 }
558}
559
560struct PopoverBody {
570 content: Option<Box<dyn Widget>>,
571 surface_variant: Option<teksilo_core::styles::PopoverVariant>,
572 surface_style: Option<SharedPopoverStyle>,
573 surface_name: String,
574 placement: OverlayPlacement,
575 body_id: Option<WidgetId>,
576}
577
578impl std::fmt::Debug for PopoverBody {
579 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580 f.debug_struct("PopoverBody").finish()
581 }
582}
583
584impl Widget for PopoverBody {
585 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
586 if let Some(id) = self.body_id {
587 return vec![id];
588 }
589 let Some(content) = self.content.take() else {
590 return Vec::new();
591 };
592 let inner_content_id = ctx.add_boxed(content);
595
596 let id = match self.surface_variant {
602 None => inner_content_id,
603 Some(variant) => {
604 let style: SharedPopoverStyle = self
605 .surface_style
606 .clone()
607 .or_else(|| ctx.theme().style_slots.popover.clone())
608 .unwrap_or_else(|| {
609 Rc::new(crate::styles::RecipePopoverStyle::for_tokens(
610 &ctx.theme().input,
611 ))
612 });
613 let cfg = PopoverStyleConfig {
614 content: inner_content_id,
615 variant,
616 name: self.surface_name.clone(),
617 placement: self.placement.clone(),
618 show_caret: false,
619 caret_size: 0.0,
620 };
621 style.make_body(&cfg, ctx)
622 }
623 };
624 self.body_id = Some(id);
625 vec![id]
626 }
627
628 fn layout_response(
629 &self,
630 proposal: SizeProposal,
631 ctx: &LayoutContext,
632 ) -> teksilo_core::widget::LayoutResponse {
633 match self.body_id {
634 Some(id) => ctx
635 .child_size(id, proposal)
636 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
637 .into(),
638 None => Size::new(0.0, 0.0).into(),
639 }
640 }
641
642 fn place_children(
643 &self,
644 bounds: Rect,
645 _proposal: SizeProposal,
646 children: &mut [WidgetPlacement],
647 _ctx: &LayoutContext,
648 ) {
649 for child in children.iter_mut() {
650 child.origin = Point::new(bounds.x, bounds.y);
651 child.size = bounds.size();
652 }
653 }
654
655 fn preserves_children_on_rebuild(&self) -> bool {
656 true
657 }
658}
659
660impl<T: PopoverTrigger> Widget for PopoverWidget<T> {
661 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
662 let content = self
663 .content
664 .take()
665 .expect("PopoverWidget::content(...) was not set");
666 let content_id = ctx.add_deferred(
673 self.popover_open.clone(),
674 PopoverBody {
675 content: Some(content),
676 surface_variant: self.surface_variant,
677 surface_style: self.surface_style.clone(),
678 surface_name: self.surface_name.clone(),
679 placement: self.placement.clone(),
680 body_id: None,
681 },
682 );
683 let focus_id = content_id;
686 ctx.set_dormant(content_id);
687 ctx.visible_when(content_id, self.popover_open.clone());
696 self.content_id = Some(content_id);
697
698 let trigger = self
699 .trigger
700 .take()
701 .expect("PopoverWidget trigger missing (build() called twice?)");
702
703 if trigger.has_on_activate() {
709 debug_assert!(
710 false,
711 "PopoverWidget: the trigger's on_activate_fn is overwritten by the popover \
712 wiring and will be discarded; use on_open / on_close instead"
713 );
714 warn_trigger_activate_discarded();
715 }
716
717 let want_caret = self.show_disclosure_caret && !trigger.suppress_caret();
718
719 let popover_open = self.popover_open.clone();
720 let self_ref = ctx.self_id();
721 let placement = self.placement.clone();
722 let dismiss_behavior = self.dismiss_behavior.clone();
723 let fade_duration = self.fade_duration;
724 let on_open = self.on_open.clone();
725 let on_close = self.on_close.clone();
726
727 let dismiss_cb: OverlayDismissCallback = {
732 let popover_open = popover_open.clone();
733 let on_close = on_close.clone();
734 Rc::new(move |_, _| {
735 popover_open.set(false);
736 if let Some(cb) = on_close.as_ref() {
737 cb();
738 }
739 })
740 };
741
742 let activate: Rc<dyn Fn(&mut EventContext)> = Rc::new({
751 let popover_open = popover_open.clone();
752 let dismiss_cb = dismiss_cb.clone();
753 let on_open = on_open.clone();
754 move |ctx_evt: &mut EventContext| {
755 if popover_open.get() {
756 popover_open.set(false);
757 ctx_evt.dismiss_all_except_hosts();
758 } else {
759 popover_open.set(true);
760 ctx_evt.materialize_now(content_id);
764 ctx_evt.activate(content_id);
765 let mut req = OverlayRequest {
766 content_id,
767 anchor: self_ref,
768 placement: placement.clone(),
769 dismiss: dismiss_behavior.clone(),
770 layer: OverlayLayer::InTree,
771 parent_overlay: None,
772 on_dismiss: Some(dismiss_cb.clone()),
773 fade_duration: None,
774 };
775 if let Some(d) = fade_duration {
776 req = req.with_fade(d);
777 }
778 ctx_evt.show_overlay(req);
779 ctx_evt.request_focus(focus_id);
780 if let Some(cb) = on_open.as_ref() {
781 cb();
782 }
783 }
784 }
785 });
786
787 if let Some(intent) = self.open_action {
792 let act = activate.clone();
793 ctx.register_action_global(
794 teksilo_core::action::Action::new(intent)
795 .on_invoke(move |_intent, ctx_evt| act(ctx_evt)),
796 );
797 }
798
799 ctx.apply_self_handlers(HandlerSet::new().on_key({
815 let popover_open = popover_open.clone();
816 let activate = activate.clone();
817 move |event: &WidgetEvent, ctx_evt: &mut EventContext| {
818 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
819 return EventResponse::Ignored;
820 };
821 match crate::common::range_nav::disclosure_chord(*key, *modifiers) {
822 Some(DisclosureChord::Open) => {
823 if !popover_open.get() {
824 activate(ctx_evt);
825 }
826 EventResponse::Handled
830 }
831 Some(DisclosureChord::Close) if popover_open.get() => {
832 activate(ctx_evt);
833 EventResponse::Handled
834 }
835 _ => EventResponse::Ignored,
836 }
837 }
838 }));
839
840 if want_caret {
845 let interaction = ctx.signal(InteractionState::Idle);
846 let role_signal = trigger.caret_role(&interaction);
847 let trigger = trigger
848 .with_shared_interaction(interaction)
849 .with_has_popup(self.has_popup)
850 .with_expanded_when(popover_open.clone())
851 .with_on_activate({
852 let act = activate.clone();
853 move |c: &mut EventContext| act(c)
854 });
855 let trigger_id = ctx.add(trigger);
856 let caret_id = ctx.add(DisclosureCaret { role: role_signal });
857 let root_id = ctx.add(ZStack::new().child(trigger_id).child(caret_id));
858 self.root_child_id = Some(root_id);
859 if let Some(content) = self.composite_tooltip_content.take() {
860 let delay = ctx.theme().motion.tooltip_delay_heavy;
861 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
862 } else if let Some(source) = self.rich_tooltip_source.clone() {
863 let delay = ctx.theme().motion.tooltip_delay;
864 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
865 } else if let Some(text) = self.tooltip_text.clone() {
866 let delay = ctx.theme().motion.tooltip_delay;
867 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
868 }
869 return vec![root_id, content_id];
877 }
878
879 let trigger = trigger
880 .with_has_popup(self.has_popup)
881 .with_expanded_when(popover_open.clone())
882 .with_on_activate(move |c: &mut EventContext| activate(c));
883 let trigger_id = ctx.add(trigger);
884 self.root_child_id = Some(trigger_id);
885 if let Some(content) = self.composite_tooltip_content.take() {
886 let delay = ctx.theme().motion.tooltip_delay_heavy;
887 crate::tooltip::attach_composite_tooltip_boxed(ctx, trigger_id, content, delay);
888 } else if let Some(source) = self.rich_tooltip_source.clone() {
889 let delay = ctx.theme().motion.tooltip_delay;
890 crate::tooltip::attach_rich_tooltip_source(ctx, trigger_id, source, delay);
891 } else if let Some(text) = self.tooltip_text.clone() {
892 let delay = ctx.theme().motion.tooltip_delay;
893 crate::tooltip::attach_plain_tooltip(ctx, trigger_id, text, delay);
894 }
895 vec![trigger_id, content_id]
897 }
898
899 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
900 match self.root_child_id {
901 Some(id) => ctx
902 .child_layout_response(id, proposal)
903 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
904 None => proposal.resolve(0.0, 0.0).into(),
905 }
906 }
907
908 fn place_children(
909 &self,
910 bounds: Rect,
911 _proposal: SizeProposal,
912 children: &mut [WidgetPlacement],
913 _ctx: &LayoutContext,
914 ) {
915 for child in children.iter_mut() {
923 if Some(child.id) == self.content_id {
924 child.size = teksilo_canvas::Size::ZERO;
925 continue;
926 }
927 child.origin = bounds.origin();
928 child.size = bounds.size();
929 }
930 }
931
932 fn children(&self) -> Vec<WidgetId> {
933 let mut out = Vec::new();
937 if let Some(id) = self.root_child_id {
938 out.push(id);
939 }
940 if let Some(id) = self.content_id {
941 out.push(id);
942 }
943 out
944 }
945
946 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
947 }
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957 use crate::primitives::{MinSize, RectWidget};
958 use teksilo_canvas::Point;
959 use teksilo_core::accesskit::{HasPopup, Role};
960 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
961 use teksilo_core::widget_tree::WidgetTree;
962 use teksilo_i18n::lit;
963
964 fn light_tree() -> WidgetTree {
965 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
966 }
967
968 fn dummy_content() -> impl Widget {
969 MinSize::new(40.0, 40.0).child(RectWidget::new())
970 }
971
972 #[test]
975 fn custom_trigger_advertises_and_answers_the_at_click() {
976 let mut tree = light_tree();
982 tree.add(
983 PopoverWidget::new(OverlayTrigger::around(dummy_content()).named("Show popover"))
984 .content(dummy_content()),
985 );
986 tree.layout(SizeProposal::exact(300.0, 120.0));
987
988 let trigger = tree.find_by_label("Show popover").unwrap();
989 assert_eq!(tree.accessibility_node(trigger).role(), Role::Button);
990 assert!(
991 tree.accessibility_node(trigger)
992 .actions()
993 .contains(&teksilo_core::accesskit::Action::Click)
994 );
995
996 assert!(tree.active_overlays().is_empty());
997 let handled = tree.dispatch_access_action(
998 teksilo_core::accessibility::widget_id_to_node_id(trigger),
999 teksilo_core::accesskit::Action::Click,
1000 None,
1001 &mut teksilo_core::NoopWindowOps,
1002 );
1003 assert!(handled, "the AT Click must be reported as handled");
1004 tree.layout(SizeProposal::exact(300.0, 120.0));
1005 assert_eq!(tree.active_overlays().len(), 1);
1006 }
1007
1008 #[test]
1011 #[should_panic(expected = "PopoverWidget::content")]
1012 fn button_panics_without_content() {
1013 let mut tree = light_tree();
1014 tree.add(PopoverButton::new(Button::new(lit!("Open"))));
1015 tree.layout(SizeProposal::exact(300.0, 80.0));
1016 }
1017
1018 #[test]
1019 fn button_trigger_announces_role_and_haspopup_dialog() {
1020 let mut tree = light_tree();
1021 tree.add(PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content()));
1022 tree.layout(SizeProposal::exact(300.0, 80.0));
1023 let update = tree.sync_accessibility();
1024 let button_node = update
1025 .nodes
1026 .iter()
1027 .find(|(_, n)| n.role() == Role::Button)
1028 .map(|(_, n)| n)
1029 .expect("button node");
1030 assert_eq!(
1031 button_node.has_popup(),
1032 Some(HasPopup::Dialog),
1033 "PopoverButton default has_popup must be Dialog",
1034 );
1035 assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
1036 }
1037
1038 #[test]
1039 fn button_enter_opens_popover_and_flips_open_signal() {
1040 let mut tree = light_tree();
1041 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content());
1042 let open_signal = pb.open_signal();
1043 let id = tree.add(pb);
1044 tree.layout(SizeProposal::exact(300.0, 80.0));
1045 let button_id = tree
1046 .first_focusable_descendant(id)
1047 .expect("PopoverButton must expose a focusable inner Button");
1048 tree.focus(button_id);
1049 assert!(!open_signal.get());
1050 tree.dispatch_event(WidgetEvent::KeyDown {
1051 key: Key::Enter,
1052 modifiers: Modifiers::NONE,
1053 text: None,
1054 });
1055 tree.dispatch_event(WidgetEvent::KeyUp {
1056 key: Key::Enter,
1057 modifiers: Modifiers::NONE,
1058 });
1059 assert!(open_signal.get(), "Enter should open the popover");
1060 }
1061
1062 #[test]
1070 fn open_action_opens_the_popover_from_a_sibling_intent() {
1071 use crate::primitives::VStack;
1072 use teksilo_core::intent::Intent;
1073
1074 let mut tree = light_tree();
1075 let pb = PopoverButton::new(Button::new(lit!("Open")))
1076 .content(dummy_content())
1077 .open_action("test.open");
1078 let open_signal = pb.open_signal();
1079 let pb_id = tree.add(pb);
1080 let fire_id = tree.add(
1081 Button::new(lit!("Fire"))
1082 .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.open"))),
1083 );
1084 tree.add(VStack::new().child(pb_id).child(fire_id));
1085 tree.layout(SizeProposal::exact(300.0, 160.0));
1086
1087 assert!(!open_signal.get(), "starts closed");
1088
1089 let fire_btn = tree.first_focusable_descendant(fire_id).unwrap_or(fire_id);
1090 tree.focus(fire_btn);
1091 tree.dispatch_event(WidgetEvent::KeyDown {
1092 key: Key::Enter,
1093 modifiers: Modifiers::NONE,
1094 text: None,
1095 });
1096 tree.dispatch_event(WidgetEvent::KeyUp {
1097 key: Key::Enter,
1098 modifiers: Modifiers::NONE,
1099 });
1100 assert!(
1101 open_signal.get(),
1102 "the named action must open the popover from off its own subtree"
1103 );
1104 }
1105
1106 #[test]
1121 fn open_action_toggles_rather_than_only_opening() {
1122 use teksilo_core::intent::Intent;
1123
1124 let mut tree = light_tree();
1125 let pb = PopoverButton::new(Button::new(lit!("Open")))
1126 .content(
1127 Button::new(lit!("Fire"))
1128 .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.toggle"))),
1129 )
1130 .open_action("test.toggle");
1131 let open_signal = pb.open_signal();
1132 let pb_id = tree.add(pb);
1133 tree.layout(SizeProposal::exact(300.0, 160.0));
1134
1135 let trigger = tree
1136 .first_focusable_descendant(pb_id)
1137 .expect("the trigger is the only focusable while closed");
1138 tree.focus(trigger);
1139 let enter = |tree: &mut WidgetTree| {
1140 tree.dispatch_event(WidgetEvent::KeyDown {
1141 key: Key::Enter,
1142 modifiers: Modifiers::NONE,
1143 text: None,
1144 });
1145 tree.dispatch_event(WidgetEvent::KeyUp {
1146 key: Key::Enter,
1147 modifiers: Modifiers::NONE,
1148 });
1149 };
1150
1151 enter(&mut tree);
1152 assert!(open_signal.get(), "first fire opens");
1153 enter(&mut tree);
1156 assert!(!open_signal.get(), "second fire closes");
1157 }
1158
1159 #[test]
1168 fn an_unopened_popover_never_builds_its_panel() {
1169 use teksilo_core::signal::Signal;
1170
1171 #[derive(Debug)]
1172 struct CountingContent {
1173 builds: Signal<u32>,
1174 }
1175 impl Widget for CountingContent {
1176 fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1177 self.builds.set(self.builds.get() + 1);
1178 Vec::new()
1179 }
1180 fn layout_response(
1181 &self,
1182 p: SizeProposal,
1183 _c: &teksilo_core::widget::LayoutContext,
1184 ) -> teksilo_core::widget::LayoutResponse {
1185 p.resolve(40.0, 20.0).into()
1186 }
1187 }
1188
1189 #[derive(Debug)]
1194 struct Owner {
1195 builds: Signal<u32>,
1196 open_out: Signal<Option<Signal<bool>>>,
1197 child: Option<WidgetId>,
1198 }
1199 impl Widget for Owner {
1200 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1201 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(CountingContent {
1202 builds: self.builds.clone(),
1203 });
1204 self.open_out.set(Some(pb.open_signal()));
1205 let id = ctx.add(pb);
1206 self.child = Some(id);
1207 vec![id]
1208 }
1209 fn layout_response(
1210 &self,
1211 p: SizeProposal,
1212 c: &teksilo_core::widget::LayoutContext,
1213 ) -> teksilo_core::widget::LayoutResponse {
1214 self.child
1215 .and_then(|id| c.child_size(id, p))
1216 .unwrap_or_else(|| p.resolve(0.0, 0.0))
1217 .into()
1218 }
1219 }
1220
1221 let builds = Signal::new(0);
1222 let open_out = Signal::new(None);
1223 let mut tree = light_tree();
1224 let owner = tree.add(Owner {
1225 builds: builds.clone(),
1226 open_out: open_out.clone(),
1227 child: None,
1228 });
1229 tree.layout(SizeProposal::exact(300.0, 120.0));
1230 assert_eq!(builds.get(), 0, "the panel was built without being opened");
1231
1232 for _ in 0..5 {
1235 tree.arena_mark_needs_rebuild_for_testing(owner);
1236 tree.layout(SizeProposal::exact(300.0, 120.0));
1237 }
1238 assert_eq!(
1239 builds.get(),
1240 0,
1241 "rebuilding the owner dragged five unopened panels into the arena"
1242 );
1243
1244 let open = open_out.get().expect("the popover published its signal");
1247 let button = tree
1248 .first_focusable_descendant(owner)
1249 .expect("focusable inner Button");
1250 let enter = move |t: &mut WidgetTree| {
1251 t.focus(button);
1254 t.dispatch_event(WidgetEvent::KeyDown {
1255 key: Key::Enter,
1256 modifiers: Modifiers::NONE,
1257 text: None,
1258 });
1259 t.dispatch_event(WidgetEvent::KeyUp {
1260 key: Key::Enter,
1261 modifiers: Modifiers::NONE,
1262 });
1263 t.layout(SizeProposal::exact(300.0, 120.0));
1264 };
1265 enter(&mut tree);
1266 assert!(open.get(), "Enter should open the popover");
1267 assert_eq!(builds.get(), 1, "opening must build the panel");
1268
1269 open.set(false);
1274 tree.layout(SizeProposal::exact(300.0, 120.0));
1275 open.set(true);
1276 tree.layout(SizeProposal::exact(300.0, 120.0));
1277 assert_eq!(
1278 builds.get(),
1279 1,
1280 "reopening rebuilt the panel — its state would have been lost"
1281 );
1282 }
1283
1284 #[test]
1285 fn default_wraps_content_in_themed_surface_bare_does_not() {
1286 fn open_overlay_content(bare: bool) -> (WidgetTree, WidgetId) {
1297 let mut tree = light_tree();
1298 let mut pb = PopoverButton::new(Button::new(lit!("Open"))).content(RectWidget::new());
1299 if bare {
1300 pb = pb.bare();
1301 }
1302 let open = pb.open_signal();
1303 let id = tree.add(pb);
1304 tree.layout(SizeProposal::exact(300.0, 120.0));
1305 let button = tree
1306 .first_focusable_descendant(id)
1307 .expect("focusable inner Button");
1308 tree.focus(button);
1309 tree.dispatch_event(WidgetEvent::KeyDown {
1310 key: Key::Enter,
1311 modifiers: Modifiers::NONE,
1312 text: None,
1313 });
1314 tree.dispatch_event(WidgetEvent::KeyUp {
1315 key: Key::Enter,
1316 modifiers: Modifiers::NONE,
1317 });
1318 assert!(open.get(), "Enter should open the popover");
1319 tree.layout(SizeProposal::exact(300.0, 120.0));
1320 let content = tree
1321 .overlay_manager()
1322 .active_content_ids()
1323 .first()
1324 .copied()
1325 .expect("an active overlay content");
1326 (tree, content)
1327 }
1328
1329 fn depth_to_leaf(tree: &WidgetTree, id: WidgetId) -> usize {
1331 let mut depth = 0;
1332 let mut cur = id;
1333 loop {
1334 let kids = tree.children(cur);
1335 match kids.first() {
1336 Some(&next) => {
1337 depth += 1;
1338 cur = next;
1339 }
1340 None => return depth,
1341 }
1342 }
1343 }
1344
1345 let (tree_def, c_def) = open_overlay_content(false);
1346 let (tree_bare, c_bare) = open_overlay_content(true);
1347 let deep = depth_to_leaf(&tree_def, c_def);
1348 let bare = depth_to_leaf(&tree_bare, c_bare);
1349 assert_eq!(
1350 deep,
1351 bare + 1,
1352 "the default surface must add exactly one node of chrome that bare() \
1353 does not (default {deep}, bare {bare})"
1354 );
1355 }
1356
1357 #[test]
1358 fn button_caret_does_not_break_pointer_clicks() {
1359 let mut tree = light_tree();
1363 let pb = PopoverButton::new(Button::new(lit!("Open")))
1364 .show_disclosure_caret(true)
1365 .content(dummy_content());
1366 let open_signal = pb.open_signal();
1367 let id = tree.add(pb);
1368 tree.layout(SizeProposal::exact(300.0, 80.0));
1369 let trigger_id = tree
1370 .first_focusable_descendant(id)
1371 .expect("must expose a focusable inner Button");
1372 let b = tree.bounds(trigger_id);
1373 let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1374 tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1375 tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1376 assert!(
1377 open_signal.get(),
1378 "click on the caret quadrant must pass through to the trigger",
1379 );
1380 }
1381
1382 #[test]
1385 #[should_panic(expected = "PopoverWidget::content")]
1386 fn icon_panics_without_content() {
1387 let mut tree = light_tree();
1388 tree.add(PopoverIconButton::new(IconButton::add()));
1389 tree.layout(SizeProposal::exact(300.0, 80.0));
1390 }
1391
1392 #[test]
1393 fn icon_trigger_announces_haspopup_menu_collapsed() {
1394 let mut tree = light_tree();
1395 tree.add(PopoverIconButton::new(IconButton::add()).content(dummy_content()));
1396 tree.layout(SizeProposal::exact(300.0, 80.0));
1397 let update = tree.sync_accessibility();
1398 let button_node = update
1399 .nodes
1400 .iter()
1401 .find(|(_, n)| n.role() == Role::Button)
1402 .map(|(_, n)| n)
1403 .expect("button node");
1404 assert_eq!(
1405 button_node.has_popup(),
1406 Some(HasPopup::Menu),
1407 "PopoverIconButton default has_popup must be Menu",
1408 );
1409 assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
1410 }
1411
1412 #[test]
1413 fn icon_enter_opens_popover_and_flips_open_signal() {
1414 let mut tree = light_tree();
1415 let pib = PopoverIconButton::new(IconButton::add()).content(dummy_content());
1416 let open_signal = pib.open_signal();
1417 let id = tree.add(pib);
1418 tree.layout(SizeProposal::exact(300.0, 80.0));
1419 let button_id = tree
1420 .first_focusable_descendant(id)
1421 .expect("must expose a focusable inner IconButton");
1422 tree.focus(button_id);
1423 assert!(!open_signal.get());
1424 tree.dispatch_event(WidgetEvent::KeyDown {
1425 key: Key::Enter,
1426 modifiers: Modifiers::NONE,
1427 text: None,
1428 });
1429 tree.dispatch_event(WidgetEvent::KeyUp {
1430 key: Key::Enter,
1431 modifiers: Modifiers::NONE,
1432 });
1433 assert!(open_signal.get(), "Enter should open the popover");
1434 }
1435
1436 #[test]
1437 fn icon_caret_false_still_focusable() {
1438 let mut tree = light_tree();
1439 let id = tree.add(
1440 PopoverIconButton::new(IconButton::add())
1441 .show_disclosure_caret(false)
1442 .content(dummy_content()),
1443 );
1444 tree.layout(SizeProposal::exact(300.0, 80.0));
1445 let _ = tree
1446 .first_focusable_descendant(id)
1447 .expect("focusable IconButton must still be present");
1448 }
1449
1450 #[test]
1451 fn icon_caret_click_through_reaches_trigger() {
1452 let mut tree = light_tree();
1453 let pib = PopoverIconButton::new(IconButton::add().toolbar()).content(dummy_content());
1454 let open_signal = pib.open_signal();
1455 let id = tree.add(pib);
1456 tree.layout(SizeProposal::exact(300.0, 80.0));
1457 let trigger_id = tree
1458 .first_focusable_descendant(id)
1459 .expect("must expose a focusable IconButton");
1460 let b = tree.bounds(trigger_id);
1461 let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1462 tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1463 tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1464 assert!(
1465 open_signal.get(),
1466 "clicking the caret quadrant of the IconButton must pass through",
1467 );
1468 }
1469
1470 #[test]
1471 fn icon_compact_skips_caret_but_still_builds() {
1472 let mut tree = light_tree();
1473 let id = tree.add(
1474 PopoverIconButton::new(IconButton::add().size(IconButtonSize::Compact))
1475 .content(dummy_content()),
1476 );
1477 tree.layout(SizeProposal::exact(300.0, 80.0));
1478 let _ = tree
1479 .first_focusable_descendant(id)
1480 .expect("focusable IconButton must be present at Compact");
1481 }
1482
1483 #[test]
1484 fn tooltip_appears_on_hover() {
1485 let mut tree = light_tree();
1486 let id = tree.add(
1487 PopoverButton::new(Button::new(lit!("Open")))
1488 .content(dummy_content())
1489 .tooltip(lit!("Tip")),
1490 );
1491 tree.layout(SizeProposal::exact(300.0, 80.0));
1492 tree.pointer_move(tree.bounds(id).center());
1493 tree.advance_time(std::time::Duration::from_secs(1));
1494 assert_eq!(
1495 tree.active_overlays().len(),
1496 1,
1497 "tooltip should appear on hover"
1498 );
1499 assert!(tree.find_by_label("Tip").is_some());
1500 }
1501
1502 #[derive(Debug)]
1503 struct FocusableLeaf;
1504 impl Widget for FocusableLeaf {
1505 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1506 ctx.apply_self_handlers(
1507 teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1508 );
1509 vec![]
1510 }
1511 fn layout_response(
1512 &self,
1513 proposal: SizeProposal,
1514 _ctx: &LayoutContext,
1515 ) -> teksilo_core::widget::LayoutResponse {
1516 proposal.resolve(12.0, 12.0).into()
1517 }
1518 }
1519
1520 #[test]
1530 fn tab_out_of_popover_dismisses_it() {
1531 let mut tree = light_tree();
1532 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1533 crate::primitives::VStack::new()
1534 .child(FocusableLeaf)
1535 .child(FocusableLeaf),
1536 );
1537 let open_signal = pb.open_signal();
1538 let id = tree.add(pb);
1539 let after = tree.add(FocusableLeaf);
1540 tree.layout(SizeProposal::exact(300.0, 400.0));
1541 let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1542
1543 tree.focus(button_id);
1544 tree.dispatch_event(WidgetEvent::KeyDown {
1545 key: Key::Enter,
1546 modifiers: Modifiers::NONE,
1547 text: None,
1548 });
1549 tree.dispatch_event(WidgetEvent::KeyUp {
1550 key: Key::Enter,
1551 modifiers: Modifiers::NONE,
1552 });
1553 assert!(open_signal.get(), "precondition: Enter opens the popover");
1554 assert_eq!(tree.active_overlays().len(), 1);
1555
1556 tree.press_key(Key::Tab, Modifiers::NONE);
1559 assert_eq!(
1560 tree.active_overlays().len(),
1561 1,
1562 "moving between the popover's own controls is not leaving it"
1563 );
1564
1565 tree.press_key(Key::Tab, Modifiers::NONE);
1567 assert_eq!(tree.focused(), Some(after), "focus lands past the trigger");
1568 assert!(
1569 tree.active_overlays().is_empty(),
1570 "the popover must not stay open behind the focus ring"
1571 );
1572 assert!(!open_signal.get(), "and its open signal must follow");
1573 }
1574
1575 #[test]
1578 fn shift_tab_off_the_front_of_a_popover_dismisses_it() {
1579 let mut tree = light_tree();
1580 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1581 crate::primitives::VStack::new()
1582 .child(FocusableLeaf)
1583 .child(FocusableLeaf),
1584 );
1585 let open_signal = pb.open_signal();
1586 let id = tree.add(pb);
1587 tree.add(FocusableLeaf);
1588 tree.layout(SizeProposal::exact(300.0, 400.0));
1589 let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1590
1591 tree.focus(button_id);
1592 tree.dispatch_event(WidgetEvent::KeyDown {
1593 key: Key::Enter,
1594 modifiers: Modifiers::NONE,
1595 text: None,
1596 });
1597 tree.dispatch_event(WidgetEvent::KeyUp {
1598 key: Key::Enter,
1599 modifiers: Modifiers::NONE,
1600 });
1601 assert!(open_signal.get());
1602
1603 tree.press_key(Key::Tab, Modifiers::SHIFT);
1604 assert_eq!(tree.focused(), Some(button_id), "back onto the trigger");
1605 assert!(
1606 tree.active_overlays().is_empty(),
1607 "leaving through the front dismisses it too"
1608 );
1609 }
1610}