1use std::cell::Cell;
182use std::rc::Rc;
183
184use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
185use frust::authoring::{
186 Affine, AnyView, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color, EventCtx,
187 EventResult, InputEvent, LayoutCtx, OutsideTap, OverlayBand, OverlayInput, OverlaySlot,
188 PaintCtx, PaintScene, Point, PointerPhase, Rect, SemanticsCtx, Size, View, Widget, any,
189 build_child, rebuild_child, rebuild_children, route_event, route_event_single, teardown_child,
190 visit_children,
191};
192use frust::{AnimationController, FrameTime, GestureDetector, Theme};
193
194use crate::button::text_button;
195use crate::overlay::{
196 ANCHORED_ENTER_SCALE, OverlayAlign, OverlayAnchor, OverlayContainer, OverlayElevation,
197 OverlayPlacement, OverlaySide, container as overlay_container, on_surface_variant,
198 reduce_motion, shadow as overlay_shadow,
199};
200use crate::tokens::{MaterialDimensions, MaterialMotion, MaterialSpacing};
201
202use frust::authoring::{
207 OverlayAlign as PortalAlign, OverlayAnchor as PortalAnchor,
208 OverlayPlacement as PortalPlacement, OverlaySide as PortalSide,
209};
210
211const LONG_PRESS_MS: u64 = 500;
218
219const PLAIN_DISMISS_MS: f64 = MaterialMotion::EXTRA_LONG_4.as_millis() as f64;
223
224const PLAIN_MAX_WIDTH: f64 = 200.0;
228const PLAIN_PAD_X: f64 = MaterialSpacing::SM;
231const PLAIN_PAD_Y: f64 = MaterialSpacing::XS;
233const RICH_MAX_WIDTH: f64 = 320.0;
235const RICH_PAD: f64 = MaterialSpacing::LG;
237const RICH_TITLE_GAP: f64 = MaterialSpacing::XS;
239const RICH_ACTIONS_GAP: f64 = MaterialSpacing::MD;
241
242const PANEL_SHADOW_SPILL: f64 = 48.0;
251
252const PROGRESS_EPSILON: f64 = 1e-4;
254
255const FALLBACK_INVERSE_SURFACE: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
259const FALLBACK_INVERSE_ON_SURFACE: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
262const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
264
265fn in_role_family(
276 mut style: TextStyle,
277 theme: Option<&Theme>,
278 pick: fn(&frust::TypeScale) -> &TextStyle,
279) -> TextStyle {
280 if let Some(theme) = theme {
281 style.family = pick(&theme.type_scale).family.clone();
282 }
283 style
284}
285
286fn plain_message_style(theme: Option<&Theme>) -> TextStyle {
287 let style = TextStyle {
288 weight: FontWeight::REGULAR,
289 letter_spacing: 0.4,
290 line_height: LineHeight::Absolute(16.0),
291 ..TextStyle::new(12.0, Color::BLACK)
292 };
293 in_role_family(style, theme, |scale| &scale.body_small)
294}
295
296fn rich_title_style(theme: Option<&Theme>) -> TextStyle {
297 let style = TextStyle {
298 weight: FontWeight::MEDIUM,
299 letter_spacing: 0.1,
300 line_height: LineHeight::Absolute(20.0),
301 ..TextStyle::new(14.0, Color::BLACK)
302 };
303 in_role_family(style, theme, |scale| &scale.title_small)
304}
305
306fn rich_body_style(theme: Option<&Theme>) -> TextStyle {
307 let style = TextStyle {
308 weight: FontWeight::REGULAR,
309 letter_spacing: 0.25,
310 line_height: LineHeight::Absolute(20.0),
311 ..TextStyle::new(14.0, Color::BLACK)
312 };
313 in_role_family(style, theme, |scale| &scale.body_medium)
314}
315
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322enum TooltipOpenVia {
323 Hover,
324 LongPress,
325}
326
327#[derive(Clone, Copy, Debug)]
329struct TooltipLatch {
330 open: bool,
331 via: TooltipOpenVia,
332 enabled: bool,
333 shows: u64,
343}
344
345impl Default for TooltipLatch {
346 fn default() -> Self {
347 Self {
348 open: false,
349 via: TooltipOpenVia::Hover,
350 enabled: true,
351 shows: 0,
352 }
353 }
354}
355
356#[derive(Clone, Debug, Default)]
365pub struct TooltipHover {
366 anchor: OverlayAnchor,
367 latch: Rc<Cell<TooltipLatch>>,
368}
369
370impl TooltipHover {
371 pub fn new() -> Self {
373 Self::default()
374 }
375
376 pub fn is_open(&self) -> bool {
378 self.latch.get().open
379 }
380
381 pub fn set_open(&self, open: bool) {
384 let mut l = self.latch.get();
385 if open && !l.open {
386 l.shows = l.shows.wrapping_add(1);
387 }
388 l.open = open;
389 self.latch.set(l);
390 }
391
392 fn show_id(&self) -> u64 {
396 self.latch.get().shows
397 }
398
399 fn via(&self) -> TooltipOpenVia {
400 self.latch.get().via
401 }
402
403 fn set_via(&self, via: TooltipOpenVia) {
404 let mut l = self.latch.get();
405 l.via = via;
406 self.latch.set(l);
407 }
408
409 fn is_enabled(&self) -> bool {
410 self.latch.get().enabled
411 }
412
413 fn set_enabled(&self, enabled: bool) {
414 let mut l = self.latch.get();
415 l.enabled = enabled;
416 self.latch.set(l);
417 }
418
419 fn anchor(&self) -> &OverlayAnchor {
422 &self.anchor
423 }
424}
425
426pub fn tooltip_trigger<State: 'static, V: View<State>>(
431 hover: &TooltipHover,
432 child: V,
433) -> TooltipTriggerView<State> {
434 let cb_hover = hover.clone();
435 let gesture = GestureDetector(child)
436 .on_long_press(move |_state: &mut State| {
437 if cb_hover.is_enabled() {
438 cb_hover.set_open(true);
439 cb_hover.set_via(TooltipOpenVia::LongPress);
440 }
441 })
442 .hold_threshold_ms(LONG_PRESS_MS);
443 TooltipTriggerView {
444 child: any(gesture),
445 hover: hover.clone(),
446 enabled: true,
447 }
448}
449
450pub struct TooltipTriggerView<State: 'static> {
452 child: AnyView<State>,
453 hover: TooltipHover,
454 enabled: bool,
455}
456
457impl<State: 'static> TooltipTriggerView<State> {
458 pub fn enabled(mut self, enabled: bool) -> Self {
462 self.enabled = enabled;
463 self
464 }
465}
466
467pub struct TooltipTriggerWidget {
469 child: ChildPod,
470 hover: TooltipHover,
471 was_hovered: bool,
472}
473
474impl<State: 'static> View<State> for TooltipTriggerView<State> {
475 type Element = TooltipTriggerWidget;
476
477 fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipTriggerWidget {
478 self.hover.set_enabled(self.enabled);
479 TooltipTriggerWidget {
480 child: build_child(&self.child, ctx),
481 hover: self.hover.clone(),
482 was_hovered: false,
483 }
484 }
485
486 fn rebuild(
487 &self,
488 prev: &Self,
489 element: &mut TooltipTriggerWidget,
490 ctx: &mut BuildCtx<'_>,
491 ) -> ChangeFlags {
492 self.hover.set_enabled(self.enabled);
493 element.hover = self.hover.clone();
494 rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
495 }
496
497 fn teardown(&self, element: &mut TooltipTriggerWidget, ctx: &mut BuildCtx<'_>) {
498 teardown_child(&self.child, &mut element.child, ctx);
499 }
500}
501
502impl Widget for TooltipTriggerWidget {
503 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
504 let size = self.child.layout_child(ctx, bc);
505 self.child.set_origin(Point::ORIGIN);
506 bc.constrain(size)
507 }
508
509 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
510 self.hover
511 .anchor()
512 .set(Rect::from_origin_size(ctx.origin(), ctx.size()));
513
514 if self.hover.is_enabled() {
515 let now = ctx.is_hovered();
518 if now != self.was_hovered {
519 self.was_hovered = now;
520 self.hover.set_open(now);
521 if now {
522 self.hover.set_via(TooltipOpenVia::Hover);
523 }
524 ctx.request_frame();
525 }
526 } else if self.was_hovered {
527 self.was_hovered = false;
529 self.hover.set_open(false);
530 ctx.request_frame();
531 }
532
533 self.child.paint_child(ctx, scene);
534 }
535
536 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
537 let routed = route_event_single(&mut self.child, ctx, event);
541 if !event.is_broadcast()
542 && let InputEvent::Pointer(p) = event
543 && p.phase == PointerPhase::Move
544 && inside(p.position, ctx.size())
545 {
546 ctx.claim_hover();
547 }
548 routed
549 }
550
551 fn semantics(&self, ctx: &mut SemanticsCtx) {
552 self.child.semantics_child(ctx);
553 }
554
555 visit_children!(child);
556}
557
558fn inside(pos: Point, size: Size) -> bool {
561 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
562}
563
564struct TooltipTextRun {
572 content: String,
573 style: TextStyle,
575 layout: Option<TextLayout>,
576}
577
578impl TooltipTextRun {
579 fn new() -> Self {
580 Self {
581 content: String::new(),
582 style: TextStyle::default(),
583 layout: None,
584 }
585 }
586
587 fn set_content(&mut self, content: &str) {
588 if self.content != content {
589 self.content = content.to_string();
590 self.layout = None;
591 }
592 }
593
594 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
595 if self.style != *style {
596 self.style = style.clone();
597 self.layout = None;
598 }
599 if let Some(layout) = &self.layout {
600 return layout.size();
601 }
602 let text_ctx = ctx.text_context::<TextContext>();
603 let laid = text_ctx.layout(&self.content, &self.style, Some(max_width as f32));
604 let size = laid.size();
605 self.layout = Some(laid);
606 size
607 }
608
609 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
610 let Some(layout) = &self.layout else {
611 return;
612 };
613 for mut run in layout.to_scene_runs(origin) {
614 run.brush = Brush::Solid(color);
615 scene.draw_glyph_run(run);
616 }
617 }
618}
619
620fn portal_placement(placement: OverlayPlacement) -> PortalPlacement {
632 PortalPlacement {
633 side: match placement.side {
634 OverlaySide::Top => PortalSide::Top,
635 OverlaySide::Right => PortalSide::Right,
636 OverlaySide::Bottom => PortalSide::Bottom,
637 OverlaySide::Left => PortalSide::Left,
638 },
639 align: match placement.align {
640 OverlayAlign::Start => PortalAlign::Start,
641 OverlayAlign::Center => PortalAlign::Center,
642 OverlayAlign::End => PortalAlign::End,
643 },
644 offset: placement.offset,
645 flip: placement.flip,
646 clamp: placement.clamp,
647 padding: 0.0,
648 }
649}
650
651fn entrance_driver() -> AnimationController {
655 AnimationController::new(MaterialMotion::SHORT_4)
656 .with_curve(MaterialMotion::EMPHASIZED_DECELERATE)
657}
658
659struct RampedPanel<PodState: 'static> {
668 content: AnyView<PodState>,
669 hover: TooltipHover,
670}
671
672struct RampedPanelWidget {
674 content: ChildPod,
675 hover: TooltipHover,
676 anim: AnimationController,
677 progress: f64,
679 armed_show: Option<u64>,
682 started: bool,
684}
685
686impl RampedPanelWidget {
687 fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) -> f64 {
695 let show = self.hover.show_id();
696 if self.armed_show != Some(show) {
697 self.armed_show = Some(show);
701 self.anim = entrance_driver();
702 self.progress = 0.0;
703 self.started = false;
704 }
705 if reduce {
706 if self.anim.is_animating() {
707 self.anim.stop();
708 }
709 self.progress = 1.0;
710 return self.progress;
711 }
712 if !self.started {
713 self.started = true;
714 self.anim.forward();
715 }
716 if self.anim.is_animating() {
717 if self.anim.advance(ctx.frame_time()) {
723 ctx.request_frame();
724 }
725 let next = self.anim.value_clamped();
726 if (next - self.progress).abs() > PROGRESS_EPSILON {
727 ctx.request_frame();
730 }
731 self.progress = next;
732 }
733 self.progress
734 }
735
736 fn pivot(&self, panel: Rect) -> Point {
744 let center = self.hover.anchor().rect().center();
745 Point::new(
746 center.x.clamp(panel.x0, panel.x1),
747 center.y.clamp(panel.y0, panel.y1),
748 )
749 }
750}
751
752impl<PodState: 'static> View<PodState> for RampedPanel<PodState> {
753 type Element = RampedPanelWidget;
754
755 fn build(&self, ctx: &mut BuildCtx<'_>) -> RampedPanelWidget {
756 RampedPanelWidget {
757 content: build_child(&self.content, ctx),
758 hover: self.hover.clone(),
759 anim: entrance_driver(),
760 progress: 0.0,
761 armed_show: None,
762 started: false,
763 }
764 }
765
766 fn rebuild(
767 &self,
768 prev: &Self,
769 element: &mut RampedPanelWidget,
770 ctx: &mut BuildCtx<'_>,
771 ) -> ChangeFlags {
772 element.hover = self.hover.clone();
773 rebuild_child(&prev.content, &self.content, &mut element.content, ctx)
774 }
775
776 fn teardown(&self, element: &mut RampedPanelWidget, ctx: &mut BuildCtx<'_>) {
777 teardown_child(&self.content, &mut element.content, ctx);
778 }
779}
780
781impl Widget for RampedPanelWidget {
782 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
783 let size = self.content.layout_child(ctx, bc);
784 self.content.set_origin(Point::ORIGIN);
785 bc.constrain(size)
786 }
787
788 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
789 let progress = self.advance(ctx, reduce_motion(Theme::from_paint_ctx(ctx)));
790 let ramping = progress < 1.0 - PROGRESS_EPSILON;
791 if ramping {
792 let panel = Rect::from_origin_size(ctx.origin(), ctx.size());
793 let layer = panel.inflate(PANEL_SHADOW_SPILL, PANEL_SHADOW_SPILL);
794 scene.push_layer(layer.origin(), layer.size(), progress as f32);
795 let pivot = self.pivot(panel);
796 let scale = ANCHORED_ENTER_SCALE + (1.0 - ANCHORED_ENTER_SCALE) * progress;
797 scene.push_transform(
798 Affine::translate(pivot.to_vec2())
799 * Affine::scale(scale)
800 * Affine::translate(-pivot.to_vec2()),
801 );
802 }
803 self.content.paint_child(ctx, scene);
804 if ramping {
805 scene.pop_transform();
806 scene.pop_layer();
807 }
808 }
809
810 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
811 route_event_single(&mut self.content, ctx, event)
812 }
813
814 fn semantics(&self, ctx: &mut SemanticsCtx) {
815 self.content.semantics_child(ctx);
820 }
821
822 visit_children!(content);
823}
824
825pub fn tooltip(hover: &TooltipHover, message: impl Into<String>) -> TooltipView {
835 TooltipView {
836 hover: hover.clone(),
837 message: message.into(),
838 placement: OverlayPlacement::default(),
839 }
840}
841
842pub struct TooltipView {
844 hover: TooltipHover,
845 message: String,
846 placement: OverlayPlacement,
847}
848
849impl TooltipView {
850 pub fn side(mut self, side: OverlaySide) -> Self {
854 self.placement.side = side;
855 self
856 }
857
858 pub fn align(mut self, align: OverlayAlign) -> Self {
860 self.placement.align = align;
861 self
862 }
863
864 pub fn offset(mut self, offset: f64) -> Self {
866 self.placement.offset = offset;
867 self
868 }
869
870 fn content_view(&self) -> AnyView<()> {
878 any(RampedPanel {
879 content: any(PlainPanel {
880 message: self.message.clone(),
881 hover: self.hover.clone(),
882 }),
883 hover: self.hover.clone(),
884 })
885 }
886}
887
888pub struct TooltipWidget {
896 hover: TooltipHover,
897 slot: OverlaySlot<()>,
898}
899
900impl TooltipView {
901 fn configure(&self, slot: &mut OverlaySlot<()>) {
904 slot.set_band(OverlayBand::Tooltip);
905 slot.set_input(OverlayInput::Transparent);
908 slot.set_outside_tap(OutsideTap::Ignore);
913 slot.set_placement(portal_placement(self.placement));
914 }
915}
916
917impl<State: 'static> View<State> for TooltipView {
918 type Element = TooltipWidget;
919
920 fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipWidget {
921 let mut slot = OverlaySlot::new();
922 self.configure(&mut slot);
923 slot.rebuild(None, Some(&self.content_view()), ctx);
924 TooltipWidget {
925 hover: self.hover.clone(),
926 slot,
927 }
928 }
929
930 fn rebuild(
931 &self,
932 prev: &Self,
933 element: &mut TooltipWidget,
934 ctx: &mut BuildCtx<'_>,
935 ) -> ChangeFlags {
936 self.configure(&mut element.slot);
937 element.hover = self.hover.clone();
938 element
939 .slot
940 .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
941 | ChangeFlags::PAINT
942 }
943
944 fn teardown(&self, element: &mut TooltipWidget, ctx: &mut BuildCtx<'_>) {
945 element.slot.rebuild(Some(&self.content_view()), None, ctx);
946 }
947}
948
949impl Widget for TooltipWidget {
950 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
951 self.slot.layout(ctx);
956 bc.constrain(Size::ZERO)
962 }
963
964 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
965 if !self.hover.is_open() {
966 return;
967 }
968 let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
971 self.slot.set_anchor(PortalAnchor::Rect(local));
972 self.slot.paint(ctx, Size::ZERO);
973 }
974
975 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
976 self.slot
981 .event(ctx, event, &mut ())
982 .unwrap_or(EventResult::Ignored)
983 }
984
985 fn semantics(&self, _ctx: &mut SemanticsCtx) {
986 }
989
990 visit_children!();
991}
992
993struct PlainPanel {
996 message: String,
997 hover: TooltipHover,
998}
999
1000impl<State: 'static> View<State> for PlainPanel {
1001 type Element = PlainPanelWidget;
1002
1003 fn build(&self, _ctx: &mut BuildCtx<'_>) -> PlainPanelWidget {
1004 let mut run = TooltipTextRun::new();
1005 run.set_content(&self.message);
1006 PlainPanelWidget {
1007 message: run,
1008 hover: self.hover.clone(),
1009 dismiss_via_long_press: false,
1010 armed_show: None,
1011 timer_start: None,
1012 dismissed: false,
1013 }
1014 }
1015
1016 fn rebuild(
1017 &self,
1018 _prev: &Self,
1019 element: &mut PlainPanelWidget,
1020 _ctx: &mut BuildCtx<'_>,
1021 ) -> ChangeFlags {
1022 let mut flags = ChangeFlags::NONE;
1023 if element.message.content != self.message {
1024 element.message.set_content(&self.message);
1025 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1026 }
1027 element.hover = self.hover.clone();
1028 flags
1029 }
1030
1031 fn teardown(&self, _element: &mut PlainPanelWidget, _ctx: &mut BuildCtx<'_>) {}
1032}
1033
1034struct PlainPanelWidget {
1036 message: TooltipTextRun,
1037 hover: TooltipHover,
1038 dismiss_via_long_press: bool,
1041 armed_show: Option<u64>,
1045 timer_start: Option<FrameTime>,
1046 dismissed: bool,
1047}
1048
1049impl PlainPanelWidget {
1050 fn arm(&mut self) {
1057 let show = self.hover.show_id();
1058 if self.armed_show == Some(show) {
1059 return;
1060 }
1061 self.armed_show = Some(show);
1062 self.dismiss_via_long_press = self.hover.via() == TooltipOpenVia::LongPress;
1063 self.timer_start = None;
1064 self.dismissed = false;
1065 }
1066}
1067
1068impl Widget for PlainPanelWidget {
1069 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1070 let width_cap = bc.max().width.min(PLAIN_MAX_WIDTH);
1071 let inner_w = (width_cap - 2.0 * PLAIN_PAD_X).max(0.0);
1072 let style = plain_message_style(Theme::from_layout_ctx(ctx));
1073 let msg = self.message.shape(ctx, &style, inner_w);
1074 let size = Size::new(
1075 msg.width + 2.0 * PLAIN_PAD_X,
1076 msg.height + 2.0 * PLAIN_PAD_Y,
1077 );
1078 bc.constrain(size)
1079 }
1080
1081 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1082 self.arm();
1083 let theme = Theme::from_paint_ctx(ctx);
1084 let container = theme.map_or(FALLBACK_INVERSE_SURFACE, |t| t.scheme().inverse_surface);
1085 let ink = theme.map_or(FALLBACK_INVERSE_ON_SURFACE, |t| {
1086 t.scheme().inverse_on_surface
1087 });
1088 let radius = theme.map_or(MaterialDimensions::RADIUS_EXTRA_SMALL, |t| {
1089 t.shape.extra_small
1090 });
1091 let (origin, size) = (ctx.origin(), ctx.size());
1092 scene.fill_rounded_rect(origin, size, radius, container);
1093 self.message.paint(
1094 Point::new(origin.x + PLAIN_PAD_X, origin.y + PLAIN_PAD_Y),
1095 ink,
1096 scene,
1097 );
1098
1099 if self.dismiss_via_long_press && !self.dismissed {
1105 let start = *self.timer_start.get_or_insert(ctx.frame_time());
1106 let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
1107 if elapsed_ms >= PLAIN_DISMISS_MS {
1108 self.dismissed = true;
1109 self.hover.set_open(false);
1110 }
1111 ctx.request_frame();
1112 }
1113 }
1114
1115 fn semantics(&self, ctx: &mut SemanticsCtx) {
1116 ctx.push_node(frust::authoring::Role::Label, |node| {
1117 node.set_label(self.message.content.clone());
1118 });
1119 }
1120}
1121
1122pub struct TooltipAction<State: 'static> {
1127 label: String,
1128 on_press: Rc<dyn Fn(&mut State)>,
1129}
1130
1131impl<State: 'static> Clone for TooltipAction<State> {
1132 fn clone(&self) -> Self {
1133 Self {
1134 label: self.label.clone(),
1135 on_press: self.on_press.clone(),
1136 }
1137 }
1138}
1139
1140pub fn tooltip_action<State: 'static, F: Fn(&mut State) + 'static>(
1142 label: impl Into<String>,
1143 on_press: F,
1144) -> TooltipAction<State> {
1145 TooltipAction {
1146 label: label.into(),
1147 on_press: Rc::new(on_press),
1148 }
1149}
1150
1151pub fn rich_tooltip<State: 'static>(
1160 hover: &TooltipHover,
1161 message: impl Into<String>,
1162) -> RichTooltipView<State> {
1163 RichTooltipView {
1164 hover: hover.clone(),
1165 title: None,
1166 message: message.into(),
1167 actions: Vec::new(),
1168 placement: OverlayPlacement::default(),
1169 }
1170}
1171
1172pub struct RichTooltipView<State: 'static> {
1174 hover: TooltipHover,
1175 title: Option<String>,
1176 message: String,
1177 actions: Vec<TooltipAction<State>>,
1178 placement: OverlayPlacement,
1179}
1180
1181impl<State: 'static> RichTooltipView<State> {
1182 pub fn title(mut self, title: impl Into<String>) -> Self {
1184 self.title = Some(title.into());
1185 self
1186 }
1187
1188 pub fn action(mut self, action: TooltipAction<State>) -> Self {
1193 self.actions.push(action);
1194 self
1195 }
1196
1197 pub fn side(mut self, side: OverlaySide) -> Self {
1199 self.placement.side = side;
1200 self
1201 }
1202
1203 pub fn align(mut self, align: OverlayAlign) -> Self {
1205 self.placement.align = align;
1206 self
1207 }
1208
1209 pub fn offset(mut self, offset: f64) -> Self {
1211 self.placement.offset = offset;
1212 self
1213 }
1214
1215 fn content_view(&self) -> AnyView<State> {
1223 any(RampedPanel {
1224 content: any(RichPanel {
1225 title: self.title.clone(),
1226 message: self.message.clone(),
1227 actions: self.actions.clone(),
1228 }),
1229 hover: self.hover.clone(),
1230 })
1231 }
1232
1233 fn configure(&self, slot: &mut OverlaySlot<State>) {
1236 slot.set_band(OverlayBand::Tooltip);
1237 slot.set_input(OverlayInput::Interactive);
1242 slot.set_outside_tap(OutsideTap::Notify { consume: false });
1245 slot.set_placement(portal_placement(self.placement));
1246 }
1247}
1248
1249pub struct RichTooltipWidget<State: 'static> {
1254 hover: TooltipHover,
1255 slot: OverlaySlot<State>,
1256}
1257
1258impl<State: 'static> View<State> for RichTooltipView<State> {
1259 type Element = RichTooltipWidget<State>;
1260
1261 fn build(&self, ctx: &mut BuildCtx<'_>) -> RichTooltipWidget<State> {
1262 let mut slot = OverlaySlot::new();
1263 self.configure(&mut slot);
1264 slot.rebuild(None, Some(&self.content_view()), ctx);
1265 RichTooltipWidget {
1266 hover: self.hover.clone(),
1267 slot,
1268 }
1269 }
1270
1271 fn rebuild(
1272 &self,
1273 prev: &Self,
1274 element: &mut RichTooltipWidget<State>,
1275 ctx: &mut BuildCtx<'_>,
1276 ) -> ChangeFlags {
1277 self.configure(&mut element.slot);
1278 element.hover = self.hover.clone();
1279 element
1280 .slot
1281 .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
1282 | ChangeFlags::PAINT
1283 }
1284
1285 fn teardown(&self, element: &mut RichTooltipWidget<State>, ctx: &mut BuildCtx<'_>) {
1286 element.slot.rebuild(Some(&self.content_view()), None, ctx);
1287 }
1288}
1289
1290impl<State: 'static> Widget for RichTooltipWidget<State> {
1291 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1292 self.slot.layout(ctx);
1293 bc.constrain(Size::ZERO)
1294 }
1295
1296 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1297 if !self.hover.is_open() {
1298 return;
1299 }
1300 let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
1301 self.slot.set_anchor(PortalAnchor::Rect(local));
1302 self.slot.paint(ctx, Size::ZERO);
1303 }
1304
1305 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1306 let Some(result) = self.slot.event_ambient(ctx, event) else {
1307 return EventResult::Ignored;
1310 };
1311 if self.slot.take_outside_down() {
1316 self.hover.set_open(false);
1317 ctx.request_redraw();
1318 }
1319 result
1320 }
1321
1322 fn semantics(&self, _ctx: &mut SemanticsCtx) {
1323 }
1326
1327 visit_children!();
1328}
1329
1330fn action_view<State: 'static>(action: &TooltipAction<State>) -> AnyView<State> {
1332 let cb = action.on_press.clone();
1333 any(text_button(action.label.clone(), move |s: &mut State| {
1334 cb(s)
1335 }))
1336}
1337
1338struct RichPanel<State: 'static> {
1341 title: Option<String>,
1342 message: String,
1343 actions: Vec<TooltipAction<State>>,
1344}
1345
1346impl<State: 'static> View<State> for RichPanel<State> {
1347 type Element = RichPanelWidget;
1348
1349 fn build(&self, ctx: &mut BuildCtx<'_>) -> RichPanelWidget {
1350 let title = self.title.as_ref().map(|t| {
1351 let mut run = TooltipTextRun::new();
1352 run.set_content(t);
1353 run
1354 });
1355 let mut body = TooltipTextRun::new();
1356 body.set_content(&self.message);
1357 let action_pods = self
1358 .actions
1359 .iter()
1360 .map(|a| build_child(&action_view(a), ctx))
1361 .collect();
1362 RichPanelWidget {
1363 title,
1364 body,
1365 action_pods,
1366 title_origin: Point::ORIGIN,
1367 body_origin: Point::ORIGIN,
1368 }
1369 }
1370
1371 fn rebuild(
1372 &self,
1373 prev: &Self,
1374 element: &mut RichPanelWidget,
1375 ctx: &mut BuildCtx<'_>,
1376 ) -> ChangeFlags {
1377 let mut flags = ChangeFlags::NONE;
1378 match (&self.title, &mut element.title) {
1379 (Some(t), Some(run)) => {
1380 if run.content != *t {
1381 run.set_content(t);
1382 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1383 }
1384 }
1385 (Some(t), None) => {
1386 let mut run = TooltipTextRun::new();
1387 run.set_content(t);
1388 element.title = Some(run);
1389 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1390 }
1391 (None, Some(_)) => {
1392 element.title = None;
1393 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1394 }
1395 (None, None) => {}
1396 }
1397 if element.body.content != self.message {
1398 element.body.set_content(&self.message);
1399 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1400 }
1401
1402 let prev_views: Vec<AnyView<State>> = prev.actions.iter().map(action_view).collect();
1403 let next_views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1404 flags |= rebuild_children(
1405 &prev_views,
1406 &next_views,
1407 &mut element.action_pods,
1408 ctx,
1409 |v: &AnyView<State>| v,
1410 |_| None,
1411 );
1412 flags
1413 }
1414
1415 fn teardown(&self, element: &mut RichPanelWidget, ctx: &mut BuildCtx<'_>) {
1416 let views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1417 for (pod, view) in element.action_pods.iter_mut().zip(views.iter()) {
1418 teardown_child(view, pod, ctx);
1419 }
1420 }
1421}
1422
1423struct RichPanelWidget {
1425 title: Option<TooltipTextRun>,
1426 body: TooltipTextRun,
1427 action_pods: Vec<ChildPod>,
1428 title_origin: Point,
1429 body_origin: Point,
1430}
1431
1432impl Widget for RichPanelWidget {
1433 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1434 let width_cap = bc.max().width.min(RICH_MAX_WIDTH);
1435 let inner_w = (width_cap - 2.0 * RICH_PAD).max(0.0);
1436 let mut content_w: f64 = 0.0;
1437 let mut y = RICH_PAD;
1438 let theme = Theme::from_layout_ctx(ctx);
1439 let (title_style, body_style) = (rich_title_style(theme), rich_body_style(theme));
1440
1441 if let Some(title) = &mut self.title {
1442 let sz = title.shape(ctx, &title_style, inner_w);
1443 self.title_origin = Point::new(RICH_PAD, y);
1444 content_w = content_w.max(sz.width);
1445 y += sz.height + RICH_TITLE_GAP;
1446 }
1447
1448 let body_sz = self.body.shape(ctx, &body_style, inner_w);
1449 self.body_origin = Point::new(RICH_PAD, y);
1450 content_w = content_w.max(body_sz.width);
1451 y += body_sz.height;
1452
1453 if !self.action_pods.is_empty() {
1454 y += RICH_ACTIONS_GAP;
1455 let mut x = RICH_PAD;
1456 let mut row_h: f64 = 0.0;
1457 for pod in &mut self.action_pods {
1458 let sz = pod.layout_child(
1459 ctx,
1460 &BoxConstraints::loose(Size::new(inner_w, f64::INFINITY)),
1461 );
1462 pod.set_origin(Point::new(x, y));
1463 x += sz.width;
1464 row_h = row_h.max(sz.height);
1465 }
1466 content_w = content_w.max(x - RICH_PAD);
1467 y += row_h;
1468 }
1469
1470 let panel_w = content_w.min(inner_w) + 2.0 * RICH_PAD;
1471 let panel_h = y + RICH_PAD;
1472 bc.constrain(Size::new(panel_w, panel_h))
1473 }
1474
1475 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1476 let theme = Theme::from_paint_ctx(ctx);
1477 let container = overlay_container(theme, OverlayContainer::Standard);
1478 let radius = theme.map_or(MaterialDimensions::RADIUS_MEDIUM, |t| t.shape.medium);
1479 let (origin, size) = (ctx.origin(), ctx.size());
1480
1481 if let Some((blur, y_offset, color)) = overlay_shadow(theme, OverlayElevation::Level2) {
1482 scene.draw_shadow(
1483 Point::new(origin.x, origin.y + y_offset),
1484 size,
1485 radius,
1486 blur,
1487 color,
1488 );
1489 }
1490 scene.fill_rounded_rect(origin, size, radius, container);
1491
1492 let title_ink = theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface);
1493 let body_ink = on_surface_variant(theme);
1494 if let Some(title) = &self.title {
1495 title.paint(
1496 Point::new(
1497 origin.x + self.title_origin.x,
1498 origin.y + self.title_origin.y,
1499 ),
1500 title_ink,
1501 scene,
1502 );
1503 }
1504 self.body.paint(
1505 Point::new(origin.x + self.body_origin.x, origin.y + self.body_origin.y),
1506 body_ink,
1507 scene,
1508 );
1509
1510 for pod in &mut self.action_pods {
1511 pod.paint_child(ctx, scene);
1512 }
1513 }
1514
1515 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1516 route_event(&mut self.action_pods, ctx, event)
1521 }
1522
1523 fn semantics(&self, ctx: &mut SemanticsCtx) {
1524 ctx.push_container(
1525 frust::authoring::Role::Group,
1526 |_node| {},
1527 |ctx| {
1528 for pod in &self.action_pods {
1529 pod.semantics_child(ctx);
1530 }
1531 },
1532 );
1533 }
1534
1535 visit_children!(action_pods);
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540 use super::*;
1541 use frust::Brightness;
1542 use frust::authoring::text::FontFamily;
1543 use frust::authoring::{
1544 Point as AuthoringPoint, PointerButton, PointerEvent, Rect as AuthoringRect,
1545 };
1546 use frust_core::RenderRoot;
1547 use std::any::Any;
1548
1549 fn ft(ms: f64) -> FrameTime {
1550 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1551 }
1552
1553 #[test]
1556 fn a_fresh_handle_is_closed_and_enabled() {
1557 let hover = TooltipHover::new();
1558 assert!(!hover.is_open());
1559 assert!(hover.is_enabled());
1560 assert_eq!(hover.via(), TooltipOpenVia::Hover);
1561 }
1562
1563 #[test]
1564 fn clones_share_the_same_cells() {
1565 let hover = TooltipHover::new();
1566 let clone = hover.clone();
1567 clone.set_open(true);
1568 assert!(hover.is_open(), "the clone shares the latch");
1569 hover.anchor().set(AuthoringRect::new(1.0, 2.0, 3.0, 4.0));
1570 assert_eq!(
1571 clone.anchor().rect(),
1572 AuthoringRect::new(1.0, 2.0, 3.0, 4.0)
1573 );
1574 }
1575
1576 #[derive(Default)]
1579 struct AppState;
1580
1581 struct Harness {
1585 root: RenderRoot<AppState, TooltipTriggerView<AppState>>,
1586 state: AppState,
1587 hover: TooltipHover,
1588 enabled: bool,
1589 }
1590
1591 const TRIGGER: Size = Size::new(80.0, 32.0);
1592
1593 impl Harness {
1594 fn new() -> Self {
1595 let mut h = Harness {
1596 root: RenderRoot::new(),
1597 state: AppState,
1598 hover: TooltipHover::new(),
1599 enabled: true,
1600 };
1601 h.pass();
1602 h.root.layout(Size::new(400.0, 400.0));
1603 h
1604 }
1605
1606 fn pass(&mut self) {
1607 let hover = self.hover.clone();
1608 let enabled = self.enabled;
1609 let mut logic = move |_s: &mut AppState| {
1610 tooltip_trigger(
1611 &hover,
1612 frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
1613 )
1614 .enabled(enabled)
1615 };
1616 self.root.rebuild(&mut logic, &mut self.state);
1617 }
1618
1619 fn paint(&mut self, ms: f64) {
1620 self.root.paint(&mut NullScene, ft(ms));
1621 }
1622
1623 fn event(&mut self, event: InputEvent) {
1624 self.root.event(&mut self.state, &event);
1625 }
1626 }
1627
1628 struct NullScene;
1629 impl PaintScene for NullScene {
1630 fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
1631 fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
1632 }
1633
1634 fn moved(x: f64, y: f64) -> InputEvent {
1635 InputEvent::Pointer(PointerEvent {
1636 phase: PointerPhase::Move,
1637 position: AuthoringPoint::new(x, y),
1638 button: PointerButton::Primary,
1639 })
1640 }
1641
1642 fn down(x: f64, y: f64) -> InputEvent {
1643 InputEvent::Pointer(PointerEvent {
1644 phase: PointerPhase::Down,
1645 position: AuthoringPoint::new(x, y),
1646 button: PointerButton::Primary,
1647 })
1648 }
1649
1650 fn up(x: f64, y: f64) -> InputEvent {
1651 InputEvent::Pointer(PointerEvent {
1652 phase: PointerPhase::Up,
1653 position: AuthoringPoint::new(x, y),
1654 button: PointerButton::Primary,
1655 })
1656 }
1657
1658 #[test]
1659 fn hover_opens_on_the_very_next_paint_zero_delay() {
1660 let mut h = Harness::new();
1661 h.paint(0.0);
1662 assert!(!h.hover.is_open());
1663 h.event(moved(10.0, 10.0));
1664 h.paint(1.0);
1667 assert!(h.hover.is_open());
1668 assert_eq!(h.hover.via(), TooltipOpenVia::Hover);
1669 }
1670
1671 #[test]
1672 fn hover_closes_the_instant_the_pointer_leaves() {
1673 let mut h = Harness::new();
1674 h.event(moved(10.0, 10.0));
1675 h.paint(1.0);
1676 assert!(h.hover.is_open());
1677 h.event(moved(500.0, 500.0));
1678 h.paint(2.0);
1679 assert!(!h.hover.is_open());
1680 }
1681
1682 #[test]
1683 fn disabled_suppresses_hover_and_snaps_an_open_panel_shut() {
1684 let mut h = Harness::new();
1685 h.event(moved(10.0, 10.0));
1686 h.paint(1.0);
1687 assert!(h.hover.is_open());
1688 h.enabled = false;
1689 h.pass();
1690 h.root.layout(Size::new(400.0, 400.0));
1691 h.paint(2.0);
1692 assert!(!h.hover.is_open(), "disabling mid-show snaps it shut");
1693 }
1694
1695 #[test]
1698 fn a_stationary_long_press_opens_after_the_threshold_via_housekeeping() {
1699 let mut h = Harness::new();
1700 h.event(down(10.0, 10.0));
1701 h.paint(0.0);
1702 let flags = h
1703 .root
1704 .paint(&mut NullScene, ft(LONG_PRESS_MS as f64 + 20.0));
1705 assert!(!h.hover.is_open(), "paint alone only latches, never fires");
1706 assert!(
1707 flags.needs_frame,
1708 "requests the follow-up frame that fires it"
1709 );
1710
1711 h.pass();
1715 assert!(
1716 h.hover.is_open(),
1717 "fires via Housekeeping, no further pointer event"
1718 );
1719 assert_eq!(h.hover.via(), TooltipOpenVia::LongPress);
1720 }
1721
1722 #[test]
1723 fn a_quick_release_before_the_threshold_never_opens() {
1724 let mut h = Harness::new();
1725 h.event(down(10.0, 10.0));
1726 h.paint(0.0);
1727 h.paint(100.0); h.event(up(11.0, 11.0));
1729 h.pass();
1730 assert!(!h.hover.is_open());
1731 }
1732
1733 fn theme_with(plain: FontFamily, title: FontFamily, body: FontFamily) -> Theme {
1738 let mut theme = crate::baseline();
1739 theme.type_scale.body_small.family = plain;
1740 theme.type_scale.title_small.family = title;
1741 theme.type_scale.body_medium.family = body;
1742 theme
1743 }
1744
1745 fn rich_panel_widget() -> RichPanelWidget {
1747 let view: RichPanel<AppState> = RichPanel {
1748 title: Some("Compose".into()),
1749 message: "Start a new draft.".into(),
1750 actions: Vec::new(),
1751 };
1752 let mut counter = 0u64;
1753 View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter))
1754 }
1755
1756 fn layout_themed(w: &mut dyn Widget, tcx: &mut TextContext, theme: Option<&Theme>) {
1759 let mut ctx =
1760 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1761 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1762 }
1763
1764 #[test]
1765 fn layout_takes_each_panel_run_s_family_from_its_role() {
1766 let (plain, title, body) = (
1767 FontFamily::named("Plain Role Probe"),
1768 FontFamily::named("Title Role Probe"),
1769 FontFamily::named("Body Role Probe"),
1770 );
1771 let theme = theme_with(plain.clone(), title.clone(), body.clone());
1772 let mut tcx = TextContext::new();
1773
1774 let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1775 layout_themed(&mut plain_w, &mut tcx, Some(&theme));
1776 assert_eq!(plain_w.message.style.family, plain, "bodySmall");
1777 assert_eq!(plain_w.message.style.size, 12.0, "the token size stays");
1778
1779 let mut rich_w = rich_panel_widget();
1780 layout_themed(&mut rich_w, &mut tcx, Some(&theme));
1781 let title_run = rich_w.title.as_ref().expect("a title is set");
1782 assert_eq!(title_run.style.family, title, "titleSmall");
1783 assert_eq!(rich_w.body.style.family, body, "bodyMedium");
1784 }
1785
1786 #[test]
1787 fn without_a_theme_the_panel_runs_keep_the_unthemed_styles() {
1788 let plain_unthemed = TextStyle {
1789 weight: FontWeight::REGULAR,
1790 letter_spacing: 0.4,
1791 line_height: LineHeight::Absolute(16.0),
1792 ..TextStyle::new(12.0, Color::BLACK)
1793 };
1794 assert_eq!(plain_message_style(None), plain_unthemed);
1795 assert_eq!(rich_title_style(None).family, FontFamily::SystemUi);
1796 assert_eq!(rich_body_style(None).family, FontFamily::SystemUi);
1797
1798 let mut tcx = TextContext::new();
1799 let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1800 layout_themed(&mut plain_w, &mut tcx, None);
1801 assert_eq!(plain_w.message.style, plain_unthemed);
1802
1803 let mut rich_w = rich_panel_widget();
1804 layout_themed(&mut rich_w, &mut tcx, None);
1805 let title_run = rich_w.title.as_ref().expect("a title is set");
1806 assert_eq!(title_run.style, rich_title_style(None));
1807 assert_eq!(rich_w.body.style, rich_body_style(None));
1808 }
1809
1810 #[test]
1811 fn a_theme_swap_reshapes_the_cached_panel_runs() {
1812 let probe_a = || FontFamily::named("Tooltip Swap Probe A");
1813 let probe_b = || FontFamily::named("Tooltip Swap Probe B");
1814 let first = theme_with(probe_a(), probe_a(), probe_a());
1815 let second = theme_with(probe_b(), probe_b(), probe_b());
1816 let mut tcx = TextContext::new();
1817 let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1818 let mut rich_w = rich_panel_widget();
1819 layout_themed(&mut plain_w, &mut tcx, Some(&first));
1820 layout_themed(&mut rich_w, &mut tcx, Some(&first));
1821
1822 let settled = tcx.shape_cache_stats();
1824 layout_themed(&mut plain_w, &mut tcx, Some(&first));
1825 layout_themed(&mut rich_w, &mut tcx, Some(&first));
1826 assert_eq!(
1827 tcx.shape_cache_stats(),
1828 settled,
1829 "an unchanged theme reshapes nothing"
1830 );
1831
1832 layout_themed(&mut plain_w, &mut tcx, Some(&second));
1833 layout_themed(&mut rich_w, &mut tcx, Some(&second));
1834 assert_eq!(
1835 tcx.shape_cache_stats().shapes,
1836 settled.shapes + 3,
1837 "a family swap must reshape the plain message, rich title and rich body"
1838 );
1839 }
1840
1841 fn plain_panel_widget(via: TooltipOpenVia) -> (PlainPanelWidget, TooltipHover) {
1844 let hover = TooltipHover::new();
1845 hover.set_open(true);
1846 hover.set_via(via);
1847 let view = PlainPanel {
1848 message: "hi".into(),
1849 hover: hover.clone(),
1850 };
1851 let mut counter = 0u64;
1852 let w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1853 (w, hover)
1854 }
1855
1856 #[test]
1857 fn a_long_press_opened_plain_tooltip_auto_dismisses_at_extra_long_4() {
1858 let (mut w, hover) = plain_panel_widget(TooltipOpenVia::LongPress);
1859 let mut tcx = TextContext::new();
1860 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1861 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1862
1863 let mut scene = NullScene;
1864 let mut ctx0 = PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(0.0));
1865 w.paint(&mut ctx0, &mut scene);
1866 assert!(hover.is_open(), "not yet at the delay");
1867
1868 let mut mid = PaintCtx::for_test(
1869 AuthoringPoint::ORIGIN,
1870 Size::new(400.0, 400.0),
1871 ft(PLAIN_DISMISS_MS - 1.0),
1872 );
1873 w.paint(&mut mid, &mut scene);
1874 assert!(hover.is_open(), "just short of it");
1875
1876 let mut done = PaintCtx::for_test(
1877 AuthoringPoint::ORIGIN,
1878 Size::new(400.0, 400.0),
1879 ft(PLAIN_DISMISS_MS),
1880 );
1881 w.paint(&mut done, &mut scene);
1882 assert!(!hover.is_open(), "1000ms (EXTRA_LONG_4) auto-dismisses it");
1883 }
1884
1885 #[test]
1886 fn a_hover_opened_plain_tooltip_never_auto_dismisses() {
1887 let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1888 let mut tcx = TextContext::new();
1889 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1890 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1891 let mut scene = NullScene;
1892 let mut ctx = PaintCtx::for_test(
1893 AuthoringPoint::ORIGIN,
1894 Size::new(400.0, 400.0),
1895 ft(PLAIN_DISMISS_MS * 10.0),
1896 );
1897 w.paint(&mut ctx, &mut scene);
1898 assert!(
1899 hover.is_open(),
1900 "hover-shown plain tooltips have no timer at all"
1901 );
1902 }
1903
1904 #[test]
1910 fn one_pod_across_two_shows_re_arms_the_timer_for_each() {
1911 let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1912 let mut tcx = TextContext::new();
1913 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1914 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1915 let mut scene = NullScene;
1916 let mut paint_at = |w: &mut PlainPanelWidget, ms: f64| {
1917 let mut ctx =
1918 PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(ms));
1919 w.paint(&mut ctx, &mut scene);
1920 };
1921
1922 paint_at(&mut w, 0.0);
1924 paint_at(&mut w, PLAIN_DISMISS_MS * 10.0);
1925 assert!(hover.is_open(), "a hover-shown plain tooltip has no timer");
1926
1927 hover.set_open(false);
1929 hover.set_via(TooltipOpenVia::LongPress);
1930 hover.set_open(true);
1931 let base = PLAIN_DISMISS_MS * 20.0;
1932 paint_at(&mut w, base);
1933 assert!(hover.is_open(), "the second show has only just started");
1934 paint_at(&mut w, base + PLAIN_DISMISS_MS - 1.0);
1935 assert!(
1936 hover.is_open(),
1937 "and its timer is measured from ITS own first paint, not the pod's build"
1938 );
1939 paint_at(&mut w, base + PLAIN_DISMISS_MS);
1940 assert!(!hover.is_open(), "1000ms into the second show, it goes");
1941 }
1942
1943 #[test]
1946 fn rich_never_arms_the_plain_auto_dismiss_timer() {
1947 let hover = TooltipHover::new();
1953 hover.set_open(true);
1954 hover.set_via(TooltipOpenVia::LongPress);
1955 let view: RichPanel<AppState> = RichPanel {
1956 title: Some("Title".into()),
1957 message: "Body copy".into(),
1958 actions: vec![],
1959 };
1960 let mut counter = 0u64;
1961 let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1962 let mut tcx = TextContext::new();
1963 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1964 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1965 let mut scene = NullScene;
1966 let mut ctx = PaintCtx::for_test(
1967 AuthoringPoint::ORIGIN,
1968 Size::new(400.0, 400.0),
1969 ft(PLAIN_DISMISS_MS * 5.0),
1970 );
1971 w.paint(&mut ctx, &mut scene);
1972 assert!(hover.is_open(), "no auto-dismiss timer for rich, ever");
1973 }
1974
1975 #[derive(Default)]
1984 struct Presses {
1985 fired: Vec<usize>,
1986 }
1987
1988 fn rich_panel_with_actions(count: usize) -> RichPanelWidget {
1991 let actions: Vec<TooltipAction<Presses>> = (0..count)
1992 .map(|i| {
1993 tooltip_action(format!("Action {i}"), move |s: &mut Presses| {
1994 s.fired.push(i)
1995 })
1996 })
1997 .collect();
1998 let view: RichPanel<Presses> = RichPanel {
1999 title: Some("Title".into()),
2000 message: "Body copy long enough to occupy real space above the actions".into(),
2001 actions,
2002 };
2003 let mut counter = 0u64;
2004 let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
2005 let mut tcx = TextContext::new();
2006 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2007 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
2008 w
2009 }
2010
2011 fn dispatch_rich(
2012 w: &mut RichPanelWidget,
2013 state: &mut Presses,
2014 phase: PointerPhase,
2015 pos: AuthoringPoint,
2016 ) -> EventResult {
2017 let state_any: &mut dyn Any = state;
2018 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 400.0));
2019 w.event(
2020 &mut ctx,
2021 &InputEvent::Pointer(PointerEvent {
2022 phase,
2023 position: pos,
2024 button: PointerButton::Primary,
2025 }),
2026 )
2027 }
2028
2029 fn body_point(w: &RichPanelWidget) -> AuthoringPoint {
2031 AuthoringPoint::new(RICH_PAD + 1.0, w.body_origin.y + 1.0)
2032 }
2033
2034 fn action_center(w: &RichPanelWidget, index: usize) -> AuthoringPoint {
2036 let pod = &w.action_pods[index];
2037 let (o, s) = (pod.origin(), pod.size());
2038 AuthoringPoint::new(o.x + s.width / 2.0, o.y + s.height / 2.0)
2039 }
2040
2041 #[test]
2042 fn a_press_on_the_body_fires_no_action_and_captures_nothing() {
2043 let mut w = rich_panel_with_actions(2);
2044 let mut state = Presses::default();
2045 let p = body_point(&w);
2046 dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2047 dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2048 assert!(state.fired.is_empty(), "no action pod sits under the body");
2049 assert!(
2050 w.action_pods.iter().all(|pod| !pod.is_active()),
2051 "nothing captured a press outside every action's own bounds"
2052 );
2053 }
2054
2055 #[test]
2056 fn press_release_on_the_second_action_fires_it() {
2057 let mut w = rich_panel_with_actions(2);
2058 let mut state = Presses::default();
2059 let p = action_center(&w, 1);
2060 dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2061 assert!(
2062 w.action_pods[1].is_active(),
2063 "the hit-tested pod captures the press, not action #0"
2064 );
2065 dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2066 assert_eq!(state.fired, vec![1], "action #1 fires — previously dead");
2067 }
2068
2069 #[test]
2070 fn down_on_body_up_over_action_0_does_not_fire() {
2071 let mut w = rich_panel_with_actions(2);
2072 let mut state = Presses::default();
2073 let down_p = body_point(&w);
2074 let up_p = action_center(&w, 0);
2075 dispatch_rich(&mut w, &mut state, PointerPhase::Down, down_p);
2076 dispatch_rich(&mut w, &mut state, PointerPhase::Up, up_p);
2077 assert!(
2078 state.fired.is_empty(),
2079 "up-inside without down-inside must not fire (the press contract)"
2080 );
2081 }
2082
2083 const WINDOW: Size = Size::new(400.0, 600.0);
2091 const RICH_TITLE: &str = "Compose";
2092 const RICH_BODY: &str = "Start a new draft with expressive defaults.";
2093 const RICH_ACTION: &str = "Got it";
2094
2095 #[derive(Default)]
2098 struct PageState {
2099 pressed: usize,
2100 action_fired: usize,
2101 }
2102
2103 struct PageProbe;
2107
2108 struct PageProbeWidget;
2110
2111 impl View<PageState> for PageProbe {
2112 type Element = PageProbeWidget;
2113
2114 fn build(&self, _ctx: &mut BuildCtx<'_>) -> PageProbeWidget {
2115 PageProbeWidget
2116 }
2117
2118 fn rebuild(
2119 &self,
2120 _prev: &Self,
2121 _element: &mut PageProbeWidget,
2122 _ctx: &mut BuildCtx<'_>,
2123 ) -> ChangeFlags {
2124 ChangeFlags::NONE
2125 }
2126
2127 fn teardown(&self, _element: &mut PageProbeWidget, _ctx: &mut BuildCtx<'_>) {}
2128 }
2129
2130 impl Widget for PageProbeWidget {
2131 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2132 bc.max()
2133 }
2134
2135 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2136
2137 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2138 if let InputEvent::Pointer(p) = event
2139 && p.phase == PointerPhase::Down
2140 {
2141 ctx.state_mut::<PageState>().pressed += 1;
2142 return EventResult::Handled;
2143 }
2144 EventResult::Ignored
2145 }
2146
2147 fn semantics(&self, _ctx: &mut SemanticsCtx) {}
2148
2149 visit_children!();
2150 }
2151
2152 #[derive(Default)]
2160 struct Recorder {
2161 rrects: Vec<(AuthoringPoint, Size)>,
2162 layers: Vec<f32>,
2163 }
2164
2165 impl Recorder {
2166 fn panel(&self) -> AuthoringRect {
2168 let (origin, size) = self.rrects[0];
2169 AuthoringRect::from_origin_size(origin, size)
2170 }
2171 }
2172
2173 impl PaintScene for Recorder {
2174 fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
2175 fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
2176 fn fill_rounded_rect(&mut self, origin: AuthoringPoint, size: Size, _r: f64, _c: Color) {
2177 self.rrects.push((origin, size));
2178 }
2179 fn push_layer(&mut self, _o: AuthoringPoint, _s: Size, alpha: f32) {
2180 self.layers.push(alpha);
2181 }
2182 fn pop_layer(&mut self) {}
2183 fn push_transform(&mut self, _t: Affine) {}
2184 fn pop_transform(&mut self) {}
2185 }
2186
2187 struct PortalHarness {
2193 root: RenderRoot<PageState, frust::StackView<PageState>>,
2194 state: PageState,
2195 tcx: TextContext,
2196 hover: TooltipHover,
2197 rich: bool,
2198 }
2199
2200 impl PortalHarness {
2201 fn new(rich: bool) -> Self {
2202 let mut h = PortalHarness {
2203 root: RenderRoot::new(),
2204 state: PageState::default(),
2205 tcx: TextContext::new(),
2206 hover: TooltipHover::new(),
2207 rich,
2208 };
2209 h.frame(0.0);
2210 h
2211 }
2212
2213 fn view(hover: &TooltipHover, rich: bool) -> frust::StackView<PageState> {
2214 let panel: AnyView<PageState> = if rich {
2215 any(rich_tooltip(hover, RICH_BODY)
2216 .title(RICH_TITLE)
2217 .action(tooltip_action(RICH_ACTION, |s: &mut PageState| {
2218 s.action_fired += 1
2219 })))
2220 } else {
2221 any(tooltip(hover, RICH_TITLE))
2222 };
2223 frust::stack()
2224 .child(PageProbe)
2225 .child(tooltip_trigger(
2226 hover,
2227 frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
2228 ))
2229 .child(panel)
2230 }
2231
2232 fn frame(&mut self, ms: f64) -> Recorder {
2235 let hover = self.hover.clone();
2236 let rich = self.rich;
2237 let mut logic = move |_s: &mut PageState| Self::view(&hover, rich);
2238 self.root.rebuild(&mut logic, &mut self.state);
2239 self.root
2240 .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
2241 self.repaint(ms)
2242 }
2243
2244 fn repaint(&mut self, ms: f64) -> Recorder {
2247 let mut rec = Recorder::default();
2248 self.root.paint(&mut rec, ft(ms));
2249 rec
2250 }
2251
2252 fn open_by_hover(&mut self) -> Recorder {
2254 self.root.event(&mut self.state, &moved(10.0, 10.0));
2255 self.frame(0.0);
2256 self.frame(SETTLED_MS)
2257 }
2258
2259 fn press(&mut self, x: f64, y: f64) -> bool {
2260 self.root.event(&mut self.state, &down(x, y)).handled
2261 }
2262 }
2263
2264 const SETTLED_MS: f64 = 1_000.0;
2267
2268 #[test]
2269 fn a_closed_tooltip_registers_nothing_and_an_opened_one_registers_its_panel() {
2270 let mut h = PortalHarness::new(false);
2271 let closed = h.frame(0.0);
2272 assert!(
2273 closed.rrects.is_empty(),
2274 "a closed tooltip floats nothing at all"
2275 );
2276 let open = h.open_by_hover();
2277 assert!(!open.rrects.is_empty(), "hovering floats the panel");
2278 }
2279
2280 #[test]
2286 fn closing_the_latch_takes_the_panel_away_on_the_very_next_paint_with_no_exit_ramp() {
2287 let mut h = PortalHarness::new(false);
2288 assert!(!h.open_by_hover().rrects.is_empty(), "open first");
2289
2290 h.hover.set_open(false);
2291 let after = h.repaint(SETTLED_MS + 16.0);
2292 assert!(
2293 after.rrects.is_empty(),
2294 "gone on the paint that follows the close — no rebuild needed"
2295 );
2296 assert!(
2297 after.layers.is_empty(),
2298 "and nothing was composited on the way out: there is no exit ramp"
2299 );
2300
2301 let rebuilt = h.frame(SETTLED_MS + 32.0);
2304 assert!(rebuilt.rrects.is_empty());
2305 assert!(rebuilt.layers.is_empty());
2306 }
2307
2308 #[test]
2309 fn the_entrance_ramp_still_plays_and_then_settles() {
2310 let mut h = PortalHarness::new(false);
2311 h.root.event(&mut h.state, &moved(10.0, 10.0));
2312 h.frame(0.0);
2315 let entering = h.repaint(MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
2316 assert!(
2317 entering.layers.iter().any(|a| *a > 0.0 && *a < 1.0),
2318 "the entrance composites a part-way layer: {:?}",
2319 entering.layers
2320 );
2321 let settled = h.repaint(SETTLED_MS);
2322 assert!(
2323 settled.layers.is_empty(),
2324 "and stops compositing once it has arrived: {:?}",
2325 settled.layers
2326 );
2327 assert!(!settled.rrects.is_empty(), "still on screen, fully present");
2328 }
2329
2330 #[test]
2340 fn a_press_on_an_open_plain_tooltip_reaches_the_page_beneath_it() {
2341 let mut h = PortalHarness::new(false);
2342 let panel = h.open_by_hover().panel();
2343 let at = panel.center();
2344 let handled = h.press(at.x, at.y);
2345 assert_eq!(
2346 h.state.pressed, 1,
2347 "the page under the panel actually fired"
2348 );
2349 assert!(handled, "and reported the press as handled, by the page");
2350 assert!(
2351 h.hover.is_open(),
2352 "a plain tooltip is not dismissed by a tap: it closes on hover-exit \
2353 or on its own timer"
2354 );
2355 }
2356
2357 #[test]
2362 fn a_press_outside_an_open_rich_tooltip_closes_it_and_still_reaches_the_page() {
2363 let mut h = PortalHarness::new(true);
2364 let panel = h.open_by_hover().panel();
2365 let away = AuthoringPoint::new(WINDOW.width - 20.0, WINDOW.height - 20.0);
2366 assert!(
2367 !panel.contains(away),
2368 "the press has to land outside the panel for this to mean anything"
2369 );
2370
2371 h.press(away.x, away.y);
2372 assert!(!h.hover.is_open(), "the outside press dismisses it");
2373 assert_eq!(
2374 h.state.pressed, 1,
2375 "and is NOT swallowed: the page under it fired too"
2376 );
2377 }
2378
2379 #[test]
2384 fn a_press_on_an_open_rich_tooltips_action_fires_it_and_not_the_page() {
2385 let mut h = PortalHarness::new(true);
2386 let panel = h.open_by_hover().panel();
2387 let local = rich_probe_action_center(0);
2388 let at = AuthoringPoint::new(panel.x0 + local.x, panel.y0 + local.y);
2389 assert!(panel.contains(at), "the action sits inside the panel");
2390
2391 h.root.event(&mut h.state, &down(at.x, at.y));
2392 h.root.event(&mut h.state, &up(at.x, at.y));
2393 assert_eq!(h.state.action_fired, 1, "the action button fired");
2394 assert_eq!(
2395 h.state.pressed, 0,
2396 "and the page beneath was not pressed through the panel"
2397 );
2398 }
2399
2400 fn rich_probe_action_center(index: usize) -> AuthoringPoint {
2406 let view: RichPanel<PageState> = RichPanel {
2407 title: Some(RICH_TITLE.into()),
2408 message: RICH_BODY.into(),
2409 actions: vec![tooltip_action(RICH_ACTION, |s: &mut PageState| {
2410 s.action_fired += 1
2411 })],
2412 };
2413 let mut counter = 0u64;
2414 let mut w = View::<PageState>::build(&view, &mut BuildCtx::new(&mut counter));
2415 let mut tcx = TextContext::new();
2416 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2417 w.layout(&mut lctx, &BoxConstraints::loose(WINDOW));
2418 action_center(&w, index)
2419 }
2420
2421 #[test]
2422 fn hover_shows_the_plain_panel_and_the_rich_panel_alike() {
2423 for rich in [false, true] {
2424 let mut h = PortalHarness::new(rich);
2425 assert!(
2426 !h.open_by_hover().rrects.is_empty(),
2427 "hovering the trigger shows the panel (rich: {rich})"
2428 );
2429 }
2430 }
2431
2432 #[test]
2433 fn a_long_press_shows_the_plain_panel_and_the_rich_panel_alike() {
2434 for rich in [false, true] {
2435 let mut h = PortalHarness::new(rich);
2436 h.root.event(&mut h.state, &down(10.0, 10.0));
2437 h.frame(0.0);
2438 h.repaint(LONG_PRESS_MS as f64 + 20.0);
2441 let shown = h.frame(LONG_PRESS_MS as f64 + 20.0);
2442 assert_eq!(
2443 h.hover.via(),
2444 TooltipOpenVia::LongPress,
2445 "opened by the long press (rich: {rich})"
2446 );
2447 assert!(
2448 !shown.rrects.is_empty(),
2449 "and the panel is floated for it (rich: {rich})"
2450 );
2451 }
2452 }
2453
2454 #[test]
2457 fn plain_and_rich_resolve_off_the_theme_not_literals() {
2458 let light = crate::baseline().with_brightness(Brightness::Light);
2459 let dark = crate::baseline().with_brightness(Brightness::Dark);
2460 assert_ne!(
2461 light.scheme().inverse_surface,
2462 dark.scheme().inverse_surface
2463 );
2464 assert_ne!(
2465 overlay_container(Some(&light), OverlayContainer::Standard),
2466 overlay_container(Some(&dark), OverlayContainer::Standard)
2467 );
2468 }
2469}