use core::fmt::{self, Debug};
use core::time::Duration;
use waterui::accessibility::{AccessibilityRole, AccessibilityState};
use waterui::layout::{
Layout, ProposalSize, Rect, Size, SubView, SubviewPlacement, container::FixedContainer,
padding::EdgeInsets,
};
use waterui::reactive::SignalExt as _;
use waterui::shape::{FixedRoundedRectangle, ShapeExt as _};
use waterui::style::Anchor;
use waterui::task::{sleep, spawn_local};
use waterui::{Binding, Environment, Str, View, ViewExt as _};
use waterui_backend_core::widget::InteractionFocusBinding;
use waterui_controls::label::{IntoLabel, Label};
use waterui_core::handler::{Handler, SharedAction, boxed_action};
use crate::color::{InverseOnSurface, InverseSurface, OnSurfaceVariant, Primary, SurfaceContainer};
use crate::elevation::{MaterialElevationLevel, material_elevation};
use crate::semantics::{interaction_style, label_plain_text};
use crate::theme::{motion, typography};
const PLAIN_TOOLTIP_CONTAINER_HEIGHT: f32 = 24.0;
const PLAIN_TOOLTIP_CONTAINER_SHAPE: f32 = 4.0;
const PLAIN_TOOLTIP_TOP_SPACE: f32 = 4.0;
const PLAIN_TOOLTIP_BOTTOM_SPACE: f32 = 4.0;
const PLAIN_TOOLTIP_LEADING_SPACE: f32 = 8.0;
const PLAIN_TOOLTIP_TRAILING_SPACE: f32 = 8.0;
const RICH_TOOLTIP_CONTAINER_SHAPE: f32 = 12.0;
const RICH_TOOLTIP_MAX_WIDTH: f32 = 312.0;
const RICH_TOOLTIP_HORIZONTAL_PADDING: f32 = 16.0;
const RICH_TOOLTIP_TOP_PADDING: f32 = 12.0;
const RICH_TOOLTIP_BOTTOM_PADDING: f32 = 8.0;
const RICH_TOOLTIP_CONTENT_SPACING: f32 = 4.0;
const RICH_TOOLTIP_ACTION_TOP_SPACE: f32 = 8.0;
const RICH_TOOLTIP_ACTION_HEIGHT: f32 = 40.0;
const PLAIN_TOOLTIP_TARGET_GAP: f32 = 4.0;
const RICH_TOOLTIP_TARGET_GAP: f32 = 0.0;
const TOOLTIP_LONG_PRESS_MS: u32 = 500;
const TOOLTIP_DISMISS_DURATION: Duration = Duration::from_millis(1500);
pub struct TooltipAnchor<Target, Popup> {
target: Target,
popup: Popup,
rich: bool,
visibility: TooltipVisibility,
}
impl<Target, Popup> Debug for TooltipAnchor<Target, Popup> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TooltipAnchor")
.field("rich", &self.rich)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
struct TooltipVisibility {
open: Binding<bool>,
generation: Binding<u64>,
target_hovered: Binding<bool>,
focused: Binding<bool>,
persistent: bool,
}
impl TooltipVisibility {
fn new(persistent: bool) -> Self {
Self {
open: Binding::bool(false),
generation: Binding::container(0),
target_hovered: Binding::bool(false),
focused: Binding::bool(false),
persistent,
}
}
fn next_generation(&self) -> u64 {
let generation = self
.generation
.get()
.checked_add(1)
.expect("Material tooltip generation overflow");
self.generation.set(generation);
generation
}
fn cancel_pending(&self) {
self.next_generation();
}
fn schedule_dismiss(&self) {
let generation = self.next_generation();
let state = self.clone();
spawn_local(async move {
sleep(TOOLTIP_DISMISS_DURATION).await;
if state.generation.get() == generation && state.open.get() {
state.open.set(false);
}
})
.detach();
}
fn set_target_hovered(&self, hovered: bool) {
self.target_hovered.set(hovered);
self.cancel_pending();
if hovered {
self.open.set(true);
} else if !self.persistent {
self.open.set(false);
}
}
fn long_press(&self) {
self.cancel_pending();
self.open.set(true);
if !self.persistent {
self.schedule_dismiss();
}
}
fn set_focused(&self, focused: bool) {
self.focused.set(focused);
self.cancel_pending();
if focused {
self.open.set(true);
} else if !self.target_hovered.get() && !self.persistent {
self.open.set(false);
}
}
fn dismiss(&self) {
self.focused.set(false);
self.cancel_pending();
self.open.set(false);
}
}
impl<Target, Popup> View for TooltipAnchor<Target, Popup>
where
Target: View + 'static,
Popup: View + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let target_enter = self.visibility.clone();
let target_exit = self.visibility.clone();
let long_press = self.visibility.clone();
let focus_change = self.visibility.clone();
let escape = self.visibility.clone();
let focused = Binding::bool(false);
let focus_binding = InteractionFocusBinding::new(&focused)
.escape_action(SharedAction::new(move |_: Environment| escape.dismiss()));
let open = self.visibility.open.clone();
let popup_accessibility = open.map(|open| AccessibilityState::new().hidden(!open));
let popup_scale = open
.map(|open| if open { 1.0 } else { 0.0 })
.with(motion::tooltip());
let target = self
.target
.on_hover_enter(move || target_enter.set_target_hovered(true))
.on_hover_exit(move || target_exit.set_target_hovered(false))
.on_long_press_gesture(TOOLTIP_LONG_PRESS_MS, move |_: Environment| {
long_press.long_press();
})
.install(focus_binding)
.on_change(&focused, move |focused| focus_change.set_focused(focused));
let popup_anchor = if self.rich {
Anchor::TOP_LEFT
} else {
Anchor::new(0.5, 1.0)
};
let popup = self
.popup
.scale_from(popup_scale.clone(), popup_scale, popup_anchor)
.a11y_state_signal(popup_accessibility)
.hittable(open);
FixedContainer::new(TooltipLayout { rich: self.rich }, (target, popup))
}
}
#[derive(Debug, Clone, Copy)]
struct TooltipLayout {
rich: bool,
}
impl Layout for TooltipLayout {
fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
let [target, _popup] = children else {
return Size::zero();
};
target.measure(proposal).size
}
fn place(
&self,
bounds: Rect,
proposal: ProposalSize,
children: &[&dyn SubView],
) -> Vec<SubviewPlacement> {
let [target, popup] = children else {
return Vec::new();
};
let target_size = target.measure(proposal).size;
let target_rect = Rect::new(bounds.origin(), target_size);
let popup_proposal = ProposalSize::new(
Some(if self.rich {
RICH_TOOLTIP_MAX_WIDTH
} else {
320.0
}),
None,
);
let popup_size = popup.measure(popup_proposal).size;
let popup_x = if self.rich {
target_rect.x() + target_rect.width() + RICH_TOOLTIP_TARGET_GAP
} else {
(target_rect.width() - popup_size.width).mul_add(0.5, target_rect.x())
};
let popup_y = if self.rich {
target_rect.y() + target_rect.height() + RICH_TOOLTIP_TARGET_GAP
} else {
target_rect.y() - popup_size.height - PLAIN_TOOLTIP_TARGET_GAP
};
vec![
SubviewPlacement::new(target_rect, proposal),
SubviewPlacement::new(
Rect::new(waterui::layout::Point::new(popup_x, popup_y), popup_size),
popup_proposal,
),
]
}
}
pub struct PlainTooltip {
supporting_text: Label,
accessibility_label: Str,
}
impl Debug for PlainTooltip {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PlainTooltip")
.field("supporting_text", &self.supporting_text)
.finish_non_exhaustive()
}
}
impl PlainTooltip {
#[must_use]
pub fn new(supporting_text: impl IntoLabel) -> Self {
let supporting_text = supporting_text.into_label();
let accessibility_label = label_plain_text(&supporting_text);
Self {
supporting_text,
accessibility_label,
}
}
#[must_use]
pub fn for_target<Target>(self, target: Target) -> TooltipAnchor<Target, Self>
where
Target: View + 'static,
{
TooltipAnchor {
target,
popup: self,
rich: false,
visibility: TooltipVisibility::new(false),
}
}
}
impl View for PlainTooltip {
fn body(self, _env: &Environment) -> impl View {
self.supporting_text
.font(typography::body_small())
.foreground(InverseOnSurface)
.padding_with(EdgeInsets::new(
PLAIN_TOOLTIP_TOP_SPACE,
PLAIN_TOOLTIP_BOTTOM_SPACE,
PLAIN_TOOLTIP_LEADING_SPACE,
PLAIN_TOOLTIP_TRAILING_SPACE,
))
.background(
FixedRoundedRectangle::new(PLAIN_TOOLTIP_CONTAINER_SHAPE).fill(InverseSurface),
)
.min_width(28.0)
.max_width(320.0)
.min_height(PLAIN_TOOLTIP_CONTAINER_HEIGHT)
.a11y_label(self.accessibility_label)
.a11y_role(AccessibilityRole::Group)
}
}
pub struct RichTooltip<Action = fn(&Environment)> {
subhead: Label,
supporting_text: Label,
accessibility_label: Str,
action: Option<(Label, Action)>,
}
impl<Action> Debug for RichTooltip<Action> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RichTooltip")
.field("subhead", &self.subhead)
.field("supporting_text", &self.supporting_text)
.finish_non_exhaustive()
}
}
impl RichTooltip<fn(&Environment)> {
#[must_use]
pub fn new(subhead: impl IntoLabel, supporting_text: impl IntoLabel) -> Self {
let subhead = subhead.into_label();
let supporting_text = supporting_text.into_label();
let accessibility_label = label_plain_text(&supporting_text);
Self {
subhead,
supporting_text,
accessibility_label,
action: None,
}
}
}
impl<Action> RichTooltip<Action> {
#[must_use]
pub fn action<F, Args>(
self,
label: impl IntoLabel,
action: F,
) -> RichTooltip<impl FnMut(&Environment)>
where
F: Handler<Args, ()> + 'static,
{
RichTooltip {
subhead: self.subhead,
supporting_text: self.supporting_text,
accessibility_label: self.accessibility_label,
action: Some((label.into_label(), boxed_action(action))),
}
}
#[must_use]
pub fn for_target<Target>(self, target: Target) -> TooltipAnchor<Target, Self>
where
Target: View + 'static,
{
TooltipAnchor {
target,
popup: self,
rich: true,
visibility: TooltipVisibility::new(true),
}
}
}
impl<Action> View for RichTooltip<Action>
where
Action: FnMut(&Environment) + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let content = waterui::component::vstack((
self.subhead
.font(typography::title_small())
.foreground(OnSurfaceVariant),
self.supporting_text
.font(typography::body_medium())
.foreground(OnSurfaceVariant),
rich_tooltip_action(self.action),
))
.spacing(RICH_TOOLTIP_CONTENT_SPACING)
.padding_with(EdgeInsets::new(
RICH_TOOLTIP_TOP_PADDING,
RICH_TOOLTIP_BOTTOM_PADDING,
RICH_TOOLTIP_HORIZONTAL_PADDING,
RICH_TOOLTIP_HORIZONTAL_PADDING,
))
.background(FixedRoundedRectangle::new(RICH_TOOLTIP_CONTAINER_SHAPE).fill(SurfaceContainer))
.max_width(RICH_TOOLTIP_MAX_WIDTH);
material_elevation(
MaterialElevationLevel::LEVEL2,
RICH_TOOLTIP_CONTAINER_SHAPE,
content,
)
.a11y_label(self.accessibility_label)
.a11y_role(AccessibilityRole::Group)
}
}
fn rich_tooltip_action<Action>(action: Option<(Label, Action)>) -> impl View
where
Action: FnMut(&Environment) + 'static,
{
let Some((label, mut action)) = action else {
return waterui::component::hstack(((),)).anyview();
};
let accessibility_label = label_plain_text(&label);
label
.font(typography::label_large())
.foreground(Primary)
.height(RICH_TOOLTIP_ACTION_HEIGHT)
.padding_with(EdgeInsets::new(
RICH_TOOLTIP_ACTION_TOP_SPACE,
0.0,
0.0,
0.0,
))
.on_tap(move |env: Environment| action(&env))
.a11y_label(accessibility_label)
.a11y_role(AccessibilityRole::Button)
.install(interaction_style(Primary, 20.0))
.anyview()
}
#[must_use]
pub fn plain_tooltip(supporting_text: impl IntoLabel) -> PlainTooltip {
PlainTooltip::new(supporting_text)
}
#[must_use]
pub fn rich_tooltip(
subhead: impl IntoLabel,
supporting_text: impl IntoLabel,
) -> RichTooltip<fn(&Environment)> {
RichTooltip::new(subhead, supporting_text)
}
#[cfg(test)]
mod tests {
use super::{
PLAIN_TOOLTIP_CONTAINER_HEIGHT, PLAIN_TOOLTIP_CONTAINER_SHAPE, PLAIN_TOOLTIP_LEADING_SPACE,
PLAIN_TOOLTIP_TARGET_GAP, PLAIN_TOOLTIP_TOP_SPACE, RICH_TOOLTIP_CONTAINER_SHAPE,
RICH_TOOLTIP_HORIZONTAL_PADDING, RICH_TOOLTIP_MAX_WIDTH, RICH_TOOLTIP_TARGET_GAP,
TOOLTIP_DISMISS_DURATION, TOOLTIP_LONG_PRESS_MS, TooltipVisibility,
};
use core::time::Duration;
#[test]
fn plain_tooltip_tokens_match_compose_plain_tooltip_tokens() {
assert_eq!(PLAIN_TOOLTIP_CONTAINER_HEIGHT, 24.0);
assert_eq!(PLAIN_TOOLTIP_CONTAINER_SHAPE, 4.0);
assert_eq!(PLAIN_TOOLTIP_TOP_SPACE, 4.0);
assert_eq!(PLAIN_TOOLTIP_LEADING_SPACE, 8.0);
assert_eq!(PLAIN_TOOLTIP_TARGET_GAP, 4.0);
assert_eq!(TOOLTIP_LONG_PRESS_MS, 500);
assert_eq!(TOOLTIP_DISMISS_DURATION, Duration::from_millis(1500));
}
#[test]
fn plain_tooltip_hover_opens_immediately_and_exit_dismisses() {
let visibility = TooltipVisibility::new(false);
visibility.set_target_hovered(true);
assert!(visibility.open.get());
visibility.set_target_hovered(false);
assert!(!visibility.open.get());
}
#[test]
fn rich_tooltip_stays_open_after_hover_exit() {
let visibility = TooltipVisibility::new(true);
visibility.set_target_hovered(true);
assert!(visibility.open.get());
visibility.set_target_hovered(false);
assert!(visibility.open.get());
}
#[test]
fn focus_loss_dismisses_plain_tooltip() {
let visibility = TooltipVisibility::new(false);
visibility.set_focused(true);
assert!(visibility.open.get());
visibility.set_focused(false);
assert!(!visibility.open.get());
}
#[test]
fn escape_dismisses_even_persistent_tooltip() {
let visibility = TooltipVisibility::new(true);
visibility.long_press();
assert!(visibility.open.get());
visibility.dismiss();
assert!(!visibility.open.get());
}
#[test]
fn rich_tooltip_tokens_match_compose_rich_tooltip_tokens() {
assert_eq!(RICH_TOOLTIP_CONTAINER_SHAPE, 12.0);
assert_eq!(RICH_TOOLTIP_MAX_WIDTH, 312.0);
assert_eq!(RICH_TOOLTIP_HORIZONTAL_PADDING, 16.0);
assert_eq!(RICH_TOOLTIP_TARGET_GAP, 0.0);
}
#[test]
fn layout_contract_tooltip_preserves_target_and_popup_proposals() {
use super::TooltipLayout;
use crate::layout_test_support::FixedLeaf;
use waterui::layout::{Layout, ProposalSize, Rect, Size};
let target = FixedLeaf(Size::new(160.0, 20.0));
let popup = FixedLeaf(Size::new(100.0, 30.0));
for rich in [false, true] {
let layout = TooltipLayout { rich };
for width in [None, Some(160.0), None] {
let proposal = ProposalSize::new(width, None);
let placements =
layout.place(Rect::from_size(target.0), proposal, &[&target, &popup]);
assert_eq!(placements[0].proposal, proposal);
assert_eq!(placements[0].frame.size(), &target.0);
assert_eq!(
placements[1].proposal,
ProposalSize::new(Some(if rich { 312.0 } else { 320.0 }), None)
);
}
}
}
}