use std::time::Duration;
use teksilo_core::build_context::BuildContext;
use teksilo_core::overlay::TooltipPlacement;
use teksilo_core::widget::Widget;
use teksilo_core::widget_id::WidgetId;
use teksilo_i18n::LocalizedString;
use crate::tooltip::TooltipWidget;
use crate::tooltip::composite::CompositeTooltipWidget;
use crate::tooltip::registry::TooltipContent;
use crate::tooltip::rich::{DWELL_PROMOTION, RichTooltipWidget};
#[derive(Debug, Clone)]
pub enum RichTooltipSource {
Key(String),
Content(TooltipContent),
}
impl<T: Into<String>> From<T> for RichTooltipSource {
fn from(value: T) -> Self {
RichTooltipSource::Key(value.into())
}
}
pub fn attach_rich_tooltip(
ctx: &mut BuildContext,
anchor_id: WidgetId,
key: impl Into<String>,
delay: Duration,
) -> WidgetId {
attach_rich_tooltip_with_placement(ctx, anchor_id, key, delay, TooltipPlacement::Below)
}
pub fn attach_rich_tooltip_with_placement(
ctx: &mut BuildContext,
anchor_id: WidgetId,
key: impl Into<String>,
delay: Duration,
placement: TooltipPlacement,
) -> WidgetId {
let tooltip = RichTooltipWidget::from_key(key);
let sink = tooltip.shown_at_sink();
let tooltip_id = ctx.add_deferred_on_demand(tooltip);
ctx.attach_tooltip_with_sticky_sink_placement(
anchor_id,
tooltip_id,
delay,
Some(DWELL_PROMOTION),
sink,
placement,
);
tooltip_id
}
pub fn attach_rich_tooltip_content(
ctx: &mut BuildContext,
anchor_id: WidgetId,
content: TooltipContent,
delay: Duration,
) -> WidgetId {
attach_rich_tooltip_content_with_placement(
ctx,
anchor_id,
content,
delay,
TooltipPlacement::Below,
)
}
pub fn attach_plain_tooltip(
ctx: &mut BuildContext,
anchor_id: WidgetId,
text: impl Into<LocalizedString>,
delay: Duration,
) -> WidgetId {
let tooltip_id = ctx.add_deferred_on_demand(TooltipWidget::new(text));
ctx.attach_tooltip(anchor_id, tooltip_id, delay);
tooltip_id
}
pub fn attach_plain_tooltip_with_placement(
ctx: &mut BuildContext,
anchor_id: WidgetId,
text: impl Into<LocalizedString>,
delay: Duration,
placement: TooltipPlacement,
) -> WidgetId {
let tooltip_id = ctx.add_deferred_on_demand(TooltipWidget::new(text));
ctx.attach_tooltip_with_placement(anchor_id, tooltip_id, delay, placement);
tooltip_id
}
pub fn attach_rich_tooltip_content_with_placement(
ctx: &mut BuildContext,
anchor_id: WidgetId,
content: TooltipContent,
delay: Duration,
placement: TooltipPlacement,
) -> WidgetId {
let tooltip = RichTooltipWidget::new(content);
let sink = tooltip.shown_at_sink();
let tooltip_id = ctx.add_deferred_on_demand(tooltip);
ctx.attach_tooltip_with_sticky_sink_placement(
anchor_id,
tooltip_id,
delay,
Some(DWELL_PROMOTION),
sink,
placement,
);
tooltip_id
}
pub fn attach_rich_tooltip_source(
ctx: &mut BuildContext,
anchor_id: WidgetId,
source: RichTooltipSource,
delay: Duration,
) -> WidgetId {
attach_rich_tooltip_source_with_placement(
ctx,
anchor_id,
source,
delay,
TooltipPlacement::Below,
)
}
pub fn attach_rich_tooltip_source_with_placement(
ctx: &mut BuildContext,
anchor_id: WidgetId,
source: RichTooltipSource,
delay: Duration,
placement: TooltipPlacement,
) -> WidgetId {
match source {
RichTooltipSource::Key(k) => {
attach_rich_tooltip_with_placement(ctx, anchor_id, k, delay, placement)
}
RichTooltipSource::Content(c) => {
attach_rich_tooltip_content_with_placement(ctx, anchor_id, c, delay, placement)
}
}
}
pub fn attach_composite_tooltip(
ctx: &mut BuildContext,
anchor_id: WidgetId,
content: impl Widget + 'static,
delay: Duration,
) -> WidgetId {
attach_composite_tooltip_boxed(ctx, anchor_id, Box::new(content), delay)
}
pub fn attach_composite_tooltip_boxed(
ctx: &mut BuildContext,
anchor_id: WidgetId,
content: Box<dyn Widget>,
delay: Duration,
) -> WidgetId {
attach_composite_tooltip_boxed_with_placement(
ctx,
anchor_id,
content,
delay,
TooltipPlacement::Below,
)
}
pub fn attach_composite_tooltip_widget_with_placement(
ctx: &mut BuildContext,
anchor_id: WidgetId,
tooltip: CompositeTooltipWidget,
delay: Duration,
placement: TooltipPlacement,
) -> WidgetId {
let sticky_after = tooltip.sticky_enabled().then_some(DWELL_PROMOTION);
let sink = tooltip.shown_at_sink();
let tooltip_id = ctx.add_detached_deferred_on_demand(tooltip);
ctx.attach_tooltip_with_sticky_sink_placement(
anchor_id,
tooltip_id,
delay,
sticky_after,
sink,
placement,
);
tooltip_id
}
pub fn attach_composite_tooltip_boxed_with_placement(
ctx: &mut BuildContext,
anchor_id: WidgetId,
content: Box<dyn Widget>,
delay: Duration,
placement: TooltipPlacement,
) -> WidgetId {
attach_composite_tooltip_widget_with_placement(
ctx,
anchor_id,
CompositeTooltipWidget::new().content_boxed(content),
delay,
placement,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::button::Button;
use crate::menu_item::MenuItem;
use crate::menu_list::MenuList;
use crate::primitives::VStack;
use crate::tooltip::TooltipWidget;
use crate::tooltip::registry::{
_reset_tooltip_registry, TooltipContent, install_tooltip_registry,
};
use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::{MockTextBackend, SizeProposal};
use teksilo_core::event::{Key, Modifiers};
use teksilo_core::signal::Signal;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
fn tree_with_backend() -> WidgetTree {
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
}
#[test]
fn a_plain_tooltips_text_lands_on_the_control_it_describes() {
fn described(
update: &teksilo_core::accesskit::TreeUpdate,
id: teksilo_core::WidgetId,
) -> Option<String> {
let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
update
.nodes
.iter()
.find(|(node_id, _)| *node_id == nid)
.and_then(|(_, n)| n.description().map(str::to_owned))
}
let mut tree = tree_with_backend();
let button = tree.add(Button::new(lit!("Export")).tooltip(lit!("Save a copy")));
tree.layout(SizeProposal::exact(300.0, 40.0));
let update = tree.sync_accessibility();
assert_eq!(
described(&update, button).as_deref(),
Some("Save a copy"),
"a Button's hint must be on the Button, not on the box inside it"
);
let mut tree = tree_with_backend();
let toggle = tree.add(
crate::toggle::Toggle::new(teksilo_core::signal::Signal::new(true))
.label(lit!("Comments"))
.tooltip(lit!("Where a note is attached")),
);
tree.layout(SizeProposal::exact(300.0, 40.0));
let update = tree.sync_accessibility();
assert_eq!(
described(&update, toggle).as_deref(),
Some("Where a note is attached"),
"a Toggle's hint must be on the Toggle"
);
let carriers = update
.nodes
.iter()
.filter(|(_, n)| n.description() == Some("Where a note is attached"))
.count();
assert_eq!(carriers, 1, "exactly one node may carry the hint");
}
#[test]
fn an_unhovered_tooltip_body_is_never_built() {
#[derive(Debug)]
struct Counted {
builds: Signal<u32>,
}
impl teksilo_core::widget::Widget for Counted {
fn build(
&mut self,
_ctx: &mut teksilo_core::build_context::BuildContext,
) -> Vec<WidgetId> {
self.builds.set(self.builds.get() + 1);
Vec::new()
}
fn layout_response(
&self,
proposal: SizeProposal,
_ctx: &teksilo_core::widget::LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
proposal.resolve(40.0, 20.0).into()
}
}
#[derive(Debug)]
struct Anchor {
builds: Signal<u32>,
anchor_builds: Signal<u32>,
root: Option<WidgetId>,
}
impl teksilo_core::widget::Widget for Anchor {
fn build(
&mut self,
ctx: &mut teksilo_core::build_context::BuildContext,
) -> Vec<WidgetId> {
self.anchor_builds.set(self.anchor_builds.get() + 1);
let root = ctx.add(Button::new(lit!("row")));
self.root = Some(root);
attach_composite_tooltip(
ctx,
root,
Counted {
builds: self.builds.clone(),
},
Duration::from_millis(10),
);
vec![root]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &teksilo_core::widget::LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or(teksilo_canvas::Size::new(0.0, 0.0))
.into()
}
}
let builds = Signal::new(0);
let anchor_builds = Signal::new(0);
let mut tree = WidgetTree::new();
let id = tree.add(Anchor {
builds: builds.clone(),
anchor_builds: anchor_builds.clone(),
root: None,
});
tree.layout(SizeProposal::exact(300.0, 40.0));
assert_eq!(builds.get(), 0, "a tooltip body was built without a dwell");
for _ in 0..5 {
tree.arena_mark_needs_rebuild_for_testing(id);
tree.layout(SizeProposal::exact(300.0, 40.0));
}
assert!(
anchor_builds.get() >= 5,
"the anchor must really have rebuilt; got {}",
anchor_builds.get()
);
assert_eq!(
builds.get(),
0,
"the anchor rebuilt {} times and dragged its unhovered tooltip along",
anchor_builds.get()
);
}
#[test]
fn tooltips_attached_to_many_children_in_one_build_stay_on_their_own_rows() {
#[derive(Debug)]
struct RowPane {
rows: Vec<WidgetId>,
}
impl teksilo_core::widget::Widget for RowPane {
fn build(
&mut self,
ctx: &mut teksilo_core::build_context::BuildContext,
) -> Vec<WidgetId> {
self.rows.clear();
for label in ["Alpha", "Beta"] {
let row = ctx.add(Button::new(lit!(String::from(label))));
let tip = ctx.add(TooltipWidget::new(lit!(String::from("about ") + label)));
ctx.attach_tooltip(row, tip, Duration::from_millis(10));
self.rows.push(row);
}
self.rows.clone()
}
fn layout_response(
&self,
proposal: teksilo_canvas::SizeProposal,
_ctx: &teksilo_core::widget::LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
proposal.resolve(200.0, 40.0).into()
}
fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::Group);
}
}
let mut tree = tree_with_backend();
let pane = tree.add(RowPane { rows: Vec::new() });
tree.layout(SizeProposal::exact(200.0, 40.0));
let update = tree.sync_accessibility();
let described: Vec<String> = update
.nodes
.iter()
.filter_map(|(_, n)| n.description().map(str::to_owned))
.collect();
assert_eq!(
described.len(),
2,
"both rows keep their own hint: {described:?}"
);
assert!(described.iter().any(|d| d == "about Alpha"));
assert!(described.iter().any(|d| d == "about Beta"));
let pane_node = update
.nodes
.iter()
.find(|(nid, _)| *nid == teksilo_core::accessibility::widget_id_to_node_id(pane))
.map(|(_, n)| n);
assert_eq!(
pane_node.and_then(|n| n.description()),
None,
"a pane that claimed one hint per row must be given none of them"
);
}
#[test]
fn a_widgets_own_description_is_not_overwritten_by_its_tooltips() {
#[derive(Debug)]
struct SelfDescribing {
inner: Option<WidgetId>,
}
impl teksilo_core::widget::Widget for SelfDescribing {
fn build(
&mut self,
ctx: &mut teksilo_core::build_context::BuildContext,
) -> Vec<WidgetId> {
let body = ctx.add(Button::new(lit!("Save")));
let tip = ctx.add(TooltipWidget::new(lit!("supplementary")));
ctx.attach_tooltip(body, tip, Duration::from_millis(10));
self.inner = Some(body);
vec![body]
}
fn layout_response(
&self,
proposal: teksilo_canvas::SizeProposal,
_ctx: &teksilo_core::widget::LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
proposal.resolve(120.0, 30.0).into()
}
fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::Button);
builder.set_name("Save");
builder.set_description("Ctrl+S");
}
}
let mut tree = tree_with_backend();
let id = tree.add(SelfDescribing { inner: None });
tree.layout(SizeProposal::exact(120.0, 30.0));
let update = tree.sync_accessibility();
let own = update
.nodes
.iter()
.find(|(nid, _)| *nid == teksilo_core::accessibility::widget_id_to_node_id(id))
.map(|(_, n)| n)
.expect("the control emits a node");
assert_eq!(
own.description(),
Some("Ctrl+S"),
"the widget's own description must survive its tooltip"
);
assert_eq!(
update
.nodes
.iter()
.filter(|(_, n)| n.description() == Some("supplementary"))
.count(),
1,
"and the tooltip's text is still emitted, on its anchor as before"
);
}
#[test]
fn escape_dismisses_a_tooltip_and_still_reaches_the_focused_widget() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
use teksilo_core::widget_builder::WidgetBuilder;
let seen: Rc<Cell<usize>> = Rc::new(Cell::new(0));
let counter = seen.clone();
let mut tree = tree_with_backend();
let btn = tree.add(
Button::new(lit!("Save As"))
.tooltip(lit!("Save the current file under a new name"))
.on_key(move |ev, _ctx| {
if let WidgetEvent::KeyDown {
key: Key::Escape, ..
} = ev
{
counter.set(counter.get() + 1);
return EventResponse::Handled;
}
EventResponse::Ignored
}),
);
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.focus(btn);
tree.pointer_move(tree.bounds(btn).center());
tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"the tooltip should be showing"
);
tree.press_key(Key::Escape, Modifiers::NONE);
assert!(
tree.active_overlays().is_empty(),
"Escape must still dismiss the tooltip (WCAG 1.4.13)"
);
assert_eq!(
seen.get(),
1,
"the focused widget never saw Escape — the tooltip swallowed it"
);
}
#[test]
fn button_rich_tooltip_appears_after_hover_delay() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new(
"save-as",
lit!("Save the current file under a new name"),
)]);
let mut tree = tree_with_backend();
let btn = tree.add(Button::new(lit!("Save As")).rich_tooltip("save-as"));
tree.layout(SizeProposal::exact(400.0, 200.0));
assert!(tree.active_overlays().is_empty());
tree.pointer_move(tree.bounds(btn).center());
assert!(
tree.active_overlays().is_empty(),
"tooltip should not appear instantly — waits for delay"
);
tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"rich tooltip should have appeared after the hover delay"
);
_reset_tooltip_registry();
}
#[test]
fn button_rich_tooltip_overrides_plain_tooltip() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new("help", lit!("Help body"))]);
let mut tree = tree_with_backend();
let btn = tree.add(
Button::new(lit!("Help"))
.tooltip(lit!("stale plain text"))
.rich_tooltip("help"),
);
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.pointer_move(tree.bounds(btn).center());
tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
assert_eq!(tree.active_overlays().len(), 1);
assert!(
tree.find_by_label("stale plain text").is_none(),
"plain tooltip text should have been cleared by .rich_tooltip(...)"
);
_reset_tooltip_registry();
}
#[test]
fn rich_tooltip_shows_on_keyboard_focus_once_focus_rests() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new(
"focus-key",
lit!("Focus-shown body"),
)]);
let mut tree = tree_with_backend();
let btn = tree.add(Button::new(lit!("Focus me")).rich_tooltip("focus-key"));
tree.layout(SizeProposal::exact(400.0, 200.0));
assert!(tree.active_overlays().is_empty());
tree.focus(btn);
assert!(
tree.active_overlays().is_empty(),
"focus arriving arms the delay; it does not show on arrival"
);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"rich tooltip appears once keyboard focus has rested for the delay"
);
_reset_tooltip_registry();
}
#[test]
fn focus_promoted_tooltip_dismisses_when_focus_leaves_scope() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new("leave-key", lit!("Goes away"))]);
let mut tree = tree_with_backend();
let btn = tree.add(Button::new(lit!("Anchor")).rich_tooltip("leave-key"));
let other = tree.add(Button::new(lit!("Elsewhere")));
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.focus(btn);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(tree.active_overlays().len(), 1);
tree.focus(other);
assert!(
tree.active_overlays().is_empty(),
"focus-promoted sticky tooltip should dismiss when focus moves outside its scope"
);
_reset_tooltip_registry();
}
#[test]
fn button_plain_tooltip_appears_after_hover_delay() {
let mut tree = tree_with_backend();
let btn = tree.add(Button::new(lit!("Save")).tooltip(lit!("Save the document")));
tree.layout(SizeProposal::exact(400.0, 200.0));
assert!(tree.active_overlays().is_empty());
tree.pointer_move(tree.bounds(btn).center());
assert!(
tree.active_overlays().is_empty(),
"plain tooltip should not appear instantly — waits for delay"
);
tree.advance_time(Duration::from_millis(550));
assert_eq!(
tree.active_overlays().len(),
1,
"plain tooltip should have appeared after the hover delay"
);
}
#[test]
fn inline_content_tooltip_attaches_without_registry_key() {
_reset_tooltip_registry();
let mut tree = tree_with_backend();
let content = TooltipContent::new("inline-only", lit!("Inline content"));
let btn = tree.add(Button::new(lit!("Go")).rich_tooltip_content(content));
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.pointer_move(tree.bounds(btn).center());
tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
assert_eq!(tree.active_overlays().len(), 1);
_reset_tooltip_registry();
}
#[test]
fn menu_container_focus_does_not_fan_out_item_tooltips() {
_reset_tooltip_registry();
install_tooltip_registry(vec![
TooltipContent::new("a", lit!("Tip A")),
TooltipContent::new("b", lit!("Tip B")),
TooltipContent::new("c", lit!("Tip C")),
]);
let mut tree = tree_with_backend();
let menu = tree.add(
MenuList::new()
.item(MenuItem::new(lit!("A")).rich_tooltip("a"))
.item(MenuItem::new(lit!("B")).rich_tooltip("b"))
.item(MenuItem::new(lit!("C")).rich_tooltip("c")),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(menu);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert!(
tree.active_overlays().is_empty(),
"focusing the menu container must not fan out item tooltips (the wall)"
);
_reset_tooltip_registry();
}
#[test]
fn self_anchored_focusable_tooltip_shows_exactly_one_overlay() {
let mut tree = tree_with_backend();
let anchor = tree.add(Button::new(lit!("Self")));
let content = tree.add(TooltipWidget::new(lit!("Tip")));
tree.attach_tooltip_with_sticky(
anchor,
content,
Duration::from_millis(200),
Some(Duration::from_secs(2)),
);
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.focus(anchor);
tree.advance_time(Duration::from_millis(250));
assert_eq!(
tree.active_overlays().len(),
1,
"self-anchored focus shows exactly one overlay (no reflexive dup)"
);
}
#[test]
fn single_button_rich_tooltip_still_shows_on_focus() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new("k", lit!("Body"))]);
let mut tree = tree_with_backend();
let btn = tree.add(Button::new(lit!("Focus me")).rich_tooltip("k"));
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.focus(btn);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"a single composing control still auto-shows its rich tooltip on focus"
);
_reset_tooltip_registry();
}
#[test]
fn segmented_control_focus_does_not_fan_out_segment_tooltips() {
_reset_tooltip_registry();
install_tooltip_registry(vec![
TooltipContent::new("s0", lit!("Seg 0")),
TooltipContent::new("s1", lit!("Seg 1")),
]);
let mut tree = tree_with_backend();
let selected = teksilo_core::signal::Signal::new(None);
let sc = tree.add(
crate::segmented_control::SegmentedControl::new(selected)
.segment(crate::segmented_control::Segment::new(lit!("A")).rich_tooltip("s0"))
.segment(crate::segmented_control::Segment::new(lit!("B")).rich_tooltip("s1")),
);
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.focus(sc);
assert!(
tree.active_overlays().is_empty(),
"focusing a SegmentedControl must not fan out its segment tooltips"
);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert!(
tree.active_overlays().is_empty(),
"…and still none once the delay has elapsed"
);
_reset_tooltip_registry();
}
#[test]
fn side_placement_opens_to_the_trailing_side() {
let mut tree = tree_with_backend();
let anchor = tree.add(Button::new(lit!("Anchor")));
let content = tree.add(TooltipWidget::new(lit!("Tip")));
tree.attach_tooltip_with_placement(
anchor,
content,
Duration::from_millis(200),
TooltipPlacement::Side,
);
let _root = tree.add(VStack::new().add_child(anchor));
tree.layout(SizeProposal::exact(600.0, 400.0));
tree.pointer_move(tree.bounds(anchor).center());
tree.advance_time(Duration::from_millis(250));
tree.layout(SizeProposal::exact(600.0, 400.0));
let a = tree.bounds(anchor);
let t = tree
.overlay_manager()
.bounds_for_content(content)
.expect("Side tooltip overlay shown");
assert!(
t.x >= a.x + a.width,
"Side tooltip opens to the trailing side: t.x {} >= anchor right {}",
t.x,
a.x + a.width
);
assert!(
t.y < a.y + a.height,
"Side tooltip is aligned to the anchor top, not below it"
);
}
#[test]
fn below_placement_opens_under_the_anchor() {
let mut tree = tree_with_backend();
let anchor = tree.add(Button::new(lit!("Anchor")));
let content = tree.add(TooltipWidget::new(lit!("Tip")));
tree.attach_tooltip(anchor, content, Duration::from_millis(200));
let _root = tree.add(VStack::new().add_child(anchor));
tree.layout(SizeProposal::exact(600.0, 400.0));
tree.pointer_move(tree.bounds(anchor).center());
tree.advance_time(Duration::from_millis(250));
tree.layout(SizeProposal::exact(600.0, 400.0));
let a = tree.bounds(anchor);
let t = tree
.overlay_manager()
.bounds_for_content(content)
.expect("Below tooltip overlay shown");
assert!(
t.y >= a.y + a.height,
"Below tooltip opens under the anchor: t.y {} >= anchor bottom {}",
t.y,
a.y + a.height
);
}
#[test]
fn keyboard_menu_navigation_surfaces_highlighted_item_tooltip() {
_reset_tooltip_registry();
install_tooltip_registry(vec![
TooltipContent::new("a", lit!("Tip A")),
TooltipContent::new("b", lit!("Tip B")),
]);
let mut tree = tree_with_backend();
let menu = tree.add(
MenuList::new()
.item(MenuItem::new(lit!("A")).rich_tooltip("a"))
.item(MenuItem::new(lit!("B")).rich_tooltip("b"))
.item(MenuItem::new(lit!("C"))),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(menu);
assert!(
tree.active_overlays().is_empty(),
"no tooltip on menu focus (Part A)"
);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(
tree.active_overlays().len(),
1,
"ArrowDown surfaces the highlighted item's tooltip (Part C)"
);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(
tree.active_overlays().len(),
1,
"moving the highlight replaces the tooltip (still exactly one)"
);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert!(
tree.active_overlays().is_empty(),
"highlighting a tooltip-less item clears the previous tooltip"
);
_reset_tooltip_registry();
}
#[test]
fn dwelling_tooltip_wake_deadline_is_due_at_its_wake() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new("k", lit!("Body"))]);
let mut tree = tree_with_backend();
tree.set_accessibility_preferences(false, true, 1.0);
let btn = tree.add(Button::new(lit!("Hover")).rich_tooltip("k"));
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.pointer_move(tree.bounds(btn).center());
tree.advance_time(Duration::from_millis(550)); tree.layout(SizeProposal::exact(400.0, 200.0));
assert_eq!(tree.active_overlays().len(), 1, "rich tooltip shown");
std::thread::sleep(Duration::from_millis(600));
let deadline = tree
.next_timer_deadline()
.expect("a dwelling tooltip must schedule a wake deadline");
assert!(
deadline <= std::time::Instant::now(),
"the dwell wake deadline must be DUE at its own wake (pinned to \
last_frame_time); a still-future deadline is the freeze bug"
);
_reset_tooltip_registry();
}
#[test]
fn plain_tooltip_schedules_no_dwell_wake() {
let mut tree = tree_with_backend();
tree.set_accessibility_preferences(false, true, 1.0); let btn = tree.add(Button::new(lit!("Hover")).tooltip(lit!("Plain")));
tree.layout(SizeProposal::exact(400.0, 200.0));
tree.pointer_move(tree.bounds(btn).center());
tree.advance_time(Duration::from_millis(550));
assert_eq!(tree.active_overlays().len(), 1, "plain tooltip shown");
tree.layout(SizeProposal::exact(400.0, 200.0));
assert!(
tree.next_timer_deadline().is_none(),
"a plain tooltip must not schedule a dwell wake deadline"
);
}
}
#[cfg(test)]
mod deferred_tooltip_drift {
use std::path::{Path, PathBuf};
fn production_sources() -> Vec<(PathBuf, String)> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let crates_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("teksilo-widgets sits in crates/")
.to_path_buf();
let mut files = Vec::new();
walk(&crates_dir, &mut files);
files
.into_iter()
.filter_map(|path| {
let text = std::fs::read_to_string(&path).ok()?;
let production = match text.find("#[cfg(test)]") {
Some(cut) => text[..cut].to_string(),
None => text,
};
Some((path, production))
})
.collect()
}
#[test]
fn no_tooltip_body_is_added_eagerly() {
const EAGER: [&str; 3] = ["ctx.add(", "ctx.add_boxed(", "ctx.add_detached("];
const BODIES: [&str; 3] = [
"TooltipWidget::new",
"RichTooltipWidget::",
"CompositeTooltipWidget::new",
];
let mut offenders: Vec<String> = Vec::new();
for (path, text) in production_sources() {
for (n, line) in text.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
if EAGER.iter().any(|a| line.contains(a)) && BODIES.iter().any(|b| line.contains(b))
{
offenders.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
}
}
}
assert!(
offenders.is_empty(),
"a tooltip body is added eagerly — route it through \
`attach_plain_tooltip`, `attach_rich_tooltip*` or \
`attach_composite_tooltip*`, which defer it until a dwell matures:\n{}",
offenders.join("\n")
);
}
}