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}