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