1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum TooltipVariant {
41 #[default]
43 Plain,
44 Rich,
46}
47
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum TooltipPlacement {
51 #[default]
53 Above,
54 Below,
56 Start,
58 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#[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 pub fn new() -> Self {
145 Self::default()
146 }
147
148 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 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 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 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#[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#[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 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}