Skip to main content

frust_material/
tooltip.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/tooltips/` — `m3e_tooltips.dart` (the `M3ETooltip` widget)
3// and `styles/m3e_tooltip_theme.dart` (every metric), retrieved 2026-08-20.
4// Upstream: <https://github.com/paadevelopments/material_3_expressive>
5//
6// Porting decisions:
7// - Placement and the rich panel's light dismiss are **not** hand-rolled: both
8//   panels float through the framework's overlay portal
9//   ([`frust::authoring::OverlaySlot`]), which owns the placement math and the
10//   outside-press notification — the same split upstream's own `OverlayPortal`/
11//   `CompositedTransformFollower` pair makes. They no longer compose under this
12//   catalog's merged `crate::overlay::anchored` host (the [`mod@crate::menu`]
13//   precedent), because that host consumes every press outside its content by
14//   design: mounted under a hover trigger it ate the press meant for whatever
15//   sat underneath. `crate::overlay::anchored` itself is untouched, and every
16//   click-opened Material overlay still presents through it.
17// - **No exit fade — deliberate, faithful to upstream.** `M3ETooltip._fade`
18//   (`m3e_tooltips.dart:187-197`) wraps only the *entrance*
19//   (`TweenAnimationBuilder<double>` from 0 to 1); `_hide()` just calls
20//   `OverlayPortalController.hide()`, which unmounts the overlay child on the
21//   spot — there is no exit tween to port. The portal has that semantics for
22//   free, and it is what this port now leans on: the registry is **per paint
23//   pass**, so an owner that stops registering its surface is gone from the
24//   paint and from the routing table at once, with nothing to unregister. So
25//   [`TooltipView`]/[`RichTooltipView`] keep their pod mounted permanently and
26//   gate only the *registration*, on a live [`TooltipHover::is_open`] read in
27//   the owner's own `paint`. That **replaces** the `SizedBox`/
28//   `anchored_overlay` `View::Element` swap this port used to need (the
29//   anchored host would have started a 150ms exit ramp upstream never has if
30//   handed a live `open` flag, so the panel had to be swapped out by type
31//   instead), and it lands closer to `hide()` than the swap did: an `Element`
32//   swap can only take effect on a `View::rebuild`, and [`TooltipHover`] is not
33//   reactive, so a hover-exit that merely requested a frame left the old panel
34//   on screen until something unrelated rebuilt the tree. The panel now goes on
35//   the very next *paint*, rebuild or no rebuild.
36// - **The entrance ramp moved with it, into the pod.** The anchored host used
37//   to own the ramp; the portal owns no motion at all, so the same ramp is
38//   played here — same tokens ([`MaterialMotion::SHORT_4`] /
39//   [`MaterialMotion::EMPHASIZED_DECELERATE`]), same start scale
40//   ([`crate::overlay::ANCHORED_ENTER_SCALE`]) and the same pivot on the
41//   content edge facing the trigger, so nothing changes on screen. It lives in
42//   the registered pod ([`RampedPanel`]) rather than in the owner because the
43//   root paints a registered pod *directly*, so an owner's own `push_layer`
44//   would already have been popped by the time the pod paints. Entrance only —
45//   there is deliberately no reverse. Upstream's own entrance is a plain
46//   opacity tween with no scale, so this port's entrance stays *closer to the
47//   rest of the catalog's anchored panels* than to this one reference file —
48//   the same trade `crate::menu`'s header documents.
49// - **The two panels register differently, and only one is input-transparent.**
50//   The plain panel is inert chrome: [`frust::OverlayBand::Tooltip`] +
51//   [`frust::OverlayInput::Transparent`], which the root's pre-pass skips
52//   outright, so every press reaches the main tree exactly as if the tooltip
53//   were not there. The rich panel cannot be transparent and still be itself: a
54//   transparent surface is hit-tested by nothing at all, so it would receive
55//   neither its `actions` row's presses nor any outside-press notification, and
56//   upstream's rich tooltip has both. It registers `Interactive` with
57//   [`frust::OutsideTap::Notify`]` { consume: false }` instead — which is
58//   upstream's own outside layer exactly (`Positioned.fill(GestureDetector(
59//   onTap: _hide, behavior: HitTestBehavior.translucent))`: told about the tap,
60//   and translucent, so whatever sits underneath is told as well). The press
61//   outside is the one the anchored host used to swallow, and no longer
62//   swallowing it is the whole point of this port.
63// - Neither trigger mechanism upstream owns a *show delay*: `MouseRegion.onEnter`
64//   calls `_show()` synchronously (no `Timer`), and `onLongPress` fires only
65//   after Flutter's own long-press recognizer threshold elapses. This port
66//   mirrors both literally — hover opens on the very next paint that observes
67//   [`PaintCtx::is_hovered`], zero added delay — and reuses this catalog's
68//   established 500ms long-press threshold
69//   ([`crate::toggle_button`]'s own citation: Android's `ViewConfiguration`
70//   ~400-500ms default, iOS's `UILongPressGestureRecognizer.minimumPressDuration`
71//   0.5s) as the trigger's own "show delay" for touch, delegated to
72//   `frust::GestureDetector` (see the [Hover vs. long-press](#hover-vs-long-press)
73//   section) rather than a hand-rolled recognizer.
74// - Upstream enforces no "one tooltip visible at a time" rule anywhere in
75//   `_M3ETooltipState` — no shared registry, no portal singleton — so this port
76//   adds none either; two [`TooltipHover`]s opened independently may both show.
77
78//! Material 3 Expressive tooltips: a plain, auto-dismissing label and a rich
79//! panel with a title, supporting text and up to two action buttons — both
80//! opened by hovering (pointer platforms) or a long-press (touch), and floated
81//! above the app by the framework's overlay portal.
82//!
83//! # Riding the framework portal
84//!
85//! Each panel's own view is an *owner*: it holds one
86//! [`frust::authoring::OverlaySlot`], lays its pod out against the window, and
87//! — on every paint where [`TooltipHover::is_open`] reads true — registers it
88//! with the root, which paints it after the whole main tree and hit-tests it
89//! before the main tree, per the band and input class the owner declared. An
90//! app mounts the owner **unconditionally**, exactly as before; it costs one
91//! layout of the panel per frame and, while closed, nothing else.
92//!
93//! [`frust::overlay_portal`]'s declarative wrapper anchors to its own child's
94//! bounds, which does not fit here: a tooltip's trigger and panel are two
95//! independently-mounted sibling views, so the panel's slot anchors instead to
96//! the rect the trigger captured into the shared handle below
97//! ([`OverlayAnchor::Rect`](frust::authoring::OverlayAnchor::Rect)).
98//!
99//! Two consequences of floating a pod rather than hosting it in the main tree,
100//! both of them the portal's own documented v1 shape rather than this port's
101//! choice: a pod publishes **no accessibility nodes** (they would attach under
102//! the owner's node, at the owner's position rather than the floated rect's),
103//! and a pod receives **no broadcast** events — neither panel needs one, since
104//! the trigger's long-press timer lives in the main tree and neither panel's
105//! own content runs a threshold-time gesture.
106//!
107//! # The shared handle
108//!
109//! [`TooltipHover`] is the one non-reactive latch a caller keeps per tooltip —
110//! the trigger writes it, the panel reads it, and
111//! [`crate::overlay::anchored`]'s own [`OverlayAnchor`] cell rides inside it
112//! (carrying the trigger's window-space rect) so a caller never juggles two
113//! handles. It is **not reactive** (`docs/CODE_STANDARDS.md`'s convention for
114//! this catalog's other paint-latched handles, e.g.
115//! [`crate::overlay::anchored::OverlayAnchor`] itself): writing it wakes
116//! nothing on its own, so every writer also calls
117//! `PaintCtx::request_frame`/relies on the pointer event that carried it — see
118//! each write site below. It is read live, during the layout/paint of the very
119//! frame that wrote it, which is what lets the panel appear and disappear
120//! without waiting for a rebuild.
121//!
122//! # Hover vs. long-press
123//!
124//! [`tooltip_trigger`] wraps an arbitrary child transparently (every event
125//! still reaches it) and does two independent things:
126//!
127//! - **Hover** is read the same way [`crate::overlay`]'s shadcn-ported sibling
128//!   catalog reads it for its own tooltip (`frust_shadcn::components::tooltip`):
129//!   `PaintCtx::is_hovered` is the *authoritative* signal (self-correcting every
130//!   paint, the one pass that reliably learns a pointer left without a
131//!   dedicated event), claimed for via `EventCtx::claim_hover` on an in-bounds
132//!   `Move` so a hovered control *inside* the child still wins the claim and
133//!   this wrapper still reads hovered through the path (the claim-ordering
134//!   rule). A transition writes [`TooltipHover`] and requests a frame; the
135//!   panel's owner reads that latch live in its own `paint`, so the frame the
136//!   write asked for is the frame the panel appears on — or vanishes on.
137//! - **Long-press** is delegated whole to `frust::GestureDetector` (nested as
138//!   this trigger's one child, wrapping the caller's real child in turn) rather
139//!   than hand-rolled: touch has no hover, and `GestureDetector`'s own
140//!   paint-clock timer + `frust_core::mark_pending_result_flush` machinery is
141//!   the only way a plugin crate reaches a threshold-time fire with no further
142//!   pointer event (`crate::toggle_button`'s own module docs explain why that
143//!   function is unreachable from a design-system plugin directly — see its
144//!   "Long-press" section). The callback ignores the `&mut State` it is handed
145//!   and writes straight into the shared, non-reactive [`TooltipHover`] instead.
146//!
147//! [`TooltipTriggerView::enabled`] gates both: `false` suppresses hover opening
148//! it, the long-press callback becomes a no-op, and an already-open tooltip
149//! snaps shut the moment `enabled` goes false mid-show.
150//!
151//! # Plain: auto-dismiss, long-press only
152//!
153//! `M3ETooltipState._scheduleHide` (`m3e_tooltips.dart:72-78`) arms a
154//! [`MaterialMotion::EXTRA_LONG_4`]-long (1000ms) one-shot timer **only** from
155//! the `onLongPress` handler — hover's `onEnter` never calls it, closing purely
156//! on `onExit` instead — and never for a rich tooltip (`if (widget._isRich)
157//! return;`). This port's plain panel widget captures
158//! `hover.via() == TooltipOpenVia::LongPress` once, on the first paint of each
159//! show (the same instant upstream's `_show(); _scheduleHide();` pair runs
160//! synchronously together), and runs the matching paint-clock timer only when
161//! that was true — a hover-shown plain tooltip never starts one, matching
162//! upstream exactly. "Each show" is counted by [`TooltipHover`] itself, which
163//! ticks a counter every time the latch goes from closed to open; a pod that
164//! stays mounted across many shows re-arms from that edge rather than from its
165//! own `View::build`, which now happens only once.
166//!
167//! # Rich: title/supporting/actions, dismiss on tap-elsewhere
168//!
169//! A rich panel never runs the auto-dismiss timer above; it stays open while
170//! the trigger keeps its hover, closes on hover-exit exactly like the plain
171//! panel, and additionally dismisses on a tap anywhere outside itself —
172//! upstream's own `Positioned.fill(child: GestureDetector(onTap: _hide,
173//! behavior: HitTestBehavior.translucent))`, ported as the portal's own
174//! [`frust::OutsideTap::Notify`]` { consume: false }` rather than a
175//! hand-rolled translucent layer: the root tells the owner that a press landed
176//! outside every floated surface, the owner closes the latch, and the press
177//! carries on into the main tree — `translucent`, exactly as upstream. Its
178//! `actions` row is routed to normally, as
179//! [`frust::OverlayInput::Interactive`] content.
180
181use std::cell::Cell;
182use std::rc::Rc;
183
184use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
185use frust::authoring::{
186    Affine, AnyView, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color, EventCtx,
187    EventResult, InputEvent, LayoutCtx, OutsideTap, OverlayBand, OverlayInput, OverlaySlot,
188    PaintCtx, PaintScene, Point, PointerPhase, Rect, SemanticsCtx, Size, View, Widget, any,
189    build_child, rebuild_child, rebuild_children, route_event, route_event_single, teardown_child,
190    visit_children,
191};
192use frust::{AnimationController, FrameTime, GestureDetector, Theme};
193
194use crate::button::text_button;
195use crate::overlay::{
196    ANCHORED_ENTER_SCALE, OverlayAlign, OverlayAnchor, OverlayContainer, OverlayElevation,
197    OverlayPlacement, OverlaySide, container as overlay_container, on_surface_variant,
198    reduce_motion, shadow as overlay_shadow,
199};
200use crate::tokens::{MaterialDimensions, MaterialMotion, MaterialSpacing};
201
202// The framework's own overlay vocabulary, kept in its own block and aliased
203// apart from `crate::overlay`'s identically-named (and identically-shaped)
204// catalog types above — those stay the ones this module's *public* builders
205// speak, so the two sets meet in exactly one place (`portal_placement`).
206use frust::authoring::{
207    OverlayAlign as PortalAlign, OverlayAnchor as PortalAnchor,
208    OverlayPlacement as PortalPlacement, OverlaySide as PortalSide,
209};
210
211// ---- Timing -----------------------------------------------------------------
212
213/// Touch's long-press threshold — [`crate::toggle_button`]'s own
214/// `LONG_PRESS_MS` citation applies verbatim (community-approximate, matching
215/// both platforms' gesture-recognizer defaults). Delegated to
216/// [`frust::GestureDetector`], not measured here — see the module docs.
217const LONG_PRESS_MS: u64 = 500;
218
219/// The plain tooltip's auto-dismiss delay after a long-press-triggered show —
220/// `M3ETooltipTheme.plainDismissDelay` (`m3e_tooltip_theme.dart:51`),
221/// [`MaterialMotion::EXTRA_LONG_4`] (1000ms).
222const PLAIN_DISMISS_MS: f64 = MaterialMotion::EXTRA_LONG_4.as_millis() as f64;
223
224// ---- Geometry (`M3ETooltipTheme.defaults`, `m3e_tooltip_theme.dart:9-18`) ---
225
226/// `plainMaxWidth`.
227const PLAIN_MAX_WIDTH: f64 = 200.0;
228/// `plainPadding` horizontal (`EdgeInsets.symmetric(horizontal: 8, ..)`) —
229/// [`MaterialSpacing::SM`].
230const PLAIN_PAD_X: f64 = MaterialSpacing::SM;
231/// `plainPadding` vertical (`.. vertical: 4)`) — [`MaterialSpacing::XS`].
232const PLAIN_PAD_Y: f64 = MaterialSpacing::XS;
233/// `richMaxWidth`.
234const RICH_MAX_WIDTH: f64 = 320.0;
235/// `richPadding` (`EdgeInsets.all(16)`) — [`MaterialSpacing::LG`].
236const RICH_PAD: f64 = MaterialSpacing::LG;
237/// `richTitleGap` — [`MaterialSpacing::XS`].
238const RICH_TITLE_GAP: f64 = MaterialSpacing::XS;
239/// `richActionsGap` — [`MaterialSpacing::MD`].
240const RICH_ACTIONS_GAP: f64 = MaterialSpacing::MD;
241
242// ---- Entrance ramp ----------------------------------------------------------
243
244/// How far outside its own bounds a panel's chrome (the rich panel's elevation
245/// shadow) reaches, in logical px — the bound the entrance's composited layer
246/// is inflated by so that shadow is not clipped out of the fade. The same
247/// allowance `crate::overlay::anchored`'s host makes for the panels it used to
248/// ramp; restated here because the ramp moved into the pod and the host's own
249/// copy is private to it.
250const PANEL_SHADOW_SPILL: f64 = 48.0;
251
252/// Progress difference below which the entrance counts as settled.
253const PROGRESS_EPSILON: f64 = 1e-4;
254
255/// Unthemed-fallback plain container fill (`colors.inverseSurface`, M3
256/// baseline light) — the same value [`crate::snackbar`]'s identically-rooted
257/// fallback carries.
258const FALLBACK_INVERSE_SURFACE: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
259/// Unthemed-fallback plain message ink (`colors.inverseOnSurface`, M3
260/// baseline light).
261const FALLBACK_INVERSE_ON_SURFACE: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
262/// Unthemed-fallback rich title ink (`colors.onSurface`, M3 baseline light).
263const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
264
265// ---- Type styles (`bodySmall`/`titleSmall`/`bodyMedium`, `type_scale.rs`'s
266// own documented table) -------------------------------------------------------
267//
268// Size, weight, tracking and line height are the role's token literals; the
269// family is the live theme's own for that role, read at layout (unthemed: the
270// platform system UI family), so a theme swap or a font picker restyles the
271// panel text along with the rest of the catalog.
272
273/// `style` in `theme`'s family for the role `pick` selects, or `style`
274/// unchanged with no theme.
275fn in_role_family(
276    mut style: TextStyle,
277    theme: Option<&Theme>,
278    pick: fn(&frust::TypeScale) -> &TextStyle,
279) -> TextStyle {
280    if let Some(theme) = theme {
281        style.family = pick(&theme.type_scale).family.clone();
282    }
283    style
284}
285
286fn plain_message_style(theme: Option<&Theme>) -> TextStyle {
287    let style = TextStyle {
288        weight: FontWeight::REGULAR,
289        letter_spacing: 0.4,
290        line_height: LineHeight::Absolute(16.0),
291        ..TextStyle::new(12.0, Color::BLACK)
292    };
293    in_role_family(style, theme, |scale| &scale.body_small)
294}
295
296fn rich_title_style(theme: Option<&Theme>) -> TextStyle {
297    let style = TextStyle {
298        weight: FontWeight::MEDIUM,
299        letter_spacing: 0.1,
300        line_height: LineHeight::Absolute(20.0),
301        ..TextStyle::new(14.0, Color::BLACK)
302    };
303    in_role_family(style, theme, |scale| &scale.title_small)
304}
305
306fn rich_body_style(theme: Option<&Theme>) -> TextStyle {
307    let style = TextStyle {
308        weight: FontWeight::REGULAR,
309        letter_spacing: 0.25,
310        line_height: LineHeight::Absolute(20.0),
311        ..TextStyle::new(14.0, Color::BLACK)
312    };
313    in_role_family(style, theme, |scale| &scale.body_medium)
314}
315
316// ---- The shared handle -------------------------------------------------------
317
318/// Which trigger mechanism opened a tooltip — the plain panel's own record of
319/// "was this the long-press that arms the auto-dismiss timer" (see the module
320/// docs' Plain section).
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322enum TooltipOpenVia {
323    Hover,
324    LongPress,
325}
326
327/// [`TooltipHover`]'s inner, `Copy` payload.
328#[derive(Clone, Copy, Debug)]
329struct TooltipLatch {
330    open: bool,
331    via: TooltipOpenVia,
332    enabled: bool,
333    /// How many times this handle has been opened — ticked on the
334    /// closed-to-open edge alone, so a redundant `set_open(true)` while already
335    /// open does not count as a new show.
336    ///
337    /// The panels' pods now outlive any one show (they stay mounted and only
338    /// their *registration* comes and goes), so a pod cannot take "this show
339    /// just started" from its own `View::build` any more. It compares this
340    /// counter against the one it last armed on instead. See
341    /// [`PlainPanelWidget`] and [`RampedPanelWidget`].
342    shows: u64,
343}
344
345impl Default for TooltipLatch {
346    fn default() -> Self {
347        Self {
348            open: false,
349            via: TooltipOpenVia::Hover,
350            enabled: true,
351            shows: 0,
352        }
353    }
354}
355
356/// The shared, non-reactive handle a caller keeps one of per tooltip: whether
357/// the panel should be showing, how it was opened, whether the trigger is
358/// enabled, and the trigger's captured window-space rect (the merged
359/// [`crate::overlay::anchored`] host's own [`OverlayAnchor`], carried inside
360/// rather than threaded separately). See the [module docs](self).
361///
362/// Clone it — the clone shares the same cells. Hand one clone to
363/// [`tooltip_trigger`], another to [`tooltip`]/[`rich_tooltip`].
364#[derive(Clone, Debug, Default)]
365pub struct TooltipHover {
366    anchor: OverlayAnchor,
367    latch: Rc<Cell<TooltipLatch>>,
368}
369
370impl TooltipHover {
371    /// A fresh handle: closed, enabled, no trigger captured yet.
372    pub fn new() -> Self {
373        Self::default()
374    }
375
376    /// Whether the panel should be showing.
377    pub fn is_open(&self) -> bool {
378        self.latch.get().open
379    }
380
381    /// Force the open state — for an app that wants to dismiss a tooltip from
382    /// outside the trigger/panel pair (e.g. on navigation).
383    pub fn set_open(&self, open: bool) {
384        let mut l = self.latch.get();
385        if open && !l.open {
386            l.shows = l.shows.wrapping_add(1);
387        }
388        l.open = open;
389        self.latch.set(l);
390    }
391
392    /// Which show this is — see [`TooltipLatch::shows`]. Wrapping is harmless:
393    /// every reader only ever tests it for *inequality* against the value it
394    /// last armed on.
395    fn show_id(&self) -> u64 {
396        self.latch.get().shows
397    }
398
399    fn via(&self) -> TooltipOpenVia {
400        self.latch.get().via
401    }
402
403    fn set_via(&self, via: TooltipOpenVia) {
404        let mut l = self.latch.get();
405        l.via = via;
406        self.latch.set(l);
407    }
408
409    fn is_enabled(&self) -> bool {
410        self.latch.get().enabled
411    }
412
413    fn set_enabled(&self, enabled: bool) {
414        let mut l = self.latch.get();
415        l.enabled = enabled;
416        self.latch.set(l);
417    }
418
419    /// The captured trigger rect, riding the merged anchored host's own
420    /// [`OverlayAnchor`].
421    fn anchor(&self) -> &OverlayAnchor {
422        &self.anchor
423    }
424}
425
426// ---- The trigger --------------------------------------------------------------
427
428/// Wrap `child` as a tooltip trigger writing into `hover`. See the [module
429/// docs](self)' Hover vs. long-press section.
430pub fn tooltip_trigger<State: 'static, V: View<State>>(
431    hover: &TooltipHover,
432    child: V,
433) -> TooltipTriggerView<State> {
434    let cb_hover = hover.clone();
435    let gesture = GestureDetector(child)
436        .on_long_press(move |_state: &mut State| {
437            if cb_hover.is_enabled() {
438                cb_hover.set_open(true);
439                cb_hover.set_via(TooltipOpenVia::LongPress);
440            }
441        })
442        .hold_threshold_ms(LONG_PRESS_MS);
443    TooltipTriggerView {
444        child: any(gesture),
445        hover: hover.clone(),
446        enabled: true,
447    }
448}
449
450/// A declarative tooltip trigger. See [`tooltip_trigger`].
451pub struct TooltipTriggerView<State: 'static> {
452    child: AnyView<State>,
453    hover: TooltipHover,
454    enabled: bool,
455}
456
457impl<State: 'static> TooltipTriggerView<State> {
458    /// Gate both trigger mechanisms. `true` by default. See the [module
459    /// docs](self)' Hover vs. long-press section for what `false` does to an
460    /// already-open tooltip.
461    pub fn enabled(mut self, enabled: bool) -> Self {
462        self.enabled = enabled;
463        self
464    }
465}
466
467/// The retained widget for a [`TooltipTriggerView`].
468pub struct TooltipTriggerWidget {
469    child: ChildPod,
470    hover: TooltipHover,
471    was_hovered: bool,
472}
473
474impl<State: 'static> View<State> for TooltipTriggerView<State> {
475    type Element = TooltipTriggerWidget;
476
477    fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipTriggerWidget {
478        self.hover.set_enabled(self.enabled);
479        TooltipTriggerWidget {
480            child: build_child(&self.child, ctx),
481            hover: self.hover.clone(),
482            was_hovered: false,
483        }
484    }
485
486    fn rebuild(
487        &self,
488        prev: &Self,
489        element: &mut TooltipTriggerWidget,
490        ctx: &mut BuildCtx<'_>,
491    ) -> ChangeFlags {
492        self.hover.set_enabled(self.enabled);
493        element.hover = self.hover.clone();
494        rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
495    }
496
497    fn teardown(&self, element: &mut TooltipTriggerWidget, ctx: &mut BuildCtx<'_>) {
498        teardown_child(&self.child, &mut element.child, ctx);
499    }
500}
501
502impl Widget for TooltipTriggerWidget {
503    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
504        let size = self.child.layout_child(ctx, bc);
505        self.child.set_origin(Point::ORIGIN);
506        bc.constrain(size)
507    }
508
509    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
510        self.hover
511            .anchor()
512            .set(Rect::from_origin_size(ctx.origin(), ctx.size()));
513
514        if self.hover.is_enabled() {
515            // `PaintCtx::is_hovered` is authoritative — see the module docs'
516            // Hover vs. long-press section.
517            let now = ctx.is_hovered();
518            if now != self.was_hovered {
519                self.was_hovered = now;
520                self.hover.set_open(now);
521                if now {
522                    self.hover.set_via(TooltipOpenVia::Hover);
523                }
524                ctx.request_frame();
525            }
526        } else if self.was_hovered {
527            // Disabled mid-hover: drop the link and snap any open panel shut.
528            self.was_hovered = false;
529            self.hover.set_open(false);
530            ctx.request_frame();
531        }
532
533        self.child.paint_child(ctx, scene);
534    }
535
536    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
537        // Transparent wrapper: the nested `GestureDetector` (and whatever real
538        // child it wraps) sees every event unchanged; this widget only claims
539        // hover, after routing (the claim-ordering rule).
540        let routed = route_event_single(&mut self.child, ctx, event);
541        if !event.is_broadcast()
542            && let InputEvent::Pointer(p) = event
543            && p.phase == PointerPhase::Move
544            && inside(p.position, ctx.size())
545        {
546            ctx.claim_hover();
547        }
548        routed
549    }
550
551    fn semantics(&self, ctx: &mut SemanticsCtx) {
552        self.child.semantics_child(ctx);
553    }
554
555    visit_children!(child);
556}
557
558/// Whether local point `pos` is inside a widget of `size` — the same
559/// hit-test [`crate::toggle_button`]'s own copy runs.
560fn inside(pos: Point, size: Size) -> bool {
561    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
562}
563
564// ---- A lazily-shaped, paint-time-rebrushed, wrap-capable text run ----------
565
566/// The same idiom [`crate::snackbar`]'s `TextRun`/[`crate::badge`]'s
567/// `LabelRun` use, generalized with a wrap width — upstream's plain `maxWidth`
568/// and rich title/body all wrap, unlike those single-line callers. The cache is
569/// keyed on the content *and* the resolved style, so a theme swap that changes
570/// the family reshapes instead of serving the old face.
571struct TooltipTextRun {
572    content: String,
573    /// The style the cached layout was shaped with.
574    style: TextStyle,
575    layout: Option<TextLayout>,
576}
577
578impl TooltipTextRun {
579    fn new() -> Self {
580        Self {
581            content: String::new(),
582            style: TextStyle::default(),
583            layout: None,
584        }
585    }
586
587    fn set_content(&mut self, content: &str) {
588        if self.content != content {
589            self.content = content.to_string();
590            self.layout = None;
591        }
592    }
593
594    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
595        if self.style != *style {
596            self.style = style.clone();
597            self.layout = None;
598        }
599        if let Some(layout) = &self.layout {
600            return layout.size();
601        }
602        let text_ctx = ctx.text_context::<TextContext>();
603        let laid = text_ctx.layout(&self.content, &self.style, Some(max_width as f32));
604        let size = laid.size();
605        self.layout = Some(laid);
606        size
607    }
608
609    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
610        let Some(layout) = &self.layout else {
611            return;
612        };
613        for mut run in layout.to_scene_runs(origin) {
614            run.brush = Brush::Solid(color);
615            scene.draw_glyph_run(run);
616        }
617    }
618}
619
620// ---- The floated pod: placement bridge + entrance ramp ----------------------
621
622/// Lower this catalog's own placement request onto the framework's.
623///
624/// The two are the same five fields under two names: [`crate::overlay`]'s set
625/// predates the framework's and is what [`TooltipView::side`]/
626/// [`TooltipView::align`]/[`TooltipView::offset`] take, and those signatures
627/// are public API this port does not change. `padding` has no counterpart
628/// because `crate::overlay::place_anchored` insets the area by nothing, so it
629/// is pinned to zero here: inheriting the framework's 8dp default would quietly
630/// move every panel that lands near a window edge.
631fn portal_placement(placement: OverlayPlacement) -> PortalPlacement {
632    PortalPlacement {
633        side: match placement.side {
634            OverlaySide::Top => PortalSide::Top,
635            OverlaySide::Right => PortalSide::Right,
636            OverlaySide::Bottom => PortalSide::Bottom,
637            OverlaySide::Left => PortalSide::Left,
638        },
639        align: match placement.align {
640            OverlayAlign::Start => PortalAlign::Start,
641            OverlayAlign::Center => PortalAlign::Center,
642            OverlayAlign::End => PortalAlign::End,
643        },
644        offset: placement.offset,
645        flip: placement.flip,
646        clamp: placement.clamp,
647        padding: 0.0,
648    }
649}
650
651/// A fresh entrance driver: [`MaterialMotion::SHORT_4`] eased by
652/// [`MaterialMotion::EMPHASIZED_DECELERATE`], the exact pair
653/// `crate::overlay::anchored` plays for the panels it hosts.
654fn entrance_driver() -> AnimationController {
655    AnimationController::new(MaterialMotion::SHORT_4)
656        .with_curve(MaterialMotion::EMPHASIZED_DECELERATE)
657}
658
659/// The pod both panels are actually registered as: `content` plus the entrance
660/// ramp, run where the root can see it.
661///
662/// This is the *only* widget of either panel that runs during the root's
663/// separate overlay paint pass, which is why the ramp state lives here rather
664/// than on the owner: the root paints a registered pod directly, so an owner's
665/// own `push_layer`/`push_transform` would already have been popped by the time
666/// the pod paints.
667struct RampedPanel<PodState: 'static> {
668    content: AnyView<PodState>,
669    hover: TooltipHover,
670}
671
672/// The retained widget for a [`RampedPanel`].
673struct RampedPanelWidget {
674    content: ChildPod,
675    hover: TooltipHover,
676    anim: AnimationController,
677    /// How present the panel is: `0.0` gone, `1.0` settled.
678    progress: f64,
679    /// Which show the current ramp belongs to — see [`TooltipLatch::shows`].
680    /// `None` until the first paint.
681    armed_show: Option<u64>,
682    /// Whether [`AnimationController::forward`] has been called for that show.
683    started: bool,
684}
685
686impl RampedPanelWidget {
687    /// Advance this paint's entrance, returning the presence to composite at.
688    ///
689    /// Entrance only, in one direction, because there is no exit to run: a
690    /// closing panel is simply not registered, so this widget is not painted at
691    /// all (see the file header's no-exit-fade decision). `reduce` collapses the
692    /// ramp to a jump, leaving the open/dismiss *timings* — which are timing,
693    /// not motion — alone.
694    fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) -> f64 {
695        let show = self.hover.show_id();
696        if self.armed_show != Some(show) {
697            // A new show on a pod that outlived the last one: restart from
698            // nothing rather than replaying whatever the previous entrance
699            // settled on.
700            self.armed_show = Some(show);
701            self.anim = entrance_driver();
702            self.progress = 0.0;
703            self.started = false;
704        }
705        if reduce {
706            if self.anim.is_animating() {
707                self.anim.stop();
708            }
709            self.progress = 1.0;
710            return self.progress;
711        }
712        if !self.started {
713            self.started = true;
714            self.anim.forward();
715        }
716        if self.anim.is_animating() {
717            // `advance`'s first call after `forward()` only seeds the clock
718            // (zero delta, but still truthy) — the continuation has to be
719            // requested on every truthy advance, not just the ones that moved
720            // `progress`, or the seeding paint never schedules the frame that
721            // would carry it off zero.
722            if self.anim.advance(ctx.frame_time()) {
723                ctx.request_frame();
724            }
725            let next = self.anim.value_clamped();
726            if (next - self.progress).abs() > PROGRESS_EPSILON {
727                // One more frame to paint the value just computed — including
728                // the settled one the final advance lands on.
729                ctx.request_frame();
730            }
731            self.progress = next;
732        }
733        self.progress
734    }
735
736    /// The point the entrance scale pivots about, in window space: the
737    /// trigger's centre clamped into the placed panel, i.e. the panel's own
738    /// edge or corner facing the trigger — so a tooltip grows out of the
739    /// control it explains. The same rule `crate::overlay::anchored`'s host
740    /// applies, computed here in window space because that is the space the
741    /// root hands a registered pod (`ctx.origin()` is the placed rect's own
742    /// origin) and the space the handle stores the trigger's rect in.
743    fn pivot(&self, panel: Rect) -> Point {
744        let center = self.hover.anchor().rect().center();
745        Point::new(
746            center.x.clamp(panel.x0, panel.x1),
747            center.y.clamp(panel.y0, panel.y1),
748        )
749    }
750}
751
752impl<PodState: 'static> View<PodState> for RampedPanel<PodState> {
753    type Element = RampedPanelWidget;
754
755    fn build(&self, ctx: &mut BuildCtx<'_>) -> RampedPanelWidget {
756        RampedPanelWidget {
757            content: build_child(&self.content, ctx),
758            hover: self.hover.clone(),
759            anim: entrance_driver(),
760            progress: 0.0,
761            armed_show: None,
762            started: false,
763        }
764    }
765
766    fn rebuild(
767        &self,
768        prev: &Self,
769        element: &mut RampedPanelWidget,
770        ctx: &mut BuildCtx<'_>,
771    ) -> ChangeFlags {
772        element.hover = self.hover.clone();
773        rebuild_child(&prev.content, &self.content, &mut element.content, ctx)
774    }
775
776    fn teardown(&self, element: &mut RampedPanelWidget, ctx: &mut BuildCtx<'_>) {
777        teardown_child(&self.content, &mut element.content, ctx);
778    }
779}
780
781impl Widget for RampedPanelWidget {
782    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
783        let size = self.content.layout_child(ctx, bc);
784        self.content.set_origin(Point::ORIGIN);
785        bc.constrain(size)
786    }
787
788    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
789        let progress = self.advance(ctx, reduce_motion(Theme::from_paint_ctx(ctx)));
790        let ramping = progress < 1.0 - PROGRESS_EPSILON;
791        if ramping {
792            let panel = Rect::from_origin_size(ctx.origin(), ctx.size());
793            let layer = panel.inflate(PANEL_SHADOW_SPILL, PANEL_SHADOW_SPILL);
794            scene.push_layer(layer.origin(), layer.size(), progress as f32);
795            let pivot = self.pivot(panel);
796            let scale = ANCHORED_ENTER_SCALE + (1.0 - ANCHORED_ENTER_SCALE) * progress;
797            scene.push_transform(
798                Affine::translate(pivot.to_vec2())
799                    * Affine::scale(scale)
800                    * Affine::translate(-pivot.to_vec2()),
801            );
802        }
803        self.content.paint_child(ctx, scene);
804        if ramping {
805            scene.pop_transform();
806            scene.pop_layer();
807        }
808    }
809
810    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
811        route_event_single(&mut self.content, ctx, event)
812    }
813
814    fn semantics(&self, ctx: &mut SemanticsCtx) {
815        // Reached by nothing today — a floated pod publishes no accessibility
816        // nodes (see the module docs) — but the content's own nodes are correct
817        // if that ever changes, and dropping them here would be a second thing
818        // to remember.
819        self.content.semantics_child(ctx);
820    }
821
822    visit_children!(content);
823}
824
825// ---- Plain tooltip -----------------------------------------------------------
826
827/// Build a plain tooltip over `hover`, to be mounted unconditionally beside
828/// [`tooltip_trigger`]'s own view — it floats its own panel above the app and
829/// registers nothing at all while closed (see the module docs' portal section).
830///
831/// Unlike the anchored host this replaced, it need not be the topmost child of
832/// anything: paint order comes from the declared [`frust::OverlayBand`], not
833/// from tree position. It occupies no layout space either way.
834pub fn tooltip(hover: &TooltipHover, message: impl Into<String>) -> TooltipView {
835    TooltipView {
836        hover: hover.clone(),
837        message: message.into(),
838        placement: OverlayPlacement::default(),
839    }
840}
841
842/// A declarative plain tooltip. See [`tooltip`].
843pub struct TooltipView {
844    hover: TooltipHover,
845    message: String,
846    placement: OverlayPlacement,
847}
848
849impl TooltipView {
850    /// Set the side the panel opens on (`bottom` by default — upstream's own
851    /// static placement, `targetAnchor: bottomCenter`/`followerAnchor:
852    /// topCenter`).
853    pub fn side(mut self, side: OverlaySide) -> Self {
854        self.placement.side = side;
855        self
856    }
857
858    /// Set the cross-axis alignment (`center` by default).
859    pub fn align(mut self, align: OverlayAlign) -> Self {
860        self.placement.align = align;
861        self
862    }
863
864    /// Set the gap between trigger and panel (`anchorOffset`, 4dp by default).
865    pub fn offset(mut self, offset: f64) -> Self {
866        self.placement.offset = offset;
867        self
868    }
869
870    /// The pod's view.
871    ///
872    /// Built over `()`, not the application state: nothing in a plain panel
873    /// calls back into an app, and keeping the pod state-free is what lets
874    /// [`TooltipView`] stay non-generic — its `View::Element` cannot depend on
875    /// `State`, and `tooltip`'s signature is public API.
876    fn content_view(&self) -> AnyView<()> {
877        any(RampedPanel {
878            content: any(PlainPanel {
879                message: self.message.clone(),
880                hover: self.hover.clone(),
881            }),
882            hover: self.hover.clone(),
883        })
884    }
885}
886
887/// The retained widget for a [`TooltipView`]: one [`OverlaySlot`] holding the
888/// plain panel, registered on every paint the latch reads open and on no other.
889///
890/// Registering nothing *is* the unmount (see the file header's no-exit-fade
891/// decision): the root's registry is per paint pass, so the frame this widget
892/// skips its registration is the frame the panel stops being painted and stops
893/// being routed to, with nothing to unregister and no ramp to run down.
894pub struct TooltipWidget {
895    hover: TooltipHover,
896    slot: OverlaySlot<()>,
897}
898
899impl TooltipView {
900    /// Push this view's routing configuration onto the slot — the half of
901    /// `build`/`rebuild` that is identical in both.
902    fn configure(&self, slot: &mut OverlaySlot<()>) {
903        slot.set_band(OverlayBand::Tooltip);
904        // Inert chrome: the root's pre-pass skips it, so a press anywhere —
905        // including over the panel itself — reaches the main tree untouched.
906        slot.set_input(OverlayInput::Transparent);
907        // A plain tooltip dismisses on hover-exit or on its own timer, never on
908        // a tap. A transparent surface is never told about an outside press in
909        // the first place — the root's pre-pass skips it for that too — so this
910        // records the intent rather than being what enforces it.
911        slot.set_outside_tap(OutsideTap::Ignore);
912        slot.set_placement(portal_placement(self.placement));
913    }
914}
915
916impl<State: 'static> View<State> for TooltipView {
917    type Element = TooltipWidget;
918
919    fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipWidget {
920        let mut slot = OverlaySlot::new();
921        self.configure(&mut slot);
922        slot.rebuild(None, Some(&self.content_view()), ctx);
923        TooltipWidget {
924            hover: self.hover.clone(),
925            slot,
926        }
927    }
928
929    fn rebuild(
930        &self,
931        prev: &Self,
932        element: &mut TooltipWidget,
933        ctx: &mut BuildCtx<'_>,
934    ) -> ChangeFlags {
935        self.configure(&mut element.slot);
936        element.hover = self.hover.clone();
937        element
938            .slot
939            .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
940            | ChangeFlags::PAINT
941    }
942
943    fn teardown(&self, element: &mut TooltipWidget, ctx: &mut BuildCtx<'_>) {
944        element.slot.rebuild(Some(&self.content_view()), None, ctx);
945    }
946}
947
948impl Widget for TooltipWidget {
949    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
950        // The pod is sized against the window, not against `bc` — it escapes
951        // this widget's box entirely, and unlike the host this replaced that no
952        // longer depends on `bc` being bounded (`crate::overlay`'s documented
953        // scroll-view trap): the pod never reads `bc.max()` at all.
954        self.slot.layout(ctx);
955        // Zero of its own: this owner draws nothing in the main tree and needs
956        // no bounds there — so mounting a tooltip beside its trigger costs no
957        // layout space, open or closed. The old mount-on-open element was zero
958        // while closed and full-area while open; a panel is no longer allowed
959        // to push its siblings around on the frame it opens.
960        bc.constrain(Size::ZERO)
961    }
962
963    fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
964        if !self.hover.is_open() {
965            return;
966        }
967        // Window space → this widget's own local space, which is what the slot
968        // takes; it adds `ctx.origin()` back on when it places the pod.
969        let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
970        self.slot.set_anchor(PortalAnchor::Rect(local));
971        self.slot.paint(ctx, Size::ZERO);
972    }
973
974    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
975        // Never fires in practice — a transparent surface is hit-tested by
976        // nothing, so the root routes it no overlay event — but the slot is
977        // still the right place to ask, and asking keeps the owner honest if the
978        // panel is ever given interactive content.
979        self.slot
980            .event(ctx, event, &mut ())
981            .unwrap_or(EventResult::Ignored)
982    }
983
984    fn semantics(&self, _ctx: &mut SemanticsCtx) {
985        // A floated pod contributes no accessibility nodes in v1 (see the
986        // module docs), and this owner has nothing of its own to publish.
987    }
988
989    visit_children!();
990}
991
992/// The plain panel's content, floated inside a [`RampedPanel`]. Never
993/// constructed by an app directly.
994struct PlainPanel {
995    message: String,
996    hover: TooltipHover,
997}
998
999impl<State: 'static> View<State> for PlainPanel {
1000    type Element = PlainPanelWidget;
1001
1002    fn build(&self, _ctx: &mut BuildCtx<'_>) -> PlainPanelWidget {
1003        let mut run = TooltipTextRun::new();
1004        run.set_content(&self.message);
1005        PlainPanelWidget {
1006            message: run,
1007            hover: self.hover.clone(),
1008            dismiss_via_long_press: false,
1009            armed_show: None,
1010            timer_start: None,
1011            dismissed: false,
1012        }
1013    }
1014
1015    fn rebuild(
1016        &self,
1017        _prev: &Self,
1018        element: &mut PlainPanelWidget,
1019        _ctx: &mut BuildCtx<'_>,
1020    ) -> ChangeFlags {
1021        let mut flags = ChangeFlags::NONE;
1022        if element.message.content != self.message {
1023            element.message.set_content(&self.message);
1024            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1025        }
1026        element.hover = self.hover.clone();
1027        flags
1028    }
1029
1030    fn teardown(&self, _element: &mut PlainPanelWidget, _ctx: &mut BuildCtx<'_>) {}
1031}
1032
1033/// The retained widget for a [`PlainPanel`].
1034struct PlainPanelWidget {
1035    message: TooltipTextRun,
1036    hover: TooltipHover,
1037    /// Captured once per show, on this widget's first paint of it — see the
1038    /// module docs' Plain section.
1039    dismiss_via_long_press: bool,
1040    /// Which show the capture above belongs to (see [`TooltipLatch::shows`]).
1041    /// This widget now outlives any one show, so `View::build` is no longer the
1042    /// moment the panel appears; the first paint of a show is.
1043    armed_show: Option<u64>,
1044    timer_start: Option<FrameTime>,
1045    dismissed: bool,
1046}
1047
1048impl PlainPanelWidget {
1049    /// Capture the trigger mechanism and re-arm the one-shot timer when this
1050    /// paint is the first of a new show.
1051    ///
1052    /// This widget is painted only while its owner registers it, i.e. only
1053    /// while the panel is on screen, so "first paint of a show" is exactly the
1054    /// instant upstream runs `_show(); _scheduleHide();` together.
1055    fn arm(&mut self) {
1056        let show = self.hover.show_id();
1057        if self.armed_show == Some(show) {
1058            return;
1059        }
1060        self.armed_show = Some(show);
1061        self.dismiss_via_long_press = self.hover.via() == TooltipOpenVia::LongPress;
1062        self.timer_start = None;
1063        self.dismissed = false;
1064    }
1065}
1066
1067impl Widget for PlainPanelWidget {
1068    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1069        let width_cap = bc.max().width.min(PLAIN_MAX_WIDTH);
1070        let inner_w = (width_cap - 2.0 * PLAIN_PAD_X).max(0.0);
1071        let style = plain_message_style(Theme::from_layout_ctx(ctx));
1072        let msg = self.message.shape(ctx, &style, inner_w);
1073        let size = Size::new(
1074            msg.width + 2.0 * PLAIN_PAD_X,
1075            msg.height + 2.0 * PLAIN_PAD_Y,
1076        );
1077        bc.constrain(size)
1078    }
1079
1080    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1081        self.arm();
1082        let theme = Theme::from_paint_ctx(ctx);
1083        let container = theme.map_or(FALLBACK_INVERSE_SURFACE, |t| t.scheme().inverse_surface);
1084        let ink = theme.map_or(FALLBACK_INVERSE_ON_SURFACE, |t| {
1085            t.scheme().inverse_on_surface
1086        });
1087        let radius = theme.map_or(MaterialDimensions::RADIUS_EXTRA_SMALL, |t| {
1088            t.shape.extra_small
1089        });
1090        let (origin, size) = (ctx.origin(), ctx.size());
1091        scene.fill_rounded_rect(origin, size, radius, container);
1092        self.message.paint(
1093            Point::new(origin.x + PLAIN_PAD_X, origin.y + PLAIN_PAD_Y),
1094            ink,
1095            scene,
1096        );
1097
1098        // Auto-dismiss (long-press-opened only) — a one-shot paint-clock
1099        // timer, matching `crate::toggle_button::LongPressState`'s idiom but
1100        // firing straight from paint: the write below is a plain, non-reactive
1101        // `TooltipHover` cell, not an app-state mutation, so no
1102        // `mark_pending_result_flush`/`EventCtx` round trip is needed.
1103        if self.dismiss_via_long_press && !self.dismissed {
1104            let start = *self.timer_start.get_or_insert(ctx.frame_time());
1105            let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
1106            if elapsed_ms >= PLAIN_DISMISS_MS {
1107                self.dismissed = true;
1108                self.hover.set_open(false);
1109            }
1110            ctx.request_frame();
1111        }
1112    }
1113
1114    fn semantics(&self, ctx: &mut SemanticsCtx) {
1115        ctx.push_node(frust::authoring::Role::Label, |node| {
1116            node.set_label(self.message.content.clone());
1117        });
1118    }
1119}
1120
1121// ---- Rich tooltip -------------------------------------------------------------
1122
1123/// One rich-tooltip action button: a label plus its own press callback. Build
1124/// with [`tooltip_action`], attach with [`RichTooltipView::action`].
1125pub struct TooltipAction<State: 'static> {
1126    label: String,
1127    on_press: Rc<dyn Fn(&mut State)>,
1128}
1129
1130impl<State: 'static> Clone for TooltipAction<State> {
1131    fn clone(&self) -> Self {
1132        Self {
1133            label: self.label.clone(),
1134            on_press: self.on_press.clone(),
1135        }
1136    }
1137}
1138
1139/// Build a rich-tooltip action labeled `label`, firing `on_press`.
1140pub fn tooltip_action<State: 'static, F: Fn(&mut State) + 'static>(
1141    label: impl Into<String>,
1142    on_press: F,
1143) -> TooltipAction<State> {
1144    TooltipAction {
1145        label: label.into(),
1146        on_press: Rc::new(on_press),
1147    }
1148}
1149
1150/// Build a rich tooltip over `hover`, to be mounted unconditionally beside
1151/// [`tooltip_trigger`]'s own view. See [`tooltip`] for the mount contract this
1152/// mirrors, and [`RichTooltipView::title`]/[`RichTooltipView::action`] for the
1153/// upstream `richTitle`/`actions` slots.
1154///
1155/// Unlike the plain panel this one is genuinely interactive: its action row is
1156/// routed to, and a press outside it closes the panel *and* carries on to
1157/// whatever it landed on.
1158pub fn rich_tooltip<State: 'static>(
1159    hover: &TooltipHover,
1160    message: impl Into<String>,
1161) -> RichTooltipView<State> {
1162    RichTooltipView {
1163        hover: hover.clone(),
1164        title: None,
1165        message: message.into(),
1166        actions: Vec::new(),
1167        placement: OverlayPlacement::default(),
1168    }
1169}
1170
1171/// A declarative rich tooltip. See [`rich_tooltip`].
1172pub struct RichTooltipView<State: 'static> {
1173    hover: TooltipHover,
1174    title: Option<String>,
1175    message: String,
1176    actions: Vec<TooltipAction<State>>,
1177    placement: OverlayPlacement,
1178}
1179
1180impl<State: 'static> RichTooltipView<State> {
1181    /// Set the title (`richTitle`), shown above the supporting text.
1182    pub fn title(mut self, title: impl Into<String>) -> Self {
1183        self.title = Some(title.into());
1184        self
1185    }
1186
1187    /// Append an action button (`actions`). Upstream accepts any number
1188    /// (`Row(children: widget.actions)`, no cap in code); M3 guidance caps a
1189    /// rich tooltip at two, which this port does not enforce either — a
1190    /// documentation convention, not a code constraint upstream carries.
1191    pub fn action(mut self, action: TooltipAction<State>) -> Self {
1192        self.actions.push(action);
1193        self
1194    }
1195
1196    /// Set the side the panel opens on (`bottom` by default).
1197    pub fn side(mut self, side: OverlaySide) -> Self {
1198        self.placement.side = side;
1199        self
1200    }
1201
1202    /// Set the cross-axis alignment (`center` by default).
1203    pub fn align(mut self, align: OverlayAlign) -> Self {
1204        self.placement.align = align;
1205        self
1206    }
1207
1208    /// Set the gap between trigger and panel (`anchorOffset`, 4dp by default).
1209    pub fn offset(mut self, offset: f64) -> Self {
1210        self.placement.offset = offset;
1211        self
1212    }
1213
1214    /// The pod's view.
1215    ///
1216    /// Built over the ambient application state, unlike [`TooltipView`]'s: the
1217    /// `actions` row's callbacks take `&mut State` and must reach the same
1218    /// state every other widget sees, which is what
1219    /// [`OverlaySlot::event_ambient`] routes them over.
1220    fn content_view(&self) -> AnyView<State> {
1221        any(RampedPanel {
1222            content: any(RichPanel {
1223                title: self.title.clone(),
1224                message: self.message.clone(),
1225                actions: self.actions.clone(),
1226            }),
1227            hover: self.hover.clone(),
1228        })
1229    }
1230
1231    /// Push this view's routing configuration onto the slot — the half of
1232    /// `build`/`rebuild` that is identical in both.
1233    fn configure(&self, slot: &mut OverlaySlot<State>) {
1234        slot.set_band(OverlayBand::Tooltip);
1235        // Interactive, unlike the plain panel: a transparent surface is
1236        // hit-tested by nothing at all, which would leave upstream's own
1237        // `actions` row unreachable and suppress the outside-press notification
1238        // below with it (see the file header's registration decision).
1239        slot.set_input(OverlayInput::Interactive);
1240        // Upstream's translucent full-area dismiss layer: told about the press,
1241        // and not consuming it, so whatever sits beneath is told as well.
1242        slot.set_outside_tap(OutsideTap::Notify { consume: false });
1243        slot.set_placement(portal_placement(self.placement));
1244    }
1245}
1246
1247/// The retained widget for a [`RichTooltipView`]: one [`OverlaySlot`] holding
1248/// the rich panel, registered on every paint the latch reads open and on no
1249/// other. See [`TooltipWidget`] for what that gates, and this view's own
1250/// `configure` for how the two panels' registrations differ.
1251pub struct RichTooltipWidget<State: 'static> {
1252    hover: TooltipHover,
1253    slot: OverlaySlot<State>,
1254}
1255
1256impl<State: 'static> View<State> for RichTooltipView<State> {
1257    type Element = RichTooltipWidget<State>;
1258
1259    fn build(&self, ctx: &mut BuildCtx<'_>) -> RichTooltipWidget<State> {
1260        let mut slot = OverlaySlot::new();
1261        self.configure(&mut slot);
1262        slot.rebuild(None, Some(&self.content_view()), ctx);
1263        RichTooltipWidget {
1264            hover: self.hover.clone(),
1265            slot,
1266        }
1267    }
1268
1269    fn rebuild(
1270        &self,
1271        prev: &Self,
1272        element: &mut RichTooltipWidget<State>,
1273        ctx: &mut BuildCtx<'_>,
1274    ) -> ChangeFlags {
1275        self.configure(&mut element.slot);
1276        element.hover = self.hover.clone();
1277        element
1278            .slot
1279            .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
1280            | ChangeFlags::PAINT
1281    }
1282
1283    fn teardown(&self, element: &mut RichTooltipWidget<State>, ctx: &mut BuildCtx<'_>) {
1284        element.slot.rebuild(Some(&self.content_view()), None, ctx);
1285    }
1286}
1287
1288impl<State: 'static> Widget for RichTooltipWidget<State> {
1289    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1290        self.slot.layout(ctx);
1291        bc.constrain(Size::ZERO)
1292    }
1293
1294    fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1295        if !self.hover.is_open() {
1296            return;
1297        }
1298        let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
1299        self.slot.set_anchor(PortalAnchor::Rect(local));
1300        self.slot.paint(ctx, Size::ZERO);
1301    }
1302
1303    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1304        let Some(result) = self.slot.event_ambient(ctx, event) else {
1305            // Not the surface's: this owner has no bounds of its own and nothing
1306            // else to claim.
1307            return EventResult::Ignored;
1308        };
1309        // The outside-press notification arrives through the same door, as a
1310        // broadcast the slot drains into this flag. Closing here rather than in
1311        // the pod is the point: the pod never saw the press (it landed on
1312        // nothing floated), and the press itself carries on into the main tree.
1313        if self.slot.take_outside_down() {
1314            self.hover.set_open(false);
1315            ctx.request_redraw();
1316        }
1317        result
1318    }
1319
1320    fn semantics(&self, _ctx: &mut SemanticsCtx) {
1321        // A floated pod contributes no accessibility nodes in v1 (see the
1322        // module docs), and this owner has nothing of its own to publish.
1323    }
1324
1325    visit_children!();
1326}
1327
1328/// Build the erased action-button view for one [`TooltipAction`].
1329fn action_view<State: 'static>(action: &TooltipAction<State>) -> AnyView<State> {
1330    let cb = action.on_press.clone();
1331    any(text_button(action.label.clone(), move |s: &mut State| {
1332        cb(s)
1333    }))
1334}
1335
1336/// The rich panel's content, floated inside a [`RampedPanel`]. Never
1337/// constructed by an app directly.
1338struct RichPanel<State: 'static> {
1339    title: Option<String>,
1340    message: String,
1341    actions: Vec<TooltipAction<State>>,
1342}
1343
1344impl<State: 'static> View<State> for RichPanel<State> {
1345    type Element = RichPanelWidget;
1346
1347    fn build(&self, ctx: &mut BuildCtx<'_>) -> RichPanelWidget {
1348        let title = self.title.as_ref().map(|t| {
1349            let mut run = TooltipTextRun::new();
1350            run.set_content(t);
1351            run
1352        });
1353        let mut body = TooltipTextRun::new();
1354        body.set_content(&self.message);
1355        let action_pods = self
1356            .actions
1357            .iter()
1358            .map(|a| build_child(&action_view(a), ctx))
1359            .collect();
1360        RichPanelWidget {
1361            title,
1362            body,
1363            action_pods,
1364            title_origin: Point::ORIGIN,
1365            body_origin: Point::ORIGIN,
1366        }
1367    }
1368
1369    fn rebuild(
1370        &self,
1371        prev: &Self,
1372        element: &mut RichPanelWidget,
1373        ctx: &mut BuildCtx<'_>,
1374    ) -> ChangeFlags {
1375        let mut flags = ChangeFlags::NONE;
1376        match (&self.title, &mut element.title) {
1377            (Some(t), Some(run)) => {
1378                if run.content != *t {
1379                    run.set_content(t);
1380                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1381                }
1382            }
1383            (Some(t), None) => {
1384                let mut run = TooltipTextRun::new();
1385                run.set_content(t);
1386                element.title = Some(run);
1387                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1388            }
1389            (None, Some(_)) => {
1390                element.title = None;
1391                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1392            }
1393            (None, None) => {}
1394        }
1395        if element.body.content != self.message {
1396            element.body.set_content(&self.message);
1397            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1398        }
1399
1400        let prev_views: Vec<AnyView<State>> = prev.actions.iter().map(action_view).collect();
1401        let next_views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1402        flags |= rebuild_children(
1403            &prev_views,
1404            &next_views,
1405            &mut element.action_pods,
1406            ctx,
1407            |v: &AnyView<State>| v,
1408            |_| None,
1409        );
1410        flags
1411    }
1412
1413    fn teardown(&self, element: &mut RichPanelWidget, ctx: &mut BuildCtx<'_>) {
1414        let views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1415        for (pod, view) in element.action_pods.iter_mut().zip(views.iter()) {
1416            teardown_child(view, pod, ctx);
1417        }
1418    }
1419}
1420
1421/// The retained widget for a [`RichPanel`].
1422struct RichPanelWidget {
1423    title: Option<TooltipTextRun>,
1424    body: TooltipTextRun,
1425    action_pods: Vec<ChildPod>,
1426    title_origin: Point,
1427    body_origin: Point,
1428}
1429
1430impl Widget for RichPanelWidget {
1431    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1432        let width_cap = bc.max().width.min(RICH_MAX_WIDTH);
1433        let inner_w = (width_cap - 2.0 * RICH_PAD).max(0.0);
1434        let mut content_w: f64 = 0.0;
1435        let mut y = RICH_PAD;
1436        let theme = Theme::from_layout_ctx(ctx);
1437        let (title_style, body_style) = (rich_title_style(theme), rich_body_style(theme));
1438
1439        if let Some(title) = &mut self.title {
1440            let sz = title.shape(ctx, &title_style, inner_w);
1441            self.title_origin = Point::new(RICH_PAD, y);
1442            content_w = content_w.max(sz.width);
1443            y += sz.height + RICH_TITLE_GAP;
1444        }
1445
1446        let body_sz = self.body.shape(ctx, &body_style, inner_w);
1447        self.body_origin = Point::new(RICH_PAD, y);
1448        content_w = content_w.max(body_sz.width);
1449        y += body_sz.height;
1450
1451        if !self.action_pods.is_empty() {
1452            y += RICH_ACTIONS_GAP;
1453            let mut x = RICH_PAD;
1454            let mut row_h: f64 = 0.0;
1455            for pod in &mut self.action_pods {
1456                let sz = pod.layout_child(
1457                    ctx,
1458                    &BoxConstraints::loose(Size::new(inner_w, f64::INFINITY)),
1459                );
1460                pod.set_origin(Point::new(x, y));
1461                x += sz.width;
1462                row_h = row_h.max(sz.height);
1463            }
1464            content_w = content_w.max(x - RICH_PAD);
1465            y += row_h;
1466        }
1467
1468        let panel_w = content_w.min(inner_w) + 2.0 * RICH_PAD;
1469        let panel_h = y + RICH_PAD;
1470        bc.constrain(Size::new(panel_w, panel_h))
1471    }
1472
1473    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1474        let theme = Theme::from_paint_ctx(ctx);
1475        let container = overlay_container(theme, OverlayContainer::Standard);
1476        let radius = theme.map_or(MaterialDimensions::RADIUS_MEDIUM, |t| t.shape.medium);
1477        let (origin, size) = (ctx.origin(), ctx.size());
1478
1479        if let Some((blur, y_offset, color)) = overlay_shadow(theme, OverlayElevation::Level2) {
1480            scene.draw_shadow(
1481                Point::new(origin.x, origin.y + y_offset),
1482                size,
1483                radius,
1484                blur,
1485                color,
1486            );
1487        }
1488        scene.fill_rounded_rect(origin, size, radius, container);
1489
1490        let title_ink = theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface);
1491        let body_ink = on_surface_variant(theme);
1492        if let Some(title) = &self.title {
1493            title.paint(
1494                Point::new(
1495                    origin.x + self.title_origin.x,
1496                    origin.y + self.title_origin.y,
1497                ),
1498                title_ink,
1499                scene,
1500            );
1501        }
1502        self.body.paint(
1503            Point::new(origin.x + self.body_origin.x, origin.y + self.body_origin.y),
1504            body_ink,
1505            scene,
1506        );
1507
1508        for pod in &mut self.action_pods {
1509            pod.paint_child(ctx, scene);
1510        }
1511    }
1512
1513    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1514        // `route_event` supplies what a hand-rolled first-Handled-wins loop
1515        // skips: the broadcast fan-out (folded in below), the `pod.contains`
1516        // hit test, focus-path gating for `Key`/`Ime`, and capture (`is_active`)
1517        // bookkeeping — see `docs/CODE_STANDARDS.md`'s Interaction Semantics.
1518        route_event(&mut self.action_pods, ctx, event)
1519    }
1520
1521    fn semantics(&self, ctx: &mut SemanticsCtx) {
1522        ctx.push_container(
1523            frust::authoring::Role::Group,
1524            |_node| {},
1525            |ctx| {
1526                for pod in &self.action_pods {
1527                    pod.semantics_child(ctx);
1528                }
1529            },
1530        );
1531    }
1532
1533    visit_children!(action_pods);
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538    use super::*;
1539    use frust::Brightness;
1540    use frust::authoring::text::FontFamily;
1541    use frust::authoring::{
1542        Point as AuthoringPoint, PointerButton, PointerEvent, Rect as AuthoringRect,
1543    };
1544    use frust_core::RenderRoot;
1545    use std::any::Any;
1546
1547    fn ft(ms: f64) -> FrameTime {
1548        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1549    }
1550
1551    // ---- TooltipHover -------------------------------------------------------
1552
1553    #[test]
1554    fn a_fresh_handle_is_closed_and_enabled() {
1555        let hover = TooltipHover::new();
1556        assert!(!hover.is_open());
1557        assert!(hover.is_enabled());
1558        assert_eq!(hover.via(), TooltipOpenVia::Hover);
1559    }
1560
1561    #[test]
1562    fn clones_share_the_same_cells() {
1563        let hover = TooltipHover::new();
1564        let clone = hover.clone();
1565        clone.set_open(true);
1566        assert!(hover.is_open(), "the clone shares the latch");
1567        hover.anchor().set(AuthoringRect::new(1.0, 2.0, 3.0, 4.0));
1568        assert_eq!(
1569            clone.anchor().rect(),
1570            AuthoringRect::new(1.0, 2.0, 3.0, 4.0)
1571        );
1572    }
1573
1574    // ---- Trigger: hover has no show-delay ------------------------------------
1575
1576    #[derive(Default)]
1577    struct AppState;
1578
1579    /// Mount the trigger over a fixed-size leaf, as the sole child of a
1580    /// `RenderRoot` — enough to drive `PaintCtx::is_hovered` through the real
1581    /// pipeline.
1582    struct Harness {
1583        root: RenderRoot<AppState, TooltipTriggerView<AppState>>,
1584        state: AppState,
1585        hover: TooltipHover,
1586        enabled: bool,
1587    }
1588
1589    const TRIGGER: Size = Size::new(80.0, 32.0);
1590
1591    impl Harness {
1592        fn new() -> Self {
1593            let mut h = Harness {
1594                root: RenderRoot::new(),
1595                state: AppState,
1596                hover: TooltipHover::new(),
1597                enabled: true,
1598            };
1599            h.pass();
1600            h.root.layout(Size::new(400.0, 400.0));
1601            h
1602        }
1603
1604        fn pass(&mut self) {
1605            let hover = self.hover.clone();
1606            let enabled = self.enabled;
1607            let mut logic = move |_s: &mut AppState| {
1608                tooltip_trigger(
1609                    &hover,
1610                    frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
1611                )
1612                .enabled(enabled)
1613            };
1614            self.root.rebuild(&mut logic, &mut self.state);
1615        }
1616
1617        fn paint(&mut self, ms: f64) {
1618            self.root.paint(&mut NullScene, ft(ms));
1619        }
1620
1621        fn event(&mut self, event: InputEvent) {
1622            self.root.event(&mut self.state, &event);
1623        }
1624    }
1625
1626    struct NullScene;
1627    impl PaintScene for NullScene {
1628        fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
1629        fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
1630    }
1631
1632    fn moved(x: f64, y: f64) -> InputEvent {
1633        InputEvent::Pointer(PointerEvent {
1634            phase: PointerPhase::Move,
1635            position: AuthoringPoint::new(x, y),
1636            button: PointerButton::Primary,
1637        })
1638    }
1639
1640    fn down(x: f64, y: f64) -> InputEvent {
1641        InputEvent::Pointer(PointerEvent {
1642            phase: PointerPhase::Down,
1643            position: AuthoringPoint::new(x, y),
1644            button: PointerButton::Primary,
1645        })
1646    }
1647
1648    fn up(x: f64, y: f64) -> InputEvent {
1649        InputEvent::Pointer(PointerEvent {
1650            phase: PointerPhase::Up,
1651            position: AuthoringPoint::new(x, y),
1652            button: PointerButton::Primary,
1653        })
1654    }
1655
1656    #[test]
1657    fn hover_opens_on_the_very_next_paint_zero_delay() {
1658        let mut h = Harness::new();
1659        h.paint(0.0);
1660        assert!(!h.hover.is_open());
1661        h.event(moved(10.0, 10.0));
1662        // No timer at all — the paint immediately following the hovering move
1663        // already opens it, matching upstream's synchronous `_show()`.
1664        h.paint(1.0);
1665        assert!(h.hover.is_open());
1666        assert_eq!(h.hover.via(), TooltipOpenVia::Hover);
1667    }
1668
1669    #[test]
1670    fn hover_closes_the_instant_the_pointer_leaves() {
1671        let mut h = Harness::new();
1672        h.event(moved(10.0, 10.0));
1673        h.paint(1.0);
1674        assert!(h.hover.is_open());
1675        h.event(moved(500.0, 500.0));
1676        h.paint(2.0);
1677        assert!(!h.hover.is_open());
1678    }
1679
1680    #[test]
1681    fn disabled_suppresses_hover_and_snaps_an_open_panel_shut() {
1682        let mut h = Harness::new();
1683        h.event(moved(10.0, 10.0));
1684        h.paint(1.0);
1685        assert!(h.hover.is_open());
1686        h.enabled = false;
1687        h.pass();
1688        h.root.layout(Size::new(400.0, 400.0));
1689        h.paint(2.0);
1690        assert!(!h.hover.is_open(), "disabling mid-show snaps it shut");
1691    }
1692
1693    // ---- Trigger: long-press threshold (frame clock) -------------------------
1694
1695    #[test]
1696    fn a_stationary_long_press_opens_after_the_threshold_via_housekeeping() {
1697        let mut h = Harness::new();
1698        h.event(down(10.0, 10.0));
1699        h.paint(0.0);
1700        let flags = h
1701            .root
1702            .paint(&mut NullScene, ft(LONG_PRESS_MS as f64 + 20.0));
1703        assert!(!h.hover.is_open(), "paint alone only latches, never fires");
1704        assert!(
1705            flags.needs_frame,
1706            "requests the follow-up frame that fires it"
1707        );
1708
1709        // Every shell runs rebuild (which drains the flush) before its own
1710        // paint — `crate::toggle_button`'s own Long-press section documents
1711        // why a plugin reaches this only through `frust::GestureDetector`.
1712        h.pass();
1713        assert!(
1714            h.hover.is_open(),
1715            "fires via Housekeeping, no further pointer event"
1716        );
1717        assert_eq!(h.hover.via(), TooltipOpenVia::LongPress);
1718    }
1719
1720    #[test]
1721    fn a_quick_release_before_the_threshold_never_opens() {
1722        let mut h = Harness::new();
1723        h.event(down(10.0, 10.0));
1724        h.paint(0.0);
1725        h.paint(100.0); // short of LONG_PRESS_MS
1726        h.event(up(11.0, 11.0));
1727        h.pass();
1728        assert!(!h.hover.is_open());
1729    }
1730
1731    // ---- Typeface: the panel runs' families follow the live theme ------------
1732
1733    /// A baseline theme whose `bodySmall`/`titleSmall`/`bodyMedium` roles name
1734    /// `plain`/`title`/`body`.
1735    fn theme_with(plain: FontFamily, title: FontFamily, body: FontFamily) -> Theme {
1736        let mut theme = crate::baseline();
1737        theme.type_scale.body_small.family = plain;
1738        theme.type_scale.title_small.family = title;
1739        theme.type_scale.body_medium.family = body;
1740        theme
1741    }
1742
1743    /// A rich panel with a title and no actions, built but not laid out.
1744    fn rich_panel_widget() -> RichPanelWidget {
1745        let view: RichPanel<AppState> = RichPanel {
1746            title: Some("Compose".into()),
1747            message: "Start a new draft.".into(),
1748            actions: Vec::new(),
1749        };
1750        let mut counter = 0u64;
1751        View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter))
1752    }
1753
1754    /// Lay `w` out against `tcx`, threading `theme` the way the render root
1755    /// does.
1756    fn layout_themed(w: &mut dyn Widget, tcx: &mut TextContext, theme: Option<&Theme>) {
1757        let mut ctx =
1758            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1759        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1760    }
1761
1762    #[test]
1763    fn layout_takes_each_panel_run_s_family_from_its_role() {
1764        let (plain, title, body) = (
1765            FontFamily::named("Plain Role Probe"),
1766            FontFamily::named("Title Role Probe"),
1767            FontFamily::named("Body Role Probe"),
1768        );
1769        let theme = theme_with(plain.clone(), title.clone(), body.clone());
1770        let mut tcx = TextContext::new();
1771
1772        let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1773        layout_themed(&mut plain_w, &mut tcx, Some(&theme));
1774        assert_eq!(plain_w.message.style.family, plain, "bodySmall");
1775        assert_eq!(plain_w.message.style.size, 12.0, "the token size stays");
1776
1777        let mut rich_w = rich_panel_widget();
1778        layout_themed(&mut rich_w, &mut tcx, Some(&theme));
1779        let title_run = rich_w.title.as_ref().expect("a title is set");
1780        assert_eq!(title_run.style.family, title, "titleSmall");
1781        assert_eq!(rich_w.body.style.family, body, "bodyMedium");
1782    }
1783
1784    #[test]
1785    fn without_a_theme_the_panel_runs_keep_the_unthemed_styles() {
1786        let plain_unthemed = TextStyle {
1787            weight: FontWeight::REGULAR,
1788            letter_spacing: 0.4,
1789            line_height: LineHeight::Absolute(16.0),
1790            ..TextStyle::new(12.0, Color::BLACK)
1791        };
1792        assert_eq!(plain_message_style(None), plain_unthemed);
1793        assert_eq!(rich_title_style(None).family, FontFamily::SystemUi);
1794        assert_eq!(rich_body_style(None).family, FontFamily::SystemUi);
1795
1796        let mut tcx = TextContext::new();
1797        let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1798        layout_themed(&mut plain_w, &mut tcx, None);
1799        assert_eq!(plain_w.message.style, plain_unthemed);
1800
1801        let mut rich_w = rich_panel_widget();
1802        layout_themed(&mut rich_w, &mut tcx, None);
1803        let title_run = rich_w.title.as_ref().expect("a title is set");
1804        assert_eq!(title_run.style, rich_title_style(None));
1805        assert_eq!(rich_w.body.style, rich_body_style(None));
1806    }
1807
1808    #[test]
1809    fn a_theme_swap_reshapes_the_cached_panel_runs() {
1810        let probe_a = || FontFamily::named("Tooltip Swap Probe A");
1811        let probe_b = || FontFamily::named("Tooltip Swap Probe B");
1812        let first = theme_with(probe_a(), probe_a(), probe_a());
1813        let second = theme_with(probe_b(), probe_b(), probe_b());
1814        let mut tcx = TextContext::new();
1815        let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1816        let mut rich_w = rich_panel_widget();
1817        layout_themed(&mut plain_w, &mut tcx, Some(&first));
1818        layout_themed(&mut rich_w, &mut tcx, Some(&first));
1819
1820        // Control: the same theme again reuses every cached run outright.
1821        let settled = tcx.shape_cache_stats();
1822        layout_themed(&mut plain_w, &mut tcx, Some(&first));
1823        layout_themed(&mut rich_w, &mut tcx, Some(&first));
1824        assert_eq!(
1825            tcx.shape_cache_stats(),
1826            settled,
1827            "an unchanged theme reshapes nothing"
1828        );
1829
1830        layout_themed(&mut plain_w, &mut tcx, Some(&second));
1831        layout_themed(&mut rich_w, &mut tcx, Some(&second));
1832        assert_eq!(
1833            tcx.shape_cache_stats().shapes,
1834            settled.shapes + 3,
1835            "a family swap must reshape the plain message, rich title and rich body"
1836        );
1837    }
1838
1839    // ---- Plain: auto-dismiss timeline (frame clock) --------------------------
1840
1841    fn plain_panel_widget(via: TooltipOpenVia) -> (PlainPanelWidget, TooltipHover) {
1842        let hover = TooltipHover::new();
1843        hover.set_open(true);
1844        hover.set_via(via);
1845        let view = PlainPanel {
1846            message: "hi".into(),
1847            hover: hover.clone(),
1848        };
1849        let mut counter = 0u64;
1850        let w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1851        (w, hover)
1852    }
1853
1854    #[test]
1855    fn a_long_press_opened_plain_tooltip_auto_dismisses_at_extra_long_4() {
1856        let (mut w, hover) = plain_panel_widget(TooltipOpenVia::LongPress);
1857        let mut tcx = TextContext::new();
1858        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1859        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1860
1861        let mut scene = NullScene;
1862        let mut ctx0 = PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(0.0));
1863        w.paint(&mut ctx0, &mut scene);
1864        assert!(hover.is_open(), "not yet at the delay");
1865
1866        let mut mid = PaintCtx::for_test(
1867            AuthoringPoint::ORIGIN,
1868            Size::new(400.0, 400.0),
1869            ft(PLAIN_DISMISS_MS - 1.0),
1870        );
1871        w.paint(&mut mid, &mut scene);
1872        assert!(hover.is_open(), "just short of it");
1873
1874        let mut done = PaintCtx::for_test(
1875            AuthoringPoint::ORIGIN,
1876            Size::new(400.0, 400.0),
1877            ft(PLAIN_DISMISS_MS),
1878        );
1879        w.paint(&mut done, &mut scene);
1880        assert!(!hover.is_open(), "1000ms (EXTRA_LONG_4) auto-dismisses it");
1881    }
1882
1883    #[test]
1884    fn a_hover_opened_plain_tooltip_never_auto_dismisses() {
1885        let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1886        let mut tcx = TextContext::new();
1887        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1888        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1889        let mut scene = NullScene;
1890        let mut ctx = PaintCtx::for_test(
1891            AuthoringPoint::ORIGIN,
1892            Size::new(400.0, 400.0),
1893            ft(PLAIN_DISMISS_MS * 10.0),
1894        );
1895        w.paint(&mut ctx, &mut scene);
1896        assert!(
1897            hover.is_open(),
1898            "hover-shown plain tooltips have no timer at all"
1899        );
1900    }
1901
1902    /// A pod now outlives any one show — it stays mounted and only its
1903    /// registration comes and goes — so the capture that used to happen in
1904    /// `View::build` has to happen on the first paint of each show instead.
1905    /// This pins the re-arm: one widget instance, two shows, opened two
1906    /// different ways, each getting the timer upstream gives it.
1907    #[test]
1908    fn one_pod_across_two_shows_re_arms_the_timer_for_each() {
1909        let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1910        let mut tcx = TextContext::new();
1911        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1912        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1913        let mut scene = NullScene;
1914        let mut paint_at = |w: &mut PlainPanelWidget, ms: f64| {
1915            let mut ctx =
1916                PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(ms));
1917            w.paint(&mut ctx, &mut scene);
1918        };
1919
1920        // Show one, opened by hover: no timer, however long it stays up.
1921        paint_at(&mut w, 0.0);
1922        paint_at(&mut w, PLAIN_DISMISS_MS * 10.0);
1923        assert!(hover.is_open(), "a hover-shown plain tooltip has no timer");
1924
1925        // Show two, on the SAME widget, opened by long-press this time.
1926        hover.set_open(false);
1927        hover.set_via(TooltipOpenVia::LongPress);
1928        hover.set_open(true);
1929        let base = PLAIN_DISMISS_MS * 20.0;
1930        paint_at(&mut w, base);
1931        assert!(hover.is_open(), "the second show has only just started");
1932        paint_at(&mut w, base + PLAIN_DISMISS_MS - 1.0);
1933        assert!(
1934            hover.is_open(),
1935            "and its timer is measured from ITS own first paint, not the pod's build"
1936        );
1937        paint_at(&mut w, base + PLAIN_DISMISS_MS);
1938        assert!(!hover.is_open(), "1000ms into the second show, it goes");
1939    }
1940
1941    // ---- Rich: slots, dismiss rules ---------------------------------------------
1942
1943    #[test]
1944    fn rich_never_arms_the_plain_auto_dismiss_timer() {
1945        // A rich panel's own widget carries no timer field at all — the
1946        // absence itself is the pin: `RichPanelWidget` has no
1947        // `dismiss_via_long_press`/`timer_start` the way `PlainPanelWidget`
1948        // does, so there is nothing to elapse. Exercised indirectly: a
1949        // long-press-opened rich tooltip stays open across many frames.
1950        let hover = TooltipHover::new();
1951        hover.set_open(true);
1952        hover.set_via(TooltipOpenVia::LongPress);
1953        let view: RichPanel<AppState> = RichPanel {
1954            title: Some("Title".into()),
1955            message: "Body copy".into(),
1956            actions: vec![],
1957        };
1958        let mut counter = 0u64;
1959        let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1960        let mut tcx = TextContext::new();
1961        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1962        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1963        let mut scene = NullScene;
1964        let mut ctx = PaintCtx::for_test(
1965            AuthoringPoint::ORIGIN,
1966            Size::new(400.0, 400.0),
1967            ft(PLAIN_DISMISS_MS * 5.0),
1968        );
1969        w.paint(&mut ctx, &mut scene);
1970        assert!(hover.is_open(), "no auto-dismiss timer for rich, ever");
1971    }
1972
1973    // ---- Rich: action-row routing (`route_event`, not a hand-rolled loop) ---
1974    //
1975    // Regression coverage for `RichPanelWidget::event` forwarding via
1976    // `route_event` instead of a first-`Handled`-wins loop over
1977    // `ChildPod::event_child` — the loop skipped `route_event`'s hit test,
1978    // capture bookkeeping, and per-child arming, so a press anywhere in the
1979    // panel armed only action #0 and any later action was unreachable.
1980
1981    #[derive(Default)]
1982    struct Presses {
1983        fired: Vec<usize>,
1984    }
1985
1986    /// A two-(or `count`-)action `RichPanelWidget`, laid out at a fixed width —
1987    /// real post-layout action-pod bounds, not hardcoded metrics.
1988    fn rich_panel_with_actions(count: usize) -> RichPanelWidget {
1989        let actions: Vec<TooltipAction<Presses>> = (0..count)
1990            .map(|i| {
1991                tooltip_action(format!("Action {i}"), move |s: &mut Presses| {
1992                    s.fired.push(i)
1993                })
1994            })
1995            .collect();
1996        let view: RichPanel<Presses> = RichPanel {
1997            title: Some("Title".into()),
1998            message: "Body copy long enough to occupy real space above the actions".into(),
1999            actions,
2000        };
2001        let mut counter = 0u64;
2002        let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
2003        let mut tcx = TextContext::new();
2004        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2005        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
2006        w
2007    }
2008
2009    fn dispatch_rich(
2010        w: &mut RichPanelWidget,
2011        state: &mut Presses,
2012        phase: PointerPhase,
2013        pos: AuthoringPoint,
2014    ) -> EventResult {
2015        let state_any: &mut dyn Any = state;
2016        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 400.0));
2017        w.event(
2018            &mut ctx,
2019            &InputEvent::Pointer(PointerEvent {
2020                phase,
2021                position: pos,
2022                button: PointerButton::Primary,
2023            }),
2024        )
2025    }
2026
2027    /// A point inside the panel's title/body region, above every action pod.
2028    fn body_point(w: &RichPanelWidget) -> AuthoringPoint {
2029        AuthoringPoint::new(RICH_PAD + 1.0, w.body_origin.y + 1.0)
2030    }
2031
2032    /// The center of action pod `index`, post-layout.
2033    fn action_center(w: &RichPanelWidget, index: usize) -> AuthoringPoint {
2034        let pod = &w.action_pods[index];
2035        let (o, s) = (pod.origin(), pod.size());
2036        AuthoringPoint::new(o.x + s.width / 2.0, o.y + s.height / 2.0)
2037    }
2038
2039    #[test]
2040    fn a_press_on_the_body_fires_no_action_and_captures_nothing() {
2041        let mut w = rich_panel_with_actions(2);
2042        let mut state = Presses::default();
2043        let p = body_point(&w);
2044        dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2045        dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2046        assert!(state.fired.is_empty(), "no action pod sits under the body");
2047        assert!(
2048            w.action_pods.iter().all(|pod| !pod.is_active()),
2049            "nothing captured a press outside every action's own bounds"
2050        );
2051    }
2052
2053    #[test]
2054    fn press_release_on_the_second_action_fires_it() {
2055        let mut w = rich_panel_with_actions(2);
2056        let mut state = Presses::default();
2057        let p = action_center(&w, 1);
2058        dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2059        assert!(
2060            w.action_pods[1].is_active(),
2061            "the hit-tested pod captures the press, not action #0"
2062        );
2063        dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2064        assert_eq!(state.fired, vec![1], "action #1 fires — previously dead");
2065    }
2066
2067    #[test]
2068    fn down_on_body_up_over_action_0_does_not_fire() {
2069        let mut w = rich_panel_with_actions(2);
2070        let mut state = Presses::default();
2071        let down_p = body_point(&w);
2072        let up_p = action_center(&w, 0);
2073        dispatch_rich(&mut w, &mut state, PointerPhase::Down, down_p);
2074        dispatch_rich(&mut w, &mut state, PointerPhase::Up, up_p);
2075        assert!(
2076            state.fired.is_empty(),
2077            "up-inside without down-inside must not fire (the press contract)"
2078        );
2079    }
2080
2081    // ---- Floated through the portal -----------------------------------------
2082    //
2083    // Everything below runs the real pipeline: a `RenderRoot` over the mount an
2084    // app actually writes (a page, the trigger and the panel as siblings under
2085    // one `Stack`), so the root's own overlay post-pass and pre-pass are what is
2086    // under test, not this module's arithmetic about them.
2087
2088    const WINDOW: Size = Size::new(400.0, 600.0);
2089    const RICH_TITLE: &str = "Compose";
2090    const RICH_BODY: &str = "Start a new draft with expressive defaults.";
2091    const RICH_ACTION: &str = "Got it";
2092
2093    /// State for the portal harness: what reached the page underneath, and what
2094    /// reached the rich panel's own action row.
2095    #[derive(Default)]
2096    struct PageState {
2097        pressed: usize,
2098        action_fired: usize,
2099    }
2100
2101    /// A page filling whatever area it is given, counting the presses that
2102    /// reach it — so a test can assert "the press got through to the widget
2103    /// beneath", not merely the weaker "nobody reported handling it".
2104    struct PageProbe;
2105
2106    /// The retained widget for a [`PageProbe`].
2107    struct PageProbeWidget;
2108
2109    impl View<PageState> for PageProbe {
2110        type Element = PageProbeWidget;
2111
2112        fn build(&self, _ctx: &mut BuildCtx<'_>) -> PageProbeWidget {
2113            PageProbeWidget
2114        }
2115
2116        fn rebuild(
2117            &self,
2118            _prev: &Self,
2119            _element: &mut PageProbeWidget,
2120            _ctx: &mut BuildCtx<'_>,
2121        ) -> ChangeFlags {
2122            ChangeFlags::NONE
2123        }
2124
2125        fn teardown(&self, _element: &mut PageProbeWidget, _ctx: &mut BuildCtx<'_>) {}
2126    }
2127
2128    impl Widget for PageProbeWidget {
2129        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2130            bc.max()
2131        }
2132
2133        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2134
2135        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2136            if let InputEvent::Pointer(p) = event
2137                && p.phase == PointerPhase::Down
2138            {
2139                ctx.state_mut::<PageState>().pressed += 1;
2140                return EventResult::Handled;
2141            }
2142            EventResult::Ignored
2143        }
2144
2145        fn semantics(&self, _ctx: &mut SemanticsCtx) {}
2146
2147        visit_children!();
2148    }
2149
2150    /// What actually reached the scene: the rounded rects a panel paints (i.e.
2151    /// whether the panel is on screen at all, and where), and the alpha of any
2152    /// composited layer (i.e. the entrance ramp).
2153    ///
2154    /// Neither the page nor the trigger paints anything, so every recorded
2155    /// rounded rect belongs to a floated panel, and `rrects[0]` is that panel's
2156    /// own container — painted before its text and before any action button.
2157    #[derive(Default)]
2158    struct Recorder {
2159        rrects: Vec<(AuthoringPoint, Size)>,
2160        layers: Vec<f32>,
2161    }
2162
2163    impl Recorder {
2164        /// The floated panel's own rect, in window space.
2165        fn panel(&self) -> AuthoringRect {
2166            let (origin, size) = self.rrects[0];
2167            AuthoringRect::from_origin_size(origin, size)
2168        }
2169    }
2170
2171    impl PaintScene for Recorder {
2172        fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
2173        fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
2174        fn fill_rounded_rect(&mut self, origin: AuthoringPoint, size: Size, _r: f64, _c: Color) {
2175            self.rrects.push((origin, size));
2176        }
2177        fn push_layer(&mut self, _o: AuthoringPoint, _s: Size, alpha: f32) {
2178            self.layers.push(alpha);
2179        }
2180        fn pop_layer(&mut self) {}
2181        fn push_transform(&mut self, _t: Affine) {}
2182        fn pop_transform(&mut self) {}
2183    }
2184
2185    /// The mount an app writes: a full-area page, the trigger, and the panel,
2186    /// all siblings of one [`frust::Stack`]. The panel no longer has to be that
2187    /// `Stack`'s topmost child — paint order comes from the declared band — and
2188    /// it is deliberately *not* topmost here, which the old full-area host would
2189    /// not have survived.
2190    struct PortalHarness {
2191        root: RenderRoot<PageState, frust::StackView<PageState>>,
2192        state: PageState,
2193        tcx: TextContext,
2194        hover: TooltipHover,
2195        rich: bool,
2196    }
2197
2198    impl PortalHarness {
2199        fn new(rich: bool) -> Self {
2200            let mut h = PortalHarness {
2201                root: RenderRoot::new(),
2202                state: PageState::default(),
2203                tcx: TextContext::new(),
2204                hover: TooltipHover::new(),
2205                rich,
2206            };
2207            h.frame(0.0);
2208            h
2209        }
2210
2211        fn view(hover: &TooltipHover, rich: bool) -> frust::StackView<PageState> {
2212            let panel: AnyView<PageState> = if rich {
2213                any(rich_tooltip(hover, RICH_BODY)
2214                    .title(RICH_TITLE)
2215                    .action(tooltip_action(RICH_ACTION, |s: &mut PageState| {
2216                        s.action_fired += 1
2217                    })))
2218            } else {
2219                any(tooltip(hover, RICH_TITLE))
2220            };
2221            frust::Stack(vec![
2222                any(PageProbe),
2223                any(tooltip_trigger(
2224                    hover,
2225                    frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
2226                )),
2227                panel,
2228            ])
2229        }
2230
2231        /// One whole frame — rebuild, layout, paint — answering with what was
2232        /// painted.
2233        fn frame(&mut self, ms: f64) -> Recorder {
2234            let hover = self.hover.clone();
2235            let rich = self.rich;
2236            let mut logic = move |_s: &mut PageState| Self::view(&hover, rich);
2237            self.root.rebuild(&mut logic, &mut self.state);
2238            self.root
2239                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
2240            self.repaint(ms)
2241        }
2242
2243        /// A paint and nothing else: no rebuild, no relayout. What a shell does
2244        /// when a widget asked for a frame without the tree changing.
2245        fn repaint(&mut self, ms: f64) -> Recorder {
2246            let mut rec = Recorder::default();
2247            self.root.paint(&mut rec, ft(ms));
2248            rec
2249        }
2250
2251        /// Open the panel by hovering the trigger, and let the entrance settle.
2252        fn open_by_hover(&mut self) -> Recorder {
2253            self.root.event(&mut self.state, &moved(10.0, 10.0));
2254            self.frame(0.0);
2255            self.frame(SETTLED_MS)
2256        }
2257
2258        fn press(&mut self, x: f64, y: f64) -> bool {
2259            self.root.event(&mut self.state, &down(x, y)).handled
2260        }
2261    }
2262
2263    /// Comfortably past [`MaterialMotion::SHORT_4`], so the entrance has
2264    /// settled and a recorded rect is the panel's untransformed placement.
2265    const SETTLED_MS: f64 = 1_000.0;
2266
2267    #[test]
2268    fn a_closed_tooltip_registers_nothing_and_an_opened_one_registers_its_panel() {
2269        let mut h = PortalHarness::new(false);
2270        let closed = h.frame(0.0);
2271        assert!(
2272            closed.rrects.is_empty(),
2273            "a closed tooltip floats nothing at all"
2274        );
2275        let open = h.open_by_hover();
2276        assert!(!open.rrects.is_empty(), "hovering floats the panel");
2277    }
2278
2279    /// Deliverable of the port this test exists for: the panel goes away with
2280    /// no exit ramp — and, because the registration is per paint pass and the
2281    /// latch is read live, it goes away on the next *paint*, without waiting
2282    /// for the rebuild the old `View::Element` swap needed. The latch is not
2283    /// reactive, so that rebuild was never guaranteed to come.
2284    #[test]
2285    fn closing_the_latch_takes_the_panel_away_on_the_very_next_paint_with_no_exit_ramp() {
2286        let mut h = PortalHarness::new(false);
2287        assert!(!h.open_by_hover().rrects.is_empty(), "open first");
2288
2289        h.hover.set_open(false);
2290        let after = h.repaint(SETTLED_MS + 16.0);
2291        assert!(
2292            after.rrects.is_empty(),
2293            "gone on the paint that follows the close — no rebuild needed"
2294        );
2295        assert!(
2296            after.layers.is_empty(),
2297            "and nothing was composited on the way out: there is no exit ramp"
2298        );
2299
2300        // The rebuild a shell would have run next changes nothing: there is no
2301        // ramp left to truncate and no placeholder to swap back in.
2302        let rebuilt = h.frame(SETTLED_MS + 32.0);
2303        assert!(rebuilt.rrects.is_empty());
2304        assert!(rebuilt.layers.is_empty());
2305    }
2306
2307    #[test]
2308    fn the_entrance_ramp_still_plays_and_then_settles() {
2309        let mut h = PortalHarness::new(false);
2310        h.root.event(&mut h.state, &moved(10.0, 10.0));
2311        // The paint that opens it seeds the driver; the next one is already
2312        // part-way in.
2313        h.frame(0.0);
2314        let entering = h.repaint(MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
2315        assert!(
2316            entering.layers.iter().any(|a| *a > 0.0 && *a < 1.0),
2317            "the entrance composites a part-way layer: {:?}",
2318            entering.layers
2319        );
2320        let settled = h.repaint(SETTLED_MS);
2321        assert!(
2322            settled.layers.is_empty(),
2323            "and stops compositing once it has arrived: {:?}",
2324            settled.layers
2325        );
2326        assert!(!settled.rrects.is_empty(), "still on screen, fully present");
2327    }
2328
2329    /// The whole point of the port, for the plain panel: a press that lands on
2330    /// the panel reaches the page *underneath* it.
2331    ///
2332    /// Negative control performed by hand while writing this: with
2333    /// `TooltipView::configure`'s `OverlayInput::Transparent` changed to
2334    /// `Interactive`, this test fails on the `state.pressed` assertion, which
2335    /// stays `0` — the root's overlay pre-pass routes the press into the
2336    /// panel's own pod instead of letting it through. So the assertion below is
2337    /// pinned to the registration, not merely satisfied by it.
2338    #[test]
2339    fn a_press_on_an_open_plain_tooltip_reaches_the_page_beneath_it() {
2340        let mut h = PortalHarness::new(false);
2341        let panel = h.open_by_hover().panel();
2342        let at = panel.center();
2343        let handled = h.press(at.x, at.y);
2344        assert_eq!(
2345            h.state.pressed, 1,
2346            "the page under the panel actually fired"
2347        );
2348        assert!(handled, "and reported the press as handled, by the page");
2349        assert!(
2350            h.hover.is_open(),
2351            "a plain tooltip is not dismissed by a tap: it closes on hover-exit \
2352             or on its own timer"
2353        );
2354    }
2355
2356    /// The rich panel's half of the same promise. It is *not* input-transparent
2357    /// — its action row has to be reachable — so what changed for it is the
2358    /// press **outside**: upstream's translucent dismiss layer tells the panel
2359    /// and lets the press through, where the anchored host swallowed it.
2360    #[test]
2361    fn a_press_outside_an_open_rich_tooltip_closes_it_and_still_reaches_the_page() {
2362        let mut h = PortalHarness::new(true);
2363        let panel = h.open_by_hover().panel();
2364        let away = AuthoringPoint::new(WINDOW.width - 20.0, WINDOW.height - 20.0);
2365        assert!(
2366            !panel.contains(away),
2367            "the press has to land outside the panel for this to mean anything"
2368        );
2369
2370        h.press(away.x, away.y);
2371        assert!(!h.hover.is_open(), "the outside press dismisses it");
2372        assert_eq!(
2373            h.state.pressed, 1,
2374            "and is NOT swallowed: the page under it fired too"
2375        );
2376    }
2377
2378    /// And the action row upstream gives a rich tooltip still works through the
2379    /// portal: the root hit-tests the registered rect first and routes the
2380    /// press into the pod, so the button fires and the page beneath never sees
2381    /// it.
2382    #[test]
2383    fn a_press_on_an_open_rich_tooltips_action_fires_it_and_not_the_page() {
2384        let mut h = PortalHarness::new(true);
2385        let panel = h.open_by_hover().panel();
2386        let local = rich_probe_action_center(0);
2387        let at = AuthoringPoint::new(panel.x0 + local.x, panel.y0 + local.y);
2388        assert!(panel.contains(at), "the action sits inside the panel");
2389
2390        h.root.event(&mut h.state, &down(at.x, at.y));
2391        h.root.event(&mut h.state, &up(at.x, at.y));
2392        assert_eq!(h.state.action_fired, 1, "the action button fired");
2393        assert_eq!(
2394            h.state.pressed, 0,
2395            "and the page beneath was not pressed through the panel"
2396        );
2397    }
2398
2399    /// A [`RichPanelWidget`] carrying the harness's own slots, laid out against
2400    /// the same window-loose constraints [`OverlaySlot::layout`] gives the
2401    /// floated pod — so an action pod's real post-layout centre can be lifted
2402    /// into window space by adding the placed panel's origin, with no hardcoded
2403    /// metric anywhere.
2404    fn rich_probe_action_center(index: usize) -> AuthoringPoint {
2405        let view: RichPanel<PageState> = RichPanel {
2406            title: Some(RICH_TITLE.into()),
2407            message: RICH_BODY.into(),
2408            actions: vec![tooltip_action(RICH_ACTION, |s: &mut PageState| {
2409                s.action_fired += 1
2410            })],
2411        };
2412        let mut counter = 0u64;
2413        let mut w = View::<PageState>::build(&view, &mut BuildCtx::new(&mut counter));
2414        let mut tcx = TextContext::new();
2415        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2416        w.layout(&mut lctx, &BoxConstraints::loose(WINDOW));
2417        action_center(&w, index)
2418    }
2419
2420    #[test]
2421    fn hover_shows_the_plain_panel_and_the_rich_panel_alike() {
2422        for rich in [false, true] {
2423            let mut h = PortalHarness::new(rich);
2424            assert!(
2425                !h.open_by_hover().rrects.is_empty(),
2426                "hovering the trigger shows the panel (rich: {rich})"
2427            );
2428        }
2429    }
2430
2431    #[test]
2432    fn a_long_press_shows_the_plain_panel_and_the_rich_panel_alike() {
2433        for rich in [false, true] {
2434            let mut h = PortalHarness::new(rich);
2435            h.root.event(&mut h.state, &down(10.0, 10.0));
2436            h.frame(0.0);
2437            // Paint alone only latches; the rebuild that follows is what drains
2438            // the flush and fires the long press (see the trigger's own test).
2439            h.repaint(LONG_PRESS_MS as f64 + 20.0);
2440            let shown = h.frame(LONG_PRESS_MS as f64 + 20.0);
2441            assert_eq!(
2442                h.hover.via(),
2443                TooltipOpenVia::LongPress,
2444                "opened by the long press (rich: {rich})"
2445            );
2446            assert!(
2447                !shown.rrects.is_empty(),
2448                "and the panel is floated for it (rich: {rich})"
2449            );
2450        }
2451    }
2452
2453    // ---- Attribution smoke: MaterialTokens-only ------------------------------
2454
2455    #[test]
2456    fn plain_and_rich_resolve_off_the_theme_not_literals() {
2457        let light = crate::baseline().with_brightness(Brightness::Light);
2458        let dark = crate::baseline().with_brightness(Brightness::Dark);
2459        assert_ne!(
2460            light.scheme().inverse_surface,
2461            dark.scheme().inverse_surface
2462        );
2463        assert_ne!(
2464            overlay_container(Some(&light), OverlayContainer::Standard),
2465            overlay_container(Some(&dark), OverlayContainer::Standard)
2466        );
2467    }
2468}