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    // erasure: keep feeds OverlaySlot::rebuild, whose public signature takes Option<&AnyView<..>>
877    fn content_view(&self) -> AnyView<()> {
878        any(RampedPanel {
879            content: any(PlainPanel {
880                message: self.message.clone(),
881                hover: self.hover.clone(),
882            }),
883            hover: self.hover.clone(),
884        })
885    }
886}
887
888/// The retained widget for a [`TooltipView`]: one [`OverlaySlot`] holding the
889/// plain panel, registered on every paint the latch reads open and on no other.
890///
891/// Registering nothing *is* the unmount (see the file header's no-exit-fade
892/// decision): the root's registry is per paint pass, so the frame this widget
893/// skips its registration is the frame the panel stops being painted and stops
894/// being routed to, with nothing to unregister and no ramp to run down.
895pub struct TooltipWidget {
896    hover: TooltipHover,
897    slot: OverlaySlot<()>,
898}
899
900impl TooltipView {
901    /// Push this view's routing configuration onto the slot — the half of
902    /// `build`/`rebuild` that is identical in both.
903    fn configure(&self, slot: &mut OverlaySlot<()>) {
904        slot.set_band(OverlayBand::Tooltip);
905        // Inert chrome: the root's pre-pass skips it, so a press anywhere —
906        // including over the panel itself — reaches the main tree untouched.
907        slot.set_input(OverlayInput::Transparent);
908        // A plain tooltip dismisses on hover-exit or on its own timer, never on
909        // a tap. A transparent surface is never told about an outside press in
910        // the first place — the root's pre-pass skips it for that too — so this
911        // records the intent rather than being what enforces it.
912        slot.set_outside_tap(OutsideTap::Ignore);
913        slot.set_placement(portal_placement(self.placement));
914    }
915}
916
917impl<State: 'static> View<State> for TooltipView {
918    type Element = TooltipWidget;
919
920    fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipWidget {
921        let mut slot = OverlaySlot::new();
922        self.configure(&mut slot);
923        slot.rebuild(None, Some(&self.content_view()), ctx);
924        TooltipWidget {
925            hover: self.hover.clone(),
926            slot,
927        }
928    }
929
930    fn rebuild(
931        &self,
932        prev: &Self,
933        element: &mut TooltipWidget,
934        ctx: &mut BuildCtx<'_>,
935    ) -> ChangeFlags {
936        self.configure(&mut element.slot);
937        element.hover = self.hover.clone();
938        element
939            .slot
940            .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
941            | ChangeFlags::PAINT
942    }
943
944    fn teardown(&self, element: &mut TooltipWidget, ctx: &mut BuildCtx<'_>) {
945        element.slot.rebuild(Some(&self.content_view()), None, ctx);
946    }
947}
948
949impl Widget for TooltipWidget {
950    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
951        // The pod is sized against the window, not against `bc` — it escapes
952        // this widget's box entirely, and unlike the host this replaced that no
953        // longer depends on `bc` being bounded (`crate::overlay`'s documented
954        // scroll-view trap): the pod never reads `bc.max()` at all.
955        self.slot.layout(ctx);
956        // Zero of its own: this owner draws nothing in the main tree and needs
957        // no bounds there — so mounting a tooltip beside its trigger costs no
958        // layout space, open or closed. The old mount-on-open element was zero
959        // while closed and full-area while open; a panel is no longer allowed
960        // to push its siblings around on the frame it opens.
961        bc.constrain(Size::ZERO)
962    }
963
964    fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
965        if !self.hover.is_open() {
966            return;
967        }
968        // Window space → this widget's own local space, which is what the slot
969        // takes; it adds `ctx.origin()` back on when it places the pod.
970        let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
971        self.slot.set_anchor(PortalAnchor::Rect(local));
972        self.slot.paint(ctx, Size::ZERO);
973    }
974
975    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
976        // Never fires in practice — a transparent surface is hit-tested by
977        // nothing, so the root routes it no overlay event — but the slot is
978        // still the right place to ask, and asking keeps the owner honest if the
979        // panel is ever given interactive content.
980        self.slot
981            .event(ctx, event, &mut ())
982            .unwrap_or(EventResult::Ignored)
983    }
984
985    fn semantics(&self, _ctx: &mut SemanticsCtx) {
986        // A floated pod contributes no accessibility nodes in v1 (see the
987        // module docs), and this owner has nothing of its own to publish.
988    }
989
990    visit_children!();
991}
992
993/// The plain panel's content, floated inside a [`RampedPanel`]. Never
994/// constructed by an app directly.
995struct PlainPanel {
996    message: String,
997    hover: TooltipHover,
998}
999
1000impl<State: 'static> View<State> for PlainPanel {
1001    type Element = PlainPanelWidget;
1002
1003    fn build(&self, _ctx: &mut BuildCtx<'_>) -> PlainPanelWidget {
1004        let mut run = TooltipTextRun::new();
1005        run.set_content(&self.message);
1006        PlainPanelWidget {
1007            message: run,
1008            hover: self.hover.clone(),
1009            dismiss_via_long_press: false,
1010            armed_show: None,
1011            timer_start: None,
1012            dismissed: false,
1013        }
1014    }
1015
1016    fn rebuild(
1017        &self,
1018        _prev: &Self,
1019        element: &mut PlainPanelWidget,
1020        _ctx: &mut BuildCtx<'_>,
1021    ) -> ChangeFlags {
1022        let mut flags = ChangeFlags::NONE;
1023        if element.message.content != self.message {
1024            element.message.set_content(&self.message);
1025            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1026        }
1027        element.hover = self.hover.clone();
1028        flags
1029    }
1030
1031    fn teardown(&self, _element: &mut PlainPanelWidget, _ctx: &mut BuildCtx<'_>) {}
1032}
1033
1034/// The retained widget for a [`PlainPanel`].
1035struct PlainPanelWidget {
1036    message: TooltipTextRun,
1037    hover: TooltipHover,
1038    /// Captured once per show, on this widget's first paint of it — see the
1039    /// module docs' Plain section.
1040    dismiss_via_long_press: bool,
1041    /// Which show the capture above belongs to (see [`TooltipLatch::shows`]).
1042    /// This widget now outlives any one show, so `View::build` is no longer the
1043    /// moment the panel appears; the first paint of a show is.
1044    armed_show: Option<u64>,
1045    timer_start: Option<FrameTime>,
1046    dismissed: bool,
1047}
1048
1049impl PlainPanelWidget {
1050    /// Capture the trigger mechanism and re-arm the one-shot timer when this
1051    /// paint is the first of a new show.
1052    ///
1053    /// This widget is painted only while its owner registers it, i.e. only
1054    /// while the panel is on screen, so "first paint of a show" is exactly the
1055    /// instant upstream runs `_show(); _scheduleHide();` together.
1056    fn arm(&mut self) {
1057        let show = self.hover.show_id();
1058        if self.armed_show == Some(show) {
1059            return;
1060        }
1061        self.armed_show = Some(show);
1062        self.dismiss_via_long_press = self.hover.via() == TooltipOpenVia::LongPress;
1063        self.timer_start = None;
1064        self.dismissed = false;
1065    }
1066}
1067
1068impl Widget for PlainPanelWidget {
1069    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1070        let width_cap = bc.max().width.min(PLAIN_MAX_WIDTH);
1071        let inner_w = (width_cap - 2.0 * PLAIN_PAD_X).max(0.0);
1072        let style = plain_message_style(Theme::from_layout_ctx(ctx));
1073        let msg = self.message.shape(ctx, &style, inner_w);
1074        let size = Size::new(
1075            msg.width + 2.0 * PLAIN_PAD_X,
1076            msg.height + 2.0 * PLAIN_PAD_Y,
1077        );
1078        bc.constrain(size)
1079    }
1080
1081    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1082        self.arm();
1083        let theme = Theme::from_paint_ctx(ctx);
1084        let container = theme.map_or(FALLBACK_INVERSE_SURFACE, |t| t.scheme().inverse_surface);
1085        let ink = theme.map_or(FALLBACK_INVERSE_ON_SURFACE, |t| {
1086            t.scheme().inverse_on_surface
1087        });
1088        let radius = theme.map_or(MaterialDimensions::RADIUS_EXTRA_SMALL, |t| {
1089            t.shape.extra_small
1090        });
1091        let (origin, size) = (ctx.origin(), ctx.size());
1092        scene.fill_rounded_rect(origin, size, radius, container);
1093        self.message.paint(
1094            Point::new(origin.x + PLAIN_PAD_X, origin.y + PLAIN_PAD_Y),
1095            ink,
1096            scene,
1097        );
1098
1099        // Auto-dismiss (long-press-opened only) — a one-shot paint-clock
1100        // timer, matching `crate::toggle_button::LongPressState`'s idiom but
1101        // firing straight from paint: the write below is a plain, non-reactive
1102        // `TooltipHover` cell, not an app-state mutation, so no
1103        // `mark_pending_result_flush`/`EventCtx` round trip is needed.
1104        if self.dismiss_via_long_press && !self.dismissed {
1105            let start = *self.timer_start.get_or_insert(ctx.frame_time());
1106            let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
1107            if elapsed_ms >= PLAIN_DISMISS_MS {
1108                self.dismissed = true;
1109                self.hover.set_open(false);
1110            }
1111            ctx.request_frame();
1112        }
1113    }
1114
1115    fn semantics(&self, ctx: &mut SemanticsCtx) {
1116        ctx.push_node(frust::authoring::Role::Label, |node| {
1117            node.set_label(self.message.content.clone());
1118        });
1119    }
1120}
1121
1122// ---- Rich tooltip -------------------------------------------------------------
1123
1124/// One rich-tooltip action button: a label plus its own press callback. Build
1125/// with [`tooltip_action`], attach with [`RichTooltipView::action`].
1126pub struct TooltipAction<State: 'static> {
1127    label: String,
1128    on_press: Rc<dyn Fn(&mut State)>,
1129}
1130
1131impl<State: 'static> Clone for TooltipAction<State> {
1132    fn clone(&self) -> Self {
1133        Self {
1134            label: self.label.clone(),
1135            on_press: self.on_press.clone(),
1136        }
1137    }
1138}
1139
1140/// Build a rich-tooltip action labeled `label`, firing `on_press`.
1141pub fn tooltip_action<State: 'static, F: Fn(&mut State) + 'static>(
1142    label: impl Into<String>,
1143    on_press: F,
1144) -> TooltipAction<State> {
1145    TooltipAction {
1146        label: label.into(),
1147        on_press: Rc::new(on_press),
1148    }
1149}
1150
1151/// Build a rich tooltip over `hover`, to be mounted unconditionally beside
1152/// [`tooltip_trigger`]'s own view. See [`tooltip`] for the mount contract this
1153/// mirrors, and [`RichTooltipView::title`]/[`RichTooltipView::action`] for the
1154/// upstream `richTitle`/`actions` slots.
1155///
1156/// Unlike the plain panel this one is genuinely interactive: its action row is
1157/// routed to, and a press outside it closes the panel *and* carries on to
1158/// whatever it landed on.
1159pub fn rich_tooltip<State: 'static>(
1160    hover: &TooltipHover,
1161    message: impl Into<String>,
1162) -> RichTooltipView<State> {
1163    RichTooltipView {
1164        hover: hover.clone(),
1165        title: None,
1166        message: message.into(),
1167        actions: Vec::new(),
1168        placement: OverlayPlacement::default(),
1169    }
1170}
1171
1172/// A declarative rich tooltip. See [`rich_tooltip`].
1173pub struct RichTooltipView<State: 'static> {
1174    hover: TooltipHover,
1175    title: Option<String>,
1176    message: String,
1177    actions: Vec<TooltipAction<State>>,
1178    placement: OverlayPlacement,
1179}
1180
1181impl<State: 'static> RichTooltipView<State> {
1182    /// Set the title (`richTitle`), shown above the supporting text.
1183    pub fn title(mut self, title: impl Into<String>) -> Self {
1184        self.title = Some(title.into());
1185        self
1186    }
1187
1188    /// Append an action button (`actions`). Upstream accepts any number
1189    /// (`Row(children: widget.actions)`, no cap in code); M3 guidance caps a
1190    /// rich tooltip at two, which this port does not enforce either — a
1191    /// documentation convention, not a code constraint upstream carries.
1192    pub fn action(mut self, action: TooltipAction<State>) -> Self {
1193        self.actions.push(action);
1194        self
1195    }
1196
1197    /// Set the side the panel opens on (`bottom` by default).
1198    pub fn side(mut self, side: OverlaySide) -> Self {
1199        self.placement.side = side;
1200        self
1201    }
1202
1203    /// Set the cross-axis alignment (`center` by default).
1204    pub fn align(mut self, align: OverlayAlign) -> Self {
1205        self.placement.align = align;
1206        self
1207    }
1208
1209    /// Set the gap between trigger and panel (`anchorOffset`, 4dp by default).
1210    pub fn offset(mut self, offset: f64) -> Self {
1211        self.placement.offset = offset;
1212        self
1213    }
1214
1215    /// The pod's view.
1216    ///
1217    /// Built over the ambient application state, unlike [`TooltipView`]'s: the
1218    /// `actions` row's callbacks take `&mut State` and must reach the same
1219    /// state every other widget sees, which is what
1220    /// [`OverlaySlot::event_ambient`] routes them over.
1221    // erasure: keep feeds OverlaySlot::rebuild, whose public signature takes Option<&AnyView<..>>
1222    fn content_view(&self) -> AnyView<State> {
1223        any(RampedPanel {
1224            content: any(RichPanel {
1225                title: self.title.clone(),
1226                message: self.message.clone(),
1227                actions: self.actions.clone(),
1228            }),
1229            hover: self.hover.clone(),
1230        })
1231    }
1232
1233    /// Push this view's routing configuration onto the slot — the half of
1234    /// `build`/`rebuild` that is identical in both.
1235    fn configure(&self, slot: &mut OverlaySlot<State>) {
1236        slot.set_band(OverlayBand::Tooltip);
1237        // Interactive, unlike the plain panel: a transparent surface is
1238        // hit-tested by nothing at all, which would leave upstream's own
1239        // `actions` row unreachable and suppress the outside-press notification
1240        // below with it (see the file header's registration decision).
1241        slot.set_input(OverlayInput::Interactive);
1242        // Upstream's translucent full-area dismiss layer: told about the press,
1243        // and not consuming it, so whatever sits beneath is told as well.
1244        slot.set_outside_tap(OutsideTap::Notify { consume: false });
1245        slot.set_placement(portal_placement(self.placement));
1246    }
1247}
1248
1249/// The retained widget for a [`RichTooltipView`]: one [`OverlaySlot`] holding
1250/// the rich panel, registered on every paint the latch reads open and on no
1251/// other. See [`TooltipWidget`] for what that gates, and this view's own
1252/// `configure` for how the two panels' registrations differ.
1253pub struct RichTooltipWidget<State: 'static> {
1254    hover: TooltipHover,
1255    slot: OverlaySlot<State>,
1256}
1257
1258impl<State: 'static> View<State> for RichTooltipView<State> {
1259    type Element = RichTooltipWidget<State>;
1260
1261    fn build(&self, ctx: &mut BuildCtx<'_>) -> RichTooltipWidget<State> {
1262        let mut slot = OverlaySlot::new();
1263        self.configure(&mut slot);
1264        slot.rebuild(None, Some(&self.content_view()), ctx);
1265        RichTooltipWidget {
1266            hover: self.hover.clone(),
1267            slot,
1268        }
1269    }
1270
1271    fn rebuild(
1272        &self,
1273        prev: &Self,
1274        element: &mut RichTooltipWidget<State>,
1275        ctx: &mut BuildCtx<'_>,
1276    ) -> ChangeFlags {
1277        self.configure(&mut element.slot);
1278        element.hover = self.hover.clone();
1279        element
1280            .slot
1281            .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
1282            | ChangeFlags::PAINT
1283    }
1284
1285    fn teardown(&self, element: &mut RichTooltipWidget<State>, ctx: &mut BuildCtx<'_>) {
1286        element.slot.rebuild(Some(&self.content_view()), None, ctx);
1287    }
1288}
1289
1290impl<State: 'static> Widget for RichTooltipWidget<State> {
1291    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1292        self.slot.layout(ctx);
1293        bc.constrain(Size::ZERO)
1294    }
1295
1296    fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1297        if !self.hover.is_open() {
1298            return;
1299        }
1300        let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
1301        self.slot.set_anchor(PortalAnchor::Rect(local));
1302        self.slot.paint(ctx, Size::ZERO);
1303    }
1304
1305    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1306        let Some(result) = self.slot.event_ambient(ctx, event) else {
1307            // Not the surface's: this owner has no bounds of its own and nothing
1308            // else to claim.
1309            return EventResult::Ignored;
1310        };
1311        // The outside-press notification arrives through the same door, as a
1312        // broadcast the slot drains into this flag. Closing here rather than in
1313        // the pod is the point: the pod never saw the press (it landed on
1314        // nothing floated), and the press itself carries on into the main tree.
1315        if self.slot.take_outside_down() {
1316            self.hover.set_open(false);
1317            ctx.request_redraw();
1318        }
1319        result
1320    }
1321
1322    fn semantics(&self, _ctx: &mut SemanticsCtx) {
1323        // A floated pod contributes no accessibility nodes in v1 (see the
1324        // module docs), and this owner has nothing of its own to publish.
1325    }
1326
1327    visit_children!();
1328}
1329
1330/// Build the erased action-button view for one [`TooltipAction`].
1331fn action_view<State: 'static>(action: &TooltipAction<State>) -> AnyView<State> {
1332    let cb = action.on_press.clone();
1333    any(text_button(action.label.clone(), move |s: &mut State| {
1334        cb(s)
1335    }))
1336}
1337
1338/// The rich panel's content, floated inside a [`RampedPanel`]. Never
1339/// constructed by an app directly.
1340struct RichPanel<State: 'static> {
1341    title: Option<String>,
1342    message: String,
1343    actions: Vec<TooltipAction<State>>,
1344}
1345
1346impl<State: 'static> View<State> for RichPanel<State> {
1347    type Element = RichPanelWidget;
1348
1349    fn build(&self, ctx: &mut BuildCtx<'_>) -> RichPanelWidget {
1350        let title = self.title.as_ref().map(|t| {
1351            let mut run = TooltipTextRun::new();
1352            run.set_content(t);
1353            run
1354        });
1355        let mut body = TooltipTextRun::new();
1356        body.set_content(&self.message);
1357        let action_pods = self
1358            .actions
1359            .iter()
1360            .map(|a| build_child(&action_view(a), ctx))
1361            .collect();
1362        RichPanelWidget {
1363            title,
1364            body,
1365            action_pods,
1366            title_origin: Point::ORIGIN,
1367            body_origin: Point::ORIGIN,
1368        }
1369    }
1370
1371    fn rebuild(
1372        &self,
1373        prev: &Self,
1374        element: &mut RichPanelWidget,
1375        ctx: &mut BuildCtx<'_>,
1376    ) -> ChangeFlags {
1377        let mut flags = ChangeFlags::NONE;
1378        match (&self.title, &mut element.title) {
1379            (Some(t), Some(run)) => {
1380                if run.content != *t {
1381                    run.set_content(t);
1382                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1383                }
1384            }
1385            (Some(t), None) => {
1386                let mut run = TooltipTextRun::new();
1387                run.set_content(t);
1388                element.title = Some(run);
1389                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1390            }
1391            (None, Some(_)) => {
1392                element.title = None;
1393                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1394            }
1395            (None, None) => {}
1396        }
1397        if element.body.content != self.message {
1398            element.body.set_content(&self.message);
1399            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1400        }
1401
1402        let prev_views: Vec<AnyView<State>> = prev.actions.iter().map(action_view).collect();
1403        let next_views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1404        flags |= rebuild_children(
1405            &prev_views,
1406            &next_views,
1407            &mut element.action_pods,
1408            ctx,
1409            |v: &AnyView<State>| v,
1410            |_| None,
1411        );
1412        flags
1413    }
1414
1415    fn teardown(&self, element: &mut RichPanelWidget, ctx: &mut BuildCtx<'_>) {
1416        let views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1417        for (pod, view) in element.action_pods.iter_mut().zip(views.iter()) {
1418            teardown_child(view, pod, ctx);
1419        }
1420    }
1421}
1422
1423/// The retained widget for a [`RichPanel`].
1424struct RichPanelWidget {
1425    title: Option<TooltipTextRun>,
1426    body: TooltipTextRun,
1427    action_pods: Vec<ChildPod>,
1428    title_origin: Point,
1429    body_origin: Point,
1430}
1431
1432impl Widget for RichPanelWidget {
1433    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1434        let width_cap = bc.max().width.min(RICH_MAX_WIDTH);
1435        let inner_w = (width_cap - 2.0 * RICH_PAD).max(0.0);
1436        let mut content_w: f64 = 0.0;
1437        let mut y = RICH_PAD;
1438        let theme = Theme::from_layout_ctx(ctx);
1439        let (title_style, body_style) = (rich_title_style(theme), rich_body_style(theme));
1440
1441        if let Some(title) = &mut self.title {
1442            let sz = title.shape(ctx, &title_style, inner_w);
1443            self.title_origin = Point::new(RICH_PAD, y);
1444            content_w = content_w.max(sz.width);
1445            y += sz.height + RICH_TITLE_GAP;
1446        }
1447
1448        let body_sz = self.body.shape(ctx, &body_style, inner_w);
1449        self.body_origin = Point::new(RICH_PAD, y);
1450        content_w = content_w.max(body_sz.width);
1451        y += body_sz.height;
1452
1453        if !self.action_pods.is_empty() {
1454            y += RICH_ACTIONS_GAP;
1455            let mut x = RICH_PAD;
1456            let mut row_h: f64 = 0.0;
1457            for pod in &mut self.action_pods {
1458                let sz = pod.layout_child(
1459                    ctx,
1460                    &BoxConstraints::loose(Size::new(inner_w, f64::INFINITY)),
1461                );
1462                pod.set_origin(Point::new(x, y));
1463                x += sz.width;
1464                row_h = row_h.max(sz.height);
1465            }
1466            content_w = content_w.max(x - RICH_PAD);
1467            y += row_h;
1468        }
1469
1470        let panel_w = content_w.min(inner_w) + 2.0 * RICH_PAD;
1471        let panel_h = y + RICH_PAD;
1472        bc.constrain(Size::new(panel_w, panel_h))
1473    }
1474
1475    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1476        let theme = Theme::from_paint_ctx(ctx);
1477        let container = overlay_container(theme, OverlayContainer::Standard);
1478        let radius = theme.map_or(MaterialDimensions::RADIUS_MEDIUM, |t| t.shape.medium);
1479        let (origin, size) = (ctx.origin(), ctx.size());
1480
1481        if let Some((blur, y_offset, color)) = overlay_shadow(theme, OverlayElevation::Level2) {
1482            scene.draw_shadow(
1483                Point::new(origin.x, origin.y + y_offset),
1484                size,
1485                radius,
1486                blur,
1487                color,
1488            );
1489        }
1490        scene.fill_rounded_rect(origin, size, radius, container);
1491
1492        let title_ink = theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface);
1493        let body_ink = on_surface_variant(theme);
1494        if let Some(title) = &self.title {
1495            title.paint(
1496                Point::new(
1497                    origin.x + self.title_origin.x,
1498                    origin.y + self.title_origin.y,
1499                ),
1500                title_ink,
1501                scene,
1502            );
1503        }
1504        self.body.paint(
1505            Point::new(origin.x + self.body_origin.x, origin.y + self.body_origin.y),
1506            body_ink,
1507            scene,
1508        );
1509
1510        for pod in &mut self.action_pods {
1511            pod.paint_child(ctx, scene);
1512        }
1513    }
1514
1515    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1516        // `route_event` supplies what a hand-rolled first-Handled-wins loop
1517        // skips: the broadcast fan-out (folded in below), the `pod.contains`
1518        // hit test, focus-path gating for `Key`/`Ime`, and capture (`is_active`)
1519        // bookkeeping — see `docs/CODE_STANDARDS.md`'s Interaction Semantics.
1520        route_event(&mut self.action_pods, ctx, event)
1521    }
1522
1523    fn semantics(&self, ctx: &mut SemanticsCtx) {
1524        ctx.push_container(
1525            frust::authoring::Role::Group,
1526            |_node| {},
1527            |ctx| {
1528                for pod in &self.action_pods {
1529                    pod.semantics_child(ctx);
1530                }
1531            },
1532        );
1533    }
1534
1535    visit_children!(action_pods);
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540    use super::*;
1541    use frust::Brightness;
1542    use frust::authoring::text::FontFamily;
1543    use frust::authoring::{
1544        Point as AuthoringPoint, PointerButton, PointerEvent, Rect as AuthoringRect,
1545    };
1546    use frust_core::RenderRoot;
1547    use std::any::Any;
1548
1549    fn ft(ms: f64) -> FrameTime {
1550        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1551    }
1552
1553    // ---- TooltipHover -------------------------------------------------------
1554
1555    #[test]
1556    fn a_fresh_handle_is_closed_and_enabled() {
1557        let hover = TooltipHover::new();
1558        assert!(!hover.is_open());
1559        assert!(hover.is_enabled());
1560        assert_eq!(hover.via(), TooltipOpenVia::Hover);
1561    }
1562
1563    #[test]
1564    fn clones_share_the_same_cells() {
1565        let hover = TooltipHover::new();
1566        let clone = hover.clone();
1567        clone.set_open(true);
1568        assert!(hover.is_open(), "the clone shares the latch");
1569        hover.anchor().set(AuthoringRect::new(1.0, 2.0, 3.0, 4.0));
1570        assert_eq!(
1571            clone.anchor().rect(),
1572            AuthoringRect::new(1.0, 2.0, 3.0, 4.0)
1573        );
1574    }
1575
1576    // ---- Trigger: hover has no show-delay ------------------------------------
1577
1578    #[derive(Default)]
1579    struct AppState;
1580
1581    /// Mount the trigger over a fixed-size leaf, as the sole child of a
1582    /// `RenderRoot` — enough to drive `PaintCtx::is_hovered` through the real
1583    /// pipeline.
1584    struct Harness {
1585        root: RenderRoot<AppState, TooltipTriggerView<AppState>>,
1586        state: AppState,
1587        hover: TooltipHover,
1588        enabled: bool,
1589    }
1590
1591    const TRIGGER: Size = Size::new(80.0, 32.0);
1592
1593    impl Harness {
1594        fn new() -> Self {
1595            let mut h = Harness {
1596                root: RenderRoot::new(),
1597                state: AppState,
1598                hover: TooltipHover::new(),
1599                enabled: true,
1600            };
1601            h.pass();
1602            h.root.layout(Size::new(400.0, 400.0));
1603            h
1604        }
1605
1606        fn pass(&mut self) {
1607            let hover = self.hover.clone();
1608            let enabled = self.enabled;
1609            let mut logic = move |_s: &mut AppState| {
1610                tooltip_trigger(
1611                    &hover,
1612                    frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
1613                )
1614                .enabled(enabled)
1615            };
1616            self.root.rebuild(&mut logic, &mut self.state);
1617        }
1618
1619        fn paint(&mut self, ms: f64) {
1620            self.root.paint(&mut NullScene, ft(ms));
1621        }
1622
1623        fn event(&mut self, event: InputEvent) {
1624            self.root.event(&mut self.state, &event);
1625        }
1626    }
1627
1628    struct NullScene;
1629    impl PaintScene for NullScene {
1630        fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
1631        fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
1632    }
1633
1634    fn moved(x: f64, y: f64) -> InputEvent {
1635        InputEvent::Pointer(PointerEvent {
1636            phase: PointerPhase::Move,
1637            position: AuthoringPoint::new(x, y),
1638            button: PointerButton::Primary,
1639        })
1640    }
1641
1642    fn down(x: f64, y: f64) -> InputEvent {
1643        InputEvent::Pointer(PointerEvent {
1644            phase: PointerPhase::Down,
1645            position: AuthoringPoint::new(x, y),
1646            button: PointerButton::Primary,
1647        })
1648    }
1649
1650    fn up(x: f64, y: f64) -> InputEvent {
1651        InputEvent::Pointer(PointerEvent {
1652            phase: PointerPhase::Up,
1653            position: AuthoringPoint::new(x, y),
1654            button: PointerButton::Primary,
1655        })
1656    }
1657
1658    #[test]
1659    fn hover_opens_on_the_very_next_paint_zero_delay() {
1660        let mut h = Harness::new();
1661        h.paint(0.0);
1662        assert!(!h.hover.is_open());
1663        h.event(moved(10.0, 10.0));
1664        // No timer at all — the paint immediately following the hovering move
1665        // already opens it, matching upstream's synchronous `_show()`.
1666        h.paint(1.0);
1667        assert!(h.hover.is_open());
1668        assert_eq!(h.hover.via(), TooltipOpenVia::Hover);
1669    }
1670
1671    #[test]
1672    fn hover_closes_the_instant_the_pointer_leaves() {
1673        let mut h = Harness::new();
1674        h.event(moved(10.0, 10.0));
1675        h.paint(1.0);
1676        assert!(h.hover.is_open());
1677        h.event(moved(500.0, 500.0));
1678        h.paint(2.0);
1679        assert!(!h.hover.is_open());
1680    }
1681
1682    #[test]
1683    fn disabled_suppresses_hover_and_snaps_an_open_panel_shut() {
1684        let mut h = Harness::new();
1685        h.event(moved(10.0, 10.0));
1686        h.paint(1.0);
1687        assert!(h.hover.is_open());
1688        h.enabled = false;
1689        h.pass();
1690        h.root.layout(Size::new(400.0, 400.0));
1691        h.paint(2.0);
1692        assert!(!h.hover.is_open(), "disabling mid-show snaps it shut");
1693    }
1694
1695    // ---- Trigger: long-press threshold (frame clock) -------------------------
1696
1697    #[test]
1698    fn a_stationary_long_press_opens_after_the_threshold_via_housekeeping() {
1699        let mut h = Harness::new();
1700        h.event(down(10.0, 10.0));
1701        h.paint(0.0);
1702        let flags = h
1703            .root
1704            .paint(&mut NullScene, ft(LONG_PRESS_MS as f64 + 20.0));
1705        assert!(!h.hover.is_open(), "paint alone only latches, never fires");
1706        assert!(
1707            flags.needs_frame,
1708            "requests the follow-up frame that fires it"
1709        );
1710
1711        // Every shell runs rebuild (which drains the flush) before its own
1712        // paint — `crate::toggle_button`'s own Long-press section documents
1713        // why a plugin reaches this only through `frust::GestureDetector`.
1714        h.pass();
1715        assert!(
1716            h.hover.is_open(),
1717            "fires via Housekeeping, no further pointer event"
1718        );
1719        assert_eq!(h.hover.via(), TooltipOpenVia::LongPress);
1720    }
1721
1722    #[test]
1723    fn a_quick_release_before_the_threshold_never_opens() {
1724        let mut h = Harness::new();
1725        h.event(down(10.0, 10.0));
1726        h.paint(0.0);
1727        h.paint(100.0); // short of LONG_PRESS_MS
1728        h.event(up(11.0, 11.0));
1729        h.pass();
1730        assert!(!h.hover.is_open());
1731    }
1732
1733    // ---- Typeface: the panel runs' families follow the live theme ------------
1734
1735    /// A baseline theme whose `bodySmall`/`titleSmall`/`bodyMedium` roles name
1736    /// `plain`/`title`/`body`.
1737    fn theme_with(plain: FontFamily, title: FontFamily, body: FontFamily) -> Theme {
1738        let mut theme = crate::baseline();
1739        theme.type_scale.body_small.family = plain;
1740        theme.type_scale.title_small.family = title;
1741        theme.type_scale.body_medium.family = body;
1742        theme
1743    }
1744
1745    /// A rich panel with a title and no actions, built but not laid out.
1746    fn rich_panel_widget() -> RichPanelWidget {
1747        let view: RichPanel<AppState> = RichPanel {
1748            title: Some("Compose".into()),
1749            message: "Start a new draft.".into(),
1750            actions: Vec::new(),
1751        };
1752        let mut counter = 0u64;
1753        View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter))
1754    }
1755
1756    /// Lay `w` out against `tcx`, threading `theme` the way the render root
1757    /// does.
1758    fn layout_themed(w: &mut dyn Widget, tcx: &mut TextContext, theme: Option<&Theme>) {
1759        let mut ctx =
1760            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1761        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1762    }
1763
1764    #[test]
1765    fn layout_takes_each_panel_run_s_family_from_its_role() {
1766        let (plain, title, body) = (
1767            FontFamily::named("Plain Role Probe"),
1768            FontFamily::named("Title Role Probe"),
1769            FontFamily::named("Body Role Probe"),
1770        );
1771        let theme = theme_with(plain.clone(), title.clone(), body.clone());
1772        let mut tcx = TextContext::new();
1773
1774        let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1775        layout_themed(&mut plain_w, &mut tcx, Some(&theme));
1776        assert_eq!(plain_w.message.style.family, plain, "bodySmall");
1777        assert_eq!(plain_w.message.style.size, 12.0, "the token size stays");
1778
1779        let mut rich_w = rich_panel_widget();
1780        layout_themed(&mut rich_w, &mut tcx, Some(&theme));
1781        let title_run = rich_w.title.as_ref().expect("a title is set");
1782        assert_eq!(title_run.style.family, title, "titleSmall");
1783        assert_eq!(rich_w.body.style.family, body, "bodyMedium");
1784    }
1785
1786    #[test]
1787    fn without_a_theme_the_panel_runs_keep_the_unthemed_styles() {
1788        let plain_unthemed = TextStyle {
1789            weight: FontWeight::REGULAR,
1790            letter_spacing: 0.4,
1791            line_height: LineHeight::Absolute(16.0),
1792            ..TextStyle::new(12.0, Color::BLACK)
1793        };
1794        assert_eq!(plain_message_style(None), plain_unthemed);
1795        assert_eq!(rich_title_style(None).family, FontFamily::SystemUi);
1796        assert_eq!(rich_body_style(None).family, FontFamily::SystemUi);
1797
1798        let mut tcx = TextContext::new();
1799        let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1800        layout_themed(&mut plain_w, &mut tcx, None);
1801        assert_eq!(plain_w.message.style, plain_unthemed);
1802
1803        let mut rich_w = rich_panel_widget();
1804        layout_themed(&mut rich_w, &mut tcx, None);
1805        let title_run = rich_w.title.as_ref().expect("a title is set");
1806        assert_eq!(title_run.style, rich_title_style(None));
1807        assert_eq!(rich_w.body.style, rich_body_style(None));
1808    }
1809
1810    #[test]
1811    fn a_theme_swap_reshapes_the_cached_panel_runs() {
1812        let probe_a = || FontFamily::named("Tooltip Swap Probe A");
1813        let probe_b = || FontFamily::named("Tooltip Swap Probe B");
1814        let first = theme_with(probe_a(), probe_a(), probe_a());
1815        let second = theme_with(probe_b(), probe_b(), probe_b());
1816        let mut tcx = TextContext::new();
1817        let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1818        let mut rich_w = rich_panel_widget();
1819        layout_themed(&mut plain_w, &mut tcx, Some(&first));
1820        layout_themed(&mut rich_w, &mut tcx, Some(&first));
1821
1822        // Control: the same theme again reuses every cached run outright.
1823        let settled = tcx.shape_cache_stats();
1824        layout_themed(&mut plain_w, &mut tcx, Some(&first));
1825        layout_themed(&mut rich_w, &mut tcx, Some(&first));
1826        assert_eq!(
1827            tcx.shape_cache_stats(),
1828            settled,
1829            "an unchanged theme reshapes nothing"
1830        );
1831
1832        layout_themed(&mut plain_w, &mut tcx, Some(&second));
1833        layout_themed(&mut rich_w, &mut tcx, Some(&second));
1834        assert_eq!(
1835            tcx.shape_cache_stats().shapes,
1836            settled.shapes + 3,
1837            "a family swap must reshape the plain message, rich title and rich body"
1838        );
1839    }
1840
1841    // ---- Plain: auto-dismiss timeline (frame clock) --------------------------
1842
1843    fn plain_panel_widget(via: TooltipOpenVia) -> (PlainPanelWidget, TooltipHover) {
1844        let hover = TooltipHover::new();
1845        hover.set_open(true);
1846        hover.set_via(via);
1847        let view = PlainPanel {
1848            message: "hi".into(),
1849            hover: hover.clone(),
1850        };
1851        let mut counter = 0u64;
1852        let w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1853        (w, hover)
1854    }
1855
1856    #[test]
1857    fn a_long_press_opened_plain_tooltip_auto_dismisses_at_extra_long_4() {
1858        let (mut w, hover) = plain_panel_widget(TooltipOpenVia::LongPress);
1859        let mut tcx = TextContext::new();
1860        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1861        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1862
1863        let mut scene = NullScene;
1864        let mut ctx0 = PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(0.0));
1865        w.paint(&mut ctx0, &mut scene);
1866        assert!(hover.is_open(), "not yet at the delay");
1867
1868        let mut mid = PaintCtx::for_test(
1869            AuthoringPoint::ORIGIN,
1870            Size::new(400.0, 400.0),
1871            ft(PLAIN_DISMISS_MS - 1.0),
1872        );
1873        w.paint(&mut mid, &mut scene);
1874        assert!(hover.is_open(), "just short of it");
1875
1876        let mut done = PaintCtx::for_test(
1877            AuthoringPoint::ORIGIN,
1878            Size::new(400.0, 400.0),
1879            ft(PLAIN_DISMISS_MS),
1880        );
1881        w.paint(&mut done, &mut scene);
1882        assert!(!hover.is_open(), "1000ms (EXTRA_LONG_4) auto-dismisses it");
1883    }
1884
1885    #[test]
1886    fn a_hover_opened_plain_tooltip_never_auto_dismisses() {
1887        let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1888        let mut tcx = TextContext::new();
1889        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1890        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1891        let mut scene = NullScene;
1892        let mut ctx = PaintCtx::for_test(
1893            AuthoringPoint::ORIGIN,
1894            Size::new(400.0, 400.0),
1895            ft(PLAIN_DISMISS_MS * 10.0),
1896        );
1897        w.paint(&mut ctx, &mut scene);
1898        assert!(
1899            hover.is_open(),
1900            "hover-shown plain tooltips have no timer at all"
1901        );
1902    }
1903
1904    /// A pod now outlives any one show — it stays mounted and only its
1905    /// registration comes and goes — so the capture that used to happen in
1906    /// `View::build` has to happen on the first paint of each show instead.
1907    /// This pins the re-arm: one widget instance, two shows, opened two
1908    /// different ways, each getting the timer upstream gives it.
1909    #[test]
1910    fn one_pod_across_two_shows_re_arms_the_timer_for_each() {
1911        let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1912        let mut tcx = TextContext::new();
1913        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1914        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1915        let mut scene = NullScene;
1916        let mut paint_at = |w: &mut PlainPanelWidget, ms: f64| {
1917            let mut ctx =
1918                PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(ms));
1919            w.paint(&mut ctx, &mut scene);
1920        };
1921
1922        // Show one, opened by hover: no timer, however long it stays up.
1923        paint_at(&mut w, 0.0);
1924        paint_at(&mut w, PLAIN_DISMISS_MS * 10.0);
1925        assert!(hover.is_open(), "a hover-shown plain tooltip has no timer");
1926
1927        // Show two, on the SAME widget, opened by long-press this time.
1928        hover.set_open(false);
1929        hover.set_via(TooltipOpenVia::LongPress);
1930        hover.set_open(true);
1931        let base = PLAIN_DISMISS_MS * 20.0;
1932        paint_at(&mut w, base);
1933        assert!(hover.is_open(), "the second show has only just started");
1934        paint_at(&mut w, base + PLAIN_DISMISS_MS - 1.0);
1935        assert!(
1936            hover.is_open(),
1937            "and its timer is measured from ITS own first paint, not the pod's build"
1938        );
1939        paint_at(&mut w, base + PLAIN_DISMISS_MS);
1940        assert!(!hover.is_open(), "1000ms into the second show, it goes");
1941    }
1942
1943    // ---- Rich: slots, dismiss rules ---------------------------------------------
1944
1945    #[test]
1946    fn rich_never_arms_the_plain_auto_dismiss_timer() {
1947        // A rich panel's own widget carries no timer field at all — the
1948        // absence itself is the pin: `RichPanelWidget` has no
1949        // `dismiss_via_long_press`/`timer_start` the way `PlainPanelWidget`
1950        // does, so there is nothing to elapse. Exercised indirectly: a
1951        // long-press-opened rich tooltip stays open across many frames.
1952        let hover = TooltipHover::new();
1953        hover.set_open(true);
1954        hover.set_via(TooltipOpenVia::LongPress);
1955        let view: RichPanel<AppState> = RichPanel {
1956            title: Some("Title".into()),
1957            message: "Body copy".into(),
1958            actions: vec![],
1959        };
1960        let mut counter = 0u64;
1961        let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1962        let mut tcx = TextContext::new();
1963        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1964        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1965        let mut scene = NullScene;
1966        let mut ctx = PaintCtx::for_test(
1967            AuthoringPoint::ORIGIN,
1968            Size::new(400.0, 400.0),
1969            ft(PLAIN_DISMISS_MS * 5.0),
1970        );
1971        w.paint(&mut ctx, &mut scene);
1972        assert!(hover.is_open(), "no auto-dismiss timer for rich, ever");
1973    }
1974
1975    // ---- Rich: action-row routing (`route_event`, not a hand-rolled loop) ---
1976    //
1977    // Regression coverage for `RichPanelWidget::event` forwarding via
1978    // `route_event` instead of a first-`Handled`-wins loop over
1979    // `ChildPod::event_child` — the loop skipped `route_event`'s hit test,
1980    // capture bookkeeping, and per-child arming, so a press anywhere in the
1981    // panel armed only action #0 and any later action was unreachable.
1982
1983    #[derive(Default)]
1984    struct Presses {
1985        fired: Vec<usize>,
1986    }
1987
1988    /// A two-(or `count`-)action `RichPanelWidget`, laid out at a fixed width —
1989    /// real post-layout action-pod bounds, not hardcoded metrics.
1990    fn rich_panel_with_actions(count: usize) -> RichPanelWidget {
1991        let actions: Vec<TooltipAction<Presses>> = (0..count)
1992            .map(|i| {
1993                tooltip_action(format!("Action {i}"), move |s: &mut Presses| {
1994                    s.fired.push(i)
1995                })
1996            })
1997            .collect();
1998        let view: RichPanel<Presses> = RichPanel {
1999            title: Some("Title".into()),
2000            message: "Body copy long enough to occupy real space above the actions".into(),
2001            actions,
2002        };
2003        let mut counter = 0u64;
2004        let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
2005        let mut tcx = TextContext::new();
2006        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2007        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
2008        w
2009    }
2010
2011    fn dispatch_rich(
2012        w: &mut RichPanelWidget,
2013        state: &mut Presses,
2014        phase: PointerPhase,
2015        pos: AuthoringPoint,
2016    ) -> EventResult {
2017        let state_any: &mut dyn Any = state;
2018        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 400.0));
2019        w.event(
2020            &mut ctx,
2021            &InputEvent::Pointer(PointerEvent {
2022                phase,
2023                position: pos,
2024                button: PointerButton::Primary,
2025            }),
2026        )
2027    }
2028
2029    /// A point inside the panel's title/body region, above every action pod.
2030    fn body_point(w: &RichPanelWidget) -> AuthoringPoint {
2031        AuthoringPoint::new(RICH_PAD + 1.0, w.body_origin.y + 1.0)
2032    }
2033
2034    /// The center of action pod `index`, post-layout.
2035    fn action_center(w: &RichPanelWidget, index: usize) -> AuthoringPoint {
2036        let pod = &w.action_pods[index];
2037        let (o, s) = (pod.origin(), pod.size());
2038        AuthoringPoint::new(o.x + s.width / 2.0, o.y + s.height / 2.0)
2039    }
2040
2041    #[test]
2042    fn a_press_on_the_body_fires_no_action_and_captures_nothing() {
2043        let mut w = rich_panel_with_actions(2);
2044        let mut state = Presses::default();
2045        let p = body_point(&w);
2046        dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2047        dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2048        assert!(state.fired.is_empty(), "no action pod sits under the body");
2049        assert!(
2050            w.action_pods.iter().all(|pod| !pod.is_active()),
2051            "nothing captured a press outside every action's own bounds"
2052        );
2053    }
2054
2055    #[test]
2056    fn press_release_on_the_second_action_fires_it() {
2057        let mut w = rich_panel_with_actions(2);
2058        let mut state = Presses::default();
2059        let p = action_center(&w, 1);
2060        dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2061        assert!(
2062            w.action_pods[1].is_active(),
2063            "the hit-tested pod captures the press, not action #0"
2064        );
2065        dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2066        assert_eq!(state.fired, vec![1], "action #1 fires — previously dead");
2067    }
2068
2069    #[test]
2070    fn down_on_body_up_over_action_0_does_not_fire() {
2071        let mut w = rich_panel_with_actions(2);
2072        let mut state = Presses::default();
2073        let down_p = body_point(&w);
2074        let up_p = action_center(&w, 0);
2075        dispatch_rich(&mut w, &mut state, PointerPhase::Down, down_p);
2076        dispatch_rich(&mut w, &mut state, PointerPhase::Up, up_p);
2077        assert!(
2078            state.fired.is_empty(),
2079            "up-inside without down-inside must not fire (the press contract)"
2080        );
2081    }
2082
2083    // ---- Floated through the portal -----------------------------------------
2084    //
2085    // Everything below runs the real pipeline: a `RenderRoot` over the mount an
2086    // app actually writes (a page, the trigger and the panel as siblings under
2087    // one `Stack`), so the root's own overlay post-pass and pre-pass are what is
2088    // under test, not this module's arithmetic about them.
2089
2090    const WINDOW: Size = Size::new(400.0, 600.0);
2091    const RICH_TITLE: &str = "Compose";
2092    const RICH_BODY: &str = "Start a new draft with expressive defaults.";
2093    const RICH_ACTION: &str = "Got it";
2094
2095    /// State for the portal harness: what reached the page underneath, and what
2096    /// reached the rich panel's own action row.
2097    #[derive(Default)]
2098    struct PageState {
2099        pressed: usize,
2100        action_fired: usize,
2101    }
2102
2103    /// A page filling whatever area it is given, counting the presses that
2104    /// reach it — so a test can assert "the press got through to the widget
2105    /// beneath", not merely the weaker "nobody reported handling it".
2106    struct PageProbe;
2107
2108    /// The retained widget for a [`PageProbe`].
2109    struct PageProbeWidget;
2110
2111    impl View<PageState> for PageProbe {
2112        type Element = PageProbeWidget;
2113
2114        fn build(&self, _ctx: &mut BuildCtx<'_>) -> PageProbeWidget {
2115            PageProbeWidget
2116        }
2117
2118        fn rebuild(
2119            &self,
2120            _prev: &Self,
2121            _element: &mut PageProbeWidget,
2122            _ctx: &mut BuildCtx<'_>,
2123        ) -> ChangeFlags {
2124            ChangeFlags::NONE
2125        }
2126
2127        fn teardown(&self, _element: &mut PageProbeWidget, _ctx: &mut BuildCtx<'_>) {}
2128    }
2129
2130    impl Widget for PageProbeWidget {
2131        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2132            bc.max()
2133        }
2134
2135        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2136
2137        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2138            if let InputEvent::Pointer(p) = event
2139                && p.phase == PointerPhase::Down
2140            {
2141                ctx.state_mut::<PageState>().pressed += 1;
2142                return EventResult::Handled;
2143            }
2144            EventResult::Ignored
2145        }
2146
2147        fn semantics(&self, _ctx: &mut SemanticsCtx) {}
2148
2149        visit_children!();
2150    }
2151
2152    /// What actually reached the scene: the rounded rects a panel paints (i.e.
2153    /// whether the panel is on screen at all, and where), and the alpha of any
2154    /// composited layer (i.e. the entrance ramp).
2155    ///
2156    /// Neither the page nor the trigger paints anything, so every recorded
2157    /// rounded rect belongs to a floated panel, and `rrects[0]` is that panel's
2158    /// own container — painted before its text and before any action button.
2159    #[derive(Default)]
2160    struct Recorder {
2161        rrects: Vec<(AuthoringPoint, Size)>,
2162        layers: Vec<f32>,
2163    }
2164
2165    impl Recorder {
2166        /// The floated panel's own rect, in window space.
2167        fn panel(&self) -> AuthoringRect {
2168            let (origin, size) = self.rrects[0];
2169            AuthoringRect::from_origin_size(origin, size)
2170        }
2171    }
2172
2173    impl PaintScene for Recorder {
2174        fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
2175        fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
2176        fn fill_rounded_rect(&mut self, origin: AuthoringPoint, size: Size, _r: f64, _c: Color) {
2177            self.rrects.push((origin, size));
2178        }
2179        fn push_layer(&mut self, _o: AuthoringPoint, _s: Size, alpha: f32) {
2180            self.layers.push(alpha);
2181        }
2182        fn pop_layer(&mut self) {}
2183        fn push_transform(&mut self, _t: Affine) {}
2184        fn pop_transform(&mut self) {}
2185    }
2186
2187    /// The mount an app writes: a full-area page, the trigger, and the panel,
2188    /// all siblings of one [`frust::Stack`]. The panel no longer has to be that
2189    /// `Stack`'s topmost child — paint order comes from the declared band — and
2190    /// it is deliberately *not* topmost here, which the old full-area host would
2191    /// not have survived.
2192    struct PortalHarness {
2193        root: RenderRoot<PageState, frust::StackView<PageState>>,
2194        state: PageState,
2195        tcx: TextContext,
2196        hover: TooltipHover,
2197        rich: bool,
2198    }
2199
2200    impl PortalHarness {
2201        fn new(rich: bool) -> Self {
2202            let mut h = PortalHarness {
2203                root: RenderRoot::new(),
2204                state: PageState::default(),
2205                tcx: TextContext::new(),
2206                hover: TooltipHover::new(),
2207                rich,
2208            };
2209            h.frame(0.0);
2210            h
2211        }
2212
2213        fn view(hover: &TooltipHover, rich: bool) -> frust::StackView<PageState> {
2214            let panel: AnyView<PageState> = if rich {
2215                any(rich_tooltip(hover, RICH_BODY)
2216                    .title(RICH_TITLE)
2217                    .action(tooltip_action(RICH_ACTION, |s: &mut PageState| {
2218                        s.action_fired += 1
2219                    })))
2220            } else {
2221                any(tooltip(hover, RICH_TITLE))
2222            };
2223            frust::stack()
2224                .child(PageProbe)
2225                .child(tooltip_trigger(
2226                    hover,
2227                    frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
2228                ))
2229                .child(panel)
2230        }
2231
2232        /// One whole frame — rebuild, layout, paint — answering with what was
2233        /// painted.
2234        fn frame(&mut self, ms: f64) -> Recorder {
2235            let hover = self.hover.clone();
2236            let rich = self.rich;
2237            let mut logic = move |_s: &mut PageState| Self::view(&hover, rich);
2238            self.root.rebuild(&mut logic, &mut self.state);
2239            self.root
2240                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
2241            self.repaint(ms)
2242        }
2243
2244        /// A paint and nothing else: no rebuild, no relayout. What a shell does
2245        /// when a widget asked for a frame without the tree changing.
2246        fn repaint(&mut self, ms: f64) -> Recorder {
2247            let mut rec = Recorder::default();
2248            self.root.paint(&mut rec, ft(ms));
2249            rec
2250        }
2251
2252        /// Open the panel by hovering the trigger, and let the entrance settle.
2253        fn open_by_hover(&mut self) -> Recorder {
2254            self.root.event(&mut self.state, &moved(10.0, 10.0));
2255            self.frame(0.0);
2256            self.frame(SETTLED_MS)
2257        }
2258
2259        fn press(&mut self, x: f64, y: f64) -> bool {
2260            self.root.event(&mut self.state, &down(x, y)).handled
2261        }
2262    }
2263
2264    /// Comfortably past [`MaterialMotion::SHORT_4`], so the entrance has
2265    /// settled and a recorded rect is the panel's untransformed placement.
2266    const SETTLED_MS: f64 = 1_000.0;
2267
2268    #[test]
2269    fn a_closed_tooltip_registers_nothing_and_an_opened_one_registers_its_panel() {
2270        let mut h = PortalHarness::new(false);
2271        let closed = h.frame(0.0);
2272        assert!(
2273            closed.rrects.is_empty(),
2274            "a closed tooltip floats nothing at all"
2275        );
2276        let open = h.open_by_hover();
2277        assert!(!open.rrects.is_empty(), "hovering floats the panel");
2278    }
2279
2280    /// Deliverable of the port this test exists for: the panel goes away with
2281    /// no exit ramp — and, because the registration is per paint pass and the
2282    /// latch is read live, it goes away on the next *paint*, without waiting
2283    /// for the rebuild the old `View::Element` swap needed. The latch is not
2284    /// reactive, so that rebuild was never guaranteed to come.
2285    #[test]
2286    fn closing_the_latch_takes_the_panel_away_on_the_very_next_paint_with_no_exit_ramp() {
2287        let mut h = PortalHarness::new(false);
2288        assert!(!h.open_by_hover().rrects.is_empty(), "open first");
2289
2290        h.hover.set_open(false);
2291        let after = h.repaint(SETTLED_MS + 16.0);
2292        assert!(
2293            after.rrects.is_empty(),
2294            "gone on the paint that follows the close — no rebuild needed"
2295        );
2296        assert!(
2297            after.layers.is_empty(),
2298            "and nothing was composited on the way out: there is no exit ramp"
2299        );
2300
2301        // The rebuild a shell would have run next changes nothing: there is no
2302        // ramp left to truncate and no placeholder to swap back in.
2303        let rebuilt = h.frame(SETTLED_MS + 32.0);
2304        assert!(rebuilt.rrects.is_empty());
2305        assert!(rebuilt.layers.is_empty());
2306    }
2307
2308    #[test]
2309    fn the_entrance_ramp_still_plays_and_then_settles() {
2310        let mut h = PortalHarness::new(false);
2311        h.root.event(&mut h.state, &moved(10.0, 10.0));
2312        // The paint that opens it seeds the driver; the next one is already
2313        // part-way in.
2314        h.frame(0.0);
2315        let entering = h.repaint(MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
2316        assert!(
2317            entering.layers.iter().any(|a| *a > 0.0 && *a < 1.0),
2318            "the entrance composites a part-way layer: {:?}",
2319            entering.layers
2320        );
2321        let settled = h.repaint(SETTLED_MS);
2322        assert!(
2323            settled.layers.is_empty(),
2324            "and stops compositing once it has arrived: {:?}",
2325            settled.layers
2326        );
2327        assert!(!settled.rrects.is_empty(), "still on screen, fully present");
2328    }
2329
2330    /// The whole point of the port, for the plain panel: a press that lands on
2331    /// the panel reaches the page *underneath* it.
2332    ///
2333    /// Negative control performed by hand while writing this: with
2334    /// `TooltipView::configure`'s `OverlayInput::Transparent` changed to
2335    /// `Interactive`, this test fails on the `state.pressed` assertion, which
2336    /// stays `0` — the root's overlay pre-pass routes the press into the
2337    /// panel's own pod instead of letting it through. So the assertion below is
2338    /// pinned to the registration, not merely satisfied by it.
2339    #[test]
2340    fn a_press_on_an_open_plain_tooltip_reaches_the_page_beneath_it() {
2341        let mut h = PortalHarness::new(false);
2342        let panel = h.open_by_hover().panel();
2343        let at = panel.center();
2344        let handled = h.press(at.x, at.y);
2345        assert_eq!(
2346            h.state.pressed, 1,
2347            "the page under the panel actually fired"
2348        );
2349        assert!(handled, "and reported the press as handled, by the page");
2350        assert!(
2351            h.hover.is_open(),
2352            "a plain tooltip is not dismissed by a tap: it closes on hover-exit \
2353             or on its own timer"
2354        );
2355    }
2356
2357    /// The rich panel's half of the same promise. It is *not* input-transparent
2358    /// — its action row has to be reachable — so what changed for it is the
2359    /// press **outside**: upstream's translucent dismiss layer tells the panel
2360    /// and lets the press through, where the anchored host swallowed it.
2361    #[test]
2362    fn a_press_outside_an_open_rich_tooltip_closes_it_and_still_reaches_the_page() {
2363        let mut h = PortalHarness::new(true);
2364        let panel = h.open_by_hover().panel();
2365        let away = AuthoringPoint::new(WINDOW.width - 20.0, WINDOW.height - 20.0);
2366        assert!(
2367            !panel.contains(away),
2368            "the press has to land outside the panel for this to mean anything"
2369        );
2370
2371        h.press(away.x, away.y);
2372        assert!(!h.hover.is_open(), "the outside press dismisses it");
2373        assert_eq!(
2374            h.state.pressed, 1,
2375            "and is NOT swallowed: the page under it fired too"
2376        );
2377    }
2378
2379    /// And the action row upstream gives a rich tooltip still works through the
2380    /// portal: the root hit-tests the registered rect first and routes the
2381    /// press into the pod, so the button fires and the page beneath never sees
2382    /// it.
2383    #[test]
2384    fn a_press_on_an_open_rich_tooltips_action_fires_it_and_not_the_page() {
2385        let mut h = PortalHarness::new(true);
2386        let panel = h.open_by_hover().panel();
2387        let local = rich_probe_action_center(0);
2388        let at = AuthoringPoint::new(panel.x0 + local.x, panel.y0 + local.y);
2389        assert!(panel.contains(at), "the action sits inside the panel");
2390
2391        h.root.event(&mut h.state, &down(at.x, at.y));
2392        h.root.event(&mut h.state, &up(at.x, at.y));
2393        assert_eq!(h.state.action_fired, 1, "the action button fired");
2394        assert_eq!(
2395            h.state.pressed, 0,
2396            "and the page beneath was not pressed through the panel"
2397        );
2398    }
2399
2400    /// A [`RichPanelWidget`] carrying the harness's own slots, laid out against
2401    /// the same window-loose constraints [`OverlaySlot::layout`] gives the
2402    /// floated pod — so an action pod's real post-layout centre can be lifted
2403    /// into window space by adding the placed panel's origin, with no hardcoded
2404    /// metric anywhere.
2405    fn rich_probe_action_center(index: usize) -> AuthoringPoint {
2406        let view: RichPanel<PageState> = RichPanel {
2407            title: Some(RICH_TITLE.into()),
2408            message: RICH_BODY.into(),
2409            actions: vec![tooltip_action(RICH_ACTION, |s: &mut PageState| {
2410                s.action_fired += 1
2411            })],
2412        };
2413        let mut counter = 0u64;
2414        let mut w = View::<PageState>::build(&view, &mut BuildCtx::new(&mut counter));
2415        let mut tcx = TextContext::new();
2416        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2417        w.layout(&mut lctx, &BoxConstraints::loose(WINDOW));
2418        action_center(&w, index)
2419    }
2420
2421    #[test]
2422    fn hover_shows_the_plain_panel_and_the_rich_panel_alike() {
2423        for rich in [false, true] {
2424            let mut h = PortalHarness::new(rich);
2425            assert!(
2426                !h.open_by_hover().rrects.is_empty(),
2427                "hovering the trigger shows the panel (rich: {rich})"
2428            );
2429        }
2430    }
2431
2432    #[test]
2433    fn a_long_press_shows_the_plain_panel_and_the_rich_panel_alike() {
2434        for rich in [false, true] {
2435            let mut h = PortalHarness::new(rich);
2436            h.root.event(&mut h.state, &down(10.0, 10.0));
2437            h.frame(0.0);
2438            // Paint alone only latches; the rebuild that follows is what drains
2439            // the flush and fires the long press (see the trigger's own test).
2440            h.repaint(LONG_PRESS_MS as f64 + 20.0);
2441            let shown = h.frame(LONG_PRESS_MS as f64 + 20.0);
2442            assert_eq!(
2443                h.hover.via(),
2444                TooltipOpenVia::LongPress,
2445                "opened by the long press (rich: {rich})"
2446            );
2447            assert!(
2448                !shown.rrects.is_empty(),
2449                "and the panel is floated for it (rich: {rich})"
2450            );
2451        }
2452    }
2453
2454    // ---- Attribution smoke: MaterialTokens-only ------------------------------
2455
2456    #[test]
2457    fn plain_and_rich_resolve_off_the_theme_not_literals() {
2458        let light = crate::baseline().with_brightness(Brightness::Light);
2459        let dark = crate::baseline().with_brightness(Brightness::Dark);
2460        assert_ne!(
2461            light.scheme().inverse_surface,
2462            dark.scheme().inverse_surface
2463        );
2464        assert_ne!(
2465            overlay_container(Some(&light), OverlayContainer::Standard),
2466            overlay_container(Some(&dark), OverlayContainer::Standard)
2467        );
2468    }
2469}