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