Skip to main content

teksilo_widgets/tooltip/
composite.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `CompositeTooltipWidget` — third-tier tooltip that hosts an arbitrary
5//! widget tree as its body.
6//!
7//! Where `TooltipWidget` is a single line of text and `RichTooltipWidget`
8//! renders a structured `TooltipContent` (body + shortcut chip + "more"
9//! disclosure with inline markup), `CompositeTooltipWidget` accepts any
10//! `impl Widget + 'static` and paints it inside the same chrome with a
11//! larger surface budget — the Crusader Kings 3 style: tabbed sections,
12//! charts, progress bars, conditional rows, dynamic numeric values.
13//!
14//! "Primary-only" by construction: the widget has no inline-markup body
15//! and no registry key, so it cannot be the target of a `[label](:key)`
16//! cascade from a rich tooltip. Child widgets *inside* the body keep
17//! their own `.tooltip(...)` / `.rich_tooltip(...)` setters and cascade
18//! normally as ordinary widget composition.
19//!
20//! Reuses the `RichTooltipWidget` dwell-to-sticky machinery: at 2 s
21//! dwell the role flips `Tooltip → Dialog`, dismiss swaps to
22//! `EscapeOrClickOutside`, and the surface becomes Tab-reachable so
23//! rare-but-allowed interactive descendants (a "Pin" button, an
24//! internal `TabWidget`'s tab strip) work cleanly.
25
26use std::cell::Cell;
27use std::rc::Rc;
28use std::time::Instant;
29
30use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
31use teksilo_core::accessibility::AccessNodeBuilder;
32use teksilo_core::build_context::BuildContext;
33use teksilo_core::signal::Signal;
34use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
35use teksilo_core::widget_builder::HandlerSet;
36use teksilo_core::widget_id::WidgetId;
37use teksilo_i18n::LocalizedString;
38use teksilo_tokens::{CornerRadius, TextRole};
39
40use crate::primitives::{Grid, Padding, Spacer, TrackSize};
41use crate::scroll_area::{ScrollArea, ScrollBarPolicy};
42use crate::tooltip::dwell_indicator::DwellIndicator;
43// Step granularity is shared with `RichTooltipWidget`'s indicator (0..=4) —
44// imported, not restated, so the two tiers cannot drift apart.
45use crate::tooltip::rich::{DWELL_STEP_DURATION, DWELL_STEPS};
46
47/// Composite tooltip surface — hosts an arbitrary widget body with the
48/// same dwell-to-sticky promotion as the rich tooltip.
49pub struct CompositeTooltipWidget {
50    body: Option<Box<dyn Widget>>,
51    body_id: Option<WidgetId>,
52    /// The padded scroll viewport holding the body, and the footer row that
53    /// carries the dwell indicator — placed by hand, see `place_children`.
54    padded_id: Option<WidgetId>,
55    footer_id: Option<WidgetId>,
56    /// The `ScrollArea` wrapping the body. Kept only so `layout_response` can discount its
57    /// placeholder intrinsic height — see there.
58    scrolled_id: Option<WidgetId>,
59    access_label: Option<String>,
60    max_width_override: Option<f32>,
61    max_height_override: Option<f32>,
62    dwell_step: Signal<u32>,
63    sticky: Signal<bool>,
64    /// Whether this surface offers dwell-to-sticky promotion at all.
65    ///
66    /// A composite tooltip is just as useful as a *read-only* surface — a fact
67    /// sheet, a row's card — where there is nothing to reach into and nothing
68    /// to pin. There the dwell machinery is all cost: a countdown indicator
69    /// promising an interaction that does not exist, a surface that outlives
70    /// the pointer, and a `Role::Dialog` for assistive tech to announce.
71    /// Turning it off makes the tier behave like a richer plain tooltip.
72    sticky_enabled: bool,
73    shown_at_sink: Rc<Cell<Option<Instant>>>,
74}
75
76impl Default for CompositeTooltipWidget {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl std::fmt::Debug for CompositeTooltipWidget {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("CompositeTooltipWidget")
85            .field("has_body", &self.body.is_some())
86            .field("access_label", &self.access_label)
87            .field("max_width_override", &self.max_width_override)
88            .field("max_height_override", &self.max_height_override)
89            .field("sticky_enabled", &self.sticky_enabled)
90            .finish()
91    }
92}
93
94impl CompositeTooltipWidget {
95    pub fn new() -> Self {
96        Self {
97            body: None,
98            body_id: None,
99            padded_id: None,
100            footer_id: None,
101            scrolled_id: None,
102            access_label: None,
103            max_width_override: None,
104            max_height_override: None,
105            dwell_step: Signal::new(0),
106            sticky: Signal::new(false),
107            // Kept on by default: it is what the tier has always done, and a
108            // body with something to reach into is the case that needs it.
109            sticky_enabled: true,
110            shown_at_sink: Rc::new(Cell::new(None)),
111        }
112    }
113
114    /// Set the tooltip body. Replaces any previously set body.
115    pub fn content(mut self, body: impl Widget + 'static) -> Self {
116        self.body = Some(Box::new(body));
117        self
118    }
119
120    /// Set the tooltip body from an already-boxed widget. Used by the
121    /// per-widget `.composite_tooltip(...)` setters that store
122    /// `Box<dyn Widget>` and forward through `attach_composite_tooltip_boxed`.
123    pub fn content_boxed(mut self, body: Box<dyn Widget>) -> Self {
124        self.body = Some(body);
125        self
126    }
127
128    /// Accessibility label (used for `set_name` on the AT node — the
129    /// `Role::Tooltip`/`Role::Dialog` would otherwise be unnamed).
130    pub fn access_label(mut self, label: impl Into<LocalizedString>) -> Self {
131        let ls: LocalizedString = label.into();
132        self.access_label = Some(ls.resolve_now());
133        self
134    }
135
136    /// Override the per-theme `composite_tooltip.max_width`.
137    /// Whether the surface offers dwell-to-sticky promotion. Default `true`.
138    ///
139    /// Turn it **off** for a read-only body — a fact sheet, a data-view row's
140    /// card. Three things follow, all of them the point:
141    ///
142    /// * no `DwellIndicator` is built, so nothing counts down towards an
143    ///   interaction that does not exist (and the footer disappears with it,
144    ///   letting the surface hug its content);
145    /// * the entry is registered with no `sticky_after`, so the tip never
146    ///   promotes, never outlives the pointer, and stays a `Role::Tooltip`
147    ///   rather than becoming a `Dialog` for assistive tech to announce;
148    /// * and, following the plain tier, it is not surfaced by keyboard focus —
149    ///   its text reaches assistive tech through the anchor's own description
150    ///   instead, which is the W3C-recommended route for supplementary hints.
151    ///
152    /// Callers reaching the tier through a widget's `.composite_tooltip(...)`
153    /// setter get the sticky default; opt out by building the widget yourself
154    /// and attaching it with
155    /// [`attach_composite_tooltip_widget_with_placement`](crate::tooltip::attach_composite_tooltip_widget_with_placement).
156    pub fn sticky(mut self, on: bool) -> Self {
157        self.sticky_enabled = on;
158        self
159    }
160
161    /// Whether dwell-to-sticky promotion is enabled — what an attach helper
162    /// consults to decide the entry's `sticky_after`.
163    pub fn sticky_enabled(&self) -> bool {
164        self.sticky_enabled
165    }
166
167    pub fn max_width(mut self, w: f32) -> Self {
168        self.max_width_override = Some(w);
169        self
170    }
171
172    /// Override the per-theme `composite_tooltip.max_height`.
173    pub fn max_height(mut self, h: f32) -> Self {
174        self.max_height_override = Some(h);
175        self
176    }
177
178    /// Cloneable `shown_at_sink` for the attach helper to thread into
179    /// `attach_tooltip_with_sticky_sink`.
180    pub fn shown_at_sink(&self) -> Rc<Cell<Option<Instant>>> {
181        self.shown_at_sink.clone()
182    }
183
184    fn tick_dwell(&self) {
185        let Some(shown_at) = self.shown_at_sink.get() else {
186            if self.dwell_step.get() != 0 {
187                self.dwell_step.set(0);
188            }
189            if self.sticky.get() {
190                self.sticky.set(false);
191            }
192            return;
193        };
194        let elapsed = Instant::now().saturating_duration_since(shown_at);
195        let new_step =
196            ((elapsed.as_millis() / DWELL_STEP_DURATION.as_millis()) as u32).min(DWELL_STEPS);
197        if self.dwell_step.get() != new_step {
198            self.dwell_step.set(new_step);
199        }
200        let now_sticky = new_step >= DWELL_STEPS;
201        if self.sticky.get() != now_sticky {
202            self.sticky.set(now_sticky);
203        }
204    }
205}
206
207/// Gap between the body viewport and the dwell-indicator footer — what the
208/// `VStack` spacing used to supply before the column was placed by hand.
209const FOOTER_GAP: f32 = 6.0;
210
211impl Widget for CompositeTooltipWidget {
212    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
213        use crate::styles::recipe_tooltip_style as tt;
214        let self_id = ctx.self_id();
215
216        // Body — mounted once and reused across rebuilds. `build()` can
217        // re-run (`rebuild_single_widget`), and taking `self.body`
218        // unconditionally would collapse a rebuilt composite to a Spacer
219        // (the body box is gone after the first take). So we take only on
220        // first build, store the id, and reuse it on every later build —
221        // the same shape `ModalContainer` uses for `pending_content` →
222        // `content_id` (see dialog.rs). Reuse is only sound because
223        // `preserves_children_on_rebuild()` returns `true` below: the
224        // body lands under the ScrollArea subtree, which a normal rebuild
225        // would `destroy_subtree`, leaving a dangling id. Preserving the
226        // subtree keeps `body_id` alive so the reused id is valid. The
227        // Spacer fallback applies only when no body was ever set.
228        let body_id = if let Some(body) = self.body.take() {
229            let id = ctx.add_boxed(body);
230            self.body_id = Some(id);
231            id
232        } else if let Some(id) = self.body_id {
233            id
234        } else {
235            let id = ctx.add(Spacer::new());
236            self.body_id = Some(id);
237            id
238        };
239
240        // Always wrap in a vertical-only ScrollArea; chrome stays
241        // invisible until overflow (AsNeeded) and the user can scroll
242        // long content with the wheel either way.
243        let scrolled = ctx.add(
244            ScrollArea::from_id(body_id)
245                .vertical_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
246                .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
247                // The chip is the dark / inverse `tooltip_bg`; tint the thumb
248                // from `tooltip_text` so it contrasts (the surface-relative
249                // `scrollbar_thumb` token would be dark-on-dark / light-on-light).
250                .scroll_bar_thumb_color(TextRole::TooltipText),
251        );
252
253        self.scrolled_id = Some(scrolled);
254
255        let padded = ctx.add(
256            Padding::symmetric(
257                tt::COMPOSITE_TOOLTIP_PADDING_VERTICAL,
258                tt::COMPOSITE_TOOLTIP_PADDING_HORIZONTAL,
259            )
260            .child_id(scrolled),
261        );
262
263        // No promotion, no countdown: a read-only surface builds no footer at
264        // all, so it hugs its content instead of reserving a row for an
265        // indicator that would promise an interaction it does not offer.
266        let footer = self.sticky_enabled.then(|| {
267            let indicator = ctx.add(DwellIndicator::new(
268                self.dwell_step.clone(),
269                self.sticky.clone(),
270                TextRole::TooltipText,
271            ));
272            // Footer: 1fr Spacer + Auto indicator — keeps the dwell pin
273            // visually anchored at the bottom-right of the surface, the
274            // same convention rich tooltips use at the top-right of their
275            // inner Grid.
276            let footer_spacer = ctx.add(Spacer::new());
277            ctx.add(
278                Grid::new()
279                    .columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
280                    .rows(vec![TrackSize::Auto])
281                    .column_gap(8.0)
282                    .add_child(footer_spacer)
283                    .add_child(indicator),
284            )
285        });
286
287        // Placed by `place_children`, not stacked by a `VStack`.
288        //
289        // A `ScrollArea`'s intrinsic height is a fixed placeholder — a viewport
290        // exists to be smaller than what it holds, so it cannot answer "how
291        // tall is my content". `layout_response` below already discounts that
292        // placeholder to report an honest height, but a `VStack` doing its own
293        // vertical distribution never saw the correction: it laid the column
294        // out at placeholder height and the footer — the dwell indicator —
295        // ended up painted below the bubble, on whatever happened to be behind
296        // the tooltip. Owning the placement is what keeps the two in step.
297        self.padded_id = Some(padded);
298        self.footer_id = footer;
299
300        // Focusable so Tab can enter the surface once promoted.
301        let handlers = HandlerSet::new().focusable(true);
302        ctx.apply_self_handlers(handlers);
303
304        // Bind sticky for the role flip in accessibility().
305        self.sticky.bind_to(
306            self_id,
307            ctx.binding_registry(),
308            teksilo_core::binding::BindingLevel::AccessibilityOnly,
309        );
310
311        self.padded_id.into_iter().chain(self.footer_id).collect()
312    }
313
314    fn layout_response(
315        &self,
316        proposal: SizeProposal,
317        ctx: &LayoutContext,
318    ) -> teksilo_core::widget::LayoutResponse {
319        use crate::styles::recipe_tooltip_style as tt;
320        let max_w = self
321            .max_width_override
322            .unwrap_or(tt::COMPOSITE_TOOLTIP_MAX_WIDTH);
323        let max_h = self
324            .max_height_override
325            .unwrap_or(tt::COMPOSITE_TOOLTIP_MAX_HEIGHT);
326        // `max_width` / `max_height` are *maxima*, so measure what the content wants and
327        // clamp the result — do NOT propose the maximum as an exact size.
328        //
329        // Proposing it was the bug: the body sits inside a `ScrollArea` (below) whose
330        // horizontal policy is `AlwaysOff`, so it fills whatever width it is handed, and the
331        // wrapper chain likewise takes the offered height. A tooltip holding a 202x16 dp row
332        // therefore painted as a 480x244 dp slab, and neither a smaller `max_width` nor a
333        // smaller `max_height` could make it hug — they only moved the number it filled to.
334        //
335        // Two passes, because width and height are not independent: measuring unbounded
336        // gives text its single-line length, and a body clamped narrower than that needs to
337        // be re-measured to learn how tall it becomes once it wraps.
338        let unbounded = SizeProposal {
339            width: None,
340            height: None,
341        };
342        let Some(padded) = self.padded_id else {
343            return Size::new(0.0, 0.0).into();
344        };
345        let footer_natural = self
346            .footer_id
347            .and_then(|id| ctx.child_size(id, unbounded))
348            .unwrap_or_else(|| Size::new(0.0, 0.0));
349        let Some(padded_natural) = ctx.child_size(padded, unbounded) else {
350            return Size::new(0.0, 0.0).into();
351        };
352        let natural = Size::new(
353            padded_natural.width.max(footer_natural.width),
354            padded_natural.height + FOOTER_GAP + footer_natural.height,
355        );
356        let avail_w = proposal.width.unwrap_or(f32::INFINITY).min(max_w);
357        let w = natural.width.min(avail_w);
358        let at_w = SizeProposal {
359            width: Some(w),
360            height: None,
361        };
362        let footer_h = self
363            .footer_id
364            .and_then(|id| ctx.child_size(id, at_w))
365            .map(|s| s.height)
366            .unwrap_or(footer_natural.height);
367        let h = ctx
368            .child_size(padded, at_w)
369            .map(|s| s.height + FOOTER_GAP + footer_h)
370            .unwrap_or(natural.height);
371
372        // Height needs one more correction. A `ScrollArea` is a viewport: asked for its
373        // intrinsic height it answers with a fixed placeholder (200 dp) rather than its
374        // content's, because a viewport's whole job is to be smaller than what it holds. The
375        // body sits inside one, so the measurement above says 244 dp for a 16 dp row.
376        //
377        // Discount the scroll area's own answer and substitute the body's. Expressed as a
378        // difference rather than by re-adding the padding and footer by hand, so it stays
379        // right if the chrome around the body ever changes.
380        let h = match (self.scrolled_id, self.body_id) {
381            (Some(scrolled), Some(body)) => {
382                let at_width = SizeProposal {
383                    width: Some(w),
384                    height: None,
385                };
386                match (
387                    ctx.child_size(scrolled, at_width),
388                    ctx.child_size(body, at_width),
389                ) {
390                    (Some(vp), Some(content)) => (h - vp.height + content.height).max(0.0),
391                    _ => h,
392                }
393            }
394            _ => h,
395        };
396        let avail_h = proposal.height.unwrap_or(f32::INFINITY).min(max_h);
397        Size::new(w, h.min(avail_h)).into()
398    }
399
400    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
401        let radius = CornerRadius::uniform(
402            crate::styles::recipe_tooltip_style::COMPOSITE_TOOLTIP_CORNER_RADIUS,
403        );
404        let _ = ctx;
405        super::paint_composite_tooltip_shadows(canvas, bounds, radius, ctx);
406        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
407        // paint() is the visibility hook — only invoked while the
408        // tooltip is active. Drives the dwell-to-sticky timer.
409        if self.sticky_enabled {
410            self.tick_dwell();
411        }
412    }
413
414    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
415        let is_sticky = self.sticky.get();
416        let role = if is_sticky {
417            teksilo_core::accesskit::Role::Dialog
418        } else {
419            teksilo_core::accesskit::Role::Tooltip
420        };
421        builder.set_role(role);
422        // Composite tooltips host arbitrary widget bodies and have no
423        // intrinsic text, so without an explicit `.access_label(...)` the
424        // node would be unnamed. Fall back to a localized generic name —
425        // same approach as `ModalContainer` / `SnackbarWidget`.
426        let name = self
427            .access_label
428            .clone()
429            .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_tooltip_name()).resolve_now());
430        builder.set_name(name);
431        if is_sticky {
432            builder.add_action(teksilo_core::accesskit::Action::Focus);
433        }
434    }
435
436    fn children(&self) -> Vec<WidgetId> {
437        self.padded_id.into_iter().chain(self.footer_id).collect()
438    }
439
440    /// Place the body viewport and the dwell-indicator footer by hand.
441    ///
442    /// The footer takes its natural height at the bottom; the viewport takes
443    /// everything above it. That is the whole fix: the viewport is *given* a
444    /// height rather than asked for one, so it can no longer claim its
445    /// placeholder and push the indicator out of the bubble. Content taller
446    /// than what is left simply scrolls, which is what the `ScrollArea` is for.
447    fn place_children(
448        &self,
449        bounds: Rect,
450        _proposal: SizeProposal,
451        children: &mut [teksilo_core::widget::WidgetPlacement],
452        ctx: &LayoutContext,
453    ) {
454        let at_w = SizeProposal {
455            width: Some(bounds.width),
456            height: None,
457        };
458        let footer_h = self
459            .footer_id
460            .and_then(|id| ctx.child_size(id, at_w))
461            .map(|s| s.height)
462            .unwrap_or(0.0);
463        let body_h = (bounds.height - footer_h - FOOTER_GAP).max(0.0);
464        for (i, child) in children.iter_mut().enumerate() {
465            if i == 0 {
466                child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
467                child.size = Size::new(bounds.width, body_h);
468            } else {
469                child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y + body_h + FOOTER_GAP);
470                child.size = Size::new(bounds.width, footer_h);
471            }
472        }
473    }
474
475    /// Reconcile children across rebuilds rather than tearing them down.
476    /// The body widget is owned once (`self.body` is taken on first build)
477    /// and cannot be reconstructed on a later `build()`, so it must survive —
478    /// otherwise the reused `body_id` would dangle. The body is re-parented
479    /// under the freshly-built chrome each rebuild; the reconciling rebuild
480    /// path follows authoritative parent pointers, so it keeps the re-parented
481    /// body and destroys only the superseded old chrome. (In practice the
482    /// composite tooltip has no `Rebuild`-level binding, so this path is rarely
483    /// exercised.) Mirrors `Switcher`'s preserve-on-rebuild contract.
484    fn preserves_children_on_rebuild(&self) -> bool {
485        true
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::button::Button;
493    use crate::primitives::{TextWidget, VStack};
494    use std::cell::RefCell;
495    use std::rc::Rc;
496    use std::time::Duration;
497    use teksilo_canvas::{MockTextBackend, SizeProposal};
498    use teksilo_core::widget_tree::WidgetTree;
499    use teksilo_i18n::lit;
500
501    fn tree_with_backend() -> WidgetTree {
502        WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
503    }
504
505    /// Test-only host widget that wires a composite tooltip onto a
506    /// child `Button` from inside its `build()`. Exposes the resulting
507    /// tooltip content id via a shared `Cell` so tests can drive the
508    /// tree's `promote_tooltip_to_sticky` API directly.
509    #[derive(Debug)]
510    struct ComposeTooltipHost {
511        anchor_id: Option<WidgetId>,
512        tooltip_id_sink: Rc<Cell<Option<WidgetId>>>,
513        sticky: bool,
514    }
515
516    impl ComposeTooltipHost {
517        fn new(tooltip_id_sink: Rc<Cell<Option<WidgetId>>>) -> Self {
518            Self {
519                anchor_id: None,
520                tooltip_id_sink,
521                sticky: true,
522            }
523        }
524        fn new_non_sticky(tooltip_id_sink: Rc<Cell<Option<WidgetId>>>) -> Self {
525            Self {
526                anchor_id: None,
527                tooltip_id_sink,
528                sticky: false,
529            }
530        }
531    }
532
533    impl Widget for ComposeTooltipHost {
534        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
535            let anchor = ctx.add(Button::new(lit!("Hover me")));
536            self.anchor_id = Some(anchor);
537            let body = VStack::new()
538                .child(TextWidget::new(lit!("Header")))
539                .child(TextWidget::new(lit!("Body")));
540            let delay = ctx.theme().motion.tooltip_delay_heavy;
541            let tip = crate::tooltip::attach_composite_tooltip_widget_with_placement(
542                ctx,
543                anchor,
544                CompositeTooltipWidget::new()
545                    .content(body)
546                    .sticky(self.sticky),
547                delay,
548                crate::tooltip::TooltipPlacement::Below,
549            );
550            self.tooltip_id_sink.set(Some(tip));
551            vec![anchor]
552        }
553        fn layout_response(
554            &self,
555            proposal: SizeProposal,
556            ctx: &LayoutContext,
557        ) -> teksilo_core::widget::LayoutResponse {
558            self.anchor_id
559                .and_then(|id| ctx.child_size(id, proposal))
560                .unwrap_or_else(|| Size::new(0.0, 0.0))
561                .into()
562        }
563        fn children(&self) -> Vec<WidgetId> {
564            self.anchor_id.map(|id| vec![id]).unwrap_or_default()
565        }
566    }
567
568    #[test]
569    fn composite_tooltip_appears_after_hover_delay() {
570        let mut tree = tree_with_backend();
571        let tooltip_id_sink = Rc::new(Cell::new(None));
572        let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
573        tree.layout(SizeProposal::exact(400.0, 200.0));
574
575        assert!(tree.active_overlays().is_empty());
576        tree.pointer_move(tree.bounds(host).center());
577        assert!(
578            tree.active_overlays().is_empty(),
579            "composite tooltip should not appear instantly — waits for delay"
580        );
581
582        tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
583        assert_eq!(
584            tree.active_overlays().len(),
585            1,
586            "composite tooltip should have appeared after the hover delay"
587        );
588    }
589
590    #[test]
591    fn composite_tooltip_does_not_appear_at_the_light_tier_delay() {
592        // The test above jumps straight from t=0 to heavy+50 ms, so it would
593        // pass just as happily if the composite path were wired to the 500 ms
594        // `tooltip_delay` instead of the 700 ms `tooltip_delay_heavy`. This is
595        // the missing checkpoint in between: a composite surface is heavier to
596        // read and to dismiss, and must demand the longer statement of intent.
597        let mut tree = tree_with_backend();
598        let tooltip_id_sink = Rc::new(Cell::new(None));
599        let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
600        tree.layout(SizeProposal::exact(400.0, 200.0));
601
602        tree.pointer_move(tree.bounds(host).center());
603        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
604        assert!(
605            tree.active_overlays().is_empty(),
606            "a composite tooltip must still be waiting at the plain 500 ms delay"
607        );
608
609        tree.advance_time(Duration::from_millis(200));
610        assert_eq!(
611            tree.active_overlays().len(),
612            1,
613            "and appear once the 700 ms heavy delay elapses"
614        );
615    }
616
617    #[test]
618    fn composite_tooltip_dismisses_on_pointer_leave_before_promotion() {
619        let mut tree = tree_with_backend();
620        let tooltip_id_sink = Rc::new(Cell::new(None));
621        let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
622        tree.layout(SizeProposal::exact(400.0, 200.0));
623
624        tree.pointer_move(tree.bounds(host).center());
625        tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
626        assert_eq!(tree.active_overlays().len(), 1);
627
628        // Pointer leaves before sticky promotion → dismiss.
629        tree.pointer_move(teksilo_canvas::Point::new(2000.0, 2000.0));
630        tree.advance_time(Duration::from_millis(500));
631        assert!(
632            tree.active_overlays().is_empty(),
633            "non-sticky composite tooltip should dismiss on pointer-leave"
634        );
635    }
636
637    #[test]
638    fn composite_tooltip_survives_pointer_leave_once_promoted() {
639        // The 2 s dwell auto-promote uses real time (not sim) — so we
640        // promote manually via `promote_tooltip_to_sticky` to test the
641        // post-sticky behavior deterministically.
642        let mut tree = tree_with_backend();
643        let tooltip_id_sink = Rc::new(Cell::new(None));
644        let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
645        tree.layout(SizeProposal::exact(400.0, 200.0));
646
647        tree.pointer_move(tree.bounds(host).center());
648        tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
649        assert_eq!(tree.active_overlays().len(), 1);
650
651        let content_id = tooltip_id_sink
652            .get()
653            .expect("tooltip id captured during build");
654        tree.promote_tooltip_to_sticky(content_id);
655
656        tree.pointer_move(teksilo_canvas::Point::new(2000.0, 2000.0));
657        tree.advance_time(Duration::from_millis(500));
658        assert_eq!(
659            tree.active_overlays().len(),
660            1,
661            "sticky composite tooltip should survive pointer-leave"
662        );
663    }
664
665    #[test]
666    fn composite_tooltip_preserves_children_so_body_survives_rebuild() {
667        // The body box is taken on first `build()` and cannot be rebuilt,
668        // so the rebuild-safe body reuse depends on the child subtree
669        // being preserved (otherwise the reused `body_id` would dangle).
670        // Assert that contract directly — it links the two halves of the
671        // fix (reuse + preserve); removing either should fail here.
672        let w = CompositeTooltipWidget::new().content(TextWidget::new(lit!("Body")));
673        assert!(
674            w.preserves_children_on_rebuild(),
675            "composite must preserve children so the reused body id stays valid across rebuild"
676        );
677    }
678
679    /// A non-sticky composite never promotes, however long the pointer rests.
680    ///
681    /// That is the whole contract of the read-only tier: it retires with the
682    /// pointer like a plain tooltip, rather than becoming a panel that outlives
683    /// it and reads to assistive tech as a `Dialog`.
684    #[test]
685    fn a_non_sticky_composite_tooltip_never_promotes() {
686        let mut tree = tree_with_backend();
687        let tooltip_id_sink = Rc::new(Cell::new(None));
688        let host = tree.add(ComposeTooltipHost::new_non_sticky(tooltip_id_sink.clone()));
689        tree.layout(SizeProposal::exact(400.0, 200.0));
690
691        tree.pointer_move(tree.bounds(host).center());
692        tree.advance_time(Duration::from_millis(750));
693        assert_eq!(
694            tree.active_overlays().len(),
695            1,
696            "it still shows on hover — only the promotion is gone"
697        );
698
699        // Well past the dwell window the sticky tier would have promoted at.
700        tree.advance_time(crate::tooltip::rich::DWELL_PROMOTION * 3);
701        tree.pointer_move(teksilo_canvas::Point::new(-100.0, -100.0));
702        tree.advance_time(Duration::from_millis(300));
703        assert!(
704            tree.active_overlays().is_empty(),
705            "a non-sticky surface must retire with the pointer, not survive it"
706        );
707    }
708
709    /// …and builds no dwell indicator, so it hugs its content.
710    #[test]
711    fn a_non_sticky_composite_tooltip_is_shorter_than_a_sticky_one() {
712        let measure = |sticky: bool| {
713            let mut tree = WidgetTree::new()
714                .with_theme(teksilo_core::presets::intui::light())
715                .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
716            tree.add_boxed(Box::new(
717                CompositeTooltipWidget::new()
718                    .content(TextWidget::new(lit!("Hi")))
719                    .sticky(sticky),
720            ));
721            tree.layout(SizeProposal::exact(1200.0, 900.0));
722            tree.measure_root_intrinsic(SizeProposal {
723                width: Some(1200.0),
724                height: Some(900.0),
725            })
726            .expect("a size")
727            .height
728        };
729        let sticky = measure(true);
730        let plain = measure(false);
731        assert!(
732            plain < sticky,
733            "without the indicator the surface should hug tighter \
734             (non-sticky {plain}, sticky {sticky})"
735        );
736    }
737
738    /// Every painted descendant must sit inside the surface that paints the
739    /// tooltip's background.
740    ///
741    /// The dwell indicator is the last child of the root `VStack`, so anything
742    /// that makes the surface report a height shorter than the column actually
743    /// needs pushes the indicator out the bottom — painted on the window, over
744    /// whatever is behind, with no tooltip under it.
745    #[test]
746    fn the_dwell_indicator_sits_inside_the_tooltip_surface() {
747        let mut tree = WidgetTree::new()
748            .with_theme(teksilo_core::presets::intui::light())
749            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
750        let tip = tree.add_boxed(Box::new(
751            CompositeTooltipWidget::new().content(TextWidget::new(lit!("Hi"))),
752        ));
753        // Lay out at the tooltip's own intrinsic size, the way the overlay
754        // manager places it — not at a generous proposal, which would place the
755        // root at the proposal and hide the discrepancy.
756        let want = tree
757            .measure_root_intrinsic(SizeProposal {
758                width: Some(1200.0),
759                height: Some(900.0),
760            })
761            .expect("the tooltip reports a size");
762        tree.layout(SizeProposal::exact(want.width, want.height));
763
764        let surface = tree.bounds(tip);
765        // Walk to the deepest descendants and check each stays within.
766        let mut stack = tree.children(tip);
767        let mut worst: Option<(f32, f32)> = None;
768        while let Some(id) = stack.pop() {
769            let b = tree.bounds(id);
770            if b.height > 0.0 && b.y + b.height > surface.y + surface.height + 0.5 {
771                let overflow = (b.y + b.height) - (surface.y + surface.height);
772                if worst.is_none_or(|(w, _)| overflow > w) {
773                    worst = Some((overflow, b.y + b.height));
774                }
775            }
776            stack.extend(tree.children(id));
777        }
778        assert!(
779            worst.is_none(),
780            "a descendant spills {:.1}dp below the tooltip surface \
781             (surface ends at {:.1}, child at {:.1}) — the dwell indicator is \
782             painted outside the bubble",
783            worst.unwrap().0,
784            surface.y + surface.height,
785            worst.unwrap().1,
786        );
787    }
788
789    /// `max_width` / `max_height` are maxima, not the size to fill.
790    ///
791    /// They used to be proposed to the content as an exact size, and since the body sits in
792    /// a `ScrollArea` that fills what it is handed, every composite tooltip painted at the
793    /// maximum: a one-line body rendered as a 480x480 slab. Lowering either maximum only
794    /// changed the number it filled to, so there was no way to get a tooltip that fit its
795    /// content. Pin the hugging directly.
796    #[test]
797    fn a_short_composite_tooltip_hugs_its_content() {
798        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
799        tree.add_boxed(Box::new(
800            CompositeTooltipWidget::new().content(TextWidget::new(lit!("Hi"))),
801        ));
802        // An overlay proposes generously; the tooltip must not ask for all of it.
803        // Measured, not `bounds()`: the widget under test is the tree root and so is
804        // *placed* at the proposal whatever it reports.
805        tree.layout(SizeProposal::exact(1200.0, 900.0));
806        let s = tree
807            .measure_root_intrinsic(SizeProposal {
808                width: Some(1200.0),
809                height: Some(900.0),
810            })
811            .expect("the tooltip reports a size");
812        assert!(
813            s.width < 200.0,
814            "a two-letter body should not ask for a {}dp-wide tooltip",
815            s.width
816        );
817        assert!(
818            s.height < 120.0,
819            "a one-line body should not ask for a {}dp-tall tooltip",
820            s.height
821        );
822    }
823
824    /// …and a body larger than the maxima is still bounded by them.
825    #[test]
826    fn a_long_composite_tooltip_is_capped_by_its_maximum() {
827        let long = "word ".repeat(400);
828        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
829        tree.add_boxed(Box::new(
830            CompositeTooltipWidget::new()
831                .content(TextWidget::new(lit!(long)))
832                .max_width(240.0)
833                .max_height(160.0),
834        ));
835        tree.layout(SizeProposal::exact(1200.0, 900.0));
836        let s = tree
837            .measure_root_intrinsic(SizeProposal {
838                width: Some(1200.0),
839                height: Some(900.0),
840            })
841            .expect("the tooltip reports a size");
842        assert!(s.width <= 240.5, "width {} exceeds its maximum", s.width);
843        assert!(s.height <= 160.5, "height {} exceeds its maximum", s.height);
844    }
845}