use std::cell::Cell;
use std::rc::Rc;
use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
use frust::authoring::{
Affine, AnyView, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color, EventCtx,
EventResult, InputEvent, LayoutCtx, OutsideTap, OverlayBand, OverlayInput, OverlaySlot,
PaintCtx, PaintScene, Point, PointerPhase, Rect, SemanticsCtx, Size, View, Widget, any,
build_child, rebuild_child, rebuild_children, route_event, route_event_single, teardown_child,
visit_children,
};
use frust::{AnimationController, FrameTime, GestureDetector, Theme};
use crate::button::text_button;
use crate::overlay::{
ANCHORED_ENTER_SCALE, OverlayAlign, OverlayAnchor, OverlayContainer, OverlayElevation,
OverlayPlacement, OverlaySide, container as overlay_container, on_surface_variant,
reduce_motion, shadow as overlay_shadow,
};
use crate::tokens::{MaterialDimensions, MaterialMotion, MaterialSpacing};
use frust::authoring::{
OverlayAlign as PortalAlign, OverlayAnchor as PortalAnchor,
OverlayPlacement as PortalPlacement, OverlaySide as PortalSide,
};
const LONG_PRESS_MS: u64 = 500;
const PLAIN_DISMISS_MS: f64 = MaterialMotion::EXTRA_LONG_4.as_millis() as f64;
const PLAIN_MAX_WIDTH: f64 = 200.0;
const PLAIN_PAD_X: f64 = MaterialSpacing::SM;
const PLAIN_PAD_Y: f64 = MaterialSpacing::XS;
const RICH_MAX_WIDTH: f64 = 320.0;
const RICH_PAD: f64 = MaterialSpacing::LG;
const RICH_TITLE_GAP: f64 = MaterialSpacing::XS;
const RICH_ACTIONS_GAP: f64 = MaterialSpacing::MD;
const PANEL_SHADOW_SPILL: f64 = 48.0;
const PROGRESS_EPSILON: f64 = 1e-4;
const FALLBACK_INVERSE_SURFACE: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
const FALLBACK_INVERSE_ON_SURFACE: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
fn in_role_family(
mut style: TextStyle,
theme: Option<&Theme>,
pick: fn(&frust::TypeScale) -> &TextStyle,
) -> TextStyle {
if let Some(theme) = theme {
style.family = pick(&theme.type_scale).family.clone();
}
style
}
fn plain_message_style(theme: Option<&Theme>) -> TextStyle {
let style = TextStyle {
weight: FontWeight::REGULAR,
letter_spacing: 0.4,
line_height: LineHeight::Absolute(16.0),
..TextStyle::new(12.0, Color::BLACK)
};
in_role_family(style, theme, |scale| &scale.body_small)
}
fn rich_title_style(theme: Option<&Theme>) -> TextStyle {
let style = TextStyle {
weight: FontWeight::MEDIUM,
letter_spacing: 0.1,
line_height: LineHeight::Absolute(20.0),
..TextStyle::new(14.0, Color::BLACK)
};
in_role_family(style, theme, |scale| &scale.title_small)
}
fn rich_body_style(theme: Option<&Theme>) -> TextStyle {
let style = TextStyle {
weight: FontWeight::REGULAR,
letter_spacing: 0.25,
line_height: LineHeight::Absolute(20.0),
..TextStyle::new(14.0, Color::BLACK)
};
in_role_family(style, theme, |scale| &scale.body_medium)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TooltipOpenVia {
Hover,
LongPress,
}
#[derive(Clone, Copy, Debug)]
struct TooltipLatch {
open: bool,
via: TooltipOpenVia,
enabled: bool,
shows: u64,
}
impl Default for TooltipLatch {
fn default() -> Self {
Self {
open: false,
via: TooltipOpenVia::Hover,
enabled: true,
shows: 0,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct TooltipHover {
anchor: OverlayAnchor,
latch: Rc<Cell<TooltipLatch>>,
}
impl TooltipHover {
pub fn new() -> Self {
Self::default()
}
pub fn is_open(&self) -> bool {
self.latch.get().open
}
pub fn set_open(&self, open: bool) {
let mut l = self.latch.get();
if open && !l.open {
l.shows = l.shows.wrapping_add(1);
}
l.open = open;
self.latch.set(l);
}
fn show_id(&self) -> u64 {
self.latch.get().shows
}
fn via(&self) -> TooltipOpenVia {
self.latch.get().via
}
fn set_via(&self, via: TooltipOpenVia) {
let mut l = self.latch.get();
l.via = via;
self.latch.set(l);
}
fn is_enabled(&self) -> bool {
self.latch.get().enabled
}
fn set_enabled(&self, enabled: bool) {
let mut l = self.latch.get();
l.enabled = enabled;
self.latch.set(l);
}
fn anchor(&self) -> &OverlayAnchor {
&self.anchor
}
}
pub fn tooltip_trigger<State: 'static, V: View<State>>(
hover: &TooltipHover,
child: V,
) -> TooltipTriggerView<State> {
let cb_hover = hover.clone();
let gesture = GestureDetector(child)
.on_long_press(move |_state: &mut State| {
if cb_hover.is_enabled() {
cb_hover.set_open(true);
cb_hover.set_via(TooltipOpenVia::LongPress);
}
})
.hold_threshold_ms(LONG_PRESS_MS);
TooltipTriggerView {
child: any(gesture),
hover: hover.clone(),
enabled: true,
}
}
pub struct TooltipTriggerView<State: 'static> {
child: AnyView<State>,
hover: TooltipHover,
enabled: bool,
}
impl<State: 'static> TooltipTriggerView<State> {
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
pub struct TooltipTriggerWidget {
child: ChildPod,
hover: TooltipHover,
was_hovered: bool,
}
impl<State: 'static> View<State> for TooltipTriggerView<State> {
type Element = TooltipTriggerWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipTriggerWidget {
self.hover.set_enabled(self.enabled);
TooltipTriggerWidget {
child: build_child(&self.child, ctx),
hover: self.hover.clone(),
was_hovered: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut TooltipTriggerWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
self.hover.set_enabled(self.enabled);
element.hover = self.hover.clone();
rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
}
fn teardown(&self, element: &mut TooltipTriggerWidget, ctx: &mut BuildCtx<'_>) {
teardown_child(&self.child, &mut element.child, ctx);
}
}
impl Widget for TooltipTriggerWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.child.layout_child(ctx, bc);
self.child.set_origin(Point::ORIGIN);
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.hover
.anchor()
.set(Rect::from_origin_size(ctx.origin(), ctx.size()));
if self.hover.is_enabled() {
let now = ctx.is_hovered();
if now != self.was_hovered {
self.was_hovered = now;
self.hover.set_open(now);
if now {
self.hover.set_via(TooltipOpenVia::Hover);
}
ctx.request_frame();
}
} else if self.was_hovered {
self.was_hovered = false;
self.hover.set_open(false);
ctx.request_frame();
}
self.child.paint_child(ctx, scene);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let routed = route_event_single(&mut self.child, ctx, event);
if !event.is_broadcast()
&& let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Move
&& inside(p.position, ctx.size())
{
ctx.claim_hover();
}
routed
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics_child(ctx);
}
visit_children!(child);
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
struct TooltipTextRun {
content: String,
style: TextStyle,
layout: Option<TextLayout>,
}
impl TooltipTextRun {
fn new() -> Self {
Self {
content: String::new(),
style: TextStyle::default(),
layout: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
}
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
if self.style != *style {
self.style = style.clone();
self.layout = None;
}
if let Some(layout) = &self.layout {
return layout.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, &self.style, Some(max_width as f32));
let size = laid.size();
self.layout = Some(laid);
size
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
fn portal_placement(placement: OverlayPlacement) -> PortalPlacement {
PortalPlacement {
side: match placement.side {
OverlaySide::Top => PortalSide::Top,
OverlaySide::Right => PortalSide::Right,
OverlaySide::Bottom => PortalSide::Bottom,
OverlaySide::Left => PortalSide::Left,
},
align: match placement.align {
OverlayAlign::Start => PortalAlign::Start,
OverlayAlign::Center => PortalAlign::Center,
OverlayAlign::End => PortalAlign::End,
},
offset: placement.offset,
flip: placement.flip,
clamp: placement.clamp,
padding: 0.0,
}
}
fn entrance_driver() -> AnimationController {
AnimationController::new(MaterialMotion::SHORT_4)
.with_curve(MaterialMotion::EMPHASIZED_DECELERATE)
}
struct RampedPanel<PodState: 'static> {
content: AnyView<PodState>,
hover: TooltipHover,
}
struct RampedPanelWidget {
content: ChildPod,
hover: TooltipHover,
anim: AnimationController,
progress: f64,
armed_show: Option<u64>,
started: bool,
}
impl RampedPanelWidget {
fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) -> f64 {
let show = self.hover.show_id();
if self.armed_show != Some(show) {
self.armed_show = Some(show);
self.anim = entrance_driver();
self.progress = 0.0;
self.started = false;
}
if reduce {
if self.anim.is_animating() {
self.anim.stop();
}
self.progress = 1.0;
return self.progress;
}
if !self.started {
self.started = true;
self.anim.forward();
}
if self.anim.is_animating() {
if self.anim.advance(ctx.frame_time()) {
ctx.request_frame();
}
let next = self.anim.value_clamped();
if (next - self.progress).abs() > PROGRESS_EPSILON {
ctx.request_frame();
}
self.progress = next;
}
self.progress
}
fn pivot(&self, panel: Rect) -> Point {
let center = self.hover.anchor().rect().center();
Point::new(
center.x.clamp(panel.x0, panel.x1),
center.y.clamp(panel.y0, panel.y1),
)
}
}
impl<PodState: 'static> View<PodState> for RampedPanel<PodState> {
type Element = RampedPanelWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> RampedPanelWidget {
RampedPanelWidget {
content: build_child(&self.content, ctx),
hover: self.hover.clone(),
anim: entrance_driver(),
progress: 0.0,
armed_show: None,
started: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut RampedPanelWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.hover = self.hover.clone();
rebuild_child(&prev.content, &self.content, &mut element.content, ctx)
}
fn teardown(&self, element: &mut RampedPanelWidget, ctx: &mut BuildCtx<'_>) {
teardown_child(&self.content, &mut element.content, ctx);
}
}
impl Widget for RampedPanelWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.content.layout_child(ctx, bc);
self.content.set_origin(Point::ORIGIN);
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let progress = self.advance(ctx, reduce_motion(Theme::from_paint_ctx(ctx)));
let ramping = progress < 1.0 - PROGRESS_EPSILON;
if ramping {
let panel = Rect::from_origin_size(ctx.origin(), ctx.size());
let layer = panel.inflate(PANEL_SHADOW_SPILL, PANEL_SHADOW_SPILL);
scene.push_layer(layer.origin(), layer.size(), progress as f32);
let pivot = self.pivot(panel);
let scale = ANCHORED_ENTER_SCALE + (1.0 - ANCHORED_ENTER_SCALE) * progress;
scene.push_transform(
Affine::translate(pivot.to_vec2())
* Affine::scale(scale)
* Affine::translate(-pivot.to_vec2()),
);
}
self.content.paint_child(ctx, scene);
if ramping {
scene.pop_transform();
scene.pop_layer();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
route_event_single(&mut self.content, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.content.semantics_child(ctx);
}
visit_children!(content);
}
pub fn tooltip(hover: &TooltipHover, message: impl Into<String>) -> TooltipView {
TooltipView {
hover: hover.clone(),
message: message.into(),
placement: OverlayPlacement::default(),
}
}
pub struct TooltipView {
hover: TooltipHover,
message: String,
placement: OverlayPlacement,
}
impl TooltipView {
pub fn side(mut self, side: OverlaySide) -> Self {
self.placement.side = side;
self
}
pub fn align(mut self, align: OverlayAlign) -> Self {
self.placement.align = align;
self
}
pub fn offset(mut self, offset: f64) -> Self {
self.placement.offset = offset;
self
}
fn content_view(&self) -> AnyView<()> {
any(RampedPanel {
content: any(PlainPanel {
message: self.message.clone(),
hover: self.hover.clone(),
}),
hover: self.hover.clone(),
})
}
}
pub struct TooltipWidget {
hover: TooltipHover,
slot: OverlaySlot<()>,
}
impl TooltipView {
fn configure(&self, slot: &mut OverlaySlot<()>) {
slot.set_band(OverlayBand::Tooltip);
slot.set_input(OverlayInput::Transparent);
slot.set_outside_tap(OutsideTap::Ignore);
slot.set_placement(portal_placement(self.placement));
}
}
impl<State: 'static> View<State> for TooltipView {
type Element = TooltipWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> TooltipWidget {
let mut slot = OverlaySlot::new();
self.configure(&mut slot);
slot.rebuild(None, Some(&self.content_view()), ctx);
TooltipWidget {
hover: self.hover.clone(),
slot,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut TooltipWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
self.configure(&mut element.slot);
element.hover = self.hover.clone();
element
.slot
.rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
| ChangeFlags::PAINT
}
fn teardown(&self, element: &mut TooltipWidget, ctx: &mut BuildCtx<'_>) {
element.slot.rebuild(Some(&self.content_view()), None, ctx);
}
}
impl Widget for TooltipWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
self.slot.layout(ctx);
bc.constrain(Size::ZERO)
}
fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
if !self.hover.is_open() {
return;
}
let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
self.slot.set_anchor(PortalAnchor::Rect(local));
self.slot.paint(ctx, Size::ZERO);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
self.slot
.event(ctx, event, &mut ())
.unwrap_or(EventResult::Ignored)
}
fn semantics(&self, _ctx: &mut SemanticsCtx) {
}
visit_children!();
}
struct PlainPanel {
message: String,
hover: TooltipHover,
}
impl<State: 'static> View<State> for PlainPanel {
type Element = PlainPanelWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> PlainPanelWidget {
let mut run = TooltipTextRun::new();
run.set_content(&self.message);
PlainPanelWidget {
message: run,
hover: self.hover.clone(),
dismiss_via_long_press: false,
armed_show: None,
timer_start: None,
dismissed: false,
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut PlainPanelWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if element.message.content != self.message {
element.message.set_content(&self.message);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
element.hover = self.hover.clone();
flags
}
fn teardown(&self, _element: &mut PlainPanelWidget, _ctx: &mut BuildCtx<'_>) {}
}
struct PlainPanelWidget {
message: TooltipTextRun,
hover: TooltipHover,
dismiss_via_long_press: bool,
armed_show: Option<u64>,
timer_start: Option<FrameTime>,
dismissed: bool,
}
impl PlainPanelWidget {
fn arm(&mut self) {
let show = self.hover.show_id();
if self.armed_show == Some(show) {
return;
}
self.armed_show = Some(show);
self.dismiss_via_long_press = self.hover.via() == TooltipOpenVia::LongPress;
self.timer_start = None;
self.dismissed = false;
}
}
impl Widget for PlainPanelWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width_cap = bc.max().width.min(PLAIN_MAX_WIDTH);
let inner_w = (width_cap - 2.0 * PLAIN_PAD_X).max(0.0);
let style = plain_message_style(Theme::from_layout_ctx(ctx));
let msg = self.message.shape(ctx, &style, inner_w);
let size = Size::new(
msg.width + 2.0 * PLAIN_PAD_X,
msg.height + 2.0 * PLAIN_PAD_Y,
);
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.arm();
let theme = Theme::from_paint_ctx(ctx);
let container = theme.map_or(FALLBACK_INVERSE_SURFACE, |t| t.scheme().inverse_surface);
let ink = theme.map_or(FALLBACK_INVERSE_ON_SURFACE, |t| {
t.scheme().inverse_on_surface
});
let radius = theme.map_or(MaterialDimensions::RADIUS_EXTRA_SMALL, |t| {
t.shape.extra_small
});
let (origin, size) = (ctx.origin(), ctx.size());
scene.fill_rounded_rect(origin, size, radius, container);
self.message.paint(
Point::new(origin.x + PLAIN_PAD_X, origin.y + PLAIN_PAD_Y),
ink,
scene,
);
if self.dismiss_via_long_press && !self.dismissed {
let start = *self.timer_start.get_or_insert(ctx.frame_time());
let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
if elapsed_ms >= PLAIN_DISMISS_MS {
self.dismissed = true;
self.hover.set_open(false);
}
ctx.request_frame();
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(frust::authoring::Role::Label, |node| {
node.set_label(self.message.content.clone());
});
}
}
pub struct TooltipAction<State: 'static> {
label: String,
on_press: Rc<dyn Fn(&mut State)>,
}
impl<State: 'static> Clone for TooltipAction<State> {
fn clone(&self) -> Self {
Self {
label: self.label.clone(),
on_press: self.on_press.clone(),
}
}
}
pub fn tooltip_action<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> TooltipAction<State> {
TooltipAction {
label: label.into(),
on_press: Rc::new(on_press),
}
}
pub fn rich_tooltip<State: 'static>(
hover: &TooltipHover,
message: impl Into<String>,
) -> RichTooltipView<State> {
RichTooltipView {
hover: hover.clone(),
title: None,
message: message.into(),
actions: Vec::new(),
placement: OverlayPlacement::default(),
}
}
pub struct RichTooltipView<State: 'static> {
hover: TooltipHover,
title: Option<String>,
message: String,
actions: Vec<TooltipAction<State>>,
placement: OverlayPlacement,
}
impl<State: 'static> RichTooltipView<State> {
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn action(mut self, action: TooltipAction<State>) -> Self {
self.actions.push(action);
self
}
pub fn side(mut self, side: OverlaySide) -> Self {
self.placement.side = side;
self
}
pub fn align(mut self, align: OverlayAlign) -> Self {
self.placement.align = align;
self
}
pub fn offset(mut self, offset: f64) -> Self {
self.placement.offset = offset;
self
}
fn content_view(&self) -> AnyView<State> {
any(RampedPanel {
content: any(RichPanel {
title: self.title.clone(),
message: self.message.clone(),
actions: self.actions.clone(),
}),
hover: self.hover.clone(),
})
}
fn configure(&self, slot: &mut OverlaySlot<State>) {
slot.set_band(OverlayBand::Tooltip);
slot.set_input(OverlayInput::Interactive);
slot.set_outside_tap(OutsideTap::Notify { consume: false });
slot.set_placement(portal_placement(self.placement));
}
}
pub struct RichTooltipWidget<State: 'static> {
hover: TooltipHover,
slot: OverlaySlot<State>,
}
impl<State: 'static> View<State> for RichTooltipView<State> {
type Element = RichTooltipWidget<State>;
fn build(&self, ctx: &mut BuildCtx<'_>) -> RichTooltipWidget<State> {
let mut slot = OverlaySlot::new();
self.configure(&mut slot);
slot.rebuild(None, Some(&self.content_view()), ctx);
RichTooltipWidget {
hover: self.hover.clone(),
slot,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut RichTooltipWidget<State>,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
self.configure(&mut element.slot);
element.hover = self.hover.clone();
element
.slot
.rebuild(Some(&prev.content_view()), Some(&self.content_view()), ctx)
| ChangeFlags::PAINT
}
fn teardown(&self, element: &mut RichTooltipWidget<State>, ctx: &mut BuildCtx<'_>) {
element.slot.rebuild(Some(&self.content_view()), None, ctx);
}
}
impl<State: 'static> Widget for RichTooltipWidget<State> {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
self.slot.layout(ctx);
bc.constrain(Size::ZERO)
}
fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
if !self.hover.is_open() {
return;
}
let local = self.hover.anchor().rect() - ctx.origin().to_vec2();
self.slot.set_anchor(PortalAnchor::Rect(local));
self.slot.paint(ctx, Size::ZERO);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let Some(result) = self.slot.event_ambient(ctx, event) else {
return EventResult::Ignored;
};
if self.slot.take_outside_down() {
self.hover.set_open(false);
ctx.request_redraw();
}
result
}
fn semantics(&self, _ctx: &mut SemanticsCtx) {
}
visit_children!();
}
fn action_view<State: 'static>(action: &TooltipAction<State>) -> AnyView<State> {
let cb = action.on_press.clone();
any(text_button(action.label.clone(), move |s: &mut State| {
cb(s)
}))
}
struct RichPanel<State: 'static> {
title: Option<String>,
message: String,
actions: Vec<TooltipAction<State>>,
}
impl<State: 'static> View<State> for RichPanel<State> {
type Element = RichPanelWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> RichPanelWidget {
let title = self.title.as_ref().map(|t| {
let mut run = TooltipTextRun::new();
run.set_content(t);
run
});
let mut body = TooltipTextRun::new();
body.set_content(&self.message);
let action_pods = self
.actions
.iter()
.map(|a| build_child(&action_view(a), ctx))
.collect();
RichPanelWidget {
title,
body,
action_pods,
title_origin: Point::ORIGIN,
body_origin: Point::ORIGIN,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut RichPanelWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
match (&self.title, &mut element.title) {
(Some(t), Some(run)) => {
if run.content != *t {
run.set_content(t);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
(Some(t), None) => {
let mut run = TooltipTextRun::new();
run.set_content(t);
element.title = Some(run);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(None, Some(_)) => {
element.title = None;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(None, None) => {}
}
if element.body.content != self.message {
element.body.set_content(&self.message);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let prev_views: Vec<AnyView<State>> = prev.actions.iter().map(action_view).collect();
let next_views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
flags |= rebuild_children(
&prev_views,
&next_views,
&mut element.action_pods,
ctx,
|v: &AnyView<State>| v,
|_| None,
);
flags
}
fn teardown(&self, element: &mut RichPanelWidget, ctx: &mut BuildCtx<'_>) {
let views: Vec<AnyView<State>> = self.actions.iter().map(action_view).collect();
for (pod, view) in element.action_pods.iter_mut().zip(views.iter()) {
teardown_child(view, pod, ctx);
}
}
}
struct RichPanelWidget {
title: Option<TooltipTextRun>,
body: TooltipTextRun,
action_pods: Vec<ChildPod>,
title_origin: Point,
body_origin: Point,
}
impl Widget for RichPanelWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width_cap = bc.max().width.min(RICH_MAX_WIDTH);
let inner_w = (width_cap - 2.0 * RICH_PAD).max(0.0);
let mut content_w: f64 = 0.0;
let mut y = RICH_PAD;
let theme = Theme::from_layout_ctx(ctx);
let (title_style, body_style) = (rich_title_style(theme), rich_body_style(theme));
if let Some(title) = &mut self.title {
let sz = title.shape(ctx, &title_style, inner_w);
self.title_origin = Point::new(RICH_PAD, y);
content_w = content_w.max(sz.width);
y += sz.height + RICH_TITLE_GAP;
}
let body_sz = self.body.shape(ctx, &body_style, inner_w);
self.body_origin = Point::new(RICH_PAD, y);
content_w = content_w.max(body_sz.width);
y += body_sz.height;
if !self.action_pods.is_empty() {
y += RICH_ACTIONS_GAP;
let mut x = RICH_PAD;
let mut row_h: f64 = 0.0;
for pod in &mut self.action_pods {
let sz = pod.layout_child(
ctx,
&BoxConstraints::loose(Size::new(inner_w, f64::INFINITY)),
);
pod.set_origin(Point::new(x, y));
x += sz.width;
row_h = row_h.max(sz.height);
}
content_w = content_w.max(x - RICH_PAD);
y += row_h;
}
let panel_w = content_w.min(inner_w) + 2.0 * RICH_PAD;
let panel_h = y + RICH_PAD;
bc.constrain(Size::new(panel_w, panel_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let container = overlay_container(theme, OverlayContainer::Standard);
let radius = theme.map_or(MaterialDimensions::RADIUS_MEDIUM, |t| t.shape.medium);
let (origin, size) = (ctx.origin(), ctx.size());
if let Some((blur, y_offset, color)) = overlay_shadow(theme, OverlayElevation::Level2) {
scene.draw_shadow(
Point::new(origin.x, origin.y + y_offset),
size,
radius,
blur,
color,
);
}
scene.fill_rounded_rect(origin, size, radius, container);
let title_ink = theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface);
let body_ink = on_surface_variant(theme);
if let Some(title) = &self.title {
title.paint(
Point::new(
origin.x + self.title_origin.x,
origin.y + self.title_origin.y,
),
title_ink,
scene,
);
}
self.body.paint(
Point::new(origin.x + self.body_origin.x, origin.y + self.body_origin.y),
body_ink,
scene,
);
for pod in &mut self.action_pods {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
route_event(&mut self.action_pods, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
frust::authoring::Role::Group,
|_node| {},
|ctx| {
for pod in &self.action_pods {
pod.semantics_child(ctx);
}
},
);
}
visit_children!(action_pods);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::Brightness;
use frust::authoring::text::FontFamily;
use frust::authoring::{
Point as AuthoringPoint, PointerButton, PointerEvent, Rect as AuthoringRect,
};
use frust_core::RenderRoot;
use std::any::Any;
fn ft(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
#[test]
fn a_fresh_handle_is_closed_and_enabled() {
let hover = TooltipHover::new();
assert!(!hover.is_open());
assert!(hover.is_enabled());
assert_eq!(hover.via(), TooltipOpenVia::Hover);
}
#[test]
fn clones_share_the_same_cells() {
let hover = TooltipHover::new();
let clone = hover.clone();
clone.set_open(true);
assert!(hover.is_open(), "the clone shares the latch");
hover.anchor().set(AuthoringRect::new(1.0, 2.0, 3.0, 4.0));
assert_eq!(
clone.anchor().rect(),
AuthoringRect::new(1.0, 2.0, 3.0, 4.0)
);
}
#[derive(Default)]
struct AppState;
struct Harness {
root: RenderRoot<AppState, TooltipTriggerView<AppState>>,
state: AppState,
hover: TooltipHover,
enabled: bool,
}
const TRIGGER: Size = Size::new(80.0, 32.0);
impl Harness {
fn new() -> Self {
let mut h = Harness {
root: RenderRoot::new(),
state: AppState,
hover: TooltipHover::new(),
enabled: true,
};
h.pass();
h.root.layout(Size::new(400.0, 400.0));
h
}
fn pass(&mut self) {
let hover = self.hover.clone();
let enabled = self.enabled;
let mut logic = move |_s: &mut AppState| {
tooltip_trigger(
&hover,
frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
)
.enabled(enabled)
};
self.root.rebuild(&mut logic, &mut self.state);
}
fn paint(&mut self, ms: f64) {
self.root.paint(&mut NullScene, ft(ms));
}
fn event(&mut self, event: InputEvent) {
self.root.event(&mut self.state, &event);
}
}
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
}
fn moved(x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Move,
position: AuthoringPoint::new(x, y),
button: PointerButton::Primary,
})
}
fn down(x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: AuthoringPoint::new(x, y),
button: PointerButton::Primary,
})
}
fn up(x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Up,
position: AuthoringPoint::new(x, y),
button: PointerButton::Primary,
})
}
#[test]
fn hover_opens_on_the_very_next_paint_zero_delay() {
let mut h = Harness::new();
h.paint(0.0);
assert!(!h.hover.is_open());
h.event(moved(10.0, 10.0));
h.paint(1.0);
assert!(h.hover.is_open());
assert_eq!(h.hover.via(), TooltipOpenVia::Hover);
}
#[test]
fn hover_closes_the_instant_the_pointer_leaves() {
let mut h = Harness::new();
h.event(moved(10.0, 10.0));
h.paint(1.0);
assert!(h.hover.is_open());
h.event(moved(500.0, 500.0));
h.paint(2.0);
assert!(!h.hover.is_open());
}
#[test]
fn disabled_suppresses_hover_and_snaps_an_open_panel_shut() {
let mut h = Harness::new();
h.event(moved(10.0, 10.0));
h.paint(1.0);
assert!(h.hover.is_open());
h.enabled = false;
h.pass();
h.root.layout(Size::new(400.0, 400.0));
h.paint(2.0);
assert!(!h.hover.is_open(), "disabling mid-show snaps it shut");
}
#[test]
fn a_stationary_long_press_opens_after_the_threshold_via_housekeeping() {
let mut h = Harness::new();
h.event(down(10.0, 10.0));
h.paint(0.0);
let flags = h
.root
.paint(&mut NullScene, ft(LONG_PRESS_MS as f64 + 20.0));
assert!(!h.hover.is_open(), "paint alone only latches, never fires");
assert!(
flags.needs_frame,
"requests the follow-up frame that fires it"
);
h.pass();
assert!(
h.hover.is_open(),
"fires via Housekeeping, no further pointer event"
);
assert_eq!(h.hover.via(), TooltipOpenVia::LongPress);
}
#[test]
fn a_quick_release_before_the_threshold_never_opens() {
let mut h = Harness::new();
h.event(down(10.0, 10.0));
h.paint(0.0);
h.paint(100.0); h.event(up(11.0, 11.0));
h.pass();
assert!(!h.hover.is_open());
}
fn theme_with(plain: FontFamily, title: FontFamily, body: FontFamily) -> Theme {
let mut theme = crate::baseline();
theme.type_scale.body_small.family = plain;
theme.type_scale.title_small.family = title;
theme.type_scale.body_medium.family = body;
theme
}
fn rich_panel_widget() -> RichPanelWidget {
let view: RichPanel<AppState> = RichPanel {
title: Some("Compose".into()),
message: "Start a new draft.".into(),
actions: Vec::new(),
};
let mut counter = 0u64;
View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn layout_themed(w: &mut dyn Widget, tcx: &mut TextContext, theme: Option<&Theme>) {
let mut ctx =
LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
}
#[test]
fn layout_takes_each_panel_run_s_family_from_its_role() {
let (plain, title, body) = (
FontFamily::named("Plain Role Probe"),
FontFamily::named("Title Role Probe"),
FontFamily::named("Body Role Probe"),
);
let theme = theme_with(plain.clone(), title.clone(), body.clone());
let mut tcx = TextContext::new();
let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
layout_themed(&mut plain_w, &mut tcx, Some(&theme));
assert_eq!(plain_w.message.style.family, plain, "bodySmall");
assert_eq!(plain_w.message.style.size, 12.0, "the token size stays");
let mut rich_w = rich_panel_widget();
layout_themed(&mut rich_w, &mut tcx, Some(&theme));
let title_run = rich_w.title.as_ref().expect("a title is set");
assert_eq!(title_run.style.family, title, "titleSmall");
assert_eq!(rich_w.body.style.family, body, "bodyMedium");
}
#[test]
fn without_a_theme_the_panel_runs_keep_the_unthemed_styles() {
let plain_unthemed = TextStyle {
weight: FontWeight::REGULAR,
letter_spacing: 0.4,
line_height: LineHeight::Absolute(16.0),
..TextStyle::new(12.0, Color::BLACK)
};
assert_eq!(plain_message_style(None), plain_unthemed);
assert_eq!(rich_title_style(None).family, FontFamily::SystemUi);
assert_eq!(rich_body_style(None).family, FontFamily::SystemUi);
let mut tcx = TextContext::new();
let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
layout_themed(&mut plain_w, &mut tcx, None);
assert_eq!(plain_w.message.style, plain_unthemed);
let mut rich_w = rich_panel_widget();
layout_themed(&mut rich_w, &mut tcx, None);
let title_run = rich_w.title.as_ref().expect("a title is set");
assert_eq!(title_run.style, rich_title_style(None));
assert_eq!(rich_w.body.style, rich_body_style(None));
}
#[test]
fn a_theme_swap_reshapes_the_cached_panel_runs() {
let probe_a = || FontFamily::named("Tooltip Swap Probe A");
let probe_b = || FontFamily::named("Tooltip Swap Probe B");
let first = theme_with(probe_a(), probe_a(), probe_a());
let second = theme_with(probe_b(), probe_b(), probe_b());
let mut tcx = TextContext::new();
let (mut plain_w, _hover) = plain_panel_widget(TooltipOpenVia::Hover);
let mut rich_w = rich_panel_widget();
layout_themed(&mut plain_w, &mut tcx, Some(&first));
layout_themed(&mut rich_w, &mut tcx, Some(&first));
let settled = tcx.shape_cache_stats();
layout_themed(&mut plain_w, &mut tcx, Some(&first));
layout_themed(&mut rich_w, &mut tcx, Some(&first));
assert_eq!(
tcx.shape_cache_stats(),
settled,
"an unchanged theme reshapes nothing"
);
layout_themed(&mut plain_w, &mut tcx, Some(&second));
layout_themed(&mut rich_w, &mut tcx, Some(&second));
assert_eq!(
tcx.shape_cache_stats().shapes,
settled.shapes + 3,
"a family swap must reshape the plain message, rich title and rich body"
);
}
fn plain_panel_widget(via: TooltipOpenVia) -> (PlainPanelWidget, TooltipHover) {
let hover = TooltipHover::new();
hover.set_open(true);
hover.set_via(via);
let view = PlainPanel {
message: "hi".into(),
hover: hover.clone(),
};
let mut counter = 0u64;
let w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
(w, hover)
}
#[test]
fn a_long_press_opened_plain_tooltip_auto_dismisses_at_extra_long_4() {
let (mut w, hover) = plain_panel_widget(TooltipOpenVia::LongPress);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
let mut scene = NullScene;
let mut ctx0 = PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(0.0));
w.paint(&mut ctx0, &mut scene);
assert!(hover.is_open(), "not yet at the delay");
let mut mid = PaintCtx::for_test(
AuthoringPoint::ORIGIN,
Size::new(400.0, 400.0),
ft(PLAIN_DISMISS_MS - 1.0),
);
w.paint(&mut mid, &mut scene);
assert!(hover.is_open(), "just short of it");
let mut done = PaintCtx::for_test(
AuthoringPoint::ORIGIN,
Size::new(400.0, 400.0),
ft(PLAIN_DISMISS_MS),
);
w.paint(&mut done, &mut scene);
assert!(!hover.is_open(), "1000ms (EXTRA_LONG_4) auto-dismisses it");
}
#[test]
fn a_hover_opened_plain_tooltip_never_auto_dismisses() {
let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
let mut scene = NullScene;
let mut ctx = PaintCtx::for_test(
AuthoringPoint::ORIGIN,
Size::new(400.0, 400.0),
ft(PLAIN_DISMISS_MS * 10.0),
);
w.paint(&mut ctx, &mut scene);
assert!(
hover.is_open(),
"hover-shown plain tooltips have no timer at all"
);
}
#[test]
fn one_pod_across_two_shows_re_arms_the_timer_for_each() {
let (mut w, hover) = plain_panel_widget(TooltipOpenVia::Hover);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
let mut scene = NullScene;
let mut paint_at = |w: &mut PlainPanelWidget, ms: f64| {
let mut ctx =
PaintCtx::for_test(AuthoringPoint::ORIGIN, Size::new(400.0, 400.0), ft(ms));
w.paint(&mut ctx, &mut scene);
};
paint_at(&mut w, 0.0);
paint_at(&mut w, PLAIN_DISMISS_MS * 10.0);
assert!(hover.is_open(), "a hover-shown plain tooltip has no timer");
hover.set_open(false);
hover.set_via(TooltipOpenVia::LongPress);
hover.set_open(true);
let base = PLAIN_DISMISS_MS * 20.0;
paint_at(&mut w, base);
assert!(hover.is_open(), "the second show has only just started");
paint_at(&mut w, base + PLAIN_DISMISS_MS - 1.0);
assert!(
hover.is_open(),
"and its timer is measured from ITS own first paint, not the pod's build"
);
paint_at(&mut w, base + PLAIN_DISMISS_MS);
assert!(!hover.is_open(), "1000ms into the second show, it goes");
}
#[test]
fn rich_never_arms_the_plain_auto_dismiss_timer() {
let hover = TooltipHover::new();
hover.set_open(true);
hover.set_via(TooltipOpenVia::LongPress);
let view: RichPanel<AppState> = RichPanel {
title: Some("Title".into()),
message: "Body copy".into(),
actions: vec![],
};
let mut counter = 0u64;
let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
let mut scene = NullScene;
let mut ctx = PaintCtx::for_test(
AuthoringPoint::ORIGIN,
Size::new(400.0, 400.0),
ft(PLAIN_DISMISS_MS * 5.0),
);
w.paint(&mut ctx, &mut scene);
assert!(hover.is_open(), "no auto-dismiss timer for rich, ever");
}
#[derive(Default)]
struct Presses {
fired: Vec<usize>,
}
fn rich_panel_with_actions(count: usize) -> RichPanelWidget {
let actions: Vec<TooltipAction<Presses>> = (0..count)
.map(|i| {
tooltip_action(format!("Action {i}"), move |s: &mut Presses| {
s.fired.push(i)
})
})
.collect();
let view: RichPanel<Presses> = RichPanel {
title: Some("Title".into()),
message: "Body copy long enough to occupy real space above the actions".into(),
actions,
};
let mut counter = 0u64;
let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
w
}
fn dispatch_rich(
w: &mut RichPanelWidget,
state: &mut Presses,
phase: PointerPhase,
pos: AuthoringPoint,
) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 400.0));
w.event(
&mut ctx,
&InputEvent::Pointer(PointerEvent {
phase,
position: pos,
button: PointerButton::Primary,
}),
)
}
fn body_point(w: &RichPanelWidget) -> AuthoringPoint {
AuthoringPoint::new(RICH_PAD + 1.0, w.body_origin.y + 1.0)
}
fn action_center(w: &RichPanelWidget, index: usize) -> AuthoringPoint {
let pod = &w.action_pods[index];
let (o, s) = (pod.origin(), pod.size());
AuthoringPoint::new(o.x + s.width / 2.0, o.y + s.height / 2.0)
}
#[test]
fn a_press_on_the_body_fires_no_action_and_captures_nothing() {
let mut w = rich_panel_with_actions(2);
let mut state = Presses::default();
let p = body_point(&w);
dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
assert!(state.fired.is_empty(), "no action pod sits under the body");
assert!(
w.action_pods.iter().all(|pod| !pod.is_active()),
"nothing captured a press outside every action's own bounds"
);
}
#[test]
fn press_release_on_the_second_action_fires_it() {
let mut w = rich_panel_with_actions(2);
let mut state = Presses::default();
let p = action_center(&w, 1);
dispatch_rich(&mut w, &mut state, PointerPhase::Down, p);
assert!(
w.action_pods[1].is_active(),
"the hit-tested pod captures the press, not action #0"
);
dispatch_rich(&mut w, &mut state, PointerPhase::Up, p);
assert_eq!(state.fired, vec![1], "action #1 fires — previously dead");
}
#[test]
fn down_on_body_up_over_action_0_does_not_fire() {
let mut w = rich_panel_with_actions(2);
let mut state = Presses::default();
let down_p = body_point(&w);
let up_p = action_center(&w, 0);
dispatch_rich(&mut w, &mut state, PointerPhase::Down, down_p);
dispatch_rich(&mut w, &mut state, PointerPhase::Up, up_p);
assert!(
state.fired.is_empty(),
"up-inside without down-inside must not fire (the press contract)"
);
}
const WINDOW: Size = Size::new(400.0, 600.0);
const RICH_TITLE: &str = "Compose";
const RICH_BODY: &str = "Start a new draft with expressive defaults.";
const RICH_ACTION: &str = "Got it";
#[derive(Default)]
struct PageState {
pressed: usize,
action_fired: usize,
}
struct PageProbe;
struct PageProbeWidget;
impl View<PageState> for PageProbe {
type Element = PageProbeWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> PageProbeWidget {
PageProbeWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut PageProbeWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
fn teardown(&self, _element: &mut PageProbeWidget, _ctx: &mut BuildCtx<'_>) {}
}
impl Widget for PageProbeWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Down
{
ctx.state_mut::<PageState>().pressed += 1;
return EventResult::Handled;
}
EventResult::Ignored
}
fn semantics(&self, _ctx: &mut SemanticsCtx) {}
visit_children!();
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(AuthoringPoint, Size)>,
layers: Vec<f32>,
}
impl Recorder {
fn panel(&self) -> AuthoringRect {
let (origin, size) = self.rrects[0];
AuthoringRect::from_origin_size(origin, size)
}
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: AuthoringPoint, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: AuthoringPoint, _t: &str) {}
fn fill_rounded_rect(&mut self, origin: AuthoringPoint, size: Size, _r: f64, _c: Color) {
self.rrects.push((origin, size));
}
fn push_layer(&mut self, _o: AuthoringPoint, _s: Size, alpha: f32) {
self.layers.push(alpha);
}
fn pop_layer(&mut self) {}
fn push_transform(&mut self, _t: Affine) {}
fn pop_transform(&mut self) {}
}
struct PortalHarness {
root: RenderRoot<PageState, frust::StackView<PageState>>,
state: PageState,
tcx: TextContext,
hover: TooltipHover,
rich: bool,
}
impl PortalHarness {
fn new(rich: bool) -> Self {
let mut h = PortalHarness {
root: RenderRoot::new(),
state: PageState::default(),
tcx: TextContext::new(),
hover: TooltipHover::new(),
rich,
};
h.frame(0.0);
h
}
fn view(hover: &TooltipHover, rich: bool) -> frust::StackView<PageState> {
let panel: AnyView<PageState> = if rich {
any(rich_tooltip(hover, RICH_BODY)
.title(RICH_TITLE)
.action(tooltip_action(RICH_ACTION, |s: &mut PageState| {
s.action_fired += 1
})))
} else {
any(tooltip(hover, RICH_TITLE))
};
frust::stack()
.child(PageProbe)
.child(tooltip_trigger(
hover,
frust::SizedBox(Some(TRIGGER.width), Some(TRIGGER.height)),
))
.child(panel)
}
fn frame(&mut self, ms: f64) -> Recorder {
let hover = self.hover.clone();
let rich = self.rich;
let mut logic = move |_s: &mut PageState| Self::view(&hover, rich);
self.root.rebuild(&mut logic, &mut self.state);
self.root
.layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
self.repaint(ms)
}
fn repaint(&mut self, ms: f64) -> Recorder {
let mut rec = Recorder::default();
self.root.paint(&mut rec, ft(ms));
rec
}
fn open_by_hover(&mut self) -> Recorder {
self.root.event(&mut self.state, &moved(10.0, 10.0));
self.frame(0.0);
self.frame(SETTLED_MS)
}
fn press(&mut self, x: f64, y: f64) -> bool {
self.root.event(&mut self.state, &down(x, y)).handled
}
}
const SETTLED_MS: f64 = 1_000.0;
#[test]
fn a_closed_tooltip_registers_nothing_and_an_opened_one_registers_its_panel() {
let mut h = PortalHarness::new(false);
let closed = h.frame(0.0);
assert!(
closed.rrects.is_empty(),
"a closed tooltip floats nothing at all"
);
let open = h.open_by_hover();
assert!(!open.rrects.is_empty(), "hovering floats the panel");
}
#[test]
fn closing_the_latch_takes_the_panel_away_on_the_very_next_paint_with_no_exit_ramp() {
let mut h = PortalHarness::new(false);
assert!(!h.open_by_hover().rrects.is_empty(), "open first");
h.hover.set_open(false);
let after = h.repaint(SETTLED_MS + 16.0);
assert!(
after.rrects.is_empty(),
"gone on the paint that follows the close — no rebuild needed"
);
assert!(
after.layers.is_empty(),
"and nothing was composited on the way out: there is no exit ramp"
);
let rebuilt = h.frame(SETTLED_MS + 32.0);
assert!(rebuilt.rrects.is_empty());
assert!(rebuilt.layers.is_empty());
}
#[test]
fn the_entrance_ramp_still_plays_and_then_settles() {
let mut h = PortalHarness::new(false);
h.root.event(&mut h.state, &moved(10.0, 10.0));
h.frame(0.0);
let entering = h.repaint(MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
assert!(
entering.layers.iter().any(|a| *a > 0.0 && *a < 1.0),
"the entrance composites a part-way layer: {:?}",
entering.layers
);
let settled = h.repaint(SETTLED_MS);
assert!(
settled.layers.is_empty(),
"and stops compositing once it has arrived: {:?}",
settled.layers
);
assert!(!settled.rrects.is_empty(), "still on screen, fully present");
}
#[test]
fn a_press_on_an_open_plain_tooltip_reaches_the_page_beneath_it() {
let mut h = PortalHarness::new(false);
let panel = h.open_by_hover().panel();
let at = panel.center();
let handled = h.press(at.x, at.y);
assert_eq!(
h.state.pressed, 1,
"the page under the panel actually fired"
);
assert!(handled, "and reported the press as handled, by the page");
assert!(
h.hover.is_open(),
"a plain tooltip is not dismissed by a tap: it closes on hover-exit \
or on its own timer"
);
}
#[test]
fn a_press_outside_an_open_rich_tooltip_closes_it_and_still_reaches_the_page() {
let mut h = PortalHarness::new(true);
let panel = h.open_by_hover().panel();
let away = AuthoringPoint::new(WINDOW.width - 20.0, WINDOW.height - 20.0);
assert!(
!panel.contains(away),
"the press has to land outside the panel for this to mean anything"
);
h.press(away.x, away.y);
assert!(!h.hover.is_open(), "the outside press dismisses it");
assert_eq!(
h.state.pressed, 1,
"and is NOT swallowed: the page under it fired too"
);
}
#[test]
fn a_press_on_an_open_rich_tooltips_action_fires_it_and_not_the_page() {
let mut h = PortalHarness::new(true);
let panel = h.open_by_hover().panel();
let local = rich_probe_action_center(0);
let at = AuthoringPoint::new(panel.x0 + local.x, panel.y0 + local.y);
assert!(panel.contains(at), "the action sits inside the panel");
h.root.event(&mut h.state, &down(at.x, at.y));
h.root.event(&mut h.state, &up(at.x, at.y));
assert_eq!(h.state.action_fired, 1, "the action button fired");
assert_eq!(
h.state.pressed, 0,
"and the page beneath was not pressed through the panel"
);
}
fn rich_probe_action_center(index: usize) -> AuthoringPoint {
let view: RichPanel<PageState> = RichPanel {
title: Some(RICH_TITLE.into()),
message: RICH_BODY.into(),
actions: vec![tooltip_action(RICH_ACTION, |s: &mut PageState| {
s.action_fired += 1
})],
};
let mut counter = 0u64;
let mut w = View::<PageState>::build(&view, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(WINDOW));
action_center(&w, index)
}
#[test]
fn hover_shows_the_plain_panel_and_the_rich_panel_alike() {
for rich in [false, true] {
let mut h = PortalHarness::new(rich);
assert!(
!h.open_by_hover().rrects.is_empty(),
"hovering the trigger shows the panel (rich: {rich})"
);
}
}
#[test]
fn a_long_press_shows_the_plain_panel_and_the_rich_panel_alike() {
for rich in [false, true] {
let mut h = PortalHarness::new(rich);
h.root.event(&mut h.state, &down(10.0, 10.0));
h.frame(0.0);
h.repaint(LONG_PRESS_MS as f64 + 20.0);
let shown = h.frame(LONG_PRESS_MS as f64 + 20.0);
assert_eq!(
h.hover.via(),
TooltipOpenVia::LongPress,
"opened by the long press (rich: {rich})"
);
assert!(
!shown.rrects.is_empty(),
"and the panel is floated for it (rich: {rich})"
);
}
}
#[test]
fn plain_and_rich_resolve_off_the_theme_not_literals() {
let light = crate::baseline().with_brightness(Brightness::Light);
let dark = crate::baseline().with_brightness(Brightness::Dark);
assert_ne!(
light.scheme().inverse_surface,
dark.scheme().inverse_surface
);
assert_ne!(
overlay_container(Some(&light), OverlayContainer::Standard),
overlay_container(Some(&dark), OverlayContainer::Standard)
);
}
}