1use std::rc::Rc;
47use std::time::Duration;
48
49use teksilo_canvas::{Point, Rect, Size, SizeProposal};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::accesskit::HasPopup;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::overlay::{
54 DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
55};
56use teksilo_core::signal::Signal;
57use teksilo_core::styles::{PopoverStyle, PopoverStyleConfig, PopoverVariant, SharedPopoverStyle};
58use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
59use teksilo_core::widget_id::WidgetId;
60use teksilo_tokens::TextRole;
61
62use crate::button::{Button, InteractionState, resolve_text_role};
63use crate::icon_button::{
64 IconButton, IconButtonSize, resolve_icon_role_embedded, resolve_icon_role_standalone,
65};
66use crate::overlay_trigger::OverlayTrigger;
67use crate::popover_caret::DisclosureCaret;
68use crate::primitives::ZStack;
69
70type OnVoid = Rc<dyn Fn()>;
71
72pub trait PopoverTrigger: Widget + Sized + 'static {
76 fn default_has_popup() -> HasPopup;
80
81 fn default_show_caret() -> bool;
85
86 fn suppress_caret(&self) -> bool {
90 false
91 }
92
93 fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole>;
98
99 fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self;
106
107 fn with_has_popup(self, kind: HasPopup) -> Self;
109
110 fn with_expanded_when(self, open: Signal<bool>) -> Self;
112
113 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self;
115
116 fn has_on_activate(&self) -> bool;
119}
120
121pub type PopoverCustom = PopoverWidget<OverlayTrigger>;
128
129impl PopoverTrigger for OverlayTrigger {
130 fn default_has_popup() -> HasPopup {
133 HasPopup::Dialog
134 }
135
136 fn default_show_caret() -> bool {
140 false
141 }
142
143 fn caret_role(&self, _interaction: &Signal<InteractionState>) -> Signal<TextRole> {
144 Signal::new(TextRole::Secondary)
147 }
148
149 fn with_shared_interaction(self, _signal: Signal<InteractionState>) -> Self {
150 self
153 }
154
155 fn with_has_popup(self, kind: HasPopup) -> Self {
156 self.has_popup(kind)
157 }
158
159 fn with_expanded_when(self, open: Signal<bool>) -> Self {
160 self.expanded_when(open)
161 }
162
163 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
164 self.on_activate(f)
165 }
166
167 fn has_on_activate(&self) -> bool {
168 self.has_on_activate()
169 }
170}
171
172impl PopoverTrigger for Button {
173 fn default_has_popup() -> HasPopup {
174 HasPopup::Dialog
175 }
176 fn default_show_caret() -> bool {
177 false
178 }
179 fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
180 let variant = self.current_variant();
181 interaction.map(move |s| resolve_text_role(variant, *s))
182 }
183 fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
184 self.share_interaction(signal)
185 }
186 fn with_has_popup(self, kind: HasPopup) -> Self {
187 self.has_popup(kind)
188 }
189 fn with_expanded_when(self, open: Signal<bool>) -> Self {
190 self.expanded_when(open)
191 }
192 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
193 self.on_activate_fn(f)
194 }
195 fn has_on_activate(&self) -> bool {
196 self.has_activate_handler()
197 }
198}
199
200impl PopoverTrigger for IconButton {
201 fn default_has_popup() -> HasPopup {
202 HasPopup::Menu
203 }
204 fn default_show_caret() -> bool {
205 true
206 }
207 fn suppress_caret(&self) -> bool {
208 matches!(self.size_variant(), IconButtonSize::Compact)
211 }
212 fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
213 if self.is_embedded() {
214 interaction.map(|s| resolve_icon_role_embedded(*s))
215 } else {
216 interaction.map(|s| resolve_icon_role_standalone(*s))
217 }
218 }
219 fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
220 self.share_interaction(signal)
221 }
222 fn with_has_popup(self, kind: HasPopup) -> Self {
223 self.has_popup(kind)
224 }
225 fn with_expanded_when(self, open: Signal<bool>) -> Self {
226 self.expanded_when(open)
227 }
228 fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
229 self.on_activate_fn(f)
230 }
231 fn has_on_activate(&self) -> bool {
232 self.has_activate_handler()
233 }
234}
235
236fn warn_trigger_activate_discarded() {
241 thread_local! {
242 static WARNED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
243 }
244 WARNED.with(|w| {
245 if !w.get() {
246 eprintln!(
247 "[teksilo-widgets::popover] PopoverWidget overwrote the trigger's \
248 on_activate_fn — the caller-set handler was discarded. Use on_open / \
249 on_close, or observe open_signal, for trigger-side side effects."
250 );
251 w.set(true);
252 }
253 });
254}
255
256pub struct PopoverWidget<T: PopoverTrigger> {
261 trigger: Option<T>,
262 content: Option<Box<dyn Widget>>,
263
264 popover_open: Signal<bool>,
265 open_action: Option<&'static str>,
268 placement: OverlayPlacement,
269 dismiss_behavior: DismissBehavior,
270 fade_duration: Option<Duration>,
271 has_popup: HasPopup,
272 show_disclosure_caret: bool,
273
274 on_open: Option<OnVoid>,
275 on_close: Option<OnVoid>,
276
277 surface_variant: Option<PopoverVariant>,
285 surface_style: Option<SharedPopoverStyle>,
288 surface_name: String,
291
292 content_id: Option<WidgetId>,
293 root_child_id: Option<WidgetId>,
294
295 tooltip_text: Option<teksilo_i18n::LocalizedString>,
299 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
301 composite_tooltip_content: Option<Box<dyn Widget>>,
303}
304
305pub type PopoverButton = PopoverWidget<Button>;
308
309pub type PopoverIconButton = PopoverWidget<IconButton>;
313
314impl<T: PopoverTrigger> std::fmt::Debug for PopoverWidget<T> {
315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 f.debug_struct("PopoverWidget")
317 .field("placement", &self.placement)
318 .field("dismiss_behavior", &self.dismiss_behavior)
319 .field("has_popup", &self.has_popup)
320 .field("show_disclosure_caret", &self.show_disclosure_caret)
321 .field("popover_open", &self.popover_open.get())
322 .finish_non_exhaustive()
323 }
324}
325
326impl<T: PopoverTrigger> PopoverWidget<T> {
327 pub fn new(trigger: T) -> Self {
330 Self {
331 trigger: Some(trigger),
332 content: None,
333 popover_open: Signal::new(false),
334 open_action: None,
335 placement: OverlayPlacement::BelowPreferred,
336 dismiss_behavior: DismissBehavior::EscapeOrClickOutside,
337 fade_duration: None,
338 has_popup: T::default_has_popup(),
339 show_disclosure_caret: T::default_show_caret(),
340 on_open: None,
341 on_close: None,
342 surface_variant: Some(PopoverVariant::Default),
343 surface_style: None,
344 surface_name: String::new(),
345 content_id: None,
346 root_child_id: None,
347 tooltip_text: None,
348 rich_tooltip_source: None,
349 composite_tooltip_content: None,
350 }
351 }
352
353 pub fn content(mut self, content: impl Widget + 'static) -> Self {
358 self.content = Some(Box::new(content));
359 self
360 }
361
362 pub fn placement(mut self, p: OverlayPlacement) -> Self {
365 self.placement = p;
366 self
367 }
368
369 pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self {
372 self.dismiss_behavior = b;
373 self
374 }
375
376 pub fn fade_duration(mut self, d: Duration) -> Self {
379 self.fade_duration = Some(d);
380 self
381 }
382
383 pub fn has_popup_kind(mut self, k: HasPopup) -> Self {
386 self.has_popup = k;
387 self
388 }
389
390 pub fn show_disclosure_caret(mut self, on: bool) -> Self {
398 self.show_disclosure_caret = on;
399 self
400 }
401
402 pub fn on_open(mut self, f: impl Fn() + 'static) -> Self {
407 self.on_open = Some(Rc::new(f));
408 self
409 }
410
411 pub fn on_close(mut self, f: impl Fn() + 'static) -> Self {
414 self.on_close = Some(Rc::new(f));
415 self
416 }
417
418 pub fn open_signal(&self) -> Signal<bool> {
426 self.popover_open.clone()
427 }
428
429 pub fn open_action(mut self, intent: &'static str) -> Self {
454 self.open_action = Some(intent);
455 self
456 }
457
458 pub fn surface(mut self, variant: PopoverVariant) -> Self {
464 self.surface_variant = Some(variant);
465 self
466 }
467
468 pub fn bare(mut self) -> Self {
475 self.surface_variant = None;
476 self
477 }
478
479 pub fn surface_style(mut self, style: impl PopoverStyle) -> Self {
484 self.surface_style = Some(Rc::new(style));
485 self
486 }
487
488 pub fn surface_name(mut self, name: impl Into<String>) -> Self {
493 self.surface_name = name.into();
494 self
495 }
496
497 pub fn tooltip(mut self, text: impl Into<teksilo_i18n::LocalizedString>) -> Self {
504 self.tooltip_text = Some(text.into());
505 self.rich_tooltip_source = None;
506 self.composite_tooltip_content = None;
507 self
508 }
509
510 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
514 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
515 self.tooltip_text = None;
516 self.composite_tooltip_content = None;
517 self
518 }
519
520 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
526 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
527 self.tooltip_text = None;
528 self.composite_tooltip_content = None;
529 self
530 }
531
532 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
536 self.composite_tooltip_content = Some(Box::new(content));
537 self.tooltip_text = None;
538 self.rich_tooltip_source = None;
539 self
540 }
541}
542
543struct PopoverBody {
553 content: Option<Box<dyn Widget>>,
554 surface_variant: Option<teksilo_core::styles::PopoverVariant>,
555 surface_style: Option<SharedPopoverStyle>,
556 surface_name: String,
557 placement: OverlayPlacement,
558 body_id: Option<WidgetId>,
559}
560
561impl std::fmt::Debug for PopoverBody {
562 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563 f.debug_struct("PopoverBody").finish()
564 }
565}
566
567impl Widget for PopoverBody {
568 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
569 if let Some(id) = self.body_id {
570 return vec![id];
571 }
572 let Some(content) = self.content.take() else {
573 return Vec::new();
574 };
575 let inner_content_id = ctx.add_boxed(content);
578
579 let id = match self.surface_variant {
585 None => inner_content_id,
586 Some(variant) => {
587 let style: SharedPopoverStyle = self
588 .surface_style
589 .clone()
590 .or_else(|| ctx.theme().style_slots.popover.clone())
591 .unwrap_or_else(|| Rc::new(crate::styles::RecipePopoverStyle::default()));
592 let cfg = PopoverStyleConfig {
593 content: inner_content_id,
594 variant,
595 name: self.surface_name.clone(),
596 placement: self.placement.clone(),
597 show_caret: false,
598 caret_size: 0.0,
599 };
600 style.make_body(&cfg, ctx)
601 }
602 };
603 self.body_id = Some(id);
604 vec![id]
605 }
606
607 fn layout_response(
608 &self,
609 proposal: SizeProposal,
610 ctx: &LayoutContext,
611 ) -> teksilo_core::widget::LayoutResponse {
612 match self.body_id {
613 Some(id) => ctx
614 .child_size(id, proposal)
615 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
616 .into(),
617 None => Size::new(0.0, 0.0).into(),
618 }
619 }
620
621 fn place_children(
622 &self,
623 bounds: Rect,
624 _proposal: SizeProposal,
625 children: &mut [WidgetPlacement],
626 _ctx: &LayoutContext,
627 ) {
628 for child in children.iter_mut() {
629 child.origin = Point::new(bounds.x, bounds.y);
630 child.size = bounds.size();
631 }
632 }
633
634 fn preserves_children_on_rebuild(&self) -> bool {
635 true
636 }
637}
638
639impl<T: PopoverTrigger> Widget for PopoverWidget<T> {
640 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
641 let content = self
642 .content
643 .take()
644 .expect("PopoverWidget::content(...) was not set");
645 let content_id = ctx.add_deferred(
652 self.popover_open.clone(),
653 PopoverBody {
654 content: Some(content),
655 surface_variant: self.surface_variant,
656 surface_style: self.surface_style.clone(),
657 surface_name: self.surface_name.clone(),
658 placement: self.placement.clone(),
659 body_id: None,
660 },
661 );
662 let focus_id = content_id;
665 ctx.set_dormant(content_id);
666 ctx.visible_when(content_id, self.popover_open.clone());
675 self.content_id = Some(content_id);
676
677 let trigger = self
678 .trigger
679 .take()
680 .expect("PopoverWidget trigger missing (build() called twice?)");
681
682 if trigger.has_on_activate() {
688 debug_assert!(
689 false,
690 "PopoverWidget: the trigger's on_activate_fn is overwritten by the popover \
691 wiring and will be discarded; use on_open / on_close instead"
692 );
693 warn_trigger_activate_discarded();
694 }
695
696 let want_caret = self.show_disclosure_caret && !trigger.suppress_caret();
697
698 let popover_open = self.popover_open.clone();
699 let self_ref = ctx.self_id();
700 let placement = self.placement.clone();
701 let dismiss_behavior = self.dismiss_behavior.clone();
702 let fade_duration = self.fade_duration;
703 let on_open = self.on_open.clone();
704 let on_close = self.on_close.clone();
705
706 let dismiss_cb: OverlayDismissCallback = {
711 let popover_open = popover_open.clone();
712 let on_close = on_close.clone();
713 Rc::new(move || {
714 popover_open.set(false);
715 if let Some(cb) = on_close.as_ref() {
716 cb();
717 }
718 })
719 };
720
721 let activate: Rc<dyn Fn(&mut EventContext)> = Rc::new({
730 let popover_open = popover_open.clone();
731 let dismiss_cb = dismiss_cb.clone();
732 let on_open = on_open.clone();
733 move |ctx_evt: &mut EventContext| {
734 if popover_open.get() {
735 popover_open.set(false);
736 ctx_evt.dismiss_all_except_hosts();
737 } else {
738 popover_open.set(true);
739 ctx_evt.materialize_now(content_id);
743 ctx_evt.activate(content_id);
744 let mut req = OverlayRequest {
745 content_id,
746 anchor: self_ref,
747 placement: placement.clone(),
748 dismiss: dismiss_behavior.clone(),
749 layer: OverlayLayer::InTree,
750 parent_overlay: None,
751 on_dismiss: Some(dismiss_cb.clone()),
752 fade_duration: None,
753 };
754 if let Some(d) = fade_duration {
755 req = req.with_fade(d);
756 }
757 ctx_evt.show_overlay(req);
758 ctx_evt.request_focus(focus_id);
759 if let Some(cb) = on_open.as_ref() {
760 cb();
761 }
762 }
763 }
764 });
765
766 if let Some(intent) = self.open_action {
771 let act = activate.clone();
772 ctx.register_action_global(
773 teksilo_core::action::Action::new(intent)
774 .on_invoke(move |_intent, ctx_evt| act(ctx_evt)),
775 );
776 }
777
778 if want_caret {
783 let interaction = ctx.signal(InteractionState::Idle);
784 let role_signal = trigger.caret_role(&interaction);
785 let trigger = trigger
786 .with_shared_interaction(interaction)
787 .with_has_popup(self.has_popup)
788 .with_expanded_when(popover_open.clone())
789 .with_on_activate({
790 let act = activate.clone();
791 move |c: &mut EventContext| act(c)
792 });
793 let trigger_id = ctx.add(trigger);
794 let caret_id = ctx.add(DisclosureCaret { role: role_signal });
795 let root_id = ctx.add(ZStack::new().add_child(trigger_id).add_child(caret_id));
796 self.root_child_id = Some(root_id);
797 if let Some(content) = self.composite_tooltip_content.take() {
798 let delay = ctx.theme().motion.tooltip_delay_heavy;
799 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
800 } else if let Some(source) = self.rich_tooltip_source.clone() {
801 let delay = ctx.theme().motion.tooltip_delay;
802 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
803 } else if let Some(text) = self.tooltip_text.clone() {
804 let delay = ctx.theme().motion.tooltip_delay;
805 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
806 }
807 return vec![root_id, content_id];
815 }
816
817 let trigger = trigger
818 .with_has_popup(self.has_popup)
819 .with_expanded_when(popover_open.clone())
820 .with_on_activate(move |c: &mut EventContext| activate(c));
821 let trigger_id = ctx.add(trigger);
822 self.root_child_id = Some(trigger_id);
823 if let Some(content) = self.composite_tooltip_content.take() {
824 let delay = ctx.theme().motion.tooltip_delay_heavy;
825 crate::tooltip::attach_composite_tooltip_boxed(ctx, trigger_id, content, delay);
826 } else if let Some(source) = self.rich_tooltip_source.clone() {
827 let delay = ctx.theme().motion.tooltip_delay;
828 crate::tooltip::attach_rich_tooltip_source(ctx, trigger_id, source, delay);
829 } else if let Some(text) = self.tooltip_text.clone() {
830 let delay = ctx.theme().motion.tooltip_delay;
831 crate::tooltip::attach_plain_tooltip(ctx, trigger_id, text, delay);
832 }
833 vec![trigger_id, content_id]
835 }
836
837 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
838 match self.root_child_id {
839 Some(id) => ctx
840 .child_layout_response(id, proposal)
841 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
842 None => proposal.resolve(0.0, 0.0).into(),
843 }
844 }
845
846 fn place_children(
847 &self,
848 bounds: Rect,
849 _proposal: SizeProposal,
850 children: &mut [WidgetPlacement],
851 _ctx: &LayoutContext,
852 ) {
853 for child in children.iter_mut() {
861 if Some(child.id) == self.content_id {
862 child.size = teksilo_canvas::Size::ZERO;
863 continue;
864 }
865 child.origin = bounds.origin();
866 child.size = bounds.size();
867 }
868 }
869
870 fn children(&self) -> Vec<WidgetId> {
871 let mut out = Vec::new();
875 if let Some(id) = self.root_child_id {
876 out.push(id);
877 }
878 if let Some(id) = self.content_id {
879 out.push(id);
880 }
881 out
882 }
883
884 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
885 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use crate::primitives::{MinSize, RectWidget};
896 use teksilo_canvas::Point;
897 use teksilo_core::accesskit::{HasPopup, Role};
898 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
899 use teksilo_core::widget_tree::WidgetTree;
900 use teksilo_i18n::lit;
901
902 fn light_tree() -> WidgetTree {
903 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
904 }
905
906 fn dummy_content() -> impl Widget {
907 MinSize::new(40.0, 40.0).child(RectWidget::new())
908 }
909
910 #[test]
913 #[should_panic(expected = "PopoverWidget::content")]
914 fn button_panics_without_content() {
915 let mut tree = light_tree();
916 tree.add(PopoverButton::new(Button::new(lit!("Open"))));
917 tree.layout(SizeProposal::exact(300.0, 80.0));
918 }
919
920 #[test]
921 fn button_trigger_announces_role_and_haspopup_dialog() {
922 let mut tree = light_tree();
923 tree.add(PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content()));
924 tree.layout(SizeProposal::exact(300.0, 80.0));
925 let update = tree.sync_accessibility();
926 let button_node = update
927 .nodes
928 .iter()
929 .find(|(_, n)| n.role() == Role::Button)
930 .map(|(_, n)| n)
931 .expect("button node");
932 assert_eq!(
933 button_node.has_popup(),
934 Some(HasPopup::Dialog),
935 "PopoverButton default has_popup must be Dialog",
936 );
937 assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
938 }
939
940 #[test]
941 fn button_enter_opens_popover_and_flips_open_signal() {
942 let mut tree = light_tree();
943 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content());
944 let open_signal = pb.open_signal();
945 let id = tree.add(pb);
946 tree.layout(SizeProposal::exact(300.0, 80.0));
947 let button_id = tree
948 .first_focusable_descendant(id)
949 .expect("PopoverButton must expose a focusable inner Button");
950 tree.focus(button_id);
951 assert!(!open_signal.get());
952 tree.dispatch_event(WidgetEvent::KeyDown {
953 key: Key::Enter,
954 modifiers: Modifiers::NONE,
955 text: None,
956 });
957 tree.dispatch_event(WidgetEvent::KeyUp {
958 key: Key::Enter,
959 modifiers: Modifiers::NONE,
960 });
961 assert!(open_signal.get(), "Enter should open the popover");
962 }
963
964 #[test]
972 fn open_action_opens_the_popover_from_a_sibling_intent() {
973 use crate::primitives::VStack;
974 use teksilo_core::intent::Intent;
975
976 let mut tree = light_tree();
977 let pb = PopoverButton::new(Button::new(lit!("Open")))
978 .content(dummy_content())
979 .open_action("test.open");
980 let open_signal = pb.open_signal();
981 let pb_id = tree.add(pb);
982 let fire_id = tree.add(
983 Button::new(lit!("Fire"))
984 .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.open"))),
985 );
986 tree.add(VStack::new().add_child(pb_id).add_child(fire_id));
987 tree.layout(SizeProposal::exact(300.0, 160.0));
988
989 assert!(!open_signal.get(), "starts closed");
990
991 let fire_btn = tree.first_focusable_descendant(fire_id).unwrap_or(fire_id);
992 tree.focus(fire_btn);
993 tree.dispatch_event(WidgetEvent::KeyDown {
994 key: Key::Enter,
995 modifiers: Modifiers::NONE,
996 text: None,
997 });
998 tree.dispatch_event(WidgetEvent::KeyUp {
999 key: Key::Enter,
1000 modifiers: Modifiers::NONE,
1001 });
1002 assert!(
1003 open_signal.get(),
1004 "the named action must open the popover from off its own subtree"
1005 );
1006 }
1007
1008 #[test]
1023 fn open_action_toggles_rather_than_only_opening() {
1024 use teksilo_core::intent::Intent;
1025
1026 let mut tree = light_tree();
1027 let pb = PopoverButton::new(Button::new(lit!("Open")))
1028 .content(
1029 Button::new(lit!("Fire"))
1030 .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.toggle"))),
1031 )
1032 .open_action("test.toggle");
1033 let open_signal = pb.open_signal();
1034 let pb_id = tree.add(pb);
1035 tree.layout(SizeProposal::exact(300.0, 160.0));
1036
1037 let trigger = tree
1038 .first_focusable_descendant(pb_id)
1039 .expect("the trigger is the only focusable while closed");
1040 tree.focus(trigger);
1041 let enter = |tree: &mut WidgetTree| {
1042 tree.dispatch_event(WidgetEvent::KeyDown {
1043 key: Key::Enter,
1044 modifiers: Modifiers::NONE,
1045 text: None,
1046 });
1047 tree.dispatch_event(WidgetEvent::KeyUp {
1048 key: Key::Enter,
1049 modifiers: Modifiers::NONE,
1050 });
1051 };
1052
1053 enter(&mut tree);
1054 assert!(open_signal.get(), "first fire opens");
1055 enter(&mut tree);
1058 assert!(!open_signal.get(), "second fire closes");
1059 }
1060
1061 #[test]
1070 fn an_unopened_popover_never_builds_its_panel() {
1071 use teksilo_core::signal::Signal;
1072
1073 #[derive(Debug)]
1074 struct CountingContent {
1075 builds: Signal<u32>,
1076 }
1077 impl Widget for CountingContent {
1078 fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1079 self.builds.set(self.builds.get() + 1);
1080 Vec::new()
1081 }
1082 fn layout_response(
1083 &self,
1084 p: SizeProposal,
1085 _c: &teksilo_core::widget::LayoutContext,
1086 ) -> teksilo_core::widget::LayoutResponse {
1087 p.resolve(40.0, 20.0).into()
1088 }
1089 }
1090
1091 #[derive(Debug)]
1096 struct Owner {
1097 builds: Signal<u32>,
1098 open_out: Signal<Option<Signal<bool>>>,
1099 child: Option<WidgetId>,
1100 }
1101 impl Widget for Owner {
1102 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1103 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(CountingContent {
1104 builds: self.builds.clone(),
1105 });
1106 self.open_out.set(Some(pb.open_signal()));
1107 let id = ctx.add(pb);
1108 self.child = Some(id);
1109 vec![id]
1110 }
1111 fn layout_response(
1112 &self,
1113 p: SizeProposal,
1114 c: &teksilo_core::widget::LayoutContext,
1115 ) -> teksilo_core::widget::LayoutResponse {
1116 self.child
1117 .and_then(|id| c.child_size(id, p))
1118 .unwrap_or_else(|| p.resolve(0.0, 0.0))
1119 .into()
1120 }
1121 }
1122
1123 let builds = Signal::new(0);
1124 let open_out = Signal::new(None);
1125 let mut tree = light_tree();
1126 let owner = tree.add(Owner {
1127 builds: builds.clone(),
1128 open_out: open_out.clone(),
1129 child: None,
1130 });
1131 tree.layout(SizeProposal::exact(300.0, 120.0));
1132 assert_eq!(builds.get(), 0, "the panel was built without being opened");
1133
1134 for _ in 0..5 {
1137 tree.arena_mark_needs_rebuild_for_testing(owner);
1138 tree.layout(SizeProposal::exact(300.0, 120.0));
1139 }
1140 assert_eq!(
1141 builds.get(),
1142 0,
1143 "rebuilding the owner dragged five unopened panels into the arena"
1144 );
1145
1146 let open = open_out.get().expect("the popover published its signal");
1149 let button = tree
1150 .first_focusable_descendant(owner)
1151 .expect("focusable inner Button");
1152 let enter = move |t: &mut WidgetTree| {
1153 t.focus(button);
1156 t.dispatch_event(WidgetEvent::KeyDown {
1157 key: Key::Enter,
1158 modifiers: Modifiers::NONE,
1159 text: None,
1160 });
1161 t.dispatch_event(WidgetEvent::KeyUp {
1162 key: Key::Enter,
1163 modifiers: Modifiers::NONE,
1164 });
1165 t.layout(SizeProposal::exact(300.0, 120.0));
1166 };
1167 enter(&mut tree);
1168 assert!(open.get(), "Enter should open the popover");
1169 assert_eq!(builds.get(), 1, "opening must build the panel");
1170
1171 open.set(false);
1176 tree.layout(SizeProposal::exact(300.0, 120.0));
1177 open.set(true);
1178 tree.layout(SizeProposal::exact(300.0, 120.0));
1179 assert_eq!(
1180 builds.get(),
1181 1,
1182 "reopening rebuilt the panel — its state would have been lost"
1183 );
1184 }
1185
1186 #[test]
1187 fn default_wraps_content_in_themed_surface_bare_does_not() {
1188 fn open_overlay_content(bare: bool) -> (WidgetTree, WidgetId) {
1199 let mut tree = light_tree();
1200 let mut pb = PopoverButton::new(Button::new(lit!("Open"))).content(RectWidget::new());
1201 if bare {
1202 pb = pb.bare();
1203 }
1204 let open = pb.open_signal();
1205 let id = tree.add(pb);
1206 tree.layout(SizeProposal::exact(300.0, 120.0));
1207 let button = tree
1208 .first_focusable_descendant(id)
1209 .expect("focusable inner Button");
1210 tree.focus(button);
1211 tree.dispatch_event(WidgetEvent::KeyDown {
1212 key: Key::Enter,
1213 modifiers: Modifiers::NONE,
1214 text: None,
1215 });
1216 tree.dispatch_event(WidgetEvent::KeyUp {
1217 key: Key::Enter,
1218 modifiers: Modifiers::NONE,
1219 });
1220 assert!(open.get(), "Enter should open the popover");
1221 tree.layout(SizeProposal::exact(300.0, 120.0));
1222 let content = tree
1223 .overlay_manager()
1224 .active_content_ids()
1225 .first()
1226 .copied()
1227 .expect("an active overlay content");
1228 (tree, content)
1229 }
1230
1231 fn depth_to_leaf(tree: &WidgetTree, id: WidgetId) -> usize {
1233 let mut depth = 0;
1234 let mut cur = id;
1235 loop {
1236 let kids = tree.children(cur);
1237 match kids.first() {
1238 Some(&next) => {
1239 depth += 1;
1240 cur = next;
1241 }
1242 None => return depth,
1243 }
1244 }
1245 }
1246
1247 let (tree_def, c_def) = open_overlay_content(false);
1248 let (tree_bare, c_bare) = open_overlay_content(true);
1249 let deep = depth_to_leaf(&tree_def, c_def);
1250 let bare = depth_to_leaf(&tree_bare, c_bare);
1251 assert_eq!(
1252 deep,
1253 bare + 1,
1254 "the default surface must add exactly one node of chrome that bare() \
1255 does not (default {deep}, bare {bare})"
1256 );
1257 }
1258
1259 #[test]
1260 fn button_caret_does_not_break_pointer_clicks() {
1261 let mut tree = light_tree();
1265 let pb = PopoverButton::new(Button::new(lit!("Open")))
1266 .show_disclosure_caret(true)
1267 .content(dummy_content());
1268 let open_signal = pb.open_signal();
1269 let id = tree.add(pb);
1270 tree.layout(SizeProposal::exact(300.0, 80.0));
1271 let trigger_id = tree
1272 .first_focusable_descendant(id)
1273 .expect("must expose a focusable inner Button");
1274 let b = tree.bounds(trigger_id);
1275 let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1276 tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1277 tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1278 assert!(
1279 open_signal.get(),
1280 "click on the caret quadrant must pass through to the trigger",
1281 );
1282 }
1283
1284 #[test]
1287 #[should_panic(expected = "PopoverWidget::content")]
1288 fn icon_panics_without_content() {
1289 let mut tree = light_tree();
1290 tree.add(PopoverIconButton::new(IconButton::add()));
1291 tree.layout(SizeProposal::exact(300.0, 80.0));
1292 }
1293
1294 #[test]
1295 fn icon_trigger_announces_haspopup_menu_collapsed() {
1296 let mut tree = light_tree();
1297 tree.add(PopoverIconButton::new(IconButton::add()).content(dummy_content()));
1298 tree.layout(SizeProposal::exact(300.0, 80.0));
1299 let update = tree.sync_accessibility();
1300 let button_node = update
1301 .nodes
1302 .iter()
1303 .find(|(_, n)| n.role() == Role::Button)
1304 .map(|(_, n)| n)
1305 .expect("button node");
1306 assert_eq!(
1307 button_node.has_popup(),
1308 Some(HasPopup::Menu),
1309 "PopoverIconButton default has_popup must be Menu",
1310 );
1311 assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
1312 }
1313
1314 #[test]
1315 fn icon_enter_opens_popover_and_flips_open_signal() {
1316 let mut tree = light_tree();
1317 let pib = PopoverIconButton::new(IconButton::add()).content(dummy_content());
1318 let open_signal = pib.open_signal();
1319 let id = tree.add(pib);
1320 tree.layout(SizeProposal::exact(300.0, 80.0));
1321 let button_id = tree
1322 .first_focusable_descendant(id)
1323 .expect("must expose a focusable inner IconButton");
1324 tree.focus(button_id);
1325 assert!(!open_signal.get());
1326 tree.dispatch_event(WidgetEvent::KeyDown {
1327 key: Key::Enter,
1328 modifiers: Modifiers::NONE,
1329 text: None,
1330 });
1331 tree.dispatch_event(WidgetEvent::KeyUp {
1332 key: Key::Enter,
1333 modifiers: Modifiers::NONE,
1334 });
1335 assert!(open_signal.get(), "Enter should open the popover");
1336 }
1337
1338 #[test]
1339 fn icon_caret_false_still_focusable() {
1340 let mut tree = light_tree();
1341 let id = tree.add(
1342 PopoverIconButton::new(IconButton::add())
1343 .show_disclosure_caret(false)
1344 .content(dummy_content()),
1345 );
1346 tree.layout(SizeProposal::exact(300.0, 80.0));
1347 let _ = tree
1348 .first_focusable_descendant(id)
1349 .expect("focusable IconButton must still be present");
1350 }
1351
1352 #[test]
1353 fn icon_caret_click_through_reaches_trigger() {
1354 let mut tree = light_tree();
1355 let pib = PopoverIconButton::new(IconButton::add().toolbar()).content(dummy_content());
1356 let open_signal = pib.open_signal();
1357 let id = tree.add(pib);
1358 tree.layout(SizeProposal::exact(300.0, 80.0));
1359 let trigger_id = tree
1360 .first_focusable_descendant(id)
1361 .expect("must expose a focusable IconButton");
1362 let b = tree.bounds(trigger_id);
1363 let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1364 tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1365 tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1366 assert!(
1367 open_signal.get(),
1368 "clicking the caret quadrant of the IconButton must pass through",
1369 );
1370 }
1371
1372 #[test]
1373 fn icon_compact_skips_caret_but_still_builds() {
1374 let mut tree = light_tree();
1375 let id = tree.add(
1376 PopoverIconButton::new(IconButton::add().size(IconButtonSize::Compact))
1377 .content(dummy_content()),
1378 );
1379 tree.layout(SizeProposal::exact(300.0, 80.0));
1380 let _ = tree
1381 .first_focusable_descendant(id)
1382 .expect("focusable IconButton must be present at Compact");
1383 }
1384
1385 #[test]
1386 fn tooltip_appears_on_hover() {
1387 let mut tree = light_tree();
1388 let id = tree.add(
1389 PopoverButton::new(Button::new(lit!("Open")))
1390 .content(dummy_content())
1391 .tooltip(lit!("Tip")),
1392 );
1393 tree.layout(SizeProposal::exact(300.0, 80.0));
1394 tree.pointer_move(tree.bounds(id).center());
1395 tree.advance_time(std::time::Duration::from_secs(1));
1396 assert_eq!(
1397 tree.active_overlays().len(),
1398 1,
1399 "tooltip should appear on hover"
1400 );
1401 assert!(tree.find_by_label("Tip").is_some());
1402 }
1403
1404 #[derive(Debug)]
1405 struct FocusableLeaf;
1406 impl Widget for FocusableLeaf {
1407 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1408 ctx.apply_self_handlers(
1409 teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1410 );
1411 vec![]
1412 }
1413 fn layout_response(
1414 &self,
1415 proposal: SizeProposal,
1416 _ctx: &LayoutContext,
1417 ) -> teksilo_core::widget::LayoutResponse {
1418 proposal.resolve(12.0, 12.0).into()
1419 }
1420 }
1421
1422 #[test]
1432 fn tab_out_of_popover_dismisses_it() {
1433 let mut tree = light_tree();
1434 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1435 crate::primitives::VStack::new()
1436 .child(FocusableLeaf)
1437 .child(FocusableLeaf),
1438 );
1439 let open_signal = pb.open_signal();
1440 let id = tree.add(pb);
1441 let after = tree.add(FocusableLeaf);
1442 tree.layout(SizeProposal::exact(300.0, 400.0));
1443 let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1444
1445 tree.focus(button_id);
1446 tree.dispatch_event(WidgetEvent::KeyDown {
1447 key: Key::Enter,
1448 modifiers: Modifiers::NONE,
1449 text: None,
1450 });
1451 tree.dispatch_event(WidgetEvent::KeyUp {
1452 key: Key::Enter,
1453 modifiers: Modifiers::NONE,
1454 });
1455 assert!(open_signal.get(), "precondition: Enter opens the popover");
1456 assert_eq!(tree.active_overlays().len(), 1);
1457
1458 tree.press_key(Key::Tab, Modifiers::NONE);
1461 assert_eq!(
1462 tree.active_overlays().len(),
1463 1,
1464 "moving between the popover's own controls is not leaving it"
1465 );
1466
1467 tree.press_key(Key::Tab, Modifiers::NONE);
1469 assert_eq!(tree.focused(), Some(after), "focus lands past the trigger");
1470 assert!(
1471 tree.active_overlays().is_empty(),
1472 "the popover must not stay open behind the focus ring"
1473 );
1474 assert!(!open_signal.get(), "and its open signal must follow");
1475 }
1476
1477 #[test]
1480 fn shift_tab_off_the_front_of_a_popover_dismisses_it() {
1481 let mut tree = light_tree();
1482 let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1483 crate::primitives::VStack::new()
1484 .child(FocusableLeaf)
1485 .child(FocusableLeaf),
1486 );
1487 let open_signal = pb.open_signal();
1488 let id = tree.add(pb);
1489 tree.add(FocusableLeaf);
1490 tree.layout(SizeProposal::exact(300.0, 400.0));
1491 let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1492
1493 tree.focus(button_id);
1494 tree.dispatch_event(WidgetEvent::KeyDown {
1495 key: Key::Enter,
1496 modifiers: Modifiers::NONE,
1497 text: None,
1498 });
1499 tree.dispatch_event(WidgetEvent::KeyUp {
1500 key: Key::Enter,
1501 modifiers: Modifiers::NONE,
1502 });
1503 assert!(open_signal.get());
1504
1505 tree.press_key(Key::Tab, Modifiers::SHIFT);
1506 assert_eq!(tree.focused(), Some(button_id), "back onto the trigger");
1507 assert!(
1508 tree.active_overlays().is_empty(),
1509 "leaving through the front dismisses it too"
1510 );
1511 }
1512}