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<()> {
877 any(RampedPanel {
878 content: any(PlainPanel {
879 message: self.message.clone(),
880 hover: self.hover.clone(),
881 }),
882 hover: self.hover.clone(),
883 })
884 }
885}
886
887pub struct TooltipWidget {
895 hover: TooltipHover,
896 slot: OverlaySlot<()>,
897}
898
899impl TooltipView {
900 fn configure(&self, slot: &mut OverlaySlot<()>) {
903 slot.set_band(OverlayBand::Tooltip);
904 slot.set_input(OverlayInput::Transparent);
907 slot.set_outside_tap(OutsideTap::Ignore);
912 slot.set_placement(portal_placement(self.placement));
913 }
914}
915
916impl<State: 'static> View<State> for TooltipView {
917 type Element = TooltipWidget;
918
919 fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipWidget {
920 let mut slot = OverlaySlot::new();
921 self.configure(&mut slot);
922 slot.rebuild(None, Some(&self.content_view()), ctx);
923 TooltipWidget {
924 hover: self.hover.clone(),
925 slot,
926 }
927 }
928
929 fn rebuild(
930 &self,
931 prev: &Self,
932 element: &mut TooltipWidget,
933 ctx: &mut BuildCtx<'_>,
934 ) -> ChangeFlags {
935 self.configure(&mut element.slot);
936 element.hover = self.hover.clone();
937 element
938 .slot
939 .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
940 | ChangeFlags::PAINT
941 }
942
943 fn teardown(&self, element: &mut TooltipWidget, ctx: &mut BuildCtx<'_>) {
944 element.slot.rebuild(Some(&self.content_view()), None, ctx);
945 }
946}
947
948impl Widget for TooltipWidget {
949 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
950 self.slot.layout(ctx);
955 bc.constrain(Size::ZERO)
961 }
962
963 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
964 if !self.hover.is_open() {
965 return;
966 }
967 let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
970 self.slot.set_anchor(PortalAnchor::Rect(local));
971 self.slot.paint(ctx, Size::ZERO);
972 }
973
974 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
975 self.slot
980 .event(ctx, event, &mut ())
981 .unwrap_or(EventResult::Ignored)
982 }
983
984 fn semantics(&self, _ctx: &mut SemanticsCtx) {
985 }
988
989 visit_children!();
990}
991
992struct PlainPanel {
995 message: String,
996 hover: TooltipHover,
997}
998
999impl<State: 'static> View<State> for PlainPanel {
1000 type Element = PlainPanelWidget;
1001
1002 fn build(&self, _ctx: &mut BuildCtx<'_>) -> PlainPanelWidget {
1003 let mut run = TooltipTextRun::new();
1004 run.set_content(&self.message);
1005 PlainPanelWidget {
1006 message: run,
1007 hover: self.hover.clone(),
1008 dismiss_via_long_press: false,
1009 armed_show: None,
1010 timer_start: None,
1011 dismissed: false,
1012 }
1013 }
1014
1015 fn rebuild(
1016 &self,
1017 _prev: &Self,
1018 element: &mut PlainPanelWidget,
1019 _ctx: &mut BuildCtx<'_>,
1020 ) -> ChangeFlags {
1021 let mut flags = ChangeFlags::NONE;
1022 if element.message.content != self.message {
1023 element.message.set_content(&self.message);
1024 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1025 }
1026 element.hover = self.hover.clone();
1027 flags
1028 }
1029
1030 fn teardown(&self, _element: &mut PlainPanelWidget, _ctx: &mut BuildCtx<'_>) {}
1031}
1032
1033struct PlainPanelWidget {
1035 message: TooltipTextRun,
1036 hover: TooltipHover,
1037 dismiss_via_long_press: bool,
1040 armed_show: Option<u64>,
1044 timer_start: Option<FrameTime>,
1045 dismissed: bool,
1046}
1047
1048impl PlainPanelWidget {
1049 fn arm(&mut self) {
1056 let show = self.hover.show_id();
1057 if self.armed_show == Some(show) {
1058 return;
1059 }
1060 self.armed_show = Some(show);
1061 self.dismiss_via_long_press = self.hover.via() == TooltipOpenVia::LongPress;
1062 self.timer_start = None;
1063 self.dismissed = false;
1064 }
1065}
1066
1067impl Widget for PlainPanelWidget {
1068 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1069 let width_cap = bc.max().width.min(PLAIN_MAX_WIDTH);
1070 let inner_w = (width_cap - 2.0 * PLAIN_PAD_X).max(0.0);
1071 let style = plain_message_style(Theme::from_layout_ctx(ctx));
1072 let msg = self.message.shape(ctx, &style, inner_w);
1073 let size = Size::new(
1074 msg.width + 2.0 * PLAIN_PAD_X,
1075 msg.height + 2.0 * PLAIN_PAD_Y,
1076 );
1077 bc.constrain(size)
1078 }
1079
1080 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1081 self.arm();
1082 let theme = Theme::from_paint_ctx(ctx);
1083 let container = theme.map_or(FALLBACK_INVERSE_SURFACE, |t| t.scheme().inverse_surface);
1084 let ink = theme.map_or(FALLBACK_INVERSE_ON_SURFACE, |t| {
1085 t.scheme().inverse_on_surface
1086 });
1087 let radius = theme.map_or(MaterialDimensions::RADIUS_EXTRA_SMALL, |t| {
1088 t.shape.extra_small
1089 });
1090 let (origin, size) = (ctx.origin(), ctx.size());
1091 scene.fill_rounded_rect(origin, size, radius, container);
1092 self.message.paint(
1093 Point::new(origin.x + PLAIN_PAD_X, origin.y + PLAIN_PAD_Y),
1094 ink,
1095 scene,
1096 );
1097
1098 if self.dismiss_via_long_press && !self.dismissed {
1104 let start = *self.timer_start.get_or_insert(ctx.frame_time());
1105 let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
1106 if elapsed_ms >= PLAIN_DISMISS_MS {
1107 self.dismissed = true;
1108 self.hover.set_open(false);
1109 }
1110 ctx.request_frame();
1111 }
1112 }
1113
1114 fn semantics(&self, ctx: &mut SemanticsCtx) {
1115 ctx.push_node(frust::authoring::Role::Label, |node| {
1116 node.set_label(self.message.content.clone());
1117 });
1118 }
1119}
1120
1121pub struct TooltipAction<State: 'static> {
1126 label: String,
1127 on_press: Rc<dyn Fn(&mut State)>,
1128}
1129
1130impl<State: 'static> Clone for TooltipAction<State> {
1131 fn clone(&self) -> Self {
1132 Self {
1133 label: self.label.clone(),
1134 on_press: self.on_press.clone(),
1135 }
1136 }
1137}
1138
1139pub fn tooltip_action<State: 'static, F: Fn(&mut State) + 'static>(
1141 label: impl Into<String>,
1142 on_press: F,
1143) -> TooltipAction<State> {
1144 TooltipAction {
1145 label: label.into(),
1146 on_press: Rc::new(on_press),
1147 }
1148}
1149
1150pub fn rich_tooltip<State: 'static>(
1159 hover: &TooltipHover,
1160 message: impl Into<String>,
1161) -> RichTooltipView<State> {
1162 RichTooltipView {
1163 hover: hover.clone(),
1164 title: None,
1165 message: message.into(),
1166 actions: Vec::new(),
1167 placement: OverlayPlacement::default(),
1168 }
1169}
1170
1171pub struct RichTooltipView<State: 'static> {
1173 hover: TooltipHover,
1174 title: Option<String>,
1175 message: String,
1176 actions: Vec<TooltipAction<State>>,
1177 placement: OverlayPlacement,
1178}
1179
1180impl<State: 'static> RichTooltipView<State> {
1181 pub fn title(mut self, title: impl Into<String>) -> Self {
1183 self.title = Some(title.into());
1184 self
1185 }
1186
1187 pub fn action(mut self, action: TooltipAction<State>) -> Self {
1192 self.actions.push(action);
1193 self
1194 }
1195
1196 pub fn side(mut self, side: OverlaySide) -> Self {
1198 self.placement.side = side;
1199 self
1200 }
1201
1202 pub fn align(mut self, align: OverlayAlign) -> Self {
1204 self.placement.align = align;
1205 self
1206 }
1207
1208 pub fn offset(mut self, offset: f64) -> Self {
1210 self.placement.offset = offset;
1211 self
1212 }
1213
1214 fn content_view(&self) -> AnyView<State> {
1221 any(RampedPanel {
1222 content: any(RichPanel {
1223 title: self.title.clone(),
1224 message: self.message.clone(),
1225 actions: self.actions.clone(),
1226 }),
1227 hover: self.hover.clone(),
1228 })
1229 }
1230
1231 fn configure(&self, slot: &mut OverlaySlot<State>) {
1234 slot.set_band(OverlayBand::Tooltip);
1235 slot.set_input(OverlayInput::Interactive);
1240 slot.set_outside_tap(OutsideTap::Notify { consume: false });
1243 slot.set_placement(portal_placement(self.placement));
1244 }
1245}
1246
1247pub struct RichTooltipWidget<State: 'static> {
1252 hover: TooltipHover,
1253 slot: OverlaySlot<State>,
1254}
1255
1256impl<State: 'static> View<State> for RichTooltipView<State> {
1257 type Element = RichTooltipWidget<State>;
1258
1259 fn build(&self, ctx: &mut BuildCtx<'_>) -> RichTooltipWidget<State> {
1260 let mut slot = OverlaySlot::new();
1261 self.configure(&mut slot);
1262 slot.rebuild(None, Some(&self.content_view()), ctx);
1263 RichTooltipWidget {
1264 hover: self.hover.clone(),
1265 slot,
1266 }
1267 }
1268
1269 fn rebuild(
1270 &self,
1271 prev: &Self,
1272 element: &mut RichTooltipWidget<State>,
1273 ctx: &mut BuildCtx<'_>,
1274 ) -> ChangeFlags {
1275 self.configure(&mut element.slot);
1276 element.hover = self.hover.clone();
1277 element
1278 .slot
1279 .rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
1280 | ChangeFlags::PAINT
1281 }
1282
1283 fn teardown(&self, element: &mut RichTooltipWidget<State>, ctx: &mut BuildCtx<'_>) {
1284 element.slot.rebuild(Some(&self.content_view()), None, ctx);
1285 }
1286}
1287
1288impl<State: 'static> Widget for RichTooltipWidget<State> {
1289 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1290 self.slot.layout(ctx);
1291 bc.constrain(Size::ZERO)
1292 }
1293
1294 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1295 if !self.hover.is_open() {
1296 return;
1297 }
1298 let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
1299 self.slot.set_anchor(PortalAnchor::Rect(local));
1300 self.slot.paint(ctx, Size::ZERO);
1301 }
1302
1303 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1304 let Some(result) = self.slot.event_ambient(ctx, event) else {
1305 return EventResult::Ignored;
1308 };
1309 if self.slot.take_outside_down() {
1314 self.hover.set_open(false);
1315 ctx.request_redraw();
1316 }
1317 result
1318 }
1319
1320 fn semantics(&self, _ctx: &mut SemanticsCtx) {
1321 }
1324
1325 visit_children!();
1326}
1327
1328fn action_view<State: 'static>(action: &TooltipAction<State>) -> AnyView<State> {
1330 let cb = action.on_press.clone();
1331 any(text_button(action.label.clone(), move |s: &mut State| {
1332 cb(s)
1333 }))
1334}
1335
1336struct RichPanel<State: 'static> {
1339 title: Option<String>,
1340 message: String,
1341 actions: Vec<TooltipAction<State>>,
1342}
1343
1344impl<State: 'static> View<State> for RichPanel<State> {
1345 type Element = RichPanelWidget;
1346
1347 fn build(&self, ctx: &mut BuildCtx<'_>) -> RichPanelWidget {
1348 let title = self.title.as_ref().map(|t| {
1349 let mut run = TooltipTextRun::new();
1350 run.set_content(t);
1351 run
1352 });
1353 let mut body = TooltipTextRun::new();
1354 body.set_content(&self.message);
1355 let action_pods = self
1356 .actions
1357 .iter()
1358 .map(|a| build_child(&action_view(a), ctx))
1359 .collect();
1360 RichPanelWidget {
1361 title,
1362 body,
1363 action_pods,
1364 title_origin: Point::ORIGIN,
1365 body_origin: Point::ORIGIN,
1366 }
1367 }
1368
1369 fn rebuild(
1370 &self,
1371 prev: &Self,
1372 element: &mut RichPanelWidget,
1373 ctx: &mut BuildCtx<'_>,
1374 ) -> ChangeFlags {
1375 let mut flags = ChangeFlags::NONE;
1376 match (&self.title, &mut element.title) {
1377 (Some(t), Some(run)) => {
1378 if run.content != *t {
1379 run.set_content(t);
1380 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1381 }
1382 }
1383 (Some(t), None) => {
1384 let mut run = TooltipTextRun::new();
1385 run.set_content(t);
1386 element.title = Some(run);
1387 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1388 }
1389 (None, Some(_)) => {
1390 element.title = None;
1391 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1392 }
1393 (None, None) => {}
1394 }
1395 if element.body.content != self.message {
1396 element.body.set_content(&self.message);
1397 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1398 }
1399
1400 let prev_views: Vec<AnyView<State>> = prev.actions.iter().map(action_view).collect();
1401 let next_views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1402 flags |= rebuild_children(
1403 &prev_views,
1404 &next_views,
1405 &mut element.action_pods,
1406 ctx,
1407 |v: &AnyView<State>| v,
1408 |_| None,
1409 );
1410 flags
1411 }
1412
1413 fn teardown(&self, element: &mut RichPanelWidget, ctx: &mut BuildCtx<'_>) {
1414 let views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
1415 for (pod, view) in element.action_pods.iter_mut().zip(views.iter()) {
1416 teardown_child(view, pod, ctx);
1417 }
1418 }
1419}
1420
1421struct RichPanelWidget {
1423 title: Option<TooltipTextRun>,
1424 body: TooltipTextRun,
1425 action_pods: Vec<ChildPod>,
1426 title_origin: Point,
1427 body_origin: Point,
1428}
1429
1430impl Widget for RichPanelWidget {
1431 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1432 let width_cap = bc.max().width.min(RICH_MAX_WIDTH);
1433 let inner_w = (width_cap - 2.0 * RICH_PAD).max(0.0);
1434 let mut content_w: f64 = 0.0;
1435 let mut y = RICH_PAD;
1436 let theme = Theme::from_layout_ctx(ctx);
1437 let (title_style, body_style) = (rich_title_style(theme), rich_body_style(theme));
1438
1439 if let Some(title) = &mut self.title {
1440 let sz = title.shape(ctx, &title_style, inner_w);
1441 self.title_origin = Point::new(RICH_PAD, y);
1442 content_w = content_w.max(sz.width);
1443 y += sz.height + RICH_TITLE_GAP;
1444 }
1445
1446 let body_sz = self.body.shape(ctx, &body_style, inner_w);
1447 self.body_origin = Point::new(RICH_PAD, y);
1448 content_w = content_w.max(body_sz.width);
1449 y += body_sz.height;
1450
1451 if !self.action_pods.is_empty() {
1452 y += RICH_ACTIONS_GAP;
1453 let mut x = RICH_PAD;
1454 let mut row_h: f64 = 0.0;
1455 for pod in &mut self.action_pods {
1456 let sz = pod.layout_child(
1457 ctx,
1458 &BoxConstraints::loose(Size::new(inner_w, f64::INFINITY)),
1459 );
1460 pod.set_origin(Point::new(x, y));
1461 x += sz.width;
1462 row_h = row_h.max(sz.height);
1463 }
1464 content_w = content_w.max(x - RICH_PAD);
1465 y += row_h;
1466 }
1467
1468 let panel_w = content_w.min(inner_w) + 2.0 * RICH_PAD;
1469 let panel_h = y + RICH_PAD;
1470 bc.constrain(Size::new(panel_w, panel_h))
1471 }
1472
1473 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1474 let theme = Theme::from_paint_ctx(ctx);
1475 let container = overlay_container(theme, OverlayContainer::Standard);
1476 let radius = theme.map_or(MaterialDimensions::RADIUS_MEDIUM, |t| t.shape.medium);
1477 let (origin, size) = (ctx.origin(), ctx.size());
1478
1479 if let Some((blur, y_offset, color)) = overlay_shadow(theme, OverlayElevation::Level2) {
1480 scene.draw_shadow(
1481 Point::new(origin.x, origin.y + y_offset),
1482 size,
1483 radius,
1484 blur,
1485 color,
1486 );
1487 }
1488 scene.fill_rounded_rect(origin, size, radius, container);
1489
1490 let title_ink = theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface);
1491 let body_ink = on_surface_variant(theme);
1492 if let Some(title) = &self.title {
1493 title.paint(
1494 Point::new(
1495 origin.x + self.title_origin.x,
1496 origin.y + self.title_origin.y,
1497 ),
1498 title_ink,
1499 scene,
1500 );
1501 }
1502 self.body.paint(
1503 Point::new(origin.x + self.body_origin.x, origin.y + self.body_origin.y),
1504 body_ink,
1505 scene,
1506 );
1507
1508 for pod in &mut self.action_pods {
1509 pod.paint_child(ctx, scene);
1510 }
1511 }
1512
1513 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1514 route_event(&mut self.action_pods, ctx, event)
1519 }
1520
1521 fn semantics(&self, ctx: &mut SemanticsCtx) {
1522 ctx.push_container(
1523 frust::authoring::Role::Group,
1524 |_node| {},
1525 |ctx| {
1526 for pod in &self.action_pods {
1527 pod.semantics_child(ctx);
1528 }
1529 },
1530 );
1531 }
1532
1533 visit_children!(action_pods);
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538 use super::*;
1539 use frust::Brightness;
1540 use frust::authoring::text::FontFamily;
1541 use frust::authoring::{
1542 Point as AuthoringPoint, PointerButton, PointerEvent, Rect as AuthoringRect,
1543 };
1544 use frust_core::RenderRoot;
1545 use std::any::Any;
1546
1547 fn ft(ms: f64) -> FrameTime {
1548 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1549 }
1550
1551 #[test]
1554 fn a_fresh_handle_is_closed_and_enabled() {
1555 let hover = TooltipHover::new();
1556 assert!(!hover.is_open());
1557 assert!(hover.is_enabled());
1558 assert_eq!(hover.via(), TooltipOpenVia::Hover);
1559 }
1560
1561 #[test]
1562 fn clones_share_the_same_cells() {
1563 let hover = TooltipHover::new();
1564 let clone = hover.clone();
1565 clone.set_open(true);
1566 assert!(hover.is_open(), "the clone shares the latch");
1567 hover.anchor().set(AuthoringRect::new(1.0, 2.0, 3.0, 4.0));
1568 assert_eq!(
1569 clone.anchor().rect(),
1570 AuthoringRect::new(1.0, 2.0, 3.0, 4.0)
1571 );
1572 }
1573
1574 #[derive(Default)]
1577 struct AppState;
1578
1579 struct Harness {
1583 root: RenderRoot<AppState, TooltipTriggerView<AppState>>,
1584 state: AppState,
1585 hover: TooltipHover,
1586 enabled: bool,
1587 }
1588
1589 const TRIGGER: Size = Size::new(80.0, 32.0);
1590
1591 impl Harness {
1592 fn new() -> Self {
1593 let mut h = Harness {
1594 root: RenderRoot::new(),
1595 state: AppState,
1596 hover: TooltipHover::new(),
1597 enabled: true,
1598 };
1599 h.pass();
1600 h.root.layout(Size::new(400.0, 400.0));
1601 h
1602 }
1603
1604 fn pass(&mut self) {
1605 let hover = self.hover.clone();
1606 let enabled = self.enabled;
1607 let mut logic = move |_s: &mut AppState| {
1608 tooltip_trigger(
1609 &hover,
1610 frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
1611 )
1612 .enabled(enabled)
1613 };
1614 self.root.rebuild(&mut logic, &mut self.state);
1615 }
1616
1617 fn paint(&mut self, ms: f64) {
1618 self.root.paint(&mut NullScene, ft(ms));
1619 }
1620
1621 fn event(&mut self, event: InputEvent) {
1622 self.root.event(&mut self.state, &event);
1623 }
1624 }
1625
1626 struct NullScene;
1627 impl PaintScene for NullScene {
1628 fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
1629 fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
1630 }
1631
1632 fn moved(x: f64, y: f64) -> InputEvent {
1633 InputEvent::Pointer(PointerEvent {
1634 phase: PointerPhase::Move,
1635 position: AuthoringPoint::new(x, y),
1636 button: PointerButton::Primary,
1637 })
1638 }
1639
1640 fn down(x: f64, y: f64) -> InputEvent {
1641 InputEvent::Pointer(PointerEvent {
1642 phase: PointerPhase::Down,
1643 position: AuthoringPoint::new(x, y),
1644 button: PointerButton::Primary,
1645 })
1646 }
1647
1648 fn up(x: f64, y: f64) -> InputEvent {
1649 InputEvent::Pointer(PointerEvent {
1650 phase: PointerPhase::Up,
1651 position: AuthoringPoint::new(x, y),
1652 button: PointerButton::Primary,
1653 })
1654 }
1655
1656 #[test]
1657 fn hover_opens_on_the_very_next_paint_zero_delay() {
1658 let mut h = Harness::new();
1659 h.paint(0.0);
1660 assert!(!h.hover.is_open());
1661 h.event(moved(10.0, 10.0));
1662 h.paint(1.0);
1665 assert!(h.hover.is_open());
1666 assert_eq!(h.hover.via(), TooltipOpenVia::Hover);
1667 }
1668
1669 #[test]
1670 fn hover_closes_the_instant_the_pointer_leaves() {
1671 let mut h = Harness::new();
1672 h.event(moved(10.0, 10.0));
1673 h.paint(1.0);
1674 assert!(h.hover.is_open());
1675 h.event(moved(500.0, 500.0));
1676 h.paint(2.0);
1677 assert!(!h.hover.is_open());
1678 }
1679
1680 #[test]
1681 fn disabled_suppresses_hover_and_snaps_an_open_panel_shut() {
1682 let mut h = Harness::new();
1683 h.event(moved(10.0, 10.0));
1684 h.paint(1.0);
1685 assert!(h.hover.is_open());
1686 h.enabled = false;
1687 h.pass();
1688 h.root.layout(Size::new(400.0, 400.0));
1689 h.paint(2.0);
1690 assert!(!h.hover.is_open(), "disabling mid-show snaps it shut");
1691 }
1692
1693 #[test]
1696 fn a_stationary_long_press_opens_after_the_threshold_via_housekeeping() {
1697 let mut h = Harness::new();
1698 h.event(down(10.0, 10.0));
1699 h.paint(0.0);
1700 let flags = h
1701 .root
1702 .paint(&mut NullScene, ft(LONG_PRESS_MS as f64 + 20.0));
1703 assert!(!h.hover.is_open(), "paint alone only latches, never fires");
1704 assert!(
1705 flags.needs_frame,
1706 "requests the follow-up frame that fires it"
1707 );
1708
1709 h.pass();
1713 assert!(
1714 h.hover.is_open(),
1715 "fires via Housekeeping, no further pointer event"
1716 );
1717 assert_eq!(h.hover.via(), TooltipOpenVia::LongPress);
1718 }
1719
1720 #[test]
1721 fn a_quick_release_before_the_threshold_never_opens() {
1722 let mut h = Harness::new();
1723 h.event(down(10.0, 10.0));
1724 h.paint(0.0);
1725 h.paint(100.0); h.event(up(11.0, 11.0));
1727 h.pass();
1728 assert!(!h.hover.is_open());
1729 }
1730
1731 fn theme_with(plain: FontFamily, title: FontFamily, body: FontFamily) -> Theme {
1736 let mut theme = crate::baseline();
1737 theme.type_scale.body_small.family = plain;
1738 theme.type_scale.title_small.family = title;
1739 theme.type_scale.body_medium.family = body;
1740 theme
1741 }
1742
1743 fn rich_panel_widget() -> RichPanelWidget {
1745 let view: RichPanel<AppState> = RichPanel {
1746 title: Some("Compose".into()),
1747 message: "Start a new draft.".into(),
1748 actions: Vec::new(),
1749 };
1750 let mut counter = 0u64;
1751 View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter))
1752 }
1753
1754 fn layout_themed(w: &mut dyn Widget, tcx: &mut TextContext, theme: Option<&Theme>) {
1757 let mut ctx =
1758 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1759 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1760 }
1761
1762 #[test]
1763 fn layout_takes_each_panel_run_s_family_from_its_role() {
1764 let (plain, title, body) = (
1765 FontFamily::named("Plain Role Probe"),
1766 FontFamily::named("Title Role Probe"),
1767 FontFamily::named("Body Role Probe"),
1768 );
1769 let theme = theme_with(plain.clone(), title.clone(), body.clone());
1770 let mut tcx = TextContext::new();
1771
1772 let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1773 layout_themed(&mut plain_w, &mut tcx, Some(&theme));
1774 assert_eq!(plain_w.message.style.family, plain, "bodySmall");
1775 assert_eq!(plain_w.message.style.size, 12.0, "the token size stays");
1776
1777 let mut rich_w = rich_panel_widget();
1778 layout_themed(&mut rich_w, &mut tcx, Some(&theme));
1779 let title_run = rich_w.title.as_ref().expect("a title is set");
1780 assert_eq!(title_run.style.family, title, "titleSmall");
1781 assert_eq!(rich_w.body.style.family, body, "bodyMedium");
1782 }
1783
1784 #[test]
1785 fn without_a_theme_the_panel_runs_keep_the_unthemed_styles() {
1786 let plain_unthemed = TextStyle {
1787 weight: FontWeight::REGULAR,
1788 letter_spacing: 0.4,
1789 line_height: LineHeight::Absolute(16.0),
1790 ..TextStyle::new(12.0, Color::BLACK)
1791 };
1792 assert_eq!(plain_message_style(None), plain_unthemed);
1793 assert_eq!(rich_title_style(None).family, FontFamily::SystemUi);
1794 assert_eq!(rich_body_style(None).family, FontFamily::SystemUi);
1795
1796 let mut tcx = TextContext::new();
1797 let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1798 layout_themed(&mut plain_w, &mut tcx, None);
1799 assert_eq!(plain_w.message.style, plain_unthemed);
1800
1801 let mut rich_w = rich_panel_widget();
1802 layout_themed(&mut rich_w, &mut tcx, None);
1803 let title_run = rich_w.title.as_ref().expect("a title is set");
1804 assert_eq!(title_run.style, rich_title_style(None));
1805 assert_eq!(rich_w.body.style, rich_body_style(None));
1806 }
1807
1808 #[test]
1809 fn a_theme_swap_reshapes_the_cached_panel_runs() {
1810 let probe_a = || FontFamily::named("Tooltip Swap Probe A");
1811 let probe_b = || FontFamily::named("Tooltip Swap Probe B");
1812 let first = theme_with(probe_a(), probe_a(), probe_a());
1813 let second = theme_with(probe_b(), probe_b(), probe_b());
1814 let mut tcx = TextContext::new();
1815 let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
1816 let mut rich_w = rich_panel_widget();
1817 layout_themed(&mut plain_w, &mut tcx, Some(&first));
1818 layout_themed(&mut rich_w, &mut tcx, Some(&first));
1819
1820 let settled = tcx.shape_cache_stats();
1822 layout_themed(&mut plain_w, &mut tcx, Some(&first));
1823 layout_themed(&mut rich_w, &mut tcx, Some(&first));
1824 assert_eq!(
1825 tcx.shape_cache_stats(),
1826 settled,
1827 "an unchanged theme reshapes nothing"
1828 );
1829
1830 layout_themed(&mut plain_w, &mut tcx, Some(&second));
1831 layout_themed(&mut rich_w, &mut tcx, Some(&second));
1832 assert_eq!(
1833 tcx.shape_cache_stats().shapes,
1834 settled.shapes + 3,
1835 "a family swap must reshape the plain message, rich title and rich body"
1836 );
1837 }
1838
1839 fn plain_panel_widget(via: TooltipOpenVia) -> (PlainPanelWidget, TooltipHover) {
1842 let hover = TooltipHover::new();
1843 hover.set_open(true);
1844 hover.set_via(via);
1845 let view = PlainPanel {
1846 message: "hi".into(),
1847 hover: hover.clone(),
1848 };
1849 let mut counter = 0u64;
1850 let w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1851 (w, hover)
1852 }
1853
1854 #[test]
1855 fn a_long_press_opened_plain_tooltip_auto_dismisses_at_extra_long_4() {
1856 let (mut w, hover) = plain_panel_widget(TooltipOpenVia::LongPress);
1857 let mut tcx = TextContext::new();
1858 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1859 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1860
1861 let mut scene = NullScene;
1862 let mut ctx0 = PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(0.0));
1863 w.paint(&mut ctx0, &mut scene);
1864 assert!(hover.is_open(), "not yet at the delay");
1865
1866 let mut mid = PaintCtx::for_test(
1867 AuthoringPoint::ORIGIN,
1868 Size::new(400.0, 400.0),
1869 ft(PLAIN_DISMISS_MS - 1.0),
1870 );
1871 w.paint(&mut mid, &mut scene);
1872 assert!(hover.is_open(), "just short of it");
1873
1874 let mut done = PaintCtx::for_test(
1875 AuthoringPoint::ORIGIN,
1876 Size::new(400.0, 400.0),
1877 ft(PLAIN_DISMISS_MS),
1878 );
1879 w.paint(&mut done, &mut scene);
1880 assert!(!hover.is_open(), "1000ms (EXTRA_LONG_4) auto-dismisses it");
1881 }
1882
1883 #[test]
1884 fn a_hover_opened_plain_tooltip_never_auto_dismisses() {
1885 let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1886 let mut tcx = TextContext::new();
1887 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1888 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1889 let mut scene = NullScene;
1890 let mut ctx = PaintCtx::for_test(
1891 AuthoringPoint::ORIGIN,
1892 Size::new(400.0, 400.0),
1893 ft(PLAIN_DISMISS_MS * 10.0),
1894 );
1895 w.paint(&mut ctx, &mut scene);
1896 assert!(
1897 hover.is_open(),
1898 "hover-shown plain tooltips have no timer at all"
1899 );
1900 }
1901
1902 #[test]
1908 fn one_pod_across_two_shows_re_arms_the_timer_for_each() {
1909 let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
1910 let mut tcx = TextContext::new();
1911 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1912 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1913 let mut scene = NullScene;
1914 let mut paint_at = |w: &mut PlainPanelWidget, ms: f64| {
1915 let mut ctx =
1916 PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(ms));
1917 w.paint(&mut ctx, &mut scene);
1918 };
1919
1920 paint_at(&mut w, 0.0);
1922 paint_at(&mut w, PLAIN_DISMISS_MS * 10.0);
1923 assert!(hover.is_open(), "a hover-shown plain tooltip has no timer");
1924
1925 hover.set_open(false);
1927 hover.set_via(TooltipOpenVia::LongPress);
1928 hover.set_open(true);
1929 let base = PLAIN_DISMISS_MS * 20.0;
1930 paint_at(&mut w, base);
1931 assert!(hover.is_open(), "the second show has only just started");
1932 paint_at(&mut w, base + PLAIN_DISMISS_MS - 1.0);
1933 assert!(
1934 hover.is_open(),
1935 "and its timer is measured from ITS own first paint, not the pod's build"
1936 );
1937 paint_at(&mut w, base + PLAIN_DISMISS_MS);
1938 assert!(!hover.is_open(), "1000ms into the second show, it goes");
1939 }
1940
1941 #[test]
1944 fn rich_never_arms_the_plain_auto_dismiss_timer() {
1945 let hover = TooltipHover::new();
1951 hover.set_open(true);
1952 hover.set_via(TooltipOpenVia::LongPress);
1953 let view: RichPanel<AppState> = RichPanel {
1954 title: Some("Title".into()),
1955 message: "Body copy".into(),
1956 actions: vec![],
1957 };
1958 let mut counter = 0u64;
1959 let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
1960 let mut tcx = TextContext::new();
1961 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1962 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
1963 let mut scene = NullScene;
1964 let mut ctx = PaintCtx::for_test(
1965 AuthoringPoint::ORIGIN,
1966 Size::new(400.0, 400.0),
1967 ft(PLAIN_DISMISS_MS * 5.0),
1968 );
1969 w.paint(&mut ctx, &mut scene);
1970 assert!(hover.is_open(), "no auto-dismiss timer for rich, ever");
1971 }
1972
1973 #[derive(Default)]
1982 struct Presses {
1983 fired: Vec<usize>,
1984 }
1985
1986 fn rich_panel_with_actions(count: usize) -> RichPanelWidget {
1989 let actions: Vec<TooltipAction<Presses>> = (0..count)
1990 .map(|i| {
1991 tooltip_action(format!("Action {i}"), move |s: &mut Presses| {
1992 s.fired.push(i)
1993 })
1994 })
1995 .collect();
1996 let view: RichPanel<Presses> = RichPanel {
1997 title: Some("Title".into()),
1998 message: "Body copy long enough to occupy real space above the actions".into(),
1999 actions,
2000 };
2001 let mut counter = 0u64;
2002 let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
2003 let mut tcx = TextContext::new();
2004 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2005 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
2006 w
2007 }
2008
2009 fn dispatch_rich(
2010 w: &mut RichPanelWidget,
2011 state: &mut Presses,
2012 phase: PointerPhase,
2013 pos: AuthoringPoint,
2014 ) -> EventResult {
2015 let state_any: &mut dyn Any = state;
2016 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 400.0));
2017 w.event(
2018 &mut ctx,
2019 &InputEvent::Pointer(PointerEvent {
2020 phase,
2021 position: pos,
2022 button: PointerButton::Primary,
2023 }),
2024 )
2025 }
2026
2027 fn body_point(w: &RichPanelWidget) -> AuthoringPoint {
2029 AuthoringPoint::new(RICH_PAD + 1.0, w.body_origin.y + 1.0)
2030 }
2031
2032 fn action_center(w: &RichPanelWidget, index: usize) -> AuthoringPoint {
2034 let pod = &w.action_pods[index];
2035 let (o, s) = (pod.origin(), pod.size());
2036 AuthoringPoint::new(o.x + s.width / 2.0, o.y + s.height / 2.0)
2037 }
2038
2039 #[test]
2040 fn a_press_on_the_body_fires_no_action_and_captures_nothing() {
2041 let mut w = rich_panel_with_actions(2);
2042 let mut state = Presses::default();
2043 let p = body_point(&w);
2044 dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2045 dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2046 assert!(state.fired.is_empty(), "no action pod sits under the body");
2047 assert!(
2048 w.action_pods.iter().all(|pod| !pod.is_active()),
2049 "nothing captured a press outside every action's own bounds"
2050 );
2051 }
2052
2053 #[test]
2054 fn press_release_on_the_second_action_fires_it() {
2055 let mut w = rich_panel_with_actions(2);
2056 let mut state = Presses::default();
2057 let p = action_center(&w, 1);
2058 dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
2059 assert!(
2060 w.action_pods[1].is_active(),
2061 "the hit-tested pod captures the press, not action #0"
2062 );
2063 dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
2064 assert_eq!(state.fired, vec![1], "action #1 fires — previously dead");
2065 }
2066
2067 #[test]
2068 fn down_on_body_up_over_action_0_does_not_fire() {
2069 let mut w = rich_panel_with_actions(2);
2070 let mut state = Presses::default();
2071 let down_p = body_point(&w);
2072 let up_p = action_center(&w, 0);
2073 dispatch_rich(&mut w, &mut state, PointerPhase::Down, down_p);
2074 dispatch_rich(&mut w, &mut state, PointerPhase::Up, up_p);
2075 assert!(
2076 state.fired.is_empty(),
2077 "up-inside without down-inside must not fire (the press contract)"
2078 );
2079 }
2080
2081 const WINDOW: Size = Size::new(400.0, 600.0);
2089 const RICH_TITLE: &str = "Compose";
2090 const RICH_BODY: &str = "Start a new draft with expressive defaults.";
2091 const RICH_ACTION: &str = "Got it";
2092
2093 #[derive(Default)]
2096 struct PageState {
2097 pressed: usize,
2098 action_fired: usize,
2099 }
2100
2101 struct PageProbe;
2105
2106 struct PageProbeWidget;
2108
2109 impl View<PageState> for PageProbe {
2110 type Element = PageProbeWidget;
2111
2112 fn build(&self, _ctx: &mut BuildCtx<'_>) -> PageProbeWidget {
2113 PageProbeWidget
2114 }
2115
2116 fn rebuild(
2117 &self,
2118 _prev: &Self,
2119 _element: &mut PageProbeWidget,
2120 _ctx: &mut BuildCtx<'_>,
2121 ) -> ChangeFlags {
2122 ChangeFlags::NONE
2123 }
2124
2125 fn teardown(&self, _element: &mut PageProbeWidget, _ctx: &mut BuildCtx<'_>) {}
2126 }
2127
2128 impl Widget for PageProbeWidget {
2129 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2130 bc.max()
2131 }
2132
2133 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2134
2135 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2136 if let InputEvent::Pointer(p) = event
2137 && p.phase == PointerPhase::Down
2138 {
2139 ctx.state_mut::<PageState>().pressed += 1;
2140 return EventResult::Handled;
2141 }
2142 EventResult::Ignored
2143 }
2144
2145 fn semantics(&self, _ctx: &mut SemanticsCtx) {}
2146
2147 visit_children!();
2148 }
2149
2150 #[derive(Default)]
2158 struct Recorder {
2159 rrects: Vec<(AuthoringPoint, Size)>,
2160 layers: Vec<f32>,
2161 }
2162
2163 impl Recorder {
2164 fn panel(&self) -> AuthoringRect {
2166 let (origin, size) = self.rrects[0];
2167 AuthoringRect::from_origin_size(origin, size)
2168 }
2169 }
2170
2171 impl PaintScene for Recorder {
2172 fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
2173 fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
2174 fn fill_rounded_rect(&mut self, origin: AuthoringPoint, size: Size, _r: f64, _c: Color) {
2175 self.rrects.push((origin, size));
2176 }
2177 fn push_layer(&mut self, _o: AuthoringPoint, _s: Size, alpha: f32) {
2178 self.layers.push(alpha);
2179 }
2180 fn pop_layer(&mut self) {}
2181 fn push_transform(&mut self, _t: Affine) {}
2182 fn pop_transform(&mut self) {}
2183 }
2184
2185 struct PortalHarness {
2191 root: RenderRoot<PageState, frust::StackView<PageState>>,
2192 state: PageState,
2193 tcx: TextContext,
2194 hover: TooltipHover,
2195 rich: bool,
2196 }
2197
2198 impl PortalHarness {
2199 fn new(rich: bool) -> Self {
2200 let mut h = PortalHarness {
2201 root: RenderRoot::new(),
2202 state: PageState::default(),
2203 tcx: TextContext::new(),
2204 hover: TooltipHover::new(),
2205 rich,
2206 };
2207 h.frame(0.0);
2208 h
2209 }
2210
2211 fn view(hover: &TooltipHover, rich: bool) -> frust::StackView<PageState> {
2212 let panel: AnyView<PageState> = if rich {
2213 any(rich_tooltip(hover, RICH_BODY)
2214 .title(RICH_TITLE)
2215 .action(tooltip_action(RICH_ACTION, |s: &mut PageState| {
2216 s.action_fired += 1
2217 })))
2218 } else {
2219 any(tooltip(hover, RICH_TITLE))
2220 };
2221 frust::Stack(vec![
2222 any(PageProbe),
2223 any(tooltip_trigger(
2224 hover,
2225 frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
2226 )),
2227 panel,
2228 ])
2229 }
2230
2231 fn frame(&mut self, ms: f64) -> Recorder {
2234 let hover = self.hover.clone();
2235 let rich = self.rich;
2236 let mut logic = move |_s: &mut PageState| Self::view(&hover, rich);
2237 self.root.rebuild(&mut logic, &mut self.state);
2238 self.root
2239 .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
2240 self.repaint(ms)
2241 }
2242
2243 fn repaint(&mut self, ms: f64) -> Recorder {
2246 let mut rec = Recorder::default();
2247 self.root.paint(&mut rec, ft(ms));
2248 rec
2249 }
2250
2251 fn open_by_hover(&mut self) -> Recorder {
2253 self.root.event(&mut self.state, &moved(10.0, 10.0));
2254 self.frame(0.0);
2255 self.frame(SETTLED_MS)
2256 }
2257
2258 fn press(&mut self, x: f64, y: f64) -> bool {
2259 self.root.event(&mut self.state, &down(x, y)).handled
2260 }
2261 }
2262
2263 const SETTLED_MS: f64 = 1_000.0;
2266
2267 #[test]
2268 fn a_closed_tooltip_registers_nothing_and_an_opened_one_registers_its_panel() {
2269 let mut h = PortalHarness::new(false);
2270 let closed = h.frame(0.0);
2271 assert!(
2272 closed.rrects.is_empty(),
2273 "a closed tooltip floats nothing at all"
2274 );
2275 let open = h.open_by_hover();
2276 assert!(!open.rrects.is_empty(), "hovering floats the panel");
2277 }
2278
2279 #[test]
2285 fn closing_the_latch_takes_the_panel_away_on_the_very_next_paint_with_no_exit_ramp() {
2286 let mut h = PortalHarness::new(false);
2287 assert!(!h.open_by_hover().rrects.is_empty(), "open first");
2288
2289 h.hover.set_open(false);
2290 let after = h.repaint(SETTLED_MS + 16.0);
2291 assert!(
2292 after.rrects.is_empty(),
2293 "gone on the paint that follows the close — no rebuild needed"
2294 );
2295 assert!(
2296 after.layers.is_empty(),
2297 "and nothing was composited on the way out: there is no exit ramp"
2298 );
2299
2300 let rebuilt = h.frame(SETTLED_MS + 32.0);
2303 assert!(rebuilt.rrects.is_empty());
2304 assert!(rebuilt.layers.is_empty());
2305 }
2306
2307 #[test]
2308 fn the_entrance_ramp_still_plays_and_then_settles() {
2309 let mut h = PortalHarness::new(false);
2310 h.root.event(&mut h.state, &moved(10.0, 10.0));
2311 h.frame(0.0);
2314 let entering = h.repaint(MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
2315 assert!(
2316 entering.layers.iter().any(|a| *a > 0.0 && *a < 1.0),
2317 "the entrance composites a part-way layer: {:?}",
2318 entering.layers
2319 );
2320 let settled = h.repaint(SETTLED_MS);
2321 assert!(
2322 settled.layers.is_empty(),
2323 "and stops compositing once it has arrived: {:?}",
2324 settled.layers
2325 );
2326 assert!(!settled.rrects.is_empty(), "still on screen, fully present");
2327 }
2328
2329 #[test]
2339 fn a_press_on_an_open_plain_tooltip_reaches_the_page_beneath_it() {
2340 let mut h = PortalHarness::new(false);
2341 let panel = h.open_by_hover().panel();
2342 let at = panel.center();
2343 let handled = h.press(at.x, at.y);
2344 assert_eq!(
2345 h.state.pressed, 1,
2346 "the page under the panel actually fired"
2347 );
2348 assert!(handled, "and reported the press as handled, by the page");
2349 assert!(
2350 h.hover.is_open(),
2351 "a plain tooltip is not dismissed by a tap: it closes on hover-exit \
2352 or on its own timer"
2353 );
2354 }
2355
2356 #[test]
2361 fn a_press_outside_an_open_rich_tooltip_closes_it_and_still_reaches_the_page() {
2362 let mut h = PortalHarness::new(true);
2363 let panel = h.open_by_hover().panel();
2364 let away = AuthoringPoint::new(WINDOW.width - 20.0, WINDOW.height - 20.0);
2365 assert!(
2366 !panel.contains(away),
2367 "the press has to land outside the panel for this to mean anything"
2368 );
2369
2370 h.press(away.x, away.y);
2371 assert!(!h.hover.is_open(), "the outside press dismisses it");
2372 assert_eq!(
2373 h.state.pressed, 1,
2374 "and is NOT swallowed: the page under it fired too"
2375 );
2376 }
2377
2378 #[test]
2383 fn a_press_on_an_open_rich_tooltips_action_fires_it_and_not_the_page() {
2384 let mut h = PortalHarness::new(true);
2385 let panel = h.open_by_hover().panel();
2386 let local = rich_probe_action_center(0);
2387 let at = AuthoringPoint::new(panel.x0 + local.x, panel.y0 + local.y);
2388 assert!(panel.contains(at), "the action sits inside the panel");
2389
2390 h.root.event(&mut h.state, &down(at.x, at.y));
2391 h.root.event(&mut h.state, &up(at.x, at.y));
2392 assert_eq!(h.state.action_fired, 1, "the action button fired");
2393 assert_eq!(
2394 h.state.pressed, 0,
2395 "and the page beneath was not pressed through the panel"
2396 );
2397 }
2398
2399 fn rich_probe_action_center(index: usize) -> AuthoringPoint {
2405 let view: RichPanel<PageState> = RichPanel {
2406 title: Some(RICH_TITLE.into()),
2407 message: RICH_BODY.into(),
2408 actions: vec![tooltip_action(RICH_ACTION, |s: &mut PageState| {
2409 s.action_fired += 1
2410 })],
2411 };
2412 let mut counter = 0u64;
2413 let mut w = View::<PageState>::build(&view, &mut BuildCtx::new(&mut counter));
2414 let mut tcx = TextContext::new();
2415 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2416 w.layout(&mut lctx, &BoxConstraints::loose(WINDOW));
2417 action_center(&w, index)
2418 }
2419
2420 #[test]
2421 fn hover_shows_the_plain_panel_and_the_rich_panel_alike() {
2422 for rich in [false, true] {
2423 let mut h = PortalHarness::new(rich);
2424 assert!(
2425 !h.open_by_hover().rrects.is_empty(),
2426 "hovering the trigger shows the panel (rich: {rich})"
2427 );
2428 }
2429 }
2430
2431 #[test]
2432 fn a_long_press_shows_the_plain_panel_and_the_rich_panel_alike() {
2433 for rich in [false, true] {
2434 let mut h = PortalHarness::new(rich);
2435 h.root.event(&mut h.state, &down(10.0, 10.0));
2436 h.frame(0.0);
2437 h.repaint(LONG_PRESS_MS as f64 + 20.0);
2440 let shown = h.frame(LONG_PRESS_MS as f64 + 20.0);
2441 assert_eq!(
2442 h.hover.via(),
2443 TooltipOpenVia::LongPress,
2444 "opened by the long press (rich: {rich})"
2445 );
2446 assert!(
2447 !shown.rrects.is_empty(),
2448 "and the panel is floated for it (rich: {rich})"
2449 );
2450 }
2451 }
2452
2453 #[test]
2456 fn plain_and_rich_resolve_off_the_theme_not_literals() {
2457 let light = crate::baseline().with_brightness(Brightness::Light);
2458 let dark = crate::baseline().with_brightness(Brightness::Dark);
2459 assert_ne!(
2460 light.scheme().inverse_surface,
2461 dark.scheme().inverse_surface
2462 );
2463 assert_ne!(
2464 overlay_container(Some(&light), OverlayContainer::Standard),
2465 overlay_container(Some(&dark), OverlayContainer::Standard)
2466 );
2467 }
2468}