use std::time::Duration;
use web_time::Instant;
use crate::state::UiState;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::popover::{Anchor, anchor_rect};
pub const HOVER_DELAY: Duration = Duration::from_millis(500);
pub fn synthesize_tooltip(root: &mut El, ui_state: &UiState, now: Instant) -> bool {
if ui_state.pressed.is_some() || ui_state.tooltip.dismissed_for_hover {
return false;
}
let Some(hover) = ui_state.hovered.as_ref() else {
return false;
};
let Some(started_at) = ui_state.tooltip.hover_started_at else {
return false;
};
let Some(text) = find_tooltip_text(root, &hover.node_id) else {
return false;
};
if now.duration_since(started_at) < HOVER_DELAY {
return true;
}
debug_assert_eq!(
root.axis,
Axis::Overlay,
"synthesize_tooltip: root must be an Axis::Overlay container so the \
tooltip layer overlays the main view. Wrap your `App::build` return \
value in `overlays(main, [])`. Got axis = {:?}",
root.axis,
);
root.children
.push(tooltip_layer(text, hover.node_id.clone()));
false
}
fn find_tooltip_text<'a>(node: &'a El, id: &str) -> Option<&'a str> {
if node.computed_id == id {
return node.tooltip.as_deref();
}
node.children.iter().find_map(|c| find_tooltip_text(c, id))
}
fn tooltip_layer(text: &str, anchor_id: String) -> El {
let panel = tooltip_panel(text);
El::new(Kind::Custom("tooltip_layer"))
.child(panel)
.fill_size()
.layout(move |ctx| {
let (w, h) = (ctx.measure)(&ctx.children[0]);
let rect = anchor_rect(
&Anchor::below_id(&anchor_id),
(w, h),
ctx.container,
ctx.rect_of_id,
crate::widgets::popover::ANCHOR_GAP,
);
vec![rect]
})
}
fn tooltip_panel(text: &str) -> El {
El::new(Kind::Custom("tooltip_panel"))
.style_profile(StyleProfile::Surface)
.surface_role(SurfaceRole::Popover)
.child(
El::new(Kind::Text)
.style_profile(StyleProfile::TextOnly)
.text(text.to_string())
.text_role(TextRole::Caption)
.text_color(tokens::FOREGROUND),
)
.fill(tokens::POPOVER)
.stroke(tokens::BORDER)
.radius(tokens::RADIUS_SM)
.shadow(tokens::SHADOW_MD)
.padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
.gap(0.0)
.width(Size::Hug)
.height(Size::Hug)
.axis(Axis::Column)
.align(Align::Stretch)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::UiTarget;
use crate::layout::{assign_ids, layout};
use crate::widgets::button::button;
fn lay_out_with_button() -> (El, UiState) {
let mut tree = button("Save").key("save").tooltip("Save changes (Ctrl+S)");
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.sync_focus_order(&tree);
(tree, state)
}
#[test]
fn pre_delay_returns_pending_no_layer() {
let (mut tree, mut state) = lay_out_with_button();
let trigger = state
.focus
.order
.iter()
.find(|t| t.key == "save")
.cloned()
.unwrap();
let now = Instant::now();
state.set_hovered(Some(trigger), now);
assign_ids(&mut tree);
let before = tree.children.len();
let pending = synthesize_tooltip(&mut tree, &state, now + Duration::from_millis(100));
assert!(pending, "delay not elapsed → caller should request redraw");
assert_eq!(tree.children.len(), before, "no tooltip layer appended yet");
}
#[test]
fn post_delay_appends_tooltip_layer() {
let (mut tree, mut state) = lay_out_with_button();
let trigger = state
.focus
.order
.iter()
.find(|t| t.key == "save")
.cloned()
.unwrap();
let now = Instant::now();
state.set_hovered(Some(trigger), now);
assign_ids(&mut tree);
let before = tree.children.len();
let pending = synthesize_tooltip(
&mut tree,
&state,
now + HOVER_DELAY + Duration::from_millis(1),
);
assert!(!pending, "tooltip placed → redraw is now animation-driven");
assert_eq!(
tree.children.len(),
before + 1,
"tooltip layer appended to root"
);
assert!(matches!(
tree.children.last().unwrap().kind,
Kind::Custom("tooltip_layer")
));
}
#[test]
fn no_tooltip_when_pressed() {
let (mut tree, mut state) = lay_out_with_button();
let trigger = state
.focus
.order
.iter()
.find(|t| t.key == "save")
.cloned()
.unwrap();
let now = Instant::now();
state.set_hovered(Some(trigger.clone()), now);
state.pressed = Some(trigger);
assign_ids(&mut tree);
let before = tree.children.len();
let pending = synthesize_tooltip(
&mut tree,
&state,
now + HOVER_DELAY + Duration::from_millis(50),
);
assert!(!pending);
assert_eq!(tree.children.len(), before, "press suppresses the tooltip");
}
#[test]
fn dismissed_for_hover_blocks_until_re_entry() {
let (mut tree, mut state) = lay_out_with_button();
let trigger = state
.focus
.order
.iter()
.find(|t| t.key == "save")
.cloned()
.unwrap();
let now = Instant::now();
state.set_hovered(Some(trigger), now);
state.tooltip.dismissed_for_hover = true;
assign_ids(&mut tree);
let before = tree.children.len();
let pending = synthesize_tooltip(
&mut tree,
&state,
now + HOVER_DELAY + Duration::from_millis(50),
);
assert!(!pending);
assert_eq!(
tree.children.len(),
before,
"dismissed flag suppresses tooltip"
);
}
#[test]
fn hover_change_resets_timer_via_set_hovered() {
let mut state = UiState::new();
let now = Instant::now();
let target_a = UiTarget {
key: "a".into(),
node_id: "/a".into(),
rect: Rect::new(0.0, 0.0, 10.0, 10.0),
};
let target_b = UiTarget {
key: "b".into(),
node_id: "/b".into(),
rect: Rect::new(0.0, 0.0, 10.0, 10.0),
};
state.set_hovered(Some(target_a), now);
let started = state.tooltip.hover_started_at;
state.set_hovered(Some(target_b), now + Duration::from_millis(100));
assert!(
state.tooltip.hover_started_at > started,
"timer reset on target change"
);
}
}