use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc;
use std::time::{Duration, Instant};
use teksilo_i18n::lit;
use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{CornerRadius, TextRole, TextStyleRole};
use crate::accordion::Accordion;
use crate::keystroke_format::format_keystroke;
use crate::primitives::{Grid, Padding, Spacer, TextWidget, TrackSize, VStack};
use crate::tooltip::dwell_indicator::DwellIndicator;
use crate::tooltip::registry::{TooltipContent, TooltipRegistry, with_tooltip_registry};
pub(crate) const DWELL_PROMOTION: Duration = Duration::from_secs(2);
pub(crate) const DWELL_STEPS: u32 = teksilo_core::widget_tree::TOOLTIP_DWELL_STEPS;
pub(crate) const DWELL_STEP_DURATION: Duration =
Duration::from_millis((DWELL_PROMOTION.as_millis() / DWELL_STEPS as u128) as u64);
const _: () = assert!(
DWELL_PROMOTION
.as_millis()
.is_multiple_of(DWELL_STEPS as u128),
"DWELL_PROMOTION must divide exactly into DWELL_STEPS"
);
const _: () = assert!(
DWELL_STEP_DURATION.as_millis() * DWELL_STEPS as u128 == DWELL_PROMOTION.as_millis(),
"dwell steps must sum to exactly DWELL_PROMOTION"
);
pub struct RichTooltipWidget {
content: Option<TooltipContent>,
pending_key: Option<String>,
root_child_id: Option<WidgetId>,
dwell_step: Signal<u32>,
sticky: Signal<bool>,
shown_at_sink: Rc<Cell<Option<Instant>>>,
is_cascade_child: bool,
cascade_ancestors: Vec<String>,
}
const MAX_CASCADE_DEPTH: usize = 8;
impl std::fmt::Debug for RichTooltipWidget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RichTooltipWidget")
.field("has_content", &self.content.is_some())
.field("pending_key", &self.pending_key)
.finish()
}
}
impl RichTooltipWidget {
pub fn new(content: TooltipContent) -> Self {
Self {
content: Some(content),
pending_key: None,
root_child_id: None,
dwell_step: Signal::new(0),
sticky: Signal::new(false),
shown_at_sink: Rc::new(Cell::new(None)),
is_cascade_child: false,
cascade_ancestors: Vec::new(),
}
}
pub fn from_key(key: impl Into<String>) -> Self {
Self {
content: None,
pending_key: Some(key.into()),
root_child_id: None,
dwell_step: Signal::new(0),
sticky: Signal::new(false),
shown_at_sink: Rc::new(Cell::new(None)),
is_cascade_child: false,
cascade_ancestors: Vec::new(),
}
}
pub(crate) fn cascade_child(mut self) -> Self {
self.is_cascade_child = true;
self
}
pub(crate) fn with_cascade_ancestors(mut self, ancestors: Vec<String>) -> Self {
self.cascade_ancestors = ancestors;
self
}
pub fn shown_at_sink(&self) -> Rc<Cell<Option<Instant>>> {
self.shown_at_sink.clone()
}
fn tick_dwell(&self) {
let Some(shown_at) = self.shown_at_sink.get() else {
if self.dwell_step.get() != 0 {
self.dwell_step.set(0);
}
if self.sticky.get() {
self.sticky.set(false);
}
return;
};
let elapsed = Instant::now().saturating_duration_since(shown_at);
let new_step =
((elapsed.as_millis() / DWELL_STEP_DURATION.as_millis()) as u32).min(DWELL_STEPS);
if self.dwell_step.get() != new_step {
self.dwell_step.set(new_step);
}
let now_sticky = new_step >= DWELL_STEPS;
if self.sticky.get() != now_sticky {
self.sticky.set(now_sticky);
}
}
}
impl Widget for RichTooltipWidget {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if self.content.is_none()
&& let Some(key) = self.pending_key.as_deref()
{
self.content = with_tooltip_registry(|reg| reg.get(key).cloned()).flatten();
}
let Some(content) = self.content.clone() else {
let id = ctx.add(Spacer::new());
self.root_child_id = Some(id);
return vec![id];
};
let theme_signal = ctx.theme_signal();
let theme = theme_signal.get();
use crate::styles::recipe_tooltip_style as tt;
let self_id = ctx.self_id();
let mut nested_ids: HashMap<String, WidgetId> = HashMap::new();
let body_source = content.text.resolve_now();
let more_source = content.more.as_ref().map(|m| m.resolve_now());
let mut nested_keys: Vec<String> = Vec::new();
scan_tooltip_key_urls(&body_source, &mut nested_keys);
if let Some(ref m) = more_source {
scan_tooltip_key_urls(m, &mut nested_keys);
}
nested_keys.sort();
nested_keys.dedup();
let mut child_ancestors = self.cascade_ancestors.clone();
child_ancestors.push(content.key.clone());
let at_depth_limit = child_ancestors.len() >= MAX_CASCADE_DEPTH;
let registered: Vec<String> = if at_depth_limit {
Vec::new()
} else {
nested_keys
.into_iter()
.filter(|k| !child_ancestors.contains(k))
.filter(|k| with_tooltip_registry(|r| r.get(k).is_some()).unwrap_or(false))
.collect()
};
for key in ®istered {
let nested = RichTooltipWidget::from_key(key.clone())
.cascade_child()
.with_cascade_ancestors(child_ancestors.clone());
let nested_id = ctx.add_detached(nested);
ctx.set_dormant(nested_id);
nested_ids.insert(key.clone(), nested_id);
}
let nested_map = Rc::new(nested_ids);
let shortcut_text: Option<String> = content.shortcut_label.clone().or_else(|| {
content.shortcut_id.and_then(|id| {
ctx.effective_shortcut(id)
.and_then(|eff| eff.primary.map(format_keystroke))
})
});
if content.shortcut_id.is_some() {
ctx.shortcut_version().bind_to(
ctx.self_id(),
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::Rebuild,
);
}
let body_widget = TextWidget::new(content.text.clone())
.style(TextStyleRole::Small)
.color(TextRole::TooltipText)
.markup(true)
.on_link_click(make_link_click_handler(nested_map.clone(), self_id))
.a11y_hidden();
let body_id = ctx.add(body_widget);
let header: WidgetId = if let Some(shortcut) = shortcut_text {
let shortcut_widget = TextWidget::new(lit!(shortcut))
.style(TextStyleRole::Small)
.color(TextRole::TooltipShortcut)
.single_line()
.a11y_hidden();
let shortcut_id = ctx.add(shortcut_widget);
ctx.add(
Grid::new()
.columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
.rows(vec![TrackSize::Auto])
.column_gap(8.0)
.add_child(body_id)
.add_child(shortcut_id),
)
} else {
body_id
};
let more_accordion: Option<WidgetId> = if let Some(more_ls) = content.more.clone() {
let more_widget = TextWidget::new(more_ls)
.style(TextStyleRole::Small)
.color(TextRole::TooltipText)
.markup(true)
.on_link_click(make_link_click_handler(nested_map.clone(), self_id));
let expanded = ctx.signal(false);
let mut accordion_title_style = theme.typography.tiny.clone();
accordion_title_style.line_height = theme.typography.small.line_height;
let accordion = Accordion::new(teksilo_i18n::tr_widget!(tooltip_more()), expanded)
.title_color(theme.colors.tooltip_text)
.title_style(accordion_title_style)
.content(more_widget);
Some(ctx.add(accordion))
} else {
None
};
let mut root_vstack = VStack::new().spacing(6.0).add_child(header);
if self.is_cascade_child {
if let Some(accordion) = more_accordion {
root_vstack = root_vstack.add_child(accordion);
}
} else {
let indicator = ctx.add(DwellIndicator::new(
self.dwell_step.clone(),
self.sticky.clone(),
TextRole::TooltipText,
));
let footer_left = more_accordion.unwrap_or_else(|| ctx.add(Spacer::new()));
let footer_row = ctx.add(
Grid::new()
.columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
.rows(vec![TrackSize::Auto])
.column_gap(8.0)
.add_child(footer_left)
.add_child(indicator),
);
root_vstack = root_vstack.add_child(footer_row);
}
let root_content = ctx.add(root_vstack);
let padded = ctx.add(
Padding::symmetric(tt::TOOLTIP_PADDING_VERTICAL, tt::TOOLTIP_PADDING_HORIZONTAL)
.child_id(root_content),
);
self.root_child_id = Some(padded);
let handlers = HandlerSet::new().focusable(true);
ctx.apply_self_handlers(handlers);
self.sticky.bind_to(
self_id,
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::AccessibilityOnly,
);
vec![padded]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let max_w = crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH;
let clamped = SizeProposal {
width: Some(proposal.width.map(|w| w.min(max_w)).unwrap_or(max_w)),
height: proposal.height,
};
self.root_child_id
.and_then(|id| ctx.child_size(id, clamped))
.unwrap_or_else(|| Size::new(0.0, 0.0))
.into()
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let radius =
CornerRadius::uniform(crate::styles::recipe_tooltip_style::TOOLTIP_CORNER_RADIUS);
super::paint_tooltip_shadows(canvas, bounds, radius, ctx);
canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
self.tick_dwell();
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
let persistent = self.sticky.get() || self.is_cascade_child;
let role = if persistent {
teksilo_core::accesskit::Role::Dialog
} else {
teksilo_core::accesskit::Role::Tooltip
};
builder.set_role(role);
if let Some(content) = self.content.as_ref() {
builder.set_name(content.text.resolve_now());
}
if persistent {
builder.add_action(teksilo_core::accesskit::Action::Focus);
}
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.map(|id| vec![id]).unwrap_or_default()
}
}
fn make_link_click_handler(
nested: Rc<HashMap<String, WidgetId>>,
anchor_id: WidgetId,
) -> impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static {
move |url, ctx| {
if let Some(key) = TooltipRegistry::parse_url(url) {
if let Some(&content_id) = nested.get(key) {
ctx.activate(content_id);
ctx.show_overlay(OverlayRequest {
content_id,
anchor: anchor_id,
placement: OverlayPlacement::NearAnchor {
offset: teksilo_canvas::Vec2 { x: 0.0, y: 8.0 },
},
dismiss: DismissBehavior::EscapeOrClickOutside,
layer: OverlayLayer::InTree,
parent_overlay: None,
on_dismiss: None,
fade_duration: None,
});
}
return;
}
#[cfg(not(test))]
{
let _ = open::that(url);
}
#[cfg(test)]
let _ = url;
}
}
fn scan_tooltip_key_urls(source: &str, out: &mut Vec<String>) {
let bytes = source.as_bytes();
let mut i = 0;
while i + 3 < bytes.len() {
if bytes[i] == b']' && bytes[i + 1] == b'(' && bytes[i + 2] == b':' {
let start = i + 3;
let mut end = start;
while end < bytes.len() && bytes[end] != b')' && bytes[end] != b'\\' {
end += 1;
}
if end < bytes.len()
&& bytes[end] == b')'
&& start < end
&& let Ok(key) = std::str::from_utf8(&bytes[start..end])
{
out.push(key.to_string());
}
i = end + 1;
} else {
i += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_tooltip_key_urls_finds_colon_keys() {
let mut out = Vec::new();
scan_tooltip_key_urls("see [docs](:docs-key) and [more](:more-key) here", &mut out);
assert_eq!(out, vec!["docs-key".to_string(), "more-key".to_string()]);
}
#[test]
fn scan_tooltip_key_urls_ignores_http_links() {
let mut out = Vec::new();
scan_tooltip_key_urls("go to [example](https://example.com)", &mut out);
assert!(out.is_empty());
}
#[test]
fn scan_tooltip_key_urls_mixed() {
let mut out = Vec::new();
scan_tooltip_key_urls(
"[regular](https://x) and [tip](:my-key) and [also](:other)",
&mut out,
);
assert_eq!(out, vec!["my-key".to_string(), "other".to_string()]);
}
#[test]
fn scan_tooltip_key_urls_empty_source() {
let mut out = Vec::new();
scan_tooltip_key_urls("", &mut out);
assert!(out.is_empty());
}
#[test]
fn scan_tooltip_key_urls_no_links() {
let mut out = Vec::new();
scan_tooltip_key_urls("no links here at all", &mut out);
assert!(out.is_empty());
}
fn rich_tooltip_height(content: TooltipContent, cascade: bool) -> f32 {
use std::cell::RefCell;
use teksilo_canvas::MockTextBackend;
use teksilo_core::widget_tree::WidgetTree;
let mut tree =
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
let mut w = RichTooltipWidget::new(content);
if cascade {
w = w.cascade_child();
}
let id = tree.add(w);
tree.layout(SizeProposal::with_width(400.0));
tree.bounds(id).height
}
#[test]
fn cascade_child_omits_dwell_indicator() {
let make = || TooltipContent::new("k", lit!("Tooltip body"));
let normal_h = rich_tooltip_height(make(), false);
let cascade_h = rich_tooltip_height(make(), true);
assert!(
cascade_h < normal_h,
"cascade child should be shorter without the dwell-indicator footer \
(cascade = {cascade_h}, normal = {normal_h})"
);
}
#[test]
fn cascade_child_announces_as_persistent_dialog() {
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::accesskit::{Action, Role};
let child = RichTooltipWidget::new(TooltipContent::new("k", lit!("Body"))).cascade_child();
let mut cb = AccessNodeBuilder::new();
child.accessibility(&mut cb);
assert_eq!(
cb.role(),
Role::Dialog,
"cascade child should read as Dialog"
);
assert!(
cb.actions().contains(&Action::Focus),
"cascade child should advertise the Focus action"
);
let normal = RichTooltipWidget::new(TooltipContent::new("k", lit!("Body")));
let mut nb = AccessNodeBuilder::new();
normal.accessibility(&mut nb);
assert_eq!(
nb.role(),
Role::Tooltip,
"non-cascade tooltip stays a Tooltip when not sticky"
);
assert!(
!nb.actions().contains(&Action::Focus),
"non-sticky tooltip should not advertise Focus"
);
}
#[test]
fn cyclic_cascade_links_do_not_overflow_the_stack() {
use crate::tooltip::registry::{_reset_tooltip_registry, install_tooltip_registry};
use std::cell::RefCell;
use teksilo_canvas::MockTextBackend;
use teksilo_core::widget_tree::WidgetTree;
_reset_tooltip_registry();
install_tooltip_registry(vec and itself [a](:a)")),
TooltipContent::new("b", lit!("B cites [c](:c)")),
TooltipContent::new("c", lit!("C cites back to [a](:a)")),
]);
let mut tree =
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
let id = tree.add(RichTooltipWidget::from_key("a"));
tree.layout(SizeProposal::with_width(400.0));
assert!(tree.bounds(id).height >= 0.0);
_reset_tooltip_registry();
}
#[test]
fn rebuilding_a_rich_tooltip_reaps_its_cascade_children() {
use crate::tooltip::registry::{_reset_tooltip_registry, install_tooltip_registry};
use std::cell::RefCell;
use teksilo_canvas::MockTextBackend;
use teksilo_core::widget_tree::WidgetTree;
_reset_tooltip_registry();
install_tooltip_registry(vec and [c](:c)")),
TooltipContent::new("b", lit!("B cites [d](:d)")),
TooltipContent::new("c", lit!("C is a leaf")),
TooltipContent::new("d", lit!("D is a leaf")),
]);
let mut tree =
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
let id = tree.add(RichTooltipWidget::from_key("root"));
tree.layout(SizeProposal::with_width(400.0));
let baseline = tree.widget_count();
assert!(
baseline > 1,
"the cascade must actually pre-create children"
);
for _ in 0..10 {
tree.arena_mark_needs_rebuild_for_testing(id);
tree.layout(SizeProposal::with_width(400.0));
}
assert_eq!(
tree.widget_count(),
baseline,
"each rebuild stranded another copy of the cascade"
);
tree.destroy_subtree_for_testing(id);
tree.layout(SizeProposal::with_width(400.0));
assert_eq!(
tree.widget_count(),
0,
"the whole cascade must die with the tooltip that owns it"
);
_reset_tooltip_registry();
}
#[derive(Debug)]
struct FocusTooltipHost {
anchor_id: Option<WidgetId>,
elsewhere_id: Option<WidgetId>,
ids_sink: Rc<std::cell::Cell<Option<(WidgetId, WidgetId, WidgetId)>>>,
}
impl teksilo_core::widget::Widget for FocusTooltipHost {
fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
let anchor = ctx.add(crate::primitives::TextWidget::new(lit!("anchor")));
let elsewhere = ctx.add(crate::primitives::TextWidget::new(lit!("elsewhere")));
let tip = crate::tooltip::attach::attach_rich_tooltip_content(
ctx,
anchor,
TooltipContent::new("focus-tip", lit!("Focus-promoted body")),
ctx.theme().motion.tooltip_delay,
);
self.anchor_id = Some(anchor);
self.elsewhere_id = Some(elsewhere);
self.ids_sink.set(Some((anchor, elsewhere, tip)));
vec![anchor, elsewhere]
}
fn layout_response(
&self,
proposal: teksilo_canvas::SizeProposal,
ctx: &teksilo_core::LayoutContext<'_>,
) -> teksilo_core::LayoutResponse {
self.anchor_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| teksilo_canvas::Size::new(0.0, 0.0))
.into()
}
fn children(&self) -> Vec<WidgetId> {
self.anchor_id
.into_iter()
.chain(self.elsewhere_id)
.collect()
}
}
fn focus_tooltip_tree_with(
reduced_motion: bool,
) -> (
teksilo_core::widget_tree::WidgetTree,
WidgetId,
WidgetId,
WidgetId,
) {
use std::cell::RefCell;
use teksilo_canvas::MockTextBackend;
use teksilo_core::widget_tree::WidgetTree;
let mut tree =
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
tree.set_accessibility_preferences(false, reduced_motion, 1.0);
let sink = Rc::new(std::cell::Cell::new(None));
tree.add(FocusTooltipHost {
anchor_id: None,
elsewhere_id: None,
ids_sink: sink.clone(),
});
tree.layout(SizeProposal::exact(400.0, 200.0));
let (anchor, elsewhere, tip) = sink.get().expect("host built");
(tree, anchor, elsewhere, tip)
}
fn two_buttons_first_with_tooltip()
-> (teksilo_core::widget_tree::WidgetTree, WidgetId, WidgetId) {
use crate::button::Button;
use std::cell::RefCell;
use teksilo_canvas::MockTextBackend;
use teksilo_core::widget_tree::WidgetTree;
let mut tree =
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
tree.set_accessibility_preferences(false, true, 1.0);
let first = tree.add(
Button::new(lit!("First")).rich_tooltip_content(TooltipContent::new(
"first-tip",
lit!("Body of the first button's tip"),
)),
);
let second = tree.add(Button::new(lit!("Second")));
tree.layout(SizeProposal::exact(400.0, 200.0));
(tree, first, second)
}
#[test]
fn focus_arms_the_delay_rather_than_showing_the_tooltip_on_arrival() {
let (mut tree, first, _second) = two_buttons_first_with_tooltip();
tree.focus(first);
assert!(
tree.active_overlays().is_empty(),
"focus arriving must not pop a tip — it arms the same delay the \\
pointer arms"
);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"and the tip appears once focus has come to rest for the delay"
);
assert!(
!tree.tooltip_is_sticky_within(first),
"resting long enough to show is still not long enough to promote"
);
}
#[test]
fn tabbing_through_without_resting_never_shows_a_tooltip() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, first, second) = two_buttons_first_with_tooltip();
tree.focus(first);
tree.advance_time(Duration::from_millis(80));
tree.press_key(Key::Tab, Modifiers::NONE);
assert_eq!(tree.focused(), Some(second));
tree.advance_time(tree.theme().motion.tooltip_delay * 4);
assert!(
tree.active_overlays().is_empty(),
"a tip armed by focus that has already moved on must be disarmed, \\
not left to open a beat later over a control the user has left"
);
}
#[test]
fn tab_skips_an_unpromoted_tooltip_and_goes_to_the_next_control() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, first, second) = two_buttons_first_with_tooltip();
tree.focus(first);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(tree.active_overlays().len(), 1);
tree.press_key(Key::Tab, Modifiers::NONE);
assert_eq!(
tree.focused(),
Some(second),
"an unpromoted tip is informational and must not capture Tab"
);
}
#[test]
fn a_promoted_tooltip_takes_the_tab_stop_right_after_its_anchor() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, first, second) = two_buttons_first_with_tooltip();
tree.focus(first);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
let tip = tree
.tooltip_content_within(first)
.expect("the button registered a tooltip");
tree.promote_tooltip_to_sticky(tip);
tree.press_key(Key::Tab, Modifiers::NONE);
let after_anchor = tree.focused().expect("Tab landed somewhere");
assert!(
after_anchor == tip || tree.is_descendant_of(after_anchor, tip),
"a promoted panel belongs immediately after the control it \
describes, the way a disclosure's panel follows its button"
);
tree.press_key(Key::Tab, Modifiers::NONE);
assert_eq!(
tree.focused(),
Some(second),
"and traversal continues to the next control once past it"
);
}
#[test]
fn escape_returns_focus_to_the_anchor_after_tabbing_into_a_focus_promoted_tooltip() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, anchor, _elsewhere, tip) = focus_tooltip_tree_with(true);
tree.focus(anchor);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"focus coming to rest must surface the rich tooltip"
);
tree.promote_tooltip_to_sticky(tip);
tree.focus(tip);
assert_eq!(
tree.active_overlays().len(),
1,
"focus moving into the tooltip's own content must not dismiss it"
);
tree.press_key(Key::Escape, Modifiers::NONE);
assert!(
tree.active_overlays().is_empty(),
"Escape must dismiss the focus-promoted tooltip"
);
assert_eq!(
tree.focused(),
Some(anchor),
"Escape must return focus to the anchor, not strand it on the \
dismissed surface"
);
}
#[test]
fn escape_does_not_immediately_re_summon_the_tooltip_it_dismissed() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, anchor, _elsewhere, tip) = focus_tooltip_tree_with(true);
tree.focus(anchor);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
tree.promote_tooltip_to_sticky(tip);
tree.focus(tip);
tree.press_key(Key::Escape, Modifiers::NONE);
assert!(
tree.active_overlays().is_empty(),
"the restored focus must not re-trigger the tooltip it just closed"
);
}
#[test]
fn a_dismissed_focus_tooltip_returns_after_focus_leaves_and_comes_back() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, anchor, elsewhere, tip) = focus_tooltip_tree_with(true);
tree.focus(anchor);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
tree.promote_tooltip_to_sticky(tip);
tree.focus(tip);
tree.press_key(Key::Escape, Modifiers::NONE);
assert!(tree.active_overlays().is_empty());
tree.focus(elsewhere);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert!(
tree.active_overlays().is_empty(),
"an unrelated widget must not surface the anchor's tooltip"
);
tree.focus(anchor);
tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
assert_eq!(
tree.active_overlays().len(),
1,
"returning to the anchor must summon its tooltip again"
);
}
}