Skip to main content

tessera_components/
tooltip.rs

1//! Anchored plain and rich contextual tooltips.
2//!
3//! ## Usage
4//!
5//! Explain controls on hover, keyboard focus, or touch long press.
6
7use tessera_ui::{
8    AxisConstraint, Callback, ComputedData, CursorEventContent, Dp, FocusDirection, FocusGroupNode,
9    FocusRequester, FocusState, FrameNanosControl, GestureState, KeyboardInput,
10    KeyboardInputModifierNode, LayoutResult, MOUSE_POINTER_ID, MeasurementError, Modifier,
11    PointerInput, PointerInputModifierNode, PressKeyEventType, Px, PxPosition, PxRect, RenderSlot,
12    RenderSlotWith, State,
13    accesskit::{Live, Role},
14    current_frame_nanos,
15    layout::{LayoutPlacementTarget, MeasureScope, PlacementResult, PlacementScope, layout},
16    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
17    receive_frame_nanos, remember, tessera, use_context,
18    winit::{
19        event::ElementState,
20        keyboard::{Key, NamedKey},
21    },
22};
23
24use crate::{
25    button::button,
26    column::column,
27    modifier::{ModifierExt as _, Padding, SemanticsArgs},
28    shape_def::Shape,
29    surface::surface,
30    text::text,
31    theme::MaterialTheme,
32};
33
34const SHOW_DELAY: u64 = 500_000_000;
35const EXIT_DELAY: u64 = 100_000_000;
36const TOUCH_DURATION: u64 = 1_500_000_000;
37
38/// Tooltip content and interaction style.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum TooltipVariant {
41    /// A brief non-interactive description with an inverse surface color.
42    #[default]
43    Plain,
44    /// Supporting text with an optional title and action.
45    Rich,
46}
47
48/// Preferred tooltip position; the opposite side is used when space is limited.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum TooltipPlacement {
51    /// Center the tooltip above the anchor.
52    #[default]
53    Above,
54    /// Center the tooltip below the anchor.
55    Below,
56    /// Place the tooltip before the anchor in a left-to-right layout.
57    Start,
58    /// Place the tooltip after the anchor in a left-to-right layout.
59    End,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq)]
63struct TouchPress {
64    pointer: u64,
65    origin: PxPosition,
66    since: u64,
67    triggered: bool,
68}
69
70/// Shared tooltip visibility, trigger bounds, and input state.
71#[derive(Clone, PartialEq)]
72pub struct TooltipController {
73    open: bool,
74    suppressed: bool,
75    anchor_hovered: bool,
76    popup_hovered: bool,
77    anchor_focused: bool,
78    popup_focused: bool,
79    manual: bool,
80    ignore_anchor_focus: bool,
81    description: String,
82    persistent: bool,
83    interactive: bool,
84    mouse_present: bool,
85    touch_focus_suppressed: bool,
86    pending_show: Option<u64>,
87    pending_hide: Option<u64>,
88    touch: Option<TouchPress>,
89    anchor: Option<State<tessera_ui::FloatRect>>,
90    focus: FocusRequester,
91    popup_focus: FocusRequester,
92    popup_group: FocusGroupNode,
93}
94
95impl std::fmt::Debug for TooltipController {
96    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        formatter
98            .debug_struct("TooltipController")
99            .field("open", &self.open)
100            .field("suppressed", &self.suppressed)
101            .field("description", &self.description)
102            .field("persistent", &self.persistent)
103            .field("interactive", &self.interactive)
104            .field("anchor_attached", &self.anchor.is_some())
105            .finish_non_exhaustive()
106    }
107}
108
109impl Default for TooltipController {
110    fn default() -> Self {
111        Self {
112            open: false,
113            suppressed: false,
114            anchor_hovered: false,
115            popup_hovered: false,
116            anchor_focused: false,
117            popup_focused: false,
118            manual: false,
119            ignore_anchor_focus: false,
120            description: String::new(),
121            persistent: false,
122            interactive: false,
123            mouse_present: false,
124            touch_focus_suppressed: false,
125            pending_show: None,
126            pending_hide: None,
127            touch: None,
128            anchor: None,
129            focus: FocusRequester::new(),
130            popup_focus: FocusRequester::new(),
131            popup_group: FocusGroupNode::new(),
132        }
133    }
134}
135
136impl TooltipController {
137    fn anchor(&self) -> tessera_ui::FloatRect {
138        self.anchor
139            .filter(|anchor| anchor.is_alive())
140            .map_or(tessera_ui::FloatRect::ZERO, |anchor| anchor.get())
141    }
142
143    /// Creates a hidden tooltip.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Shows the tooltip until it is dismissed.
149    pub fn show(&mut self) {
150        self.popup_hovered = false;
151        self.open = true;
152        self.manual = true;
153        self.suppressed = false;
154        self.pending_show = None;
155        self.pending_hide = None;
156    }
157
158    /// Hides the tooltip until the pointer or focus leaves and re-enters.
159    pub fn dismiss(&mut self) {
160        self.ignore_anchor_focus = self.popup_focused;
161        self.popup_focused = false;
162        self.touch = self.touch.filter(|touch| touch.triggered);
163        self.open = false;
164        self.manual = false;
165        self.suppressed = true;
166        self.pending_show = None;
167        self.pending_hide = None;
168    }
169
170    /// Returns whether the tooltip is visible.
171    pub fn is_open(&self) -> bool {
172        self.open
173    }
174
175    fn engaged(&self) -> bool {
176        self.anchor_hovered
177            || self.anchor_focused
178            || (self.open && (self.popup_hovered || self.popup_focused))
179    }
180
181    fn hover(&mut self, popup: bool, inside: bool, now: u64) {
182        let hovered = if popup {
183            &mut self.popup_hovered
184        } else {
185            &mut self.anchor_hovered
186        };
187        if *hovered == inside {
188            return;
189        }
190        *hovered = inside;
191        if popup && inside && !self.open {
192            return;
193        }
194        if inside {
195            if !popup && !self.open {
196                self.popup_hovered = false;
197            }
198            self.suppressed = false;
199            self.pending_hide = None;
200            if !self.open {
201                self.pending_show = Some(now.saturating_add(SHOW_DELAY));
202            }
203        } else if !self.engaged() && !self.manual {
204            self.pending_show = None;
205            self.pending_hide = self.open.then_some(now.saturating_add(EXIT_DELAY));
206        }
207    }
208
209    fn focus(&mut self, popup: bool, focused: bool, now: u64) {
210        if !popup && !focused {
211            self.touch_focus_suppressed = false;
212            self.ignore_anchor_focus = false;
213        }
214        if !popup && focused && self.touch_focus_suppressed {
215            return;
216        }
217        if focused && self.touch.is_some_and(|touch| !touch.triggered) {
218            return;
219        }
220        let previous = if popup {
221            &mut self.popup_focused
222        } else {
223            &mut self.anchor_focused
224        };
225        if *previous == focused {
226            return;
227        }
228        *previous = focused;
229        if popup && focused && !self.open {
230            return;
231        }
232        if focused {
233            if !popup && self.ignore_anchor_focus {
234                self.ignore_anchor_focus = false;
235                return;
236            }
237            self.suppressed = false;
238            self.open = true;
239            self.pending_show = None;
240            self.pending_hide = None;
241        } else if !self.engaged() && !self.manual {
242            self.pending_hide = Some(now.saturating_add(EXIT_DELAY));
243        }
244    }
245
246    fn needs_frame(&self) -> bool {
247        self.pending_show.is_some()
248            || self.pending_hide.is_some()
249            || self.touch.is_some_and(|touch| !touch.triggered)
250    }
251
252    fn advance(&mut self, now: u64) {
253        if self.pending_show.is_some_and(|deadline| now >= deadline) {
254            self.pending_show = None;
255            if !self.suppressed && self.engaged() {
256                self.open = true;
257            }
258        }
259        if self
260            .touch
261            .is_some_and(|touch| !touch.triggered && now.saturating_sub(touch.since) >= SHOW_DELAY)
262        {
263            if let Some(touch) = &mut self.touch {
264                touch.triggered = true;
265            }
266            self.open = true;
267            self.pending_show = None;
268            self.pending_hide = None;
269        }
270        if self.pending_hide.is_some_and(|deadline| now >= deadline) {
271            self.pending_hide = None;
272            if !self.engaged() && !self.manual {
273                self.open = false;
274            }
275        }
276    }
277}
278
279#[derive(Clone, Copy)]
280struct TooltipPointer {
281    controller: State<TooltipController>,
282    popup: bool,
283    persistent: bool,
284}
285
286fn within(position: Option<PxPosition>, rect: impl Into<tessera_ui::FloatRect>) -> bool {
287    let rect = rect.into();
288    position.is_some_and(|position| rect.contains(position))
289}
290
291impl PointerInputModifierNode for TooltipPointer {
292    fn on_pointer_input(&self, input: PointerInput<'_>) {
293        let now = current_frame_nanos();
294        let rect = PxRect::new(
295            Px::ZERO,
296            Px::ZERO,
297            input.computed_data.width,
298            input.computed_data.height,
299        );
300        let inside = within(input.cursor_position_rel, rect);
301        let mouse = self.controller.with(|controller| controller.mouse_present);
302        let changed = self.controller.with(|controller| {
303            (if self.popup {
304                controller.popup_hovered
305            } else {
306                controller.anchor_hovered
307            }) != inside
308        });
309        if (mouse || input.cursor_position_rel.is_none()) && changed {
310            self.controller
311                .with_mut(|controller| controller.hover(self.popup, inside, now));
312        }
313        if self.popup {
314            return;
315        }
316        let window_position = input.cursor_position_abs();
317        for event in input
318            .pointer_changes
319            .iter_mut()
320            .filter(|event| event.pointer_id != MOUSE_POINTER_ID)
321        {
322            let active = self.controller.with(|controller| controller.touch);
323            match event.content {
324                CursorEventContent::Pressed(PressKeyEventType::Left)
325                    if inside && !event.is_consumed() =>
326                {
327                    if let Some(origin) = event.position().or(window_position) {
328                        self.controller.with_mut(|controller| {
329                            controller.touch_focus_suppressed = true;
330                            controller.anchor_focused = false;
331                            controller.popup_focused = false;
332                            controller.anchor_hovered = false;
333                            controller.popup_hovered = false;
334                            controller.pending_show = None;
335                            controller.pending_hide = None;
336                            controller.open = controller.manual;
337                            controller.suppressed = false;
338                            controller.touch = Some(TouchPress {
339                                pointer: event.pointer_id,
340                                origin,
341                                since: now,
342                                triggered: false,
343                            });
344                        });
345                    }
346                }
347                CursorEventContent::Moved(_) | CursorEventContent::Scroll(_)
348                    if active.is_some_and(|touch| {
349                        touch.pointer == event.pointer_id && !touch.triggered
350                    }) =>
351                {
352                    let touch = active.expect("active touch was checked");
353                    let moved = event
354                        .position()
355                        .or(window_position)
356                        .is_some_and(|position| {
357                            (position.x - touch.origin.x).hypot(position.y - touch.origin.y)
358                                > Dp(8.0).to_pixels_f32()
359                        });
360                    if moved || !inside || event.gesture_state == GestureState::Dragged {
361                        self.controller
362                            .with_mut(|controller| controller.touch = None);
363                    }
364                }
365                CursorEventContent::Released(_) | CursorEventContent::Cancelled(_)
366                    if active.is_some_and(|touch| touch.pointer == event.pointer_id) =>
367                {
368                    let triggered = active.is_some_and(|touch| touch.triggered);
369                    let cancelled = matches!(event.content, CursorEventContent::Cancelled(_));
370                    if triggered && !cancelled {
371                        // Descendant recognizers need cancellation to clear
372                        // their active press.
373                        event.content = CursorEventContent::Cancelled(PressKeyEventType::Left);
374                        event.gesture_state = GestureState::Dragged;
375                    }
376                    self.controller.with_mut(|controller| {
377                        controller.touch = None;
378                        if cancelled {
379                            controller.dismiss();
380                        } else if triggered {
381                            controller.manual = self.persistent;
382                            controller.pending_hide =
383                                (!self.persistent).then_some(now.saturating_add(TOUCH_DURATION));
384                        }
385                    });
386                }
387                _ => {}
388            }
389        }
390    }
391}
392
393struct TooltipDismissInput {
394    controller: State<TooltipController>,
395    popup: State<tessera_ui::FloatRect>,
396}
397
398impl TooltipDismissInput {
399    fn handle_tab(&self, state: ElementState, previous: bool) -> bool {
400        let (open, interactive, group, anchor) = self.controller.with(|controller| {
401            (
402                controller.open,
403                controller.interactive,
404                controller.popup_group,
405                controller.focus,
406            )
407        });
408        if !open || !interactive {
409            return false;
410        }
411        let in_popup = group.has_focus();
412        if !in_popup && (!anchor.has_focus() || previous) {
413            return false;
414        }
415        if state == ElementState::Released {
416            return true;
417        }
418        if in_popup {
419            if !group.move_focus(if previous {
420                FocusDirection::Previous
421            } else {
422                FocusDirection::Next
423            }) {
424                if !previous {
425                    self.controller.with_mut(|controller| {
426                        controller.dismiss();
427                        controller.ignore_anchor_focus = true;
428                    });
429                }
430                anchor.request_focus();
431            }
432            true
433        } else {
434            group.move_focus(FocusDirection::Next)
435        }
436    }
437}
438
439impl KeyboardInputModifierNode for TooltipDismissInput {
440    fn on_keyboard_input(&self, input: KeyboardInput<'_>) {
441        input.keyboard_events.retain(|event| {
442            if event.logical_key == Key::Named(NamedKey::Tab) {
443                return !self.handle_tab(event.state, input.key_modifiers.shift_key());
444            }
445            if event.logical_key != Key::Named(NamedKey::Escape)
446                || !self.controller.with(|controller| {
447                    controller.open
448                        || controller.pending_show.is_some()
449                        || controller.touch.is_some()
450                })
451            {
452                return true;
453            }
454            if event.state == ElementState::Pressed {
455                let (group, anchor) = self
456                    .controller
457                    .with(|controller| (controller.popup_group, controller.focus));
458                let restore = group.has_focus();
459                self.controller.with_mut(|controller| {
460                    controller.dismiss();
461                    if restore {
462                        controller.ignore_anchor_focus = true;
463                    }
464                });
465                if restore {
466                    anchor.request_focus();
467                }
468            }
469            false
470        });
471    }
472}
473
474impl PointerInputModifierNode for TooltipDismissInput {
475    fn on_pointer_input(&self, input: PointerInput<'_>) {
476        if let Some(mouse) = input
477            .pointer_changes
478            .last()
479            .map(|event| event.pointer_id == MOUSE_POINTER_ID)
480            && self.controller.with(|controller| controller.mouse_present) != mouse
481        {
482            self.controller.with_mut(|controller| {
483                controller.mouse_present = mouse;
484                if mouse {
485                    controller.touch_focus_suppressed = false;
486                }
487                if !mouse {
488                    controller.hover(false, false, current_frame_nanos());
489                    controller.hover(true, false, current_frame_nanos());
490                }
491            });
492        }
493        if !self.controller.with(TooltipController::is_open) {
494            return;
495        }
496        let anchor = self.controller.with(TooltipController::anchor);
497        let pressed = input
498            .pointer_changes
499            .iter()
500            .any(|event| matches!(event.content, CursorEventContent::Pressed(_)));
501        if pressed
502            && !within(input.cursor_position_abs(), anchor)
503            && !within(input.cursor_position_abs(), self.popup.get())
504        {
505            self.controller.with_mut(TooltipController::dismiss);
506        }
507    }
508}
509
510struct TooltipRestoreInput {
511    controller: State<TooltipController>,
512}
513
514fn sync_tooltip_focus(controller: State<TooltipController>, popup: bool) {
515    let (anchor, group) = controller.with(|controller| (controller.focus, controller.popup_group));
516    let focused = if popup {
517        group.has_focus()
518    } else {
519        anchor.has_focus()
520    };
521    let changed = controller.with(|controller| {
522        let previous = if popup {
523            controller.popup_focused
524        } else {
525            controller.anchor_focused
526        };
527        previous != focused
528            || (!popup
529                && !focused
530                && (controller.touch_focus_suppressed || controller.ignore_anchor_focus))
531    });
532    if changed {
533        controller.with_mut(|controller| controller.focus(popup, focused, current_frame_nanos()));
534    }
535}
536
537impl TooltipRestoreInput {
538    fn restore(&self) {
539        let (open, group, anchor) = self
540            .controller
541            .with(|controller| (controller.open, controller.popup_group, controller.focus));
542        if !open && group.has_focus() {
543            self.controller
544                .with_mut(|controller| controller.ignore_anchor_focus = true);
545            anchor.request_focus();
546        }
547    }
548}
549
550impl KeyboardInputModifierNode for TooltipRestoreInput {
551    fn on_keyboard_input(&self, _input: KeyboardInput<'_>) {
552        self.restore();
553    }
554}
555
556impl PointerInputModifierNode for TooltipRestoreInput {
557    fn on_pointer_input(&self, _input: PointerInput<'_>) {
558        self.restore();
559    }
560}
561
562#[derive(Clone, PartialEq)]
563struct TooltipLayout {
564    open: bool,
565    anchor: State<tessera_ui::FloatRect>,
566    origin: State<tessera_ui::LayoutCoordinates>,
567    visible: State<Option<tessera_ui::FloatRect>>,
568    visible_size: State<Option<ComputedData>>,
569    placement: TooltipPlacement,
570}
571
572fn publish_tooltip_viewport_size(
573    origin: State<tessera_ui::LayoutCoordinates>,
574    visible: State<Option<tessera_ui::FloatRect>>,
575    size: State<Option<ComputedData>>,
576) {
577    let transform = origin.get().local_to_window;
578    let next = visible.get().map(|bounds| {
579        let local = transform.inverse_rect(bounds);
580        ComputedData {
581            width: Px(local.width.floor() as i32),
582            height: Px(local.height.floor() as i32),
583        }
584    });
585    size.set_if_changed(next);
586}
587
588fn popup_position(
589    anchor: tessera_ui::FloatRect,
590    size: ComputedData,
591    available: tessera_ui::FloatRect,
592    placement: TooltipPlacement,
593) -> PxPosition {
594    let gap = Dp(4.0).to_pixels_f32();
595    let margin = Dp(8.0).to_pixels_f32();
596    let anchor = tessera_ui::FloatRect {
597        x: anchor.x - available.x,
598        y: anchor.y - available.y,
599        ..anchor
600    };
601    let width = available.width.max(0.0);
602    let height = available.height.max(0.0);
603    let popup_width = size.width.to_f32();
604    let popup_height = size.height.to_f32();
605    let above = anchor.y - gap - popup_height;
606    let below = anchor.y + anchor.height + gap;
607    let start = anchor.x - gap - popup_width;
608    let end = anchor.x + anchor.width + gap;
609    let center_x = anchor.x + (anchor.width - popup_width) * 0.5;
610    let center_y = anchor.y + (anchor.height - popup_height) * 0.5;
611    let (x, y) = match placement {
612        TooltipPlacement::Above => (center_x, if above >= margin { above } else { below }),
613        TooltipPlacement::Below => (
614            center_x,
615            if below + popup_height <= height - margin {
616                below
617            } else {
618                above
619            },
620        ),
621        TooltipPlacement::Start => (if start >= margin { start } else { end }, center_y),
622        TooltipPlacement::End => (
623            if end + popup_width <= width - margin {
624                end
625            } else {
626                start
627            },
628            center_y,
629        ),
630    };
631    PxPosition::from_f32_xy(
632        available.x
633            + x.clamp(
634                margin.min(width),
635                (width - popup_width - margin).max(margin.min(width)),
636            ),
637        available.y
638            + y.clamp(
639                margin.min(height),
640                (height - popup_height - margin).max(margin.min(height)),
641            ),
642    )
643}
644
645impl TooltipLayout {
646    fn place<T: LayoutPlacementTarget>(
647        &self,
648        children: &[T],
649        sizes: &[ComputedData],
650    ) -> LayoutResult {
651        let mut result = LayoutResult::new(sizes[0]);
652        result.place_child(children[0], PxPosition::ZERO);
653        if self.open
654            && children.len() > 1
655            && let Some(visible) = self.visible.get()
656        {
657            let transform = self.origin.get().local_to_window;
658            let visible = transform.inverse_rect(visible);
659            let anchor = transform.inverse_rect(self.anchor.get());
660            result.place_child(
661                children[1],
662                popup_position(anchor, sizes[1], visible, self.placement),
663            );
664        }
665        result
666    }
667}
668
669impl tessera_ui::LayoutModifierNode for TooltipLayout {
670    fn measure_eq(&self, other: &Self) -> bool {
671        self.open == other.open && self.visible_size == other.visible_size
672    }
673
674    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
675        let children = input.children();
676        let constraint = input.parent_constraint().without_min();
677        let main = children[0].measure(input, &constraint)?.size();
678        let mut sizes = vec![main];
679        if self.open && children.len() > 1 {
680            let visible = self.visible_size.get().unwrap_or(main);
681            let margin = Dp(16.0).to_px();
682            let constraint = tessera_ui::Constraint::new(
683                AxisConstraint::at_most((visible.width - margin).max(Px::ZERO)),
684                AxisConstraint::at_most((visible.height - margin).max(Px::ZERO)),
685            );
686            sizes.push(children[1].measure(input, &constraint)?.size());
687        }
688        Ok(input.placement(|| self.place(&children, &sizes)))
689    }
690
691    fn place_children(
692        &self,
693        scope: &PlacementScope<'_>,
694        _inner: &PlacementResult,
695    ) -> Option<PlacementResult> {
696        let children = scope.children();
697        let sizes = children
698            .iter()
699            .map(|child| child.size())
700            .collect::<Vec<_>>();
701        Some(self.place(&children, &sizes).into_placement_result())
702    }
703}
704
705/// # tooltip_provider
706///
707/// Host a contextual explanation above page content for a control or action.
708///
709/// ## Usage
710///
711/// Wrap a page and place its trigger inside `tooltip_anchor`.
712///
713/// ## Parameters
714///
715/// - `message` — supporting text shown in the popup
716/// - `variant`, `headline` — plain or rich appearance and optional rich title
717/// - `action_label`, `on_action` — optional rich action, which dismisses the
718///   popup
719/// - `actions` — custom rich actions, replacing the default text action
720/// - `controller` — optional shared visibility state for the provider and
721///   anchor
722/// - `placement` — preferred side, with edge avoidance and opposite-side
723///   fallback
724/// - `persistent` — keep a touch-triggered popup open until dismissal
725/// - `modifier` — modifiers for the page-sized host
726/// - `popup_modifier` — modifiers for the tooltip container
727/// - `child` — page content, containing one anchor for this provider
728///
729/// ## Examples
730///
731/// ```
732/// use tessera_components::tooltip::TooltipController;
733/// let mut tooltip = TooltipController::new();
734/// assert!(!tooltip.is_open());
735/// tooltip.show();
736/// assert!(tooltip.is_open());
737/// tooltip.dismiss();
738/// assert!(!tooltip.is_open());
739/// ```
740#[tessera]
741pub fn tooltip_provider(
742    #[prop(into)] message: String,
743    variant: Option<TooltipVariant>,
744    #[prop(into)] headline: Option<String>,
745    #[prop(into)] action_label: Option<String>,
746    on_action: Option<Callback>,
747    actions: Option<RenderSlot>,
748    controller: Option<State<TooltipController>>,
749    group: Option<State<Option<State<TooltipController>>>>,
750    placement: Option<TooltipPlacement>,
751    persistent: Option<bool>,
752    modifier: Option<Modifier>,
753    popup_modifier: Option<Modifier>,
754    child: Option<RenderSlotWith<State<TooltipController>>>,
755) {
756    let active = if let Some(group) = group {
757        group
758    } else {
759        remember(|| None::<State<TooltipController>>)
760    };
761    let controller = if let Some(controller) = controller {
762        controller
763    } else {
764        remember(TooltipController::new)
765    };
766    let variant = variant.unwrap_or_default();
767    let interactive =
768        variant == TooltipVariant::Rich && (action_label.is_some() || actions.is_some());
769    let persistent = persistent.unwrap_or(interactive);
770    let anchor = remember(|| tessera_ui::FloatRect::ZERO);
771    if controller.with(|controller| {
772        controller.anchor != Some(anchor)
773            || controller.description != message
774            || controller.persistent != persistent
775            || controller.interactive != interactive
776    }) {
777        controller.with_mut(|controller| {
778            controller.anchor = Some(anchor);
779            controller.description = message.clone();
780            controller.persistent = persistent;
781            controller.interactive = interactive;
782        });
783    }
784    let origin = remember(tessera_ui::LayoutCoordinates::default);
785    let popup = remember(|| tessera_ui::FloatRect::ZERO);
786    if controller.with(TooltipController::needs_frame) {
787        receive_frame_nanos(move |now| {
788            controller.with_mut(|controller| controller.advance(now));
789            if controller.with(TooltipController::needs_frame) {
790                FrameNanosControl::Continue
791            } else {
792                FrameNanosControl::Stop
793            }
794        });
795    }
796    let open = controller.with(TooltipController::is_open);
797    if open && active.get() != Some(controller) {
798        if let Some(previous) = active.get()
799            && previous.is_alive()
800        {
801            previous.with_mut(TooltipController::dismiss);
802        }
803        active.set(Some(controller));
804    } else if !open && active.get() == Some(controller) {
805        active.set(None);
806    }
807    if !open
808        && controller
809            .with(|controller| controller.popup_group)
810            .has_focus()
811    {
812        controller.with_mut(|controller| controller.ignore_anchor_focus = true);
813        controller
814            .with(|controller| controller.focus)
815            .request_focus();
816    }
817    let next = (
818        open,
819        message,
820        headline,
821        action_label,
822        on_action,
823        child,
824        variant,
825        persistent,
826        actions,
827        popup_modifier,
828    );
829    let render_data = remember(|| next.clone());
830    if render_data.with(|current| current != &next) {
831        render_data.set(next);
832    }
833    let mut modifier = modifier.unwrap_or_default();
834    modifier = modifier
835        .push_keyboard_preview_input(TooltipDismissInput {
836            controller,
837            popup: popup,
838        })
839        .push_pointer_preview_input(TooltipDismissInput {
840            controller,
841            popup: popup,
842        })
843        .push_keyboard_input(TooltipRestoreInput { controller })
844        .push_pointer_final_input(TooltipRestoreInput { controller });
845    let visible = remember(|| None::<tessera_ui::FloatRect>);
846    let visible_size = remember(|| None::<ComputedData>);
847    modifier = modifier
848        .on_layout_coordinates(move |coordinates| {
849            origin.set_if_changed(coordinates);
850            publish_tooltip_viewport_size(origin, visible, visible_size);
851        })
852        .on_visible_window_bounds(move |bounds| {
853            visible.set_if_changed(bounds);
854            publish_tooltip_viewport_size(origin, visible, visible_size);
855        });
856    layout()
857        .modifier(modifier)
858        .policy(TooltipLayout {
859            open,
860            anchor,
861            origin,
862            visible,
863            visible_size,
864            placement: placement.unwrap_or_default(),
865        })
866        .child(move || {
867            let (
868                open,
869                message,
870                headline,
871                action_label,
872                on_action,
873                child,
874                variant,
875                persistent,
876                actions,
877                popup_modifier,
878            ) = render_data.get();
879            if let Some(child) = child {
880                child.render(controller);
881            }
882            if open {
883                let has_actions = actions.is_some();
884                tooltip_panel(message.clone())
885                    .modifier_optional(popup_modifier)
886                    .variant(variant)
887                    .headline(headline.clone().unwrap_or_default())
888                    .action_label(action_label.clone().unwrap_or_default())
889                    .on_action_shared(on_action.unwrap_or_default())
890                    .controller(controller)
891                    .popup(popup)
892                    .persistent(persistent)
893                    .actions_shared(actions.unwrap_or_else(RenderSlot::empty))
894                    .has_actions(has_actions);
895            }
896        });
897}
898
899/// # tooltip_anchor
900///
901/// Attach hover, focus, and touch explanations to a control without changing
902/// its action.
903///
904/// ## Usage
905///
906/// Wrap a button or icon in the page content of `tooltip_provider`.
907///
908/// ## Parameters
909///
910/// - `controller` — controller received from the tooltip provider content slot
911/// - `modifier` — modifiers for the trigger wrapper
912/// - `child` — the control that receives focus and normal activation
913///
914/// ## Examples
915///
916/// ```
917/// use tessera_components::tooltip::TooltipPlacement;
918/// assert_eq!(TooltipPlacement::default(), TooltipPlacement::Above);
919/// ```
920#[tessera]
921pub fn tooltip_anchor(
922    controller: State<TooltipController>,
923    modifier: Option<Modifier>,
924    child: Option<RenderSlot>,
925) {
926    let child = child.unwrap_or_else(RenderSlot::empty);
927    let focus = controller.with(|controller| controller.focus);
928    let anchor = controller
929        .with(|controller| controller.anchor)
930        .expect("tooltip anchor requires a provider");
931    let modifier = modifier
932        .unwrap_or_default()
933        .semantics(SemanticsArgs {
934            role: Some(Role::Group),
935            merge_descendants: true,
936            tooltip: Some(controller.with(|controller| controller.description.clone())),
937            ..Default::default()
938        })
939        .focus_group()
940        .focus_requester(focus)
941        .on_focus_changed(move |_: FocusState| sync_tooltip_focus(controller, false))
942        .on_globally_positioned_precise(move |rect| {
943            anchor.set_if_changed(rect);
944        })
945        .push_pointer_preview_input(TooltipPointer {
946            controller,
947            popup: false,
948            persistent: controller.with(|controller| controller.persistent),
949        });
950    layout().modifier(modifier).child(move || child.render());
951}
952
953#[tessera]
954fn tooltip_panel(
955    #[prop(into)] message: String,
956    variant: Option<TooltipVariant>,
957    headline: Option<String>,
958    action_label: Option<String>,
959    on_action: Option<Callback>,
960    actions: Option<RenderSlot>,
961    has_actions: Option<bool>,
962    controller: Option<State<TooltipController>>,
963    popup: Option<State<tessera_ui::FloatRect>>,
964    persistent: Option<bool>,
965    modifier: Option<Modifier>,
966) {
967    let theme = use_context::<MaterialTheme>()
968        .expect("MaterialTheme must be provided")
969        .get();
970    let variant = variant.unwrap_or_default();
971    let rich = variant == TooltipVariant::Rich;
972    let has_actions = has_actions.unwrap_or(false);
973    let actions = actions.unwrap_or_else(RenderSlot::empty);
974    let headline = headline.filter(|text| !text.is_empty());
975    let action_label = action_label.filter(|text| !text.is_empty());
976    let controller = controller.expect("tooltip panel requires controller");
977    let popup = popup.expect("tooltip panel requires bounds");
978    let max_width = if rich { Dp(320.0) } else { Dp(200.0) };
979    let next = (
980        theme.clone(),
981        message.clone(),
982        headline.clone(),
983        action_label.clone(),
984        rich,
985        actions,
986        has_actions,
987    );
988    let render_data = remember(|| next.clone());
989    if render_data.with(|current| current != &next) {
990        render_data.set(next);
991    }
992    let modifier = modifier
993        .unwrap_or_default()
994        .size_in(Some(Dp(40.0)), Some(max_width), Some(Dp(24.0)), None)
995        .focus_group_with(controller.with(|controller| controller.popup_group))
996        .focus_requester(controller.with(|controller| controller.popup_focus))
997        .on_focus_changed(move |_: FocusState| sync_tooltip_focus(controller, true))
998        .push_keyboard_input(TooltipRestoreInput { controller })
999        .push_pointer_final_input(TooltipRestoreInput { controller })
1000        .on_globally_positioned_precise(move |rect| {
1001            popup.set_if_changed(rect);
1002        })
1003        .push_pointer_preview_input(TooltipPointer {
1004            controller,
1005            popup: true,
1006            persistent: persistent.unwrap_or(false),
1007        })
1008        .semantics(SemanticsArgs {
1009            role: Some(if rich && (action_label.is_some() || has_actions) {
1010                Role::Dialog
1011            } else {
1012                Role::Tooltip
1013            }),
1014            label: Some(if rich {
1015                headline.clone().unwrap_or_else(|| message.clone())
1016            } else {
1017                message.clone()
1018            }),
1019            live: Some(Live::Polite),
1020            description: (rich && headline.is_some()).then(|| message.clone()),
1021            merge_descendants: rich && (action_label.is_some() || has_actions),
1022            ..Default::default()
1023        });
1024    surface()
1025        .modifier(modifier)
1026        .shape(Shape::rounded_rectangle(if rich {
1027            Dp(12.0)
1028        } else {
1029            Dp(4.0)
1030        }))
1031        .style(
1032            if rich {
1033                theme.color_scheme.surface_container
1034            } else {
1035                theme.color_scheme.inverse_surface
1036            }
1037            .into(),
1038        )
1039        .content_color(if rich {
1040            theme.color_scheme.on_surface_variant
1041        } else {
1042            theme.color_scheme.inverse_on_surface
1043        })
1044        .elevation(if rich { Dp(3.0) } else { Dp(0.0) })
1045        .child(move || {
1046            let (rich, action_row) = render_data.with(|(_, _, _, label, rich, _, has_actions)| {
1047                (*rich, label.is_some() || *has_actions)
1048            });
1049            column()
1050                .modifier(Modifier::new().padding(if rich {
1051                    Padding::new(
1052                        Dp::ZERO,
1053                        Dp(12.0),
1054                        Dp::ZERO,
1055                        if action_row { Dp(8.0) } else { Dp(16.0) },
1056                    )
1057                } else {
1058                    Padding::symmetric(Dp(8.0), Dp(4.0))
1059                }))
1060                .children(move || {
1061                    let (theme, message, headline, action_label, rich, actions, has_actions) =
1062                        render_data.get();
1063                    if rich {
1064                        column()
1065                            .modifier(
1066                                Modifier::new().padding(Padding::symmetric(Dp(16.0), Dp::ZERO)),
1067                            )
1068                            .children(move || {
1069                                if let Some(headline) = &headline {
1070                                    text(headline.clone()).style(theme.typography.title_small);
1071                                    crate::spacer::spacer()
1072                                        .modifier(Modifier::new().height(Dp(4.0)));
1073                                }
1074                                text(message.clone()).style(theme.typography.body_medium);
1075                            });
1076                    } else {
1077                        text(message.clone()).style(theme.typography.body_small);
1078                    }
1079                    if rich && has_actions {
1080                        crate::spacer::spacer().modifier(Modifier::new().height(Dp(12.0)));
1081                        actions.render();
1082                    } else if rich && action_label.is_some() {
1083                        crate::spacer::spacer().modifier(Modifier::new().height(Dp(12.0)));
1084                        button()
1085                            .text()
1086                            .accessibility_label(action_label.clone().unwrap_or_default())
1087                            .on_click(move || {
1088                                controller.with_mut(TooltipController::dismiss);
1089                                if let Some(on_action) = on_action {
1090                                    on_action.call();
1091                                }
1092                            })
1093                            .child(move || {
1094                                let label = render_data.with(|(_, _, _, label, _, _, _)| {
1095                                    label.clone().unwrap_or_default()
1096                                });
1097                                text(label).style(render_data.with(|(theme, _, _, _, _, _, _)| {
1098                                    theme.typography.label_large
1099                                }));
1100                            });
1101                    }
1102                });
1103        });
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108
1109    use tessera_ui::{
1110        accesskit::{Action, ActionRequest, TreeId},
1111        headless::{HeadlessConfig, HeadlessRenderer},
1112    };
1113
1114    use crate::{boxed::boxed, theme::material_theme};
1115
1116    use super::*;
1117
1118    #[test]
1119    fn hover_delay_gap_crossing_escape_suppression_and_idle_clock() {
1120        let mut controller = TooltipController::new();
1121        controller.hover(false, true, 0);
1122        controller.advance(SHOW_DELAY - 1);
1123        assert!(!controller.is_open());
1124        controller.advance(SHOW_DELAY);
1125        assert!(controller.is_open());
1126        assert!(!controller.needs_frame());
1127        controller.hover(false, false, SHOW_DELAY);
1128        controller.hover(true, true, SHOW_DELAY + EXIT_DELAY / 2);
1129        controller.advance(SHOW_DELAY + EXIT_DELAY * 2);
1130        assert!(controller.is_open());
1131        controller.dismiss();
1132        controller.hover(true, true, SHOW_DELAY * 2);
1133        controller.advance(SHOW_DELAY * 3);
1134        assert!(!controller.is_open());
1135        assert!(!controller.needs_frame());
1136        controller.hover(true, false, SHOW_DELAY * 3);
1137        controller.hover(false, true, SHOW_DELAY * 4);
1138        controller.advance(SHOW_DELAY * 5);
1139        assert!(controller.is_open());
1140    }
1141
1142    #[test]
1143    fn focus_keeps_hovered_content_and_escape_restoration_does_not_reopen_it() {
1144        let mut controller = TooltipController::new();
1145        controller.focus(false, true, 0);
1146        assert!(controller.is_open());
1147        controller.focus(false, false, 1);
1148        controller.focus(true, true, 2);
1149        controller.advance(EXIT_DELAY * 2);
1150        assert!(controller.is_open());
1151        controller.dismiss();
1152        controller.focus(true, false, 3);
1153        controller.focus(false, true, 4);
1154        controller.advance(EXIT_DELAY * 3);
1155        assert!(!controller.is_open());
1156        controller.focus(false, false, EXIT_DELAY * 4);
1157        controller.focus(false, true, EXIT_DELAY * 5);
1158        assert!(controller.is_open());
1159        controller.dismiss();
1160        controller.ignore_anchor_focus = true;
1161        controller.focus(false, false, EXIT_DELAY * 6);
1162        controller.focus(false, true, EXIT_DELAY * 7);
1163        assert!(controller.is_open());
1164    }
1165
1166    #[test]
1167    fn touch_waits_for_threshold_and_manual_visibility_does_not_run_a_clock() {
1168        let mut controller = TooltipController::new();
1169        controller.touch = Some(TouchPress {
1170            pointer: 1,
1171            origin: PxPosition::ZERO,
1172            since: 0,
1173            triggered: false,
1174        });
1175        controller.focus(false, true, 0);
1176        controller.advance(SHOW_DELAY - 1);
1177        assert!(!controller.is_open());
1178        controller.advance(SHOW_DELAY);
1179        assert!(controller.is_open());
1180        assert!(!controller.needs_frame());
1181        controller.show();
1182        controller.advance(u64::MAX);
1183        assert!(controller.is_open());
1184        assert!(!controller.needs_frame());
1185    }
1186
1187    #[test]
1188    fn stale_popup_input_cannot_reopen_a_dismissed_tooltip() {
1189        let mut controller = TooltipController::new();
1190        controller.show();
1191        controller.dismiss();
1192        controller.focus(true, true, 1);
1193        controller.hover(true, true, 2);
1194        controller.advance(SHOW_DELAY * 2);
1195        assert!(!controller.is_open());
1196        assert!(!controller.needs_frame());
1197        controller.focus(true, false, SHOW_DELAY * 3);
1198        controller.focus(false, true, SHOW_DELAY * 4);
1199        assert!(controller.is_open());
1200    }
1201
1202    #[test]
1203    fn touch_focus_suppression_clears_when_actual_focus_leaves_the_anchor() {
1204        use tessera_ui::testing::{
1205            layout_test, pump_layout_test_session, start_layout_test_session,
1206        };
1207
1208        let observed = tessera_ui::testing::TestProbe::default();
1209        let fixture_observed = observed.clone();
1210        let mut session = start_layout_test_session(
1211            layout_test(move || {
1212                fixture(fixture_observed.clone());
1213            })
1214            .viewport_px(400, 320),
1215        );
1216        pump_layout_test_session(&mut session, 0);
1217        let controller = observed.get().controller.expect("controller");
1218        let anchor = controller.with(|controller| controller.focus);
1219        controller.with_mut(|controller| {
1220            controller.touch_focus_suppressed = true;
1221            controller.anchor_focused = false;
1222        });
1223        anchor.request_focus();
1224        assert!(anchor.has_focus());
1225        assert!(!controller.with(|controller| controller.anchor_focused));
1226        anchor.clear_focus();
1227        assert!(!controller.with(|controller| controller.touch_focus_suppressed));
1228        anchor.request_focus();
1229        assert!(controller.with(TooltipController::is_open));
1230    }
1231
1232    #[test]
1233    fn placement_flips_at_each_edge_and_clamps_to_window_margins() {
1234        let available = tessera_ui::FloatRect {
1235            x: 0.0,
1236            y: 0.0,
1237            width: 400.0,
1238            height: 300.0,
1239        };
1240        let popup = ComputedData {
1241            width: Px(120),
1242            height: Px(40),
1243        };
1244        let cases = [
1245            (
1246                TooltipPlacement::Above,
1247                PxRect::new(Px(150), Px(2), Px(40), Px(40)),
1248                PxPosition::new(Px(110), Px(46)),
1249            ),
1250            (
1251                TooltipPlacement::Below,
1252                PxRect::new(Px(150), Px(260), Px(40), Px(40)),
1253                PxPosition::new(Px(110), Px(216)),
1254            ),
1255            (
1256                TooltipPlacement::Start,
1257                PxRect::new(Px(2), Px(100), Px(40), Px(40)),
1258                PxPosition::new(Px(46), Px(100)),
1259            ),
1260            (
1261                TooltipPlacement::End,
1262                PxRect::new(Px(360), Px(100), Px(40), Px(40)),
1263                PxPosition::new(Px(236), Px(100)),
1264            ),
1265            (
1266                TooltipPlacement::Below,
1267                PxRect::new(Px(150), Px(100), Px(41), Px(40)),
1268                PxPosition::from_f32_xy(110.5, 144.0),
1269            ),
1270        ];
1271        for (placement, anchor, expected) in cases {
1272            assert_eq!(
1273                popup_position(anchor.into(), popup, available, placement),
1274                expected
1275            );
1276        }
1277        let left = popup_position(
1278            PxRect::new(Px(0), Px(100), Px(40), Px(40)).into(),
1279            popup,
1280            available,
1281            TooltipPlacement::Above,
1282        );
1283        assert_eq!(left.x, 8.0);
1284    }
1285
1286    #[derive(Clone, PartialEq, Default)]
1287    struct Settings {
1288        rich: bool,
1289        changed: bool,
1290        focus_stress: bool,
1291        custom_actions: bool,
1292        without_headline: bool,
1293        popup_width: Option<Dp>,
1294    }
1295
1296    #[derive(Clone, Copy, Default)]
1297    struct FixtureHandles {
1298        visible_host: Option<tessera_ui::FloatRect>,
1299        clip_geometry: Option<tessera_ui::LayoutCoordinates>,
1300        host_geometry: Option<tessera_ui::LayoutCoordinates>,
1301        popup_bounds: Option<tessera_ui::FloatRect>,
1302        scratch_popup: Option<State<tessera_ui::FloatRect>>,
1303        controller: Option<State<TooltipController>>,
1304        settings: Option<State<Settings>>,
1305        clicks: Option<State<u32>>,
1306        actions: Option<State<u32>>,
1307    }
1308
1309    #[tessera]
1310    fn fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1311        let scratch_popup = remember(|| tessera_ui::FloatRect::ZERO);
1312        observed.with_mut(|handles| handles.scratch_popup = Some(scratch_popup));
1313        let controller = remember(TooltipController::new);
1314        let settings = remember(Settings::default);
1315        let clicks = remember(|| 0_u32);
1316        let actions = remember(|| 0_u32);
1317        let other_tooltip = remember(TooltipController::new);
1318        let group = remember(|| None::<State<TooltipController>>);
1319        // Rebuild the parent boundary when an action also updates sibling
1320        // content.
1321        let _ = actions.get();
1322        observed.with_mut(|handles| handles.controller = Some(controller));
1323        observed.with_mut(|handles| handles.settings = Some(settings));
1324        observed.with_mut(|handles| handles.clicks = Some(clicks));
1325        observed.with_mut(|handles| handles.actions = Some(actions));
1326        material_theme().child(move || {
1327            let config = settings.get();
1328            let popup_modifier = Modifier::new()
1329                .on_globally_positioned_precise({ let observed = observed.clone(); move |bounds| observed.with_mut(|handles| handles.popup_bounds = Some(bounds)) });
1330            let popup_modifier = if let Some(width) = config.popup_width {
1331                popup_modifier.width(width)
1332            } else {
1333                popup_modifier
1334            };
1335            let tooltip = tooltip_provider(if config.changed { "Updated help text with multiple words that wraps within the tooltip's maximum width." } else { "Helpful description" })
1336                .popup_modifier(popup_modifier)
1337                .controller(controller)
1338                .group(group)
1339                .variant(if config.rich { TooltipVariant::Rich } else { TooltipVariant::Plain })
1340                .headline(if config.without_headline { "" } else { "More information" })
1341                .action_label(if config.rich { "Got it" } else { "" })
1342                .on_action(move || actions.set(actions.get() + 1));
1343            let tooltip = if config.custom_actions {
1344                tooltip.actions(move || {
1345                    crate::row::row().children(move || {
1346                        button().text().accessibility_label("Not now").on_click(move || controller.with_mut(TooltipController::dismiss)).child(|| { text("Not now"); });
1347                        button().text().accessibility_label("Got it").on_click(move || {
1348                            controller.with_mut(TooltipController::dismiss);
1349                            actions.set(actions.get() + 1);
1350                        }).child(|| { text("Got it"); });
1351                    });
1352                })
1353            } else {
1354                tooltip
1355            };
1356            tooltip.child(move |_| {
1357                tooltip_provider("Other help").controller(other_tooltip).group(group).child(move |_| {
1358                    boxed().modifier(Modifier::new().fill_max_size().padding_all(Dp(80.0))).children(move || {
1359                        tooltip_anchor(controller).child(move || {
1360                            button().modifier(Modifier::new().width(Dp(120.0))).accessibility_label("Trigger").on_click(move || {
1361                                clicks.set(clicks.get() + 1);
1362                                if settings.with(|settings| settings.focus_stress) {
1363                                    let (anchor, group) = controller.with(|controller| (controller.focus, controller.popup_group));
1364                                    assert!(group.move_focus(FocusDirection::Next));
1365                                    anchor.request_focus();
1366                                    assert!(group.move_focus(FocusDirection::Next));
1367                                    controller.with_mut(TooltipController::dismiss);
1368                                    TooltipRestoreInput { controller }.restore();
1369                                }
1370                            }).child(|| { text("Trigger"); });
1371                        });
1372                    });
1373                });
1374            });
1375        });
1376    }
1377
1378    fn renderer() -> (
1379        HeadlessRenderer,
1380        tessera_ui::testing::TestProbe<FixtureHandles>,
1381    ) {
1382        let observed = tessera_ui::testing::TestProbe::default();
1383        let fixture_observed = observed.clone();
1384        let mut renderer = HeadlessRenderer::new(
1385            Box::new(move || {
1386                fixture(fixture_observed.clone());
1387            }),
1388            vec![Box::new(crate::TesseraComponents)],
1389            HeadlessConfig {
1390                width: 400,
1391                height: 320,
1392                ..Default::default()
1393            },
1394        )
1395        .expect("headless renderer");
1396        renderer.render_frames(3);
1397        (renderer, observed)
1398    }
1399
1400    fn anchor_center(observed: &tessera_ui::testing::TestProbe<FixtureHandles>) -> PxPosition {
1401        let rect = observed
1402            .get()
1403            .controller
1404            .expect("controller")
1405            .with(TooltipController::anchor);
1406        PxPosition::from_f32_xy(rect.x + rect.width * 0.5, rect.y + rect.height * 0.5)
1407    }
1408
1409    #[tessera]
1410    fn clipped_host_fixture(scale: f32, observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1411        let controller = remember(|| {
1412            let mut controller = TooltipController::new();
1413            controller.show();
1414            controller
1415        });
1416        observed.with_mut(|handles| handles.controller = Some(controller));
1417        tessera_ui::scaled_box(scale).child(move || {
1418            let observed = observed.clone();
1419            material_theme().child(move || {
1420                let observed = observed.clone();
1421                boxed()
1422                    .modifier(
1423                        Modifier::new()
1424                            .clip_to_bounds()
1425                            .required_size(Dp(400.0), Dp(300.0))
1426                            .on_layout_coordinates({
1427                                let observed = observed.clone();
1428                                move |coordinates| {
1429                                    observed.with_mut(|handles| {
1430                                        handles.clip_geometry = Some(coordinates)
1431                                    });
1432                                }
1433                            }),
1434                    )
1435                    .children(move || {
1436                        tooltip_provider("This explanation must remain visible above the trigger.")
1437                            .controller(controller)
1438                            .variant(TooltipVariant::Rich)
1439                            .action_label("Got it")
1440                            .placement(TooltipPlacement::Below)
1441                            .modifier(
1442                                Modifier::new()
1443                                    .required_size(Dp(400.0), Dp(1000.0))
1444                                    .on_visible_window_bounds({
1445                                        let observed = observed.clone();
1446                                        move |bounds| {
1447                                            observed
1448                                                .with_mut(|handles| handles.visible_host = bounds)
1449                                        }
1450                                    })
1451                                    .on_layout_coordinates({
1452                                        let observed = observed.clone();
1453                                        move |coordinates| {
1454                                            observed.with_mut(|handles| {
1455                                                handles.host_geometry = Some(coordinates)
1456                                            });
1457                                        }
1458                                    }),
1459                            )
1460                            .child(move |_| {
1461                                boxed()
1462                                    .modifier(Modifier::new().required_size(Dp(400.0), Dp(1000.0)))
1463                                    .children(move || {
1464                                        tooltip_anchor(controller)
1465                                            .modifier(Modifier::new().offset(Dp(120.0), Dp(240.0)))
1466                                            .child(|| {
1467                                                button().child(|| {
1468                                                    crate::text::text("Trigger");
1469                                                });
1470                                            });
1471                                    });
1472                            });
1473                    });
1474            });
1475        });
1476    }
1477
1478    #[test]
1479    #[ignore = "requires a native GPU adapter"]
1480    fn gpu_rich_tooltip_flips_inside_clipped_scaled_hosts_and_its_action_remains_available() {
1481        for scale in [1.0, 0.75] {
1482            let observed = tessera_ui::testing::TestProbe::default();
1483            let fixture_observed = observed.clone();
1484            let mut renderer = HeadlessRenderer::new(
1485                Box::new(move || {
1486                    clipped_host_fixture(scale, fixture_observed.clone());
1487                }),
1488                vec![Box::new(crate::TesseraComponents)],
1489                HeadlessConfig {
1490                    width: 400,
1491                    height: 320,
1492                    ..Default::default()
1493                },
1494            )
1495            .unwrap();
1496            renderer.render_frames(6);
1497            let tree = renderer.accessibility_snapshot().unwrap();
1498            let popup = tree
1499                .nodes
1500                .iter()
1501                .find(|(_, node)| node.role() == Role::Dialog)
1502                .expect("placed rich tooltip")
1503                .1
1504                .bounds()
1505                .unwrap();
1506            let anchor = observed
1507                .get()
1508                .controller
1509                .unwrap()
1510                .with(TooltipController::anchor);
1511            assert_eq!(
1512                observed.get().visible_host,
1513                Some(tessera_ui::FloatRect {
1514                    x: 0.0,
1515                    y: 0.0,
1516                    width: 400.0 * scale,
1517                    height: 300.0 * scale,
1518                }),
1519                "the overflow host must be clipped by its actual scaled ancestor"
1520            );
1521
1522            assert!(popup.y0 >= 8.0 * f64::from(scale) - 1.0, "{popup:?}");
1523            assert!(
1524                popup.y1 <= f64::from(anchor.y) - 4.0 * f64::from(scale) + 1.0,
1525                "a preferred-below popup must flip above the bottom-edge trigger: {popup:?}, {anchor:?}, visible {:?}, host {:?}, clip {:?}",
1526                observed.get().visible_host,
1527                observed.get().host_geometry,
1528                observed.get().clip_geometry
1529            );
1530            assert!(popup.y1 <= 300.0 * f64::from(scale), "{popup:?}");
1531            renderer.resize(400, 200);
1532            renderer.render_frames(6);
1533            let tree = renderer.accessibility_snapshot().unwrap();
1534            let resized = tree
1535                .nodes
1536                .iter()
1537                .find(|(_, node)| node.role() == Role::Dialog)
1538                .unwrap()
1539                .1
1540                .bounds()
1541                .unwrap();
1542            assert!(
1543                resized.y1 <= 200.0 - 8.0 * f64::from(scale) + 1.0,
1544                "window resize must update visible bounds even when the host keeps its size: {resized:?}"
1545            );
1546            let action = tree
1547                .nodes
1548                .iter()
1549                .find(|(_, node)| node.role() == Role::Button && node.label() == Some("Got it"))
1550                .expect("tooltip action")
1551                .0;
1552            assert!(renderer.dispatch_accessibility_action(ActionRequest {
1553                action: Action::Click,
1554                target_node: action,
1555                data: None,
1556                target_tree: TreeId::ROOT,
1557            }));
1558            renderer.render_frames(4);
1559            assert!(
1560                !observed
1561                    .get()
1562                    .controller
1563                    .unwrap()
1564                    .with(TooltipController::is_open)
1565            );
1566        }
1567    }
1568
1569    #[test]
1570    #[ignore = "requires a native GPU adapter"]
1571    fn gpu_hover_restores_pixels_preserves_focus_and_updates_content_and_variant() {
1572        for without_headline in [false, true] {
1573            check_tooltip_interactions(false, without_headline);
1574        }
1575    }
1576
1577    #[test]
1578    #[ignore = "requires a native GPU adapter"]
1579    fn gpu_custom_actions_restore_focus_and_pixels_after_parent_rebuild() {
1580        for without_headline in [false, true] {
1581            check_tooltip_interactions(true, without_headline);
1582        }
1583    }
1584
1585    #[test]
1586    #[ignore = "requires a native GPU adapter"]
1587    fn gpu_popup_modifier_preserves_requested_width_and_restores_natural_bounds() {
1588        let (mut renderer, observed) = renderer();
1589        let settings = observed.get().settings.expect("settings");
1590        settings.with_mut(|settings| {
1591            settings.rich = true;
1592            settings.popup_width = Some(Dp(312.0));
1593        });
1594        observed
1595            .get()
1596            .controller
1597            .expect("controller")
1598            .with_mut(TooltipController::show);
1599        renderer.render_frames(4);
1600        let requested = renderer.accessibility_snapshot().expect("tree");
1601        let requested = requested
1602            .nodes
1603            .iter()
1604            .find(|(_, node)| node.role() == Role::Dialog)
1605            .expect("rich tooltip")
1606            .1
1607            .bounds()
1608            .expect("popup bounds");
1609        assert_eq!(requested.width(), 312.0);
1610        assert_eq!(
1611            observed.get().popup_bounds.expect("observed popup").width,
1612            312.0
1613        );
1614
1615        settings.with_mut(|settings| settings.popup_width = None);
1616        renderer.render_frames(4);
1617        let natural = renderer.accessibility_snapshot().expect("tree");
1618        let natural = natural
1619            .nodes
1620            .iter()
1621            .find(|(_, node)| node.role() == Role::Dialog)
1622            .expect("rich tooltip")
1623            .1
1624            .bounds()
1625            .expect("popup bounds");
1626        assert!(natural.width() >= 40.0 && natural.width() <= 320.0);
1627        assert!(natural.width() < requested.width());
1628        assert_eq!(
1629            observed.get().popup_bounds.expect("observed popup").width as f64,
1630            natural.width()
1631        );
1632    }
1633
1634    fn assert_rich_action_insets(
1635        renderer: &HeadlessRenderer,
1636        custom_actions: bool,
1637        without_headline: bool,
1638    ) {
1639        let tree = renderer.accessibility_snapshot().expect("tree");
1640        let bounds = |role: Role, label: &str| {
1641            tree.nodes
1642                .iter()
1643                .find(|(_, node)| node.role() == role && node.label() == Some(label))
1644                .unwrap_or_else(|| panic!("missing {label} ({role:?})"))
1645                .1
1646                .bounds()
1647                .expect("semantic bounds")
1648        };
1649        let popup = tree
1650            .nodes
1651            .iter()
1652            .find(|(_, node)| node.role() == Role::Dialog)
1653            .expect("rich tooltip")
1654            .1
1655            .bounds()
1656            .expect("popup bounds");
1657        let body = bounds(Role::Label, "Helpful description");
1658        assert_eq!(body.x0 - popup.x0, 16.0);
1659        if without_headline {
1660            assert!(
1661                !tree.nodes.iter().any(|(_, node)| node.role() == Role::Label
1662                    && node.label() == Some("More information"))
1663            );
1664            assert_eq!(body.y0 - popup.y0, 12.0);
1665        } else {
1666            let headline = bounds(Role::Label, "More information");
1667            assert_eq!(headline.x0 - popup.x0, 16.0);
1668            assert_eq!(headline.y0 - popup.y0, 12.0);
1669            assert_eq!(body.y0 - headline.y1, 4.0);
1670        }
1671        let first_label = if custom_actions { "Not now" } else { "Got it" };
1672        let first = bounds(Role::Button, first_label);
1673        let first_text = bounds(Role::Label, first_label);
1674        assert_eq!(first_text.x0 - popup.x0, 16.0);
1675        assert_eq!(first.y0 - body.y1, 12.0);
1676        assert_eq!(popup.y1 - first.y1, 8.0);
1677        let labels: &[&str] = if custom_actions {
1678            &["Not now", "Got it"]
1679        } else {
1680            &["Got it"]
1681        };
1682        for label in labels {
1683            let button = bounds(Role::Button, label);
1684            let text = bounds(Role::Label, label);
1685            assert!(button.width() >= 48.0 && button.height() >= 48.0);
1686            assert!(
1687                text.x0 >= button.x0
1688                    && text.x1 <= button.x1
1689                    && text.y0 >= button.y0
1690                    && text.y1 <= button.y1
1691            );
1692            assert_eq!(text.x0 - button.x0, 16.0);
1693        }
1694        if custom_actions {
1695            let second = bounds(Role::Button, "Got it");
1696            assert_eq!(second.x0, first.x1);
1697            assert_eq!(second.y0, first.y0);
1698            assert_eq!(second.y1, first.y1);
1699        }
1700    }
1701
1702    fn check_tooltip_interactions(custom_actions: bool, without_headline: bool) {
1703        let (mut renderer, observed) = renderer();
1704        observed
1705            .get()
1706            .settings
1707            .expect("settings")
1708            .with_mut(|settings| {
1709                settings.custom_actions = custom_actions;
1710                settings.without_headline = without_headline;
1711            });
1712        renderer.render_frames(2);
1713        let before = renderer.read_pixels().expect("pixels");
1714        let focus = renderer.accessibility_snapshot().expect("tree").focus;
1715        renderer.pointer_move(MOUSE_POINTER_ID, anchor_center(&observed));
1716        renderer.render_frames(35);
1717        assert!(
1718            observed
1719                .get()
1720                .controller
1721                .expect("controller")
1722                .with(TooltipController::is_open),
1723            "{:?}",
1724            observed.get().controller.expect("controller").get()
1725        );
1726        let tree = renderer.accessibility_snapshot().expect("tree");
1727        assert_eq!(tree.focus, focus);
1728        let tooltip = tree
1729            .nodes
1730            .iter()
1731            .find(|(_, node)| node.role() == Role::Tooltip)
1732            .expect("tooltip");
1733        assert_eq!(tooltip.1.label(), Some("Helpful description"));
1734        let bounds = tooltip.1.bounds().expect("bounds");
1735        let popup_bounds = observed
1736            .get()
1737            .popup_bounds
1738            .expect("popup modifier observes the actual container");
1739        assert_eq!(popup_bounds.x as f64, bounds.x0);
1740        assert_eq!(popup_bounds.y as f64, bounds.y0);
1741        assert_eq!(popup_bounds.width as f64, bounds.width());
1742        assert_eq!(popup_bounds.height as f64, bounds.height());
1743        assert!(bounds.x1 - bounds.x0 <= 200.0);
1744        assert!(bounds.y1 - bounds.y0 >= 24.0);
1745        let trigger = tree
1746            .nodes
1747            .iter()
1748            .find(|(_, node)| node.label() == Some("Trigger") && node.role() == Role::Button)
1749            .expect("trigger");
1750        assert_eq!(trigger.1.description(), Some("Helpful description"));
1751        observed
1752            .get()
1753            .settings
1754            .expect("settings")
1755            .with_mut(|settings| settings.changed = true);
1756        renderer.render_frames(3);
1757        assert!(
1758            renderer
1759                .accessibility_snapshot()
1760                .expect("tree")
1761                .nodes
1762                .iter()
1763                .any(|(_, node)| node
1764                    .label()
1765                    .is_some_and(|label| label.starts_with("Updated help text")))
1766        );
1767        observed
1768            .get()
1769            .settings
1770            .expect("settings")
1771            .with_mut(|settings| {
1772                settings.rich = true;
1773                settings.changed = false;
1774            });
1775        renderer.render_frames(3);
1776        let tree = renderer.accessibility_snapshot().expect("tree");
1777        assert!(
1778            tree.nodes
1779                .iter()
1780                .any(|(_, node)| node.role() == Role::Dialog)
1781        );
1782        let body = tree
1783            .nodes
1784            .iter()
1785            .find(|(_, node)| {
1786                node.role() == Role::Label && node.label() == Some("Helpful description")
1787            })
1788            .expect("rich supporting text")
1789            .1
1790            .bounds()
1791            .expect("supporting text bounds");
1792        let action = tree
1793            .nodes
1794            .iter()
1795            .find(|(_, node)| node.role() == Role::Button && node.label() == Some("Got it"))
1796            .expect("rich action")
1797            .1
1798            .bounds()
1799            .expect("action bounds");
1800        assert_eq!(
1801            action.y0 - body.y1,
1802            12.0,
1803            "rich tooltip separates supporting text from actual actions by 12dp"
1804        );
1805        assert_rich_action_insets(&renderer, custom_actions, without_headline);
1806        let controller = observed.get().controller.expect("controller");
1807        let trigger = controller.with(|controller| controller.focus);
1808        trigger.request_focus();
1809        renderer.render_frames(2);
1810        let navigation = TooltipDismissInput {
1811            controller,
1812            popup: observed.get().scratch_popup.expect("test popup state"),
1813        };
1814        for _ in 0..2 {
1815            assert!(navigation.handle_tab(ElementState::Pressed, false));
1816            if custom_actions {
1817                assert!(navigation.handle_tab(ElementState::Pressed, false));
1818            }
1819            renderer.render_frames(2);
1820            let focused = renderer.accessibility_snapshot().expect("tree");
1821            assert_eq!(
1822                focused
1823                    .nodes
1824                    .iter()
1825                    .find(|(id, _)| *id == focused.focus)
1826                    .expect("focused action")
1827                    .1
1828                    .label(),
1829                Some("Got it")
1830            );
1831            assert!(navigation.handle_tab(ElementState::Pressed, true));
1832            if custom_actions {
1833                assert!(navigation.handle_tab(ElementState::Pressed, true));
1834            }
1835            renderer.render_frames(2);
1836            let focused = renderer.accessibility_snapshot().expect("tree");
1837            assert_eq!(
1838                focused
1839                    .nodes
1840                    .iter()
1841                    .find(|(id, _)| *id == focused.focus)
1842                    .expect("focused trigger")
1843                    .1
1844                    .label(),
1845                Some("Trigger")
1846            );
1847        }
1848        assert!(navigation.handle_tab(ElementState::Pressed, false));
1849        controller.with_mut(TooltipController::dismiss);
1850        TooltipRestoreInput { controller }.restore();
1851        assert!(trigger.has_focus());
1852        assert!(
1853            !controller
1854                .with(|controller| controller.popup_group)
1855                .has_focus()
1856        );
1857        renderer.render_frames(2);
1858        assert!(!controller.with(TooltipController::is_open));
1859        controller.with_mut(TooltipController::show);
1860        observed
1861            .get()
1862            .settings
1863            .expect("settings")
1864            .with_mut(|settings| settings.focus_stress = true);
1865        renderer.render_frames(2);
1866        renderer.pointer_press(
1867            MOUSE_POINTER_ID,
1868            anchor_center(&observed),
1869            PressKeyEventType::Left,
1870        );
1871        renderer.step_frame();
1872        renderer.pointer_release(
1873            MOUSE_POINTER_ID,
1874            anchor_center(&observed),
1875            PressKeyEventType::Left,
1876        );
1877        renderer.render_frames(3);
1878        assert!(!controller.with(TooltipController::is_open));
1879        assert!(trigger.has_focus());
1880        observed
1881            .get()
1882            .settings
1883            .expect("settings")
1884            .with_mut(|settings| settings.focus_stress = false);
1885        controller.with_mut(TooltipController::show);
1886        renderer.render_frames(2);
1887        let action = tree
1888            .nodes
1889            .iter()
1890            .find(|(_, node)| node.role() == Role::Button && node.label() == Some("Got it"))
1891            .expect("rich action")
1892            .1
1893            .bounds()
1894            .expect("bounds");
1895        let position = PxPosition::from_f32_xy(
1896            ((action.x0 + action.x1) / 2.0) as f32,
1897            ((action.y0 + action.y1) / 2.0) as f32,
1898        );
1899        renderer.pointer_press(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
1900        renderer.step_frame();
1901        renderer.pointer_release(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
1902        renderer.render_frames(3);
1903        assert_eq!(observed.get().actions.expect("actions").get(), 1);
1904        assert!(
1905            !observed
1906                .get()
1907                .controller
1908                .expect("controller")
1909                .with(TooltipController::is_open)
1910        );
1911        renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::new(Px(390), Px(310)));
1912        let focus = observed
1913            .get()
1914            .controller
1915            .expect("controller")
1916            .with(|controller| controller.focus);
1917        focus.clear_focus();
1918        assert!(
1919            !focus.has_focus(),
1920            "clear must remove focus from the trigger subtree"
1921        );
1922        observed.get().settings.expect("settings").set(Settings {
1923            custom_actions,
1924            ..Settings::default()
1925        });
1926        renderer.render_frames(40);
1927        let (open, anchor_focused, popup_focused, hovered, popup_hovered, manual, ignore) =
1928            controller.with(|controller| {
1929                (
1930                    controller.open,
1931                    controller.anchor_focused,
1932                    controller.popup_focused,
1933                    controller.anchor_hovered,
1934                    controller.popup_hovered,
1935                    controller.manual,
1936                    controller.ignore_anchor_focus,
1937                )
1938            });
1939        assert!(
1940            !open,
1941            "tooltip reopened: anchor={anchor_focused}, popup={popup_focused}, hover={hovered}, popup_hover={popup_hovered}, manual={manual}, ignore={ignore}, actual_anchor={}, actual_popup={}",
1942            focus.has_focus(),
1943            controller
1944                .with(|controller| controller.popup_group)
1945                .has_focus()
1946        );
1947        let restored = renderer.read_pixels().expect("restored pixels");
1948        let changed = before
1949            .chunks_exact(4)
1950            .zip(restored.chunks_exact(4))
1951            .filter(|(before, after)| before != after)
1952            .count();
1953        assert!(
1954            restored == before,
1955            "dismissed tooltip must restore the original pixels after interaction animations settle ({changed} pixels changed)"
1956        );
1957    }
1958
1959    #[test]
1960    #[ignore = "requires a native GPU adapter"]
1961    fn gpu_long_press_consumes_release_and_scrolling_cancels_pending_help() {
1962        let (mut renderer, observed) = renderer();
1963        let position = anchor_center(&observed);
1964        renderer.pointer_press(1, position, PressKeyEventType::Left);
1965        renderer.render_frames(35);
1966        assert!(
1967            observed
1968                .get()
1969                .controller
1970                .expect("controller")
1971                .with(TooltipController::is_open)
1972        );
1973        renderer.pointer_release(1, position, PressKeyEventType::Left);
1974        renderer.render_frames(3);
1975        assert_eq!(observed.get().clicks.expect("clicks").get(), 0);
1976        renderer.render_frames(100);
1977        assert!(
1978            !observed
1979                .get()
1980                .controller
1981                .expect("controller")
1982                .with(TooltipController::is_open)
1983        );
1984        assert!(
1985            !observed
1986                .get()
1987                .controller
1988                .expect("controller")
1989                .with(TooltipController::needs_frame)
1990        );
1991        renderer.pointer_press(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
1992        renderer.step_frame();
1993        renderer.pointer_release(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
1994        renderer.step_frame();
1995        assert_eq!(observed.get().clicks.expect("clicks").get(), 1);
1996        let controller = observed.get().controller.expect("controller");
1997        let focus = controller.with(|controller| controller.focus);
1998        focus.clear_focus();
1999        controller.with_mut(TooltipController::dismiss);
2000        renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::new(Px(390), Px(310)));
2001        renderer.render_frames(8);
2002        renderer.pointer_press(2, position, PressKeyEventType::Left);
2003        renderer.step_frame();
2004        renderer.pointer_move(2, PxPosition::from_f32_xy(position.x + 20.0, position.y));
2005        renderer.step_frame();
2006        assert!(
2007            controller.with(|controller| controller.touch.is_none()),
2008            "after movement: {:?}",
2009            controller.get()
2010        );
2011        renderer.render_frames(35);
2012        assert!(
2013            !controller.with(TooltipController::is_open),
2014            "after cancellation: {:?}",
2015            controller.get()
2016        );
2017        renderer.pointer_release(2, position, PressKeyEventType::Left);
2018        renderer.step_frame();
2019    }
2020}