Skip to main content

teksilo_widgets/
popover_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `PopoverWidget<T>` — a generic trigger that opens a popover when
5//! activated, plus the [`PopoverButton`] / [`PopoverIconButton`] aliases.
6//!
7//! Wraps a caller-built trigger (`T: PopoverTrigger`) with overlay
8//! wiring: owns a `popover_open: Signal<bool>` toggled on activate /
9//! dismiss, sets `has_popup` and `expanded_when` on the inner trigger so
10//! AT announces the disclosure state, adds the popover content as a
11//! dormant subtree whose panel is built the first time it is opened, and
12//! shows / hides it via [`OverlayRequest`]. The
13//! `set_dormant` + `activate` + `show_overlay` sequence and the
14//! dismiss-callback shape match [`DateEdit`](crate::date_edit::DateEdit)
15//! so behavior across the disclosure family stays consistent.
16//!
17//! # Keyboard
18//!
19//! Beyond whatever activates the trigger itself, `Alt+ArrowDown` opens the
20//! popover and `Alt+ArrowUp` closes it — the platform disclosure chord
21//! (Win32 / WinForms / WPF drop-downs, and the W3C ARIA combobox pattern).
22//! Every consumer inherits it, so [`PopoverButton`], [`PopoverIconButton`]
23//! and [`ColorEdit`](crate::color_edit::ColorEdit) share one
24//! implementation. `F4` is deliberately *not* bound here: this generic also
25//! backs toolbar chevrons and menu buttons, which carry no such
26//! convention, so the drop-down fields bind it themselves.
27//!
28//! ```rust
29//! # use teksilo_widgets::{Button, ButtonVariant, IconButton, MenuList, MenuItem, PopoverButton, PopoverIconButton};
30//! # use teksilo_widgets::primitives::TextWidget;
31//! # use teksilo_i18n::lit;
32//! // Text trigger (HasPopup::Dialog by default, no caret):
33//! let _w = PopoverButton::new(Button::new(lit!("Choose…")).variant(ButtonVariant::Plain))
34//!     .content(TextWidget::new(lit!("Pick")));
35//!
36//! // Icon trigger (HasPopup::Menu by default, corner caret on):
37//! let _w = PopoverIconButton::new(IconButton::add().toolbar())
38//!     .content(MenuList::new().item(MenuItem::new(lit!("New file"))));
39//! ```
40//!
41//! # Trigger configuration overrides
42//!
43//! `build()` configures the inner trigger by calling `has_popup`,
44//! `expanded_when`, and `on_activate_fn` (and `share_interaction` when a
45//! caret is shown). These **replace** any previous values the caller set
46//! — in particular any `on_activate_fn` set before `::new` is discarded,
47//! because the activate slot is owned by the popover wiring. Use
48//! `on_open` / `on_close`, or observe `open_signal`, for side effects;
49//! `on_close_with` also says why the popover closed (a [`DismissReason`]:
50//! an outside press, Escape, the popover closing itself, a cascade).
51//!
52//! # Per-trigger differences (the `PopoverTrigger` trait)
53//!
54//! `Button` and `IconButton` differ only in: the default `has_popup`
55//! kind, whether the disclosure caret shows by default, whether the
56//! caret is suppressed (IconButton at `Compact`), and how the caret's
57//! color is derived. Those four points live behind `PopoverTrigger`;
58//! everything else is shared by the generic.
59
60use std::cell::Cell;
61use std::rc::Rc;
62use std::time::Duration;
63
64use teksilo_canvas::{Point, Rect, Size, SizeProposal};
65use teksilo_core::accessibility::AccessNodeBuilder;
66use teksilo_core::accesskit::HasPopup;
67use teksilo_core::build_context::BuildContext;
68use teksilo_core::event::{EventResponse, WidgetEvent};
69use teksilo_core::overlay::{
70    DismissBehavior, DismissReason, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
71};
72use teksilo_core::signal::Signal;
73use teksilo_core::styles::{PopoverStyle, PopoverStyleConfig, PopoverVariant, SharedPopoverStyle};
74use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
75use teksilo_core::widget_builder::HandlerSet;
76use teksilo_core::widget_id::WidgetId;
77use teksilo_tokens::TextRole;
78
79use crate::button::{Button, InteractionState, resolve_text_role};
80use crate::common::range_nav::DisclosureChord;
81use crate::icon_button::{
82    IconButton, IconButtonSize, resolve_icon_role_embedded, resolve_icon_role_standalone,
83};
84use crate::overlay_trigger::OverlayTrigger;
85use crate::popover_caret::DisclosureCaret;
86use crate::primitives::ZStack;
87
88type OnVoid = Rc<dyn Fn()>;
89type OnClose = Rc<dyn Fn(DismissReason)>;
90
91/// A trigger widget usable with [`PopoverWidget`]. Implemented for
92/// [`Button`], [`IconButton`] and [`OverlayTrigger`]. Captures the few
93/// points where the triggers differ; everything else is handled by the
94/// generic wrapper.
95pub trait PopoverTrigger: Widget + Sized + 'static {
96    /// The `has_popup` kind announced by AT when the caller doesn't
97    /// override it. `Button` → [`HasPopup::Dialog`]; `IconButton` →
98    /// [`HasPopup::Menu`].
99    fn default_has_popup() -> HasPopup;
100
101    /// Whether the disclosure caret is painted by default. `Button` →
102    /// `false` (text buttons advertise via an inline trailing chevron);
103    /// `IconButton` → `true` (icon-only triggers have no label slot).
104    fn default_show_caret() -> bool;
105
106    /// Whether the caret must be suppressed for this trigger regardless
107    /// of the flag (e.g. `IconButton` at `Compact` has no room).
108    /// Default: never suppressed.
109    fn suppress_caret(&self) -> bool {
110        false
111    }
112
113    /// The `TextRole` the disclosure caret tints with, derived from the
114    /// shared interaction signal so the caret and trigger tint together
115    /// across hover / press / focus / disabled. Only called when a caret
116    /// is shown.
117    fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole>;
118
119    // The remaining methods delegate to inherent builder methods that
120    // exist on both triggers; they're on the trait so the generic can
121    // call them on a bare `T`.
122
123    /// Share an externally-allocated interaction signal so the caret colour
124    /// tracks the trigger's state (hover / press / focus / disabled) exactly.
125    fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self;
126
127    /// Annotate the trigger with the given `has_popup` kind for AT.
128    fn with_has_popup(self, kind: HasPopup) -> Self;
129
130    /// Bind the trigger's `set_expanded` disclosure state to `open`.
131    fn with_expanded_when(self, open: Signal<bool>) -> Self;
132
133    /// Install the popover's open/close handler as the trigger's activate callback.
134    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self;
135
136    /// Return `true` if the trigger already has an activate handler set by
137    /// the caller — the wrapper replaces it and will warn at build time.
138    fn has_on_activate(&self) -> bool;
139}
140
141/// A popover whose trigger is an arbitrary widget, wrapped in
142/// [`OverlayTrigger`].
143///
144/// The third stock shape beside [`PopoverButton`] and [`PopoverIconButton`],
145/// and what replaced the standalone `Popover` widget: that type existed only
146/// because this generic could not take a non-button trigger.
147pub type PopoverCustom = PopoverWidget<OverlayTrigger>;
148
149impl PopoverTrigger for OverlayTrigger {
150    /// A custom trigger opens a panel, not a menu — the same announcement the
151    /// standalone `Popover` made.
152    fn default_has_popup() -> HasPopup {
153        HasPopup::Dialog
154    }
155
156    /// No caret. A caller supplying their own trigger has drawn whatever
157    /// affordance they want; painting a disclosure chevron over it would be the
158    /// framework second-guessing them.
159    fn default_show_caret() -> bool {
160        false
161    }
162
163    fn caret_role(&self, _interaction: &Signal<InteractionState>) -> Signal<TextRole> {
164        // Never consulted while `default_show_caret` is false, and a custom
165        // trigger has no interaction signal of its own to derive a tint from.
166        Signal::new(TextRole::Secondary)
167    }
168
169    fn with_shared_interaction(self, _signal: Signal<InteractionState>) -> Self {
170        // Nothing to share: the caret this exists to tint is not drawn, and an
171        // arbitrary widget has no `InteractionState` the framework can read.
172        self
173    }
174
175    fn with_has_popup(self, kind: HasPopup) -> Self {
176        self.has_popup(kind)
177    }
178
179    fn with_expanded_when(self, open: Signal<bool>) -> Self {
180        self.expanded_when(open)
181    }
182
183    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
184        self.on_activate(f)
185    }
186
187    fn has_on_activate(&self) -> bool {
188        self.has_on_activate()
189    }
190}
191
192impl PopoverTrigger for Button {
193    fn default_has_popup() -> HasPopup {
194        HasPopup::Dialog
195    }
196    fn default_show_caret() -> bool {
197        false
198    }
199    fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
200        let variant = self.current_variant();
201        interaction.map(move |s| resolve_text_role(variant, *s))
202    }
203    fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
204        self.share_interaction(signal)
205    }
206    fn with_has_popup(self, kind: HasPopup) -> Self {
207        self.has_popup(kind)
208    }
209    fn with_expanded_when(self, open: Signal<bool>) -> Self {
210        self.expanded_when(open)
211    }
212    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
213        self.on_activate_fn(f)
214    }
215    fn has_on_activate(&self) -> bool {
216        self.has_activate_handler()
217    }
218}
219
220impl PopoverTrigger for IconButton {
221    fn default_has_popup() -> HasPopup {
222        HasPopup::Menu
223    }
224    fn default_show_caret() -> bool {
225        true
226    }
227    fn suppress_caret(&self) -> bool {
228        // Compact (22 dp) has no room for the caret without crowding the
229        // icon, and Compact buttons aren't typically menu triggers.
230        matches!(self.size_variant(), IconButtonSize::Compact)
231    }
232    fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
233        if self.is_embedded() {
234            interaction.map(|s| resolve_icon_role_embedded(*s))
235        } else {
236            interaction.map(|s| resolve_icon_role_standalone(*s))
237        }
238    }
239    fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
240        self.share_interaction(signal)
241    }
242    fn with_has_popup(self, kind: HasPopup) -> Self {
243        self.has_popup(kind)
244    }
245    fn with_expanded_when(self, open: Signal<bool>) -> Self {
246        self.expanded_when(open)
247    }
248    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
249        self.on_activate_fn(f)
250    }
251    fn has_on_activate(&self) -> bool {
252        self.has_activate_handler()
253    }
254}
255
256/// One-shot stderr warning when a `PopoverWidget` trigger arrives with an
257/// activate handler that the wrapper will overwrite. Thread-local flag
258/// keeps it from repeating. (Stderr rather than `log::warn!` to avoid a
259/// `log` dependency on teksilo-widgets, matching the crate convention.)
260fn warn_trigger_activate_discarded() {
261    thread_local! {
262        static WARNED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
263    }
264    WARNED.with(|w| {
265        if !w.get() {
266            eprintln!(
267                "[teksilo-widgets::popover] PopoverWidget overwrote the trigger's \
268                 on_activate_fn — the caller-set handler was discarded. Use on_open / \
269                 on_close, or observe open_signal, for trigger-side side effects."
270            );
271            w.set(true);
272        }
273    });
274}
275
276/// A trigger paired with a popover surface. See the module docs for the
277/// contract on which trigger properties get overridden during `build()`.
278/// Use the [`PopoverButton`] / [`PopoverIconButton`] aliases for the
279/// concrete trigger types.
280pub struct PopoverWidget<T: PopoverTrigger> {
281    trigger: Option<T>,
282    content: Option<Box<dyn Widget>>,
283
284    popover_open: Signal<bool>,
285    /// Name of the global action that toggles this popover, if the caller asked
286    /// for one. See [`PopoverWidget::open_action`].
287    open_action: Option<&'static str>,
288    placement: OverlayPlacement,
289    dismiss_behavior: DismissBehavior,
290    fade_duration: Option<Duration>,
291    has_popup: HasPopup,
292    show_disclosure_caret: bool,
293
294    on_open: Option<OnVoid>,
295    /// Set by [`on_close`](PopoverWidget::on_close) or
296    /// [`on_close_with`](PopoverWidget::on_close_with); the last call wins.
297    on_close: Option<OnClose>,
298
299    /// Which themed [`PopoverStyle`] surface to wrap the content in.
300    /// `Some(variant)` (the default — `PopoverVariant::Default`) routes
301    /// the content through the active popover style so it gets a
302    /// background, border, padding, and shadow for free. `None`
303    /// (`bare()`) adds the content raw — for content that is already
304    /// self-chromed (a `MenuList`, which itself routes through the Menu
305    /// `PopoverStyle`, or a hand-rolled surface `Panel`).
306    surface_variant: Option<PopoverVariant>,
307    /// Per-call style override (highest precedence over the theme slot
308    /// and the built-in `RecipePopoverStyle`). Mirrors the per-call
309    /// override the standalone `Popover` used to offer.
310    surface_style: Option<SharedPopoverStyle>,
311    /// Accessible name for the surface's `Role::Dialog` node. Empty by
312    /// default, and then the surface is named by the trigger.
313    surface_name: String,
314
315    content_id: Option<WidgetId>,
316    root_child_id: Option<WidgetId>,
317
318    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
319    /// with the rich / composite slots — every setter clears the other two so
320    /// the last call wins.
321    tooltip_text: Option<teksilo_i18n::LocalizedString>,
322    /// Optional rich tooltip source (registry key or inline content).
323    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
324    /// Optional composite tooltip body (arbitrary widget tree).
325    composite_tooltip_content: Option<Box<dyn Widget>>,
326}
327
328/// A [`Button`] that opens a popover when activated. Alias for
329/// `PopoverWidget<Button>` — `HasPopup::Dialog`, no caret by default.
330pub type PopoverButton = PopoverWidget<Button>;
331
332/// An [`IconButton`] that opens a popover when activated. Alias for
333/// `PopoverWidget<IconButton>` — `HasPopup::Menu`, corner caret on by
334/// default (skipped at `Compact`).
335pub type PopoverIconButton = PopoverWidget<IconButton>;
336
337impl<T: PopoverTrigger> std::fmt::Debug for PopoverWidget<T> {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("PopoverWidget")
340            .field("placement", &self.placement)
341            .field("dismiss_behavior", &self.dismiss_behavior)
342            .field("has_popup", &self.has_popup)
343            .field("show_disclosure_caret", &self.show_disclosure_caret)
344            .field("popover_open", &self.popover_open.get())
345            .finish_non_exhaustive()
346    }
347}
348
349impl<T: PopoverTrigger> PopoverWidget<T> {
350    /// Wrap a pre-configured trigger. The popover content is set
351    /// separately via [`Self::content`] (required).
352    pub fn new(trigger: T) -> Self {
353        Self {
354            trigger: Some(trigger),
355            content: None,
356            popover_open: Signal::new(false),
357            open_action: None,
358            placement: OverlayPlacement::BelowPreferred,
359            dismiss_behavior: DismissBehavior::EscapeOrClickOutside,
360            fade_duration: None,
361            has_popup: T::default_has_popup(),
362            show_disclosure_caret: T::default_show_caret(),
363            on_open: None,
364            on_close: None,
365            surface_variant: Some(PopoverVariant::Default),
366            surface_style: None,
367            surface_name: String::new(),
368            content_id: None,
369            root_child_id: None,
370            tooltip_text: None,
371            rich_tooltip_source: None,
372            composite_tooltip_content: None,
373        }
374    }
375
376    /// Set the popover content — added to the tree as a dormant subtree
377    /// during `build()`, woken via
378    /// [`EventContext::activate`](teksilo_core::widget::EventContext::activate)
379    /// when the trigger fires. Required.
380    pub fn content(mut self, content: impl Widget + 'static) -> Self {
381        self.content = Some(Box::new(content));
382        self
383    }
384
385    /// Override the popover's placement relative to the trigger.
386    /// Default: [`OverlayPlacement::BelowPreferred`].
387    pub fn placement(mut self, p: OverlayPlacement) -> Self {
388        self.placement = p;
389        self
390    }
391
392    /// Override the dismiss behavior. Default:
393    /// [`DismissBehavior::EscapeOrClickOutside`].
394    pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self {
395        self.dismiss_behavior = b;
396        self
397    }
398
399    /// Animate the overlay in / out over the given duration. Default:
400    /// no fade. See [`OverlayRequest::with_fade`] for the mechanism.
401    pub fn fade_duration(mut self, d: Duration) -> Self {
402        self.fade_duration = Some(d);
403        self
404    }
405
406    /// Override the `has_popup` kind announced by AT. Defaults to the
407    /// trigger type's [`PopoverTrigger::default_has_popup`].
408    pub fn has_popup_kind(mut self, k: HasPopup) -> Self {
409        self.has_popup = k;
410        self
411    }
412
413    /// Whether to paint the disclosure triangle in the trigger's
414    /// bottom-right corner. Defaults to the trigger type's
415    /// [`PopoverTrigger::default_show_caret`]. The caret is
416    /// suppressed automatically when
417    /// [`PopoverTrigger::suppress_caret`] returns `true` (e.g.
418    /// `IconButton` at `Compact`) regardless of this flag. AT-hidden —
419    /// the popup is announced via `set_has_popup` + `set_expanded`.
420    pub fn show_disclosure_caret(mut self, on: bool) -> Self {
421        self.show_disclosure_caret = on;
422        self
423    }
424
425    /// Notification fired on the rising edge of the popover (after the
426    /// overlay show request is dispatched). No `EventContext` — observe
427    /// [`Self::open_signal`] from your `build()` if you need
428    /// frame / dispatch context.
429    pub fn on_open(mut self, f: impl Fn() + 'static) -> Self {
430        self.on_open = Some(Rc::new(f));
431        self
432    }
433
434    /// Notification fired on the falling edge of the popover (when the
435    /// overlay's dismiss callback runs).
436    ///
437    /// Replaces any [`on_close_with`](Self::on_close_with) set before it. Use
438    /// that one to learn how the popover was closed.
439    pub fn on_close(mut self, f: impl Fn() + 'static) -> Self {
440        self.on_close = Some(Rc::new(move |_reason| f()));
441        self
442    }
443
444    /// [`on_close`](Self::on_close), told why the popover closed.
445    ///
446    /// The [`DismissReason`] is the overlay manager's own:
447    ///
448    /// * [`OutsidePress`](DismissReason::OutsidePress) — a press outside the
449    ///   panel, including a click on the trigger itself while the popover is
450    ///   open (the press closes it before the trigger sees it);
451    /// * [`Escape`](DismissReason::Escape) — Escape pressed in the panel;
452    /// * [`Programmatic`](DismissReason::Programmatic) — the popover closed
453    ///   itself: `Alt+ArrowUp`, Enter or Space on the trigger, the
454    ///   [`open_action`](Self::open_action) toggle, Tab out of the panel, or
455    ///   the application dismissing it;
456    /// * [`Cascade`](DismissReason::Cascade) — an overlay this popover was
457    ///   opened from went away and took it along.
458    ///
459    /// The enum is `#[non_exhaustive]`, so match it with a wildcard arm.
460    /// Replaces any [`on_close`](Self::on_close) set before it.
461    pub fn on_close_with(mut self, f: impl Fn(DismissReason) + 'static) -> Self {
462        self.on_close = Some(Rc::new(f));
463        self
464    }
465
466    /// Observe-only handle to the popover-open state.
467    ///
468    /// **Read-back only — writing this does not open the popover.** Presenting
469    /// an overlay needs an `EventContext` (`show_overlay` + `request_focus`),
470    /// which no signal observer has; this field is the mirror the trigger writes
471    /// after it has done that work. To open the popover from somewhere other
472    /// than its trigger, use [`open_action`](Self::open_action).
473    pub fn open_signal(&self) -> Signal<bool> {
474        self.popover_open.clone()
475    }
476
477    /// Register a **named global action** that toggles this popover, so a menu
478    /// entry, a global shortcut or `ctx.send_intent(...)` can open it — not only
479    /// a click on its own trigger.
480    ///
481    /// Without this a popover is reachable by pointer alone. `on_open` /
482    /// `on_close` are notification-only and `open_signal` is a read-back mirror
483    /// (see its doc), so an app that wanted "Go to… ⌘G" next to its button had
484    /// no way to wire the second half. Action handlers are the one place that
485    /// *does* get an `EventContext`, which is exactly what presenting an overlay
486    /// requires — so the action runs the identical toggle the trigger runs, and
487    /// the two can never drift.
488    ///
489    /// Registered with `register_action_global`, deliberately: intents walk
490    /// source-widget → root, and a menu renders in an **overlay** that is a
491    /// sibling of the popover's own subtree, so a plain `register_action` would
492    /// never be reached from a menu item. Pair it with
493    /// `register_shortcut_global` in the app for the keystroke.
494    ///
495    /// ```ignore
496    /// PopoverButton::new(Button::new(tr!(go_to())))
497    ///     .content(palette)
498    ///     .open_action("go.to")
499    /// // elsewhere: MenuEntry::new(tr!(go_to())).intent("go.to").shortcut("go.to")
500    /// ```
501    pub fn open_action(mut self, intent: &'static str) -> Self {
502        self.open_action = Some(intent);
503        self
504    }
505
506    /// Choose which themed [`PopoverVariant`] surface wraps the content.
507    /// Default is [`PopoverVariant::Default`] (elevated panel with
508    /// padding + shadow). The surface is resolved from the active
509    /// [`PopoverStyle`] (`theme.style_slots.popover`), so it themes
510    /// app-wide.
511    pub fn surface(mut self, variant: PopoverVariant) -> Self {
512        self.surface_variant = Some(variant);
513        self
514    }
515
516    /// Opt OUT of the themed surface: the content is added raw, with no
517    /// background / border / padding. Use when the content already
518    /// supplies its own chrome — a [`MenuList`](crate::MenuList) (which
519    /// routes through the Menu `PopoverStyle` itself) or a hand-rolled
520    /// surface `Panel`. Without this, such content would be
521    /// double-chromed.
522    pub fn bare(mut self) -> Self {
523        self.surface_variant = None;
524        self
525    }
526
527    /// Per-call [`PopoverStyle`] override for the surface (highest
528    /// precedence over the theme slot and the built-in default). Mirrors
529    /// the per-call override the standalone `Popover` used to offer. No effect under
530    /// [`bare`](Self::bare).
531    pub fn surface_style(mut self, style: impl PopoverStyle) -> Self {
532        self.surface_style = Some(Rc::new(style));
533        self
534    }
535
536    /// Accessible name for the surface's `Role::Dialog` node. Without one
537    /// the surface is named by the trigger that opens it. No effect under
538    /// [`bare`](Self::bare) or for the Menu variant (which is
539    /// presentational).
540    pub fn surface_name(mut self, name: impl Into<String>) -> Self {
541        self.surface_name = name.into();
542        self
543    }
544
545    /// Show a plain single-line tooltip on the trigger after a hover delay.
546    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
547    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
548    /// [`composite_tooltip`](Self::composite_tooltip) — each setter clears
549    /// the other three so the last call wins. The tooltip anchors on the
550    /// trigger, not on the popover content.
551    pub fn tooltip(mut self, text: impl Into<teksilo_i18n::LocalizedString>) -> Self {
552        self.tooltip_text = Some(text.into());
553        self.rich_tooltip_source = None;
554        self.composite_tooltip_content = None;
555        self
556    }
557
558    /// Show a rich tooltip (looked up by registry key) on the trigger after
559    /// a hover delay. Mutually exclusive with the other tooltip setters —
560    /// the last call wins.
561    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
562        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
563        self.tooltip_text = None;
564        self.composite_tooltip_content = None;
565        self
566    }
567
568    /// Show an inline rich tooltip (pre-built [`TooltipContent`]) on the
569    /// trigger after a hover delay. Mutually exclusive with the other tooltip
570    /// setters — the last call wins.
571    ///
572    /// [`TooltipContent`]: crate::tooltip::TooltipContent
573    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
574        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
575        self.tooltip_text = None;
576        self.composite_tooltip_content = None;
577        self
578    }
579
580    /// Show a composite tooltip (arbitrary widget tree) on the trigger after
581    /// a longer hover delay. Mutually exclusive with the other tooltip setters
582    /// — the last call wins.
583    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
584        self.composite_tooltip_content = Some(Box::new(content));
585        self.tooltip_text = None;
586        self.rich_tooltip_source = None;
587        self
588    }
589}
590
591/// The popover's panel: the caller's content, wrapped in the themed surface.
592///
593/// A widget of its own so the whole thing — surface included — can sit behind a
594/// [`DeferredSubtree`](teksilo_core::deferred_subtree::DeferredSubtree) and be
595/// built the first time the popover is opened. It was inline in
596/// `PopoverWidget::build` until then, which meant every popover built its panel
597/// whether or not anyone ever opened it, on every rebuild of its owner. In a
598/// virtualized table that is once per row per rebuild; see `DeferredSubtree`
599/// for the measurement.
600struct PopoverBody {
601    content: Option<Box<dyn Widget>>,
602    surface_variant: Option<teksilo_core::styles::PopoverVariant>,
603    surface_style: Option<SharedPopoverStyle>,
604    surface_name: String,
605    placement: OverlayPlacement,
606    body_id: Option<WidgetId>,
607    /// The trigger, once `PopoverWidget::build` has added it, which is before
608    /// the body is first built (on the first open). It names the surface when
609    /// the caller gave it no name, and a `MenuList` content is named after it.
610    opener: Rc<Cell<Option<WidgetId>>>,
611}
612
613impl std::fmt::Debug for PopoverBody {
614    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615        f.debug_struct("PopoverBody").finish()
616    }
617}
618
619impl Widget for PopoverBody {
620    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
621        if let Some(id) = self.body_id {
622            return vec![id];
623        }
624        let Some(mut content) = self.content.take() else {
625            return Vec::new();
626        };
627        // A menu in a popover is named after the button that opens it, so a
628        // reader entering it hears "Add menu" rather than "menu".
629        if let Some(opener) = self.opener.get()
630            && let Some(menu) = content
631                .as_any_mut()
632                .and_then(|a| a.downcast_mut::<crate::menu_list::MenuList>())
633        {
634            menu.set_opener(opener);
635        }
636        // Materialize the inner content first so the surface style sees a ready
637        // WidgetId (same pattern as the `Popover` widget).
638        let inner_content_id = ctx.add_boxed(content);
639
640        // Wrap the inner content in the themed popover surface (background,
641        // border, padding, shadow) unless the caller opted out with `bare()`.
642        // The surface is resolved per-call > theme slot > built-in
643        // `RecipePopoverStyle`, so popovers theme app-wide via
644        // `theme.style_slots.popover`.
645        // Whether anything in the content takes focus. Asked now, while the
646        // content is new and before it is parented: `add` has built its whole
647        // subtree.
648        let content_takes_focus = ctx.first_focusable_descendant(inner_content_id).is_some();
649
650        let id = match self.surface_variant {
651            None => inner_content_id,
652            Some(variant) => {
653                let style: SharedPopoverStyle = self
654                    .surface_style
655                    .clone()
656                    .or_else(|| ctx.theme().style_slots.popover.clone())
657                    .unwrap_or_else(|| {
658                        Rc::new(crate::styles::RecipePopoverStyle::for_tokens(
659                            &ctx.theme().input,
660                        ))
661                    });
662                let cfg = PopoverStyleConfig {
663                    content: inner_content_id,
664                    variant,
665                    name: self.surface_name.clone(),
666                    placement: self.placement.clone(),
667                    show_caret: false,
668                    caret_size: 0.0,
669                };
670                let surface = style.make_body(&cfg, ctx);
671                // Every surface but the Menu one is a `Role::Dialog`, and a
672                // dialog needs a name. With none given, it is named by the
673                // control that opened it, as a disclosure's panel is.
674                if variant != PopoverVariant::Menu {
675                    if self.surface_name.is_empty()
676                        && let Some(trigger) = self.opener.get()
677                    {
678                        ctx.access_labelled_by(surface, trigger);
679                    }
680                    // With nothing inside to take focus, the dialog takes it,
681                    // so opening the popover puts the reader on a named node
682                    // that holds the text, and Tab carries on from here, after
683                    // the trigger. Left to the fallback, focus parked on the
684                    // unnamed host above this body: a node no reader could
685                    // name, from which Tab restarted at the top of the window.
686                    if !content_takes_focus {
687                        ctx.apply_handlers(surface, HandlerSet::new().focusable(true));
688                    }
689                }
690                surface
691            }
692        };
693        self.body_id = Some(id);
694        vec![id]
695    }
696
697    fn layout_response(
698        &self,
699        proposal: SizeProposal,
700        ctx: &LayoutContext,
701    ) -> teksilo_core::widget::LayoutResponse {
702        match self.body_id {
703            Some(id) => ctx
704                .child_size(id, proposal)
705                .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
706                .into(),
707            None => Size::new(0.0, 0.0).into(),
708        }
709    }
710
711    fn place_children(
712        &self,
713        bounds: Rect,
714        _proposal: SizeProposal,
715        children: &mut [WidgetPlacement],
716        _ctx: &LayoutContext,
717    ) {
718        for child in children.iter_mut() {
719            child.origin = Point::new(bounds.x, bounds.y);
720            child.size = bounds.size();
721        }
722    }
723
724    fn preserves_children_on_rebuild(&self) -> bool {
725        true
726    }
727}
728
729impl<T: PopoverTrigger> Widget for PopoverWidget<T> {
730    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
731        let content = self
732            .content
733            .take()
734            .expect("PopoverWidget::content(...) was not set");
735        // The panel — content *and* the surface around it — is built the first
736        // time the popover is opened, not here. The id below is a real node
737        // from this moment, so everything downstream (dormant / gated / shown /
738        // returned-as-child / dismissed) is unchanged; only when the subtree
739        // under it exists has moved. `materialize_now` in the open handler
740        // closes it up before the overlay is placed and focus moves in.
741        let opener: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
742        let content_id = ctx.add_deferred(
743            self.popover_open.clone(),
744            PopoverBody {
745                content: Some(content),
746                surface_variant: self.surface_variant,
747                surface_style: self.surface_style.clone(),
748                surface_name: self.surface_name.clone(),
749                placement: self.placement.clone(),
750                body_id: None,
751                opener: opener.clone(),
752            },
753        );
754        ctx.set_dormant(content_id);
755        // Gate the content's activation on `popover_open` so it is the single
756        // source of truth. Without this, when the PopoverWidget itself is woken
757        // by an ancestor's `visible_when` re-activation (e.g. a Toolbar overflow
758        // chevron appearing), the activation cascade would wake the dormant
759        // content in-tree — its rows would "float" outside the (closed) popover.
760        // The per-pass visibility reconciliation keeps the content dormant
761        // whenever the popover is closed, and `arena.activate` skips it in the
762        // cascade because its gate is `false`.
763        ctx.visible_when(content_id, self.popover_open.clone());
764        self.content_id = Some(content_id);
765
766        let trigger = self
767            .trigger
768            .take()
769            .expect("PopoverWidget trigger missing (build() called twice?)");
770
771        // The wrapper owns the trigger's activate slot (it opens the
772        // popover), so any caller-set `on_activate_fn` is about to be
773        // discarded. That is documented but easy to do by accident — make
774        // it loud. Use `on_open` / `on_close` (or observe `open_signal`)
775        // for trigger-side side effects instead.
776        if trigger.has_on_activate() {
777            debug_assert!(
778                false,
779                "PopoverWidget: the trigger's on_activate_fn is overwritten by the popover \
780                 wiring and will be discarded; use on_open / on_close instead"
781            );
782            warn_trigger_activate_discarded();
783        }
784
785        let want_caret = self.show_disclosure_caret && !trigger.suppress_caret();
786
787        let popover_open = self.popover_open.clone();
788        let self_ref = ctx.self_id();
789        let placement = self.placement.clone();
790        let dismiss_behavior = self.dismiss_behavior.clone();
791        let fade_duration = self.fade_duration;
792        let on_open = self.on_open.clone();
793        let on_close = self.on_close.clone();
794
795        // Dismiss callback — runs when the overlay manager closes the
796        // overlay, whatever the route (Escape, click-outside, the trigger's
797        // own toggle, an explicit dismiss, a cascade). Flips popover_open and
798        // hands the manager's reason to the user's on_close. The context the
799        // manager passes is not forwarded: on_close is a notification.
800        let dismiss_cb: OverlayDismissCallback = {
801            let popover_open = popover_open.clone();
802            let on_close = on_close.clone();
803            Rc::new(move |reason, _| {
804                popover_open.set(false);
805                if let Some(cb) = on_close.as_ref() {
806                    cb(reason);
807                }
808            })
809        };
810
811        // Activate handler installed onto the trigger. Toggles the
812        // popover: if open, dismiss; if closed, wake the dormant content,
813        // request the overlay, and move focus into it.
814        //
815        // Built as an `Rc` so `open_action` can register the *same* closure as a
816        // named global action. Sharing it (rather than writing a second, similar
817        // one) is the point: a menu entry and the trigger must not be able to
818        // disagree about what opening this popover means.
819        let activate: Rc<dyn Fn(&mut EventContext)> = Rc::new({
820            let popover_open = popover_open.clone();
821            let dismiss_cb = dismiss_cb.clone();
822            let on_open = on_open.clone();
823            move |ctx_evt: &mut EventContext| {
824                if popover_open.get() {
825                    popover_open.set(false);
826                    ctx_evt.dismiss_all_except_hosts();
827                } else {
828                    popover_open.set(true);
829                    // Build the panel if this is its first open, before the
830                    // overlay below is measured against it and before focus
831                    // moves into it — both happen in this same drain.
832                    ctx_evt.materialize_now(content_id);
833                    ctx_evt.activate(content_id);
834                    let mut req = OverlayRequest::new(
835                        content_id,
836                        self_ref,
837                        placement.clone(),
838                        dismiss_behavior.clone(),
839                    )
840                    .on_dismiss(dismiss_cb.clone());
841                    if let Some(d) = fade_duration {
842                        req = req.with_fade(d);
843                    }
844                    ctx_evt.show_overlay(req);
845                    // Into the panel: its first control, or the dialog itself
846                    // when it holds none (see `PopoverBody::build`). A bare
847                    // panel with nothing to focus leaves focus on the trigger
848                    // rather than on an unnamed node.
849                    ctx_evt.request_focus_into(content_id);
850                    if let Some(cb) = on_open.as_ref() {
851                        cb();
852                    }
853                }
854            }
855        });
856
857        // The named-action door. Registered global, not local: intents walk
858        // source-widget → root, and a menu renders in an overlay that is a
859        // sibling of this widget's subtree, so a plain `register_action` would
860        // never be reached from a menu item.
861        if let Some(intent) = self.open_action {
862            let act = activate.clone();
863            ctx.register_action_global(
864                teksilo_core::action::Action::new(intent)
865                    .on_invoke(move |_intent, ctx_evt| act(ctx_evt)),
866            );
867        }
868
869        // `Alt+ArrowDown` opens the popover and `Alt+ArrowUp` closes it — the
870        // platform disclosure chord (Win32 / WinForms / WPF drop-downs, and the
871        // W3C ARIA combobox pattern), read from the one table `ComboBox` and
872        // `DateEdit` read. It lives on the generic rather than in each
873        // consumer, so `PopoverButton`, `PopoverIconButton` and `ColorEdit` —
874        // whose module doc has always promised it — inherit one implementation
875        // and cannot drift from each other.
876        //
877        // Bubble phase, not preview: the panel's own content (a `MenuList`, a
878        // `ColorPicker`) must keep first refusal on every key, and the trigger
879        // is a child of this node, so an unclaimed chord still arrives here.
880        //
881        // `DisclosureChord::Toggle` (`F4`) is deliberately unmatched: this
882        // generic also backs toolbar chevrons and menu buttons, which carry no
883        // such convention. The drop-down *fields* bind it themselves.
884        ctx.apply_self_handlers(HandlerSet::new().on_key({
885            let popover_open = popover_open.clone();
886            let activate = activate.clone();
887            move |event: &WidgetEvent, ctx_evt: &mut EventContext| {
888                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
889                    return EventResponse::Ignored;
890                };
891                match crate::common::range_nav::disclosure_chord(*key, *modifiers) {
892                    Some(DisclosureChord::Open) => {
893                        if !popover_open.get() {
894                            activate(ctx_evt);
895                        }
896                        // Already open: the chord is ours, so swallow it rather
897                        // than letting a second `Alt+ArrowDown` reach an
898                        // ancestor.
899                        EventResponse::Handled
900                    }
901                    Some(DisclosureChord::Close) if popover_open.get() => {
902                        activate(ctx_evt);
903                        EventResponse::Handled
904                    }
905                    _ => EventResponse::Ignored,
906                }
907            }
908        }));
909
910        // With a caret, allocate the interaction signal up-front and
911        // share it with the trigger so the caret's color tracks the
912        // trigger's exactly. Without a caret, the trigger allocates its
913        // own signal as before.
914        if want_caret {
915            let interaction = ctx.signal(InteractionState::Idle);
916            let role_signal = trigger.caret_role(&interaction);
917            let trigger = trigger
918                .with_shared_interaction(interaction)
919                .with_has_popup(self.has_popup)
920                .with_expanded_when(popover_open.clone())
921                .with_on_activate({
922                    let act = activate.clone();
923                    move |c: &mut EventContext| act(c)
924                });
925            let trigger_id = ctx.add(trigger);
926            opener.set(Some(trigger_id));
927            let caret_id = ctx.add(DisclosureCaret { role: role_signal });
928            let root_id = ctx.add(ZStack::new().child(trigger_id).child(caret_id));
929            self.root_child_id = Some(root_id);
930            if let Some(content) = self.composite_tooltip_content.take() {
931                let delay = ctx.theme().motion.tooltip_delay_heavy;
932                crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
933            } else if let Some(source) = self.rich_tooltip_source.clone() {
934                let delay = ctx.theme().motion.tooltip_delay;
935                crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
936            } else if let Some(text) = self.tooltip_text.clone() {
937                let delay = ctx.theme().motion.tooltip_delay;
938                crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
939            }
940            // Return BOTH the trigger root AND the dormant content as
941            // children so the framework links content_id under this
942            // widget in the arena. Without this, content_id stays an
943            // orphan root and `arena.hit_test_at` walks its subtree on
944            // every click (descendants added during the content's own
945            // build can re-surface as hit targets at their pre-dormant
946            // positions). The layout pass skips dormant children.
947            return vec![root_id, content_id];
948        }
949
950        let trigger = trigger
951            .with_has_popup(self.has_popup)
952            .with_expanded_when(popover_open.clone())
953            .with_on_activate(move |c: &mut EventContext| activate(c));
954        let trigger_id = ctx.add(trigger);
955        opener.set(Some(trigger_id));
956        self.root_child_id = Some(trigger_id);
957        if let Some(content) = self.composite_tooltip_content.take() {
958            let delay = ctx.theme().motion.tooltip_delay_heavy;
959            crate::tooltip::attach_composite_tooltip_boxed(ctx, trigger_id, content, delay);
960        } else if let Some(source) = self.rich_tooltip_source.clone() {
961            let delay = ctx.theme().motion.tooltip_delay;
962            crate::tooltip::attach_rich_tooltip_source(ctx, trigger_id, source, delay);
963        } else if let Some(text) = self.tooltip_text.clone() {
964            let delay = ctx.theme().motion.tooltip_delay;
965            crate::tooltip::attach_plain_tooltip(ctx, trigger_id, text, delay);
966        }
967        // See the disclosure-caret branch for the content-linking rationale.
968        vec![trigger_id, content_id]
969    }
970
971    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
972        match self.root_child_id {
973            Some(id) => ctx
974                .child_layout_response(id, proposal)
975                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
976            None => proposal.resolve(0.0, 0.0).into(),
977        }
978    }
979
980    fn place_children(
981        &self,
982        bounds: Rect,
983        _proposal: SizeProposal,
984        children: &mut [WidgetPlacement],
985        _ctx: &LayoutContext,
986    ) {
987        // The trigger fills our bounds; the dormant/active content never
988        // participates in trigger layout — its bounds are owned by the
989        // overlay manager when shown and stay at zero while dormant.
990        // Dormant children are already filtered out before placements
991        // reach here; if the content is active (popover open), zero its
992        // placement so the parent's bounds don't clobber overlay
993        // positioning.
994        for child in children.iter_mut() {
995            if Some(child.id) == self.content_id {
996                child.size = teksilo_canvas::Size::ZERO;
997                continue;
998            }
999            child.origin = bounds.origin();
1000            child.size = bounds.size();
1001        }
1002    }
1003
1004    fn children(&self) -> Vec<WidgetId> {
1005        // Include both the trigger root AND the dormant content so
1006        // `set_dormant` cascades correctly and `arena.hit_test_at` can
1007        // prune the content subtree when it's not visible.
1008        let mut out = Vec::new();
1009        if let Some(id) = self.root_child_id {
1010            out.push(id);
1011        }
1012        if let Some(id) = self.content_id {
1013            out.push(id);
1014        }
1015        out
1016    }
1017
1018    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
1019        // No AT presence of our own — the inner trigger declares
1020        // `Role::Button`, `set_has_popup`, and `set_expanded`; the popover
1021        // content advertises its own role / live region. The disclosure
1022        // caret is decorative (set_hidden in its own accessibility()).
1023    }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use crate::primitives::{MinSize, RectWidget};
1030    use teksilo_canvas::Point;
1031    use teksilo_core::accesskit::{HasPopup, Role};
1032    use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1033    use teksilo_core::widget_tree::WidgetTree;
1034    use teksilo_i18n::lit;
1035
1036    fn light_tree() -> WidgetTree {
1037        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
1038    }
1039
1040    fn dummy_content() -> impl Widget {
1041        MinSize::new(40.0, 40.0).child(RectWidget::new())
1042    }
1043
1044    // ── Custom trigger (OverlayTrigger) ─────────────────────────────
1045
1046    #[test]
1047    fn custom_trigger_advertises_and_answers_the_at_click() {
1048        // The trigger node is the one carrying `Role::Button`, so it is the
1049        // one an adapter invokes — and the pointer/key handlers deliberately
1050        // live on the child, out of reach of that dispatch. Both halves are
1051        // asserted: an advertised action nothing answers and an answered
1052        // action nothing advertises are equally unusable.
1053        let mut tree = light_tree();
1054        tree.add(
1055            PopoverWidget::new(OverlayTrigger::around(dummy_content()).named("Show popover"))
1056                .content(dummy_content()),
1057        );
1058        tree.layout(SizeProposal::exact(300.0, 120.0));
1059
1060        let trigger = tree.find_by_label("Show popover").unwrap();
1061        assert_eq!(tree.accessibility_node(trigger).role(), Role::Button);
1062        assert!(
1063            tree.accessibility_node(trigger)
1064                .actions()
1065                .contains(&teksilo_core::accesskit::Action::Click)
1066        );
1067
1068        assert!(tree.active_overlays().is_empty());
1069        let handled = tree.dispatch_access_action(
1070            teksilo_core::accessibility::widget_id_to_node_id(trigger),
1071            teksilo_core::accesskit::Action::Click,
1072            None,
1073            &mut teksilo_core::NoopWindowOps,
1074        );
1075        assert!(handled, "the AT Click must be reported as handled");
1076        tree.layout(SizeProposal::exact(300.0, 120.0));
1077        assert_eq!(tree.active_overlays().len(), 1);
1078    }
1079
1080    // ── PopoverButton (text trigger) ────────────────────────────────
1081
1082    #[test]
1083    #[should_panic(expected = "PopoverWidget::content")]
1084    fn button_panics_without_content() {
1085        let mut tree = light_tree();
1086        tree.add(PopoverButton::new(Button::new(lit!("Open"))));
1087        tree.layout(SizeProposal::exact(300.0, 80.0));
1088    }
1089
1090    #[test]
1091    fn button_trigger_announces_role_and_haspopup_dialog() {
1092        let mut tree = light_tree();
1093        tree.add(PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content()));
1094        tree.layout(SizeProposal::exact(300.0, 80.0));
1095        let update = tree.sync_accessibility();
1096        let button_node = update
1097            .nodes
1098            .iter()
1099            .find(|(_, n)| n.role() == Role::Button)
1100            .map(|(_, n)| n)
1101            .expect("button node");
1102        assert_eq!(
1103            button_node.has_popup(),
1104            Some(HasPopup::Dialog),
1105            "PopoverButton default has_popup must be Dialog",
1106        );
1107        assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
1108    }
1109
1110    #[test]
1111    fn button_enter_opens_popover_and_flips_open_signal() {
1112        let mut tree = light_tree();
1113        let pb = PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content());
1114        let open_signal = pb.open_signal();
1115        let id = tree.add(pb);
1116        tree.layout(SizeProposal::exact(300.0, 80.0));
1117        let button_id = tree
1118            .first_focusable_descendant(id)
1119            .expect("PopoverButton must expose a focusable inner Button");
1120        tree.focus(button_id);
1121        assert!(!open_signal.get());
1122        tree.dispatch_event(WidgetEvent::KeyDown {
1123            key: Key::Enter,
1124            modifiers: Modifiers::NONE,
1125            text: None,
1126        });
1127        tree.dispatch_event(WidgetEvent::KeyUp {
1128            key: Key::Enter,
1129            modifiers: Modifiers::NONE,
1130        });
1131        assert!(open_signal.get(), "Enter should open the popover");
1132    }
1133
1134    /// `open_action` opens the popover from a **sibling** widget's intent.
1135    ///
1136    /// The sibling placement is the test, not incidental scenery: intents walk
1137    /// source-widget → root, so a locally-registered action would never be
1138    /// reached from a menu — which renders in an overlay that is a sibling of
1139    /// the popover's subtree, exactly like this button. Firing from a child of
1140    /// the popover would pass with either registration and prove nothing.
1141    #[test]
1142    fn open_action_opens_the_popover_from_a_sibling_intent() {
1143        use crate::primitives::VStack;
1144        use teksilo_core::intent::Intent;
1145
1146        let mut tree = light_tree();
1147        let pb = PopoverButton::new(Button::new(lit!("Open")))
1148            .content(dummy_content())
1149            .open_action("test.open");
1150        let open_signal = pb.open_signal();
1151        let pb_id = tree.add(pb);
1152        let fire_id = tree.add(
1153            Button::new(lit!("Fire"))
1154                .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.open"))),
1155        );
1156        tree.add(VStack::new().child(pb_id).child(fire_id));
1157        tree.layout(SizeProposal::exact(300.0, 160.0));
1158
1159        assert!(!open_signal.get(), "starts closed");
1160
1161        let fire_btn = tree.first_focusable_descendant(fire_id).unwrap_or(fire_id);
1162        tree.focus(fire_btn);
1163        tree.dispatch_event(WidgetEvent::KeyDown {
1164            key: Key::Enter,
1165            modifiers: Modifiers::NONE,
1166            text: None,
1167        });
1168        tree.dispatch_event(WidgetEvent::KeyUp {
1169            key: Key::Enter,
1170            modifiers: Modifiers::NONE,
1171        });
1172        assert!(
1173            open_signal.get(),
1174            "the named action must open the popover from off its own subtree"
1175        );
1176    }
1177
1178    /// The action *toggles*, sharing one closure with the trigger — so a menu
1179    /// entry and a click can never disagree about what the popover does.
1180    ///
1181    /// Fired from **inside** the panel, which is the only place the toggle's
1182    /// close branch is still reachable. A sibling cannot reach it: taking focus
1183    /// away from an open popover now dismisses it (non-modal overlays follow
1184    /// focus out rather than trapping it), so by the time an outside control is
1185    /// focused enough to be activated, there is nothing left to close and the
1186    /// shared closure correctly takes its *open* branch. That is not new
1187    /// asymmetry — the popover's default `EscapeOrClickOutside` already meant a
1188    /// real pointer click on that sibling dismissed it before activating. The
1189    /// keyboard simply stopped disagreeing with the mouse.
1190    /// `open_action_opens_the_popover_from_a_sibling_intent` above still pins
1191    /// the global-registration half.
1192    #[test]
1193    fn open_action_toggles_rather_than_only_opening() {
1194        use teksilo_core::intent::Intent;
1195
1196        let mut tree = light_tree();
1197        let pb = PopoverButton::new(Button::new(lit!("Open")))
1198            .content(
1199                Button::new(lit!("Fire"))
1200                    .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.toggle"))),
1201            )
1202            .open_action("test.toggle");
1203        let open_signal = pb.open_signal();
1204        let pb_id = tree.add(pb);
1205        tree.layout(SizeProposal::exact(300.0, 160.0));
1206
1207        let trigger = tree
1208            .first_focusable_descendant(pb_id)
1209            .expect("the trigger is the only focusable while closed");
1210        tree.focus(trigger);
1211        let enter = |tree: &mut WidgetTree| {
1212            tree.dispatch_event(WidgetEvent::KeyDown {
1213                key: Key::Enter,
1214                modifiers: Modifiers::NONE,
1215                text: None,
1216            });
1217            tree.dispatch_event(WidgetEvent::KeyUp {
1218                key: Key::Enter,
1219                modifiers: Modifiers::NONE,
1220            });
1221        };
1222
1223        enter(&mut tree);
1224        assert!(open_signal.get(), "first fire opens");
1225        // Opening moved focus into the panel, onto its own Fire button — so the
1226        // next Enter runs the same shared closure without focus ever leaving.
1227        enter(&mut tree);
1228        assert!(!open_signal.get(), "second fire closes");
1229    }
1230
1231    /// **A popover that is never opened never builds its panel** — and keeps
1232    /// not building it however often its owner rebuilds.
1233    ///
1234    /// This is the regression guard for the reason `DeferredSubtree` exists. A
1235    /// `PopoverIconButton` in a virtualized table is constructed once per
1236    /// visible row per rebuild; building each one's menu was measured at ~85%
1237    /// of the whole table's rebuild cost. The panel here counts its own builds,
1238    /// so "parked dormant" cannot pass for "not built".
1239    #[test]
1240    fn an_unopened_popover_never_builds_its_panel() {
1241        use teksilo_core::signal::Signal;
1242
1243        #[derive(Debug)]
1244        struct CountingContent {
1245            builds: Signal<u32>,
1246        }
1247        impl Widget for CountingContent {
1248            fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1249                self.builds.set(self.builds.get() + 1);
1250                Vec::new()
1251            }
1252            fn layout_response(
1253                &self,
1254                p: SizeProposal,
1255                _c: &teksilo_core::widget::LayoutContext,
1256            ) -> teksilo_core::widget::LayoutResponse {
1257                p.resolve(40.0, 20.0).into()
1258            }
1259        }
1260
1261        /// The owner: rebuilds on demand and constructs a **fresh**
1262        /// `PopoverButton` each time, which is what a virtualized table's cell
1263        /// delegate does. Constructing the widget value is nearly free; adding
1264        /// its panel to the arena is what used to cost.
1265        #[derive(Debug)]
1266        struct Owner {
1267            builds: Signal<u32>,
1268            open_out: Signal<Option<Signal<bool>>>,
1269            child: Option<WidgetId>,
1270        }
1271        impl Widget for Owner {
1272            fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1273                let pb = PopoverButton::new(Button::new(lit!("Open"))).content(CountingContent {
1274                    builds: self.builds.clone(),
1275                });
1276                self.open_out.set(Some(pb.open_signal()));
1277                let id = ctx.add(pb);
1278                self.child = Some(id);
1279                vec![id]
1280            }
1281            fn layout_response(
1282                &self,
1283                p: SizeProposal,
1284                c: &teksilo_core::widget::LayoutContext,
1285            ) -> teksilo_core::widget::LayoutResponse {
1286                self.child
1287                    .and_then(|id| c.child_size(id, p))
1288                    .unwrap_or_else(|| p.resolve(0.0, 0.0))
1289                    .into()
1290            }
1291        }
1292
1293        let builds = Signal::new(0);
1294        let open_out = Signal::new(None);
1295        let mut tree = light_tree();
1296        let owner = tree.add(Owner {
1297            builds: builds.clone(),
1298            open_out: open_out.clone(),
1299            child: None,
1300        });
1301        tree.layout(SizeProposal::exact(300.0, 120.0));
1302        assert_eq!(builds.get(), 0, "the panel was built without being opened");
1303
1304        // Rebuild the owner repeatedly — one fresh popover per pass, exactly as
1305        // a table cell produces one per row per rebuild.
1306        for _ in 0..5 {
1307            tree.arena_mark_needs_rebuild_for_testing(owner);
1308            tree.layout(SizeProposal::exact(300.0, 120.0));
1309        }
1310        assert_eq!(
1311            builds.get(),
1312            0,
1313            "rebuilding the owner dragged five unopened panels into the arena"
1314        );
1315
1316        // Opening builds it — once — and it survives a close/reopen, so
1317        // whatever state the panel holds is not thrown away.
1318        let open = open_out.get().expect("the popover published its signal");
1319        let button = tree
1320            .first_focusable_descendant(owner)
1321            .expect("focusable inner Button");
1322        let enter = move |t: &mut WidgetTree| {
1323            // Re-aim at the trigger each time: opening moves focus into the
1324            // panel, and this panel has no focusable control of its own.
1325            t.focus(button);
1326            t.dispatch_event(WidgetEvent::KeyDown {
1327                key: Key::Enter,
1328                modifiers: Modifiers::NONE,
1329                text: None,
1330            });
1331            t.dispatch_event(WidgetEvent::KeyUp {
1332                key: Key::Enter,
1333                modifiers: Modifiers::NONE,
1334            });
1335            t.layout(SizeProposal::exact(300.0, 120.0));
1336        };
1337        enter(&mut tree);
1338        assert!(open.get(), "Enter should open the popover");
1339        assert_eq!(builds.get(), 1, "opening must build the panel");
1340
1341        // Close and reopen. Driven through the widget's own open signal rather
1342        // than a second keystroke: opening moved focus into the panel, and
1343        // routing a key back out of it is a different guarantee, covered by
1344        // `open_action_toggles_rather_than_only_opening`.
1345        open.set(false);
1346        tree.layout(SizeProposal::exact(300.0, 120.0));
1347        open.set(true);
1348        tree.layout(SizeProposal::exact(300.0, 120.0));
1349        assert_eq!(
1350            builds.get(),
1351            1,
1352            "reopening rebuilt the panel — its state would have been lost"
1353        );
1354    }
1355
1356    #[test]
1357    fn default_wraps_content_in_themed_surface_bare_does_not() {
1358        // A pure-leaf content (RectWidget has no children) makes the wrapping
1359        // observable: the default surface puts one more node between the
1360        // overlay's content id and that leaf than `bare()` does.
1361        //
1362        // Asserted as a *difference* rather than as an absolute depth on
1363        // purpose. The panel is now built behind a `DeferredSubtree` (so an
1364        // unopened popover costs nothing), which puts two layout-transparent
1365        // wrappers above it; pinning the exact chain would make this test a
1366        // record of how many wrappers there happen to be rather than of the
1367        // thing it is named after.
1368        fn open_overlay_content(bare: bool) -> (WidgetTree, WidgetId) {
1369            let mut tree = light_tree();
1370            let mut pb = PopoverButton::new(Button::new(lit!("Open"))).content(RectWidget::new());
1371            if bare {
1372                pb = pb.bare();
1373            }
1374            let open = pb.open_signal();
1375            let id = tree.add(pb);
1376            tree.layout(SizeProposal::exact(300.0, 120.0));
1377            let button = tree
1378                .first_focusable_descendant(id)
1379                .expect("focusable inner Button");
1380            tree.focus(button);
1381            tree.dispatch_event(WidgetEvent::KeyDown {
1382                key: Key::Enter,
1383                modifiers: Modifiers::NONE,
1384                text: None,
1385            });
1386            tree.dispatch_event(WidgetEvent::KeyUp {
1387                key: Key::Enter,
1388                modifiers: Modifiers::NONE,
1389            });
1390            assert!(open.get(), "Enter should open the popover");
1391            tree.layout(SizeProposal::exact(300.0, 120.0));
1392            let content = tree
1393                .overlay_manager()
1394                .active_content_ids()
1395                .first()
1396                .copied()
1397                .expect("an active overlay content");
1398            (tree, content)
1399        }
1400
1401        /// Steps from `id` down to the first node with no children.
1402        fn depth_to_leaf(tree: &WidgetTree, id: WidgetId) -> usize {
1403            let mut depth = 0;
1404            let mut cur = id;
1405            loop {
1406                let kids = tree.children(cur);
1407                match kids.first() {
1408                    Some(&next) => {
1409                        depth += 1;
1410                        cur = next;
1411                    }
1412                    None => return depth,
1413                }
1414            }
1415        }
1416
1417        let (tree_def, c_def) = open_overlay_content(false);
1418        let (tree_bare, c_bare) = open_overlay_content(true);
1419        let deep = depth_to_leaf(&tree_def, c_def);
1420        let bare = depth_to_leaf(&tree_bare, c_bare);
1421        assert_eq!(
1422            deep,
1423            bare + 1,
1424            "the default surface must add exactly one node of chrome that bare() \
1425             does not (default {deep}, bare {bare})"
1426        );
1427    }
1428
1429    #[test]
1430    fn button_caret_does_not_break_pointer_clicks() {
1431        // The disclosure caret is layered on top of the trigger in a
1432        // ZStack; it must be pointer-pass-through so mouse clicks reach
1433        // the trigger. Aim at the bottom-right quadrant where it paints.
1434        let mut tree = light_tree();
1435        let pb = PopoverButton::new(Button::new(lit!("Open")))
1436            .show_disclosure_caret(true)
1437            .content(dummy_content());
1438        let open_signal = pb.open_signal();
1439        let id = tree.add(pb);
1440        tree.layout(SizeProposal::exact(300.0, 80.0));
1441        let trigger_id = tree
1442            .first_focusable_descendant(id)
1443            .expect("must expose a focusable inner Button");
1444        let b = tree.bounds(trigger_id);
1445        let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1446        tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1447        tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1448        assert!(
1449            open_signal.get(),
1450            "click on the caret quadrant must pass through to the trigger",
1451        );
1452    }
1453
1454    // ── PopoverIconButton (icon trigger) ────────────────────────────
1455
1456    #[test]
1457    #[should_panic(expected = "PopoverWidget::content")]
1458    fn icon_panics_without_content() {
1459        let mut tree = light_tree();
1460        tree.add(PopoverIconButton::new(IconButton::add()));
1461        tree.layout(SizeProposal::exact(300.0, 80.0));
1462    }
1463
1464    #[test]
1465    fn icon_trigger_announces_haspopup_menu_collapsed() {
1466        let mut tree = light_tree();
1467        tree.add(PopoverIconButton::new(IconButton::add()).content(dummy_content()));
1468        tree.layout(SizeProposal::exact(300.0, 80.0));
1469        let update = tree.sync_accessibility();
1470        let button_node = update
1471            .nodes
1472            .iter()
1473            .find(|(_, n)| n.role() == Role::Button)
1474            .map(|(_, n)| n)
1475            .expect("button node");
1476        assert_eq!(
1477            button_node.has_popup(),
1478            Some(HasPopup::Menu),
1479            "PopoverIconButton default has_popup must be Menu",
1480        );
1481        assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
1482    }
1483
1484    #[test]
1485    fn icon_enter_opens_popover_and_flips_open_signal() {
1486        let mut tree = light_tree();
1487        let pib = PopoverIconButton::new(IconButton::add()).content(dummy_content());
1488        let open_signal = pib.open_signal();
1489        let id = tree.add(pib);
1490        tree.layout(SizeProposal::exact(300.0, 80.0));
1491        let button_id = tree
1492            .first_focusable_descendant(id)
1493            .expect("must expose a focusable inner IconButton");
1494        tree.focus(button_id);
1495        assert!(!open_signal.get());
1496        tree.dispatch_event(WidgetEvent::KeyDown {
1497            key: Key::Enter,
1498            modifiers: Modifiers::NONE,
1499            text: None,
1500        });
1501        tree.dispatch_event(WidgetEvent::KeyUp {
1502            key: Key::Enter,
1503            modifiers: Modifiers::NONE,
1504        });
1505        assert!(open_signal.get(), "Enter should open the popover");
1506    }
1507
1508    #[test]
1509    fn icon_caret_false_still_focusable() {
1510        let mut tree = light_tree();
1511        let id = tree.add(
1512            PopoverIconButton::new(IconButton::add())
1513                .show_disclosure_caret(false)
1514                .content(dummy_content()),
1515        );
1516        tree.layout(SizeProposal::exact(300.0, 80.0));
1517        let _ = tree
1518            .first_focusable_descendant(id)
1519            .expect("focusable IconButton must still be present");
1520    }
1521
1522    #[test]
1523    fn icon_caret_click_through_reaches_trigger() {
1524        let mut tree = light_tree();
1525        let pib = PopoverIconButton::new(IconButton::add().toolbar()).content(dummy_content());
1526        let open_signal = pib.open_signal();
1527        let id = tree.add(pib);
1528        tree.layout(SizeProposal::exact(300.0, 80.0));
1529        let trigger_id = tree
1530            .first_focusable_descendant(id)
1531            .expect("must expose a focusable IconButton");
1532        let b = tree.bounds(trigger_id);
1533        let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1534        tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1535        tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1536        assert!(
1537            open_signal.get(),
1538            "clicking the caret quadrant of the IconButton must pass through",
1539        );
1540    }
1541
1542    #[test]
1543    fn icon_compact_skips_caret_but_still_builds() {
1544        let mut tree = light_tree();
1545        let id = tree.add(
1546            PopoverIconButton::new(IconButton::add().size(IconButtonSize::Compact))
1547                .content(dummy_content()),
1548        );
1549        tree.layout(SizeProposal::exact(300.0, 80.0));
1550        let _ = tree
1551            .first_focusable_descendant(id)
1552            .expect("focusable IconButton must be present at Compact");
1553    }
1554
1555    #[test]
1556    fn tooltip_appears_on_hover() {
1557        let mut tree = light_tree();
1558        let id = tree.add(
1559            PopoverButton::new(Button::new(lit!("Open")))
1560                .content(dummy_content())
1561                .tooltip(lit!("Tip")),
1562        );
1563        tree.layout(SizeProposal::exact(300.0, 80.0));
1564        tree.pointer_move(tree.bounds(id).center());
1565        tree.advance_time(std::time::Duration::from_secs(1));
1566        assert_eq!(
1567            tree.active_overlays().len(),
1568            1,
1569            "tooltip should appear on hover"
1570        );
1571        assert!(tree.find_by_label("Tip").is_some());
1572    }
1573
1574    #[derive(Debug)]
1575    struct FocusableLeaf;
1576    impl Widget for FocusableLeaf {
1577        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1578            ctx.apply_self_handlers(
1579                teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1580            );
1581            vec![]
1582        }
1583        fn layout_response(
1584            &self,
1585            proposal: SizeProposal,
1586            _ctx: &LayoutContext,
1587        ) -> teksilo_core::widget::LayoutResponse {
1588            proposal.resolve(12.0, 12.0).into()
1589        }
1590    }
1591
1592    /// Open a popover, Tab past its last control, and it must go.
1593    ///
1594    /// A popover implements the Disclosure pattern, which mandates no focus
1595    /// containment — so Tab genuinely leaves. What must *not* survive that is
1596    /// the panel itself: an open popover with the focus ring somewhere behind
1597    /// it fails WCAG 2.2 SC 2.4.11 (Focus Not Obscured). Note the content's
1598    /// natural Tab slot is already correct — it is built as a child of the
1599    /// trigger, so it follows the trigger the way a disclosure's panel follows
1600    /// its button. Only the dismissal was missing.
1601    #[test]
1602    fn tab_out_of_popover_dismisses_it() {
1603        let mut tree = light_tree();
1604        let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1605            crate::primitives::VStack::new()
1606                .child(FocusableLeaf)
1607                .child(FocusableLeaf),
1608        );
1609        let open_signal = pb.open_signal();
1610        let id = tree.add(pb);
1611        let after = tree.add(FocusableLeaf);
1612        tree.layout(SizeProposal::exact(300.0, 400.0));
1613        let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1614
1615        tree.focus(button_id);
1616        tree.dispatch_event(WidgetEvent::KeyDown {
1617            key: Key::Enter,
1618            modifiers: Modifiers::NONE,
1619            text: None,
1620        });
1621        tree.dispatch_event(WidgetEvent::KeyUp {
1622            key: Key::Enter,
1623            modifiers: Modifiers::NONE,
1624        });
1625        assert!(open_signal.get(), "precondition: Enter opens the popover");
1626        assert_eq!(tree.active_overlays().len(), 1);
1627
1628        // Tab within the content — two focusables, so the first Tab stays inside
1629        // and must NOT dismiss anything.
1630        tree.press_key(Key::Tab, Modifiers::NONE);
1631        assert_eq!(
1632            tree.active_overlays().len(),
1633            1,
1634            "moving between the popover's own controls is not leaving it"
1635        );
1636
1637        // The next Tab leaves the content for good.
1638        tree.press_key(Key::Tab, Modifiers::NONE);
1639        assert_eq!(tree.focused(), Some(after), "focus lands past the trigger");
1640        assert!(
1641            tree.active_overlays().is_empty(),
1642            "the popover must not stay open behind the focus ring"
1643        );
1644        assert!(!open_signal.get(), "and its open signal must follow");
1645    }
1646
1647    /// Shift+Tab off the front of the content leaves it just as surely — and
1648    /// lands on the trigger, which is where Escape would have left it.
1649    #[test]
1650    fn shift_tab_off_the_front_of_a_popover_dismisses_it() {
1651        let mut tree = light_tree();
1652        let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1653            crate::primitives::VStack::new()
1654                .child(FocusableLeaf)
1655                .child(FocusableLeaf),
1656        );
1657        let open_signal = pb.open_signal();
1658        let id = tree.add(pb);
1659        tree.add(FocusableLeaf);
1660        tree.layout(SizeProposal::exact(300.0, 400.0));
1661        let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1662
1663        tree.focus(button_id);
1664        tree.dispatch_event(WidgetEvent::KeyDown {
1665            key: Key::Enter,
1666            modifiers: Modifiers::NONE,
1667            text: None,
1668        });
1669        tree.dispatch_event(WidgetEvent::KeyUp {
1670            key: Key::Enter,
1671            modifiers: Modifiers::NONE,
1672        });
1673        assert!(open_signal.get());
1674
1675        tree.press_key(Key::Tab, Modifiers::SHIFT);
1676        assert_eq!(tree.focused(), Some(button_id), "back onto the trigger");
1677        assert!(
1678            tree.active_overlays().is_empty(),
1679            "leaving through the front dismisses it too"
1680        );
1681    }
1682}