use std::time::Duration;
use web_time::Instant;
use crate::geometry::{Point, Rect};
use crate::text::measure_advance;
use super::render::{
current_tooltip_viewport, panel_size, place_panel, submit_tooltip, TooltipRequest,
};
use super::timings::{
last_tooltip_visible_at, note_tooltip_visible, tooltip_now, tooltip_timings,
};
use super::{TooltipLine, TooltipLineKind, TOOLTIP_FONT_SIZE};
#[derive(Default)]
struct Controller {
target: Option<Vec<usize>>,
text: String,
anchor: Point,
hover_started_at: Option<Instant>,
pending_delay: Duration,
visible: bool,
tip_shown_at: Option<Instant>,
suppressed: bool,
pointer_down: bool,
last_rect: Option<Rect>,
}
thread_local! {
static CONTROLLER: std::cell::RefCell<Controller> =
std::cell::RefCell::new(Controller::default());
}
impl Controller {
fn effective_delay(&self) -> Duration {
let t = tooltip_timings();
match last_tooltip_visible_at() {
Some(at) if tooltip_now().saturating_duration_since(at) <= t.reshow_window() => {
t.reshow_delay
}
_ => t.initial_delay,
}
}
fn set_target(&mut self, target: Option<(Vec<usize>, String)>, anchor: Option<Point>) {
if let Some(a) = anchor {
self.anchor = a;
}
match target {
Some((path, text)) => {
if self.target.as_deref() != Some(path.as_slice()) {
self.target = Some(path);
self.hover_started_at = Some(tooltip_now());
self.pending_delay = self.effective_delay();
self.suppressed = false;
if self.visible {
self.hide();
}
crate::animation::request_draw_after_tagged(
self.pending_delay,
"tooltip.controller.arm_hover",
);
}
self.text = text;
}
None => {
if self.target.is_some() {
self.target = None;
self.hover_started_at = None;
self.suppressed = false;
self.hide();
}
}
}
}
fn should_show(&self) -> bool {
if self.suppressed || self.pointer_down || self.target.is_none() {
return false;
}
match self.hover_started_at {
Some(started) => tooltip_now().saturating_duration_since(started) >= self.pending_delay,
None => false,
}
}
fn remaining_delay(&self) -> Option<Duration> {
if self.target.is_none() || self.suppressed || self.pointer_down {
return None;
}
let elapsed = tooltip_now().saturating_duration_since(self.hover_started_at?);
Some(self.pending_delay.saturating_sub(elapsed))
}
fn hide(&mut self) {
if self.visible {
crate::animation::request_draw_tagged("tooltip.controller.hide");
}
self.visible = false;
self.tip_shown_at = None;
self.last_rect = None;
}
fn update_visibility(&mut self) -> bool {
if self.visible {
if let Some(shown) = self.tip_shown_at {
let autopop = tooltip_timings().autopop;
let elapsed = tooltip_now().saturating_duration_since(shown);
if elapsed >= autopop {
self.hide();
self.suppressed = true;
return false;
}
crate::animation::request_draw_after_tagged(
autopop - elapsed,
"tooltip.controller.autopop_rearm",
);
}
}
if self.should_show() {
if !self.visible {
self.visible = true;
self.tip_shown_at = Some(tooltip_now());
crate::animation::request_draw_tagged("tooltip.controller.show");
crate::animation::request_draw_after_tagged(
tooltip_timings().autopop,
"tooltip.controller.autopop_arm",
);
}
note_tooltip_visible();
return true;
}
if self.visible {
self.hide();
} else if let Some(remaining) = self.remaining_delay() {
if remaining.is_zero() {
crate::animation::request_draw_tagged("tooltip.controller.remaining_zero");
} else {
crate::animation::request_draw_after_tagged(
remaining,
"tooltip.controller.remaining",
);
}
}
false
}
fn submit(&mut self) {
let Some(font) = crate::font_settings::current_system_font() else {
self.last_rect = None;
return;
};
let text_w = measure_advance(&font, &self.text, TOOLTIP_FONT_SIZE);
let size = panel_size(text_w, 1);
let viewport = current_tooltip_viewport();
if viewport.width > 0.0 && viewport.height > 0.0 {
self.last_rect = Some(place_panel(self.anchor, size, viewport, true));
}
submit_tooltip(TooltipRequest {
font,
lines: vec![TooltipLine {
text: self.text.clone(),
kind: TooltipLineKind::Text,
}],
anchor: self.anchor,
at_pointer: true,
});
}
}
pub(crate) fn drive(target: Option<(Vec<usize>, String)>, anchor: Option<Point>) {
CONTROLLER.with(|c| {
let mut c = c.borrow_mut();
c.set_target(target, anchor);
if c.update_visibility() {
c.submit();
}
});
}
pub(crate) fn on_pointer_down() {
CONTROLLER.with(|c| {
let mut c = c.borrow_mut();
c.pointer_down = true;
c.suppressed = true;
c.hide();
});
}
pub(crate) fn on_pointer_up() {
CONTROLLER.with(|c| c.borrow_mut().pointer_down = false);
}
#[doc(hidden)]
pub fn is_visible() -> bool {
CONTROLLER.with(|c| c.borrow().visible)
}
#[doc(hidden)]
pub fn visible_text() -> Option<String> {
CONTROLLER.with(|c| {
let c = c.borrow();
c.visible.then(|| c.text.clone())
})
}
#[doc(hidden)]
pub fn visible_rect() -> Option<Rect> {
CONTROLLER.with(|c| c.borrow().last_rect)
}
#[doc(hidden)]
pub fn reset() {
CONTROLLER.with(|c| *c.borrow_mut() = Controller::default());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::draw_ctx::DrawCtx;
use crate::event::{Event, EventResult};
use crate::geometry::{Rect, Size};
use crate::layout_props::WidgetBase;
use crate::text::Font;
use crate::widget::{App, Widget};
use std::sync::Arc;
use super::super::timings::{
advance_tooltip_test_clock, reset_tooltip_test_state, set_tooltip_test_clock,
};
const FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/NotoSans-Regular.ttf");
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
reset_tooltip_test_state();
reset();
crate::font_settings::set_system_font(None);
}
}
fn pin() -> Guard {
reset_tooltip_test_state();
reset();
set_tooltip_test_clock(Some(Instant::now()));
crate::font_settings::set_system_font(Some(Arc::new(
Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"),
)));
Guard
}
struct Tipped {
bounds: Rect,
children: Vec<Box<dyn Widget>>,
base: WidgetBase,
}
impl Tipped {
fn new(tip: &str) -> Self {
Self {
bounds: Rect::default(),
children: Vec::new(),
base: WidgetBase::new().with_tooltip(tip),
}
}
}
impl Widget for Tipped {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, b: Rect) {
self.bounds = b;
}
fn children(&self) -> &[Box<dyn Widget>] {
&self.children
}
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
&mut self.children
}
fn layout(&mut self, available: Size) -> Size {
self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
available
}
fn paint(&mut self, _: &mut dyn DrawCtx) {}
fn on_event(&mut self, _: &Event) -> EventResult {
EventResult::Ignored
}
fn widget_base(&self) -> Option<&WidgetBase> {
Some(&self.base)
}
fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
Some(&mut self.base)
}
}
fn hover(app: &mut App, wx: f64, wy: f64) {
app.on_mouse_move(wx, 600.0 - wy);
}
#[test]
fn tip_shows_after_initial_delay_when_hovered() {
let _g = pin();
let timings = tooltip_timings();
let mut app = App::new(Box::new(Tipped::new("Bold")));
app.layout(Size::new(800.0, 600.0));
hover(&mut app, 400.0, 300.0);
app.update_tooltips_for_test();
assert!(!is_visible(), "no tip before the initial delay elapses");
advance_tooltip_test_clock(timings.initial_delay);
app.update_tooltips_for_test();
assert!(is_visible(), "tip appears once the initial delay elapses");
assert_eq!(visible_text().as_deref(), Some("Bold"));
}
#[test]
fn moving_between_tipped_widgets_reshows_single_tip() {
let _g = pin();
let timings = tooltip_timings();
let anchor = Some(Point::new(100.0, 100.0));
drive(Some((vec![0], "A".into())), anchor);
advance_tooltip_test_clock(timings.initial_delay);
drive(Some((vec![0], "A".into())), anchor);
assert_eq!(visible_text().as_deref(), Some("A"));
drive(None, anchor);
assert!(!is_visible());
advance_tooltip_test_clock(timings.reshow_delay);
drive(Some((vec![1], "B".into())), anchor);
assert!(!is_visible(), "B not shown until the (short) reshow delay elapses");
advance_tooltip_test_clock(timings.reshow_delay);
drive(Some((vec![1], "B".into())), anchor);
assert!(is_visible());
assert_eq!(visible_text().as_deref(), Some("B"), "exactly one tip: B's");
}
#[test]
fn visible_tip_follows_live_system_font_swap() {
let _g = pin();
let timings = tooltip_timings();
let anchor = Some(Point::new(100.0, 100.0));
let font_a = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("font a"));
let font_b = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("font b"));
assert!(!Arc::ptr_eq(&font_a, &font_b));
crate::font_settings::set_system_font(Some(Arc::clone(&font_a)));
drive(Some((vec![0], "Tip".into())), anchor);
advance_tooltip_test_clock(timings.initial_delay);
drive(Some((vec![0], "Tip".into())), anchor);
assert!(is_visible(), "tip visible after the initial delay");
let submitted_a =
super::super::render::last_submitted_font().expect("tip submitted a request");
assert!(
Arc::ptr_eq(&submitted_a, &font_a),
"tip first paints with the installed font A"
);
crate::font_settings::set_system_font(Some(Arc::clone(&font_b)));
drive(Some((vec![0], "Tip".into())), anchor);
let submitted_b =
super::super::render::last_submitted_font().expect("tip re-submitted after swap");
assert!(
Arc::ptr_eq(&submitted_b, &font_b),
"still-visible tip re-renders with the NEW system font B"
);
assert!(
!Arc::ptr_eq(&submitted_b, &font_a),
"the swapped tip must not keep painting with the old font A"
);
}
#[test]
fn tip_at_viewport_edge_stays_inside() {
let _g = pin();
let timings = tooltip_timings();
let mut app = App::new(Box::new(Tipped::new("Increase indent")));
let viewport = Size::new(800.0, 600.0);
app.layout(viewport);
hover(&mut app, 798.0, 300.0);
app.update_tooltips_for_test();
advance_tooltip_test_clock(timings.initial_delay);
app.update_tooltips_for_test();
assert!(is_visible());
let r = visible_rect().expect("visible tip has a placed rect");
assert!(r.x >= 0.0, "left edge inside viewport: {r:?}");
assert!(
r.x + r.width <= viewport.width,
"right edge inside viewport: {r:?}"
);
assert!(r.y >= 0.0 && r.y + r.height <= viewport.height, "vertically inside: {r:?}");
}
#[test]
fn armed_hover_delay_surfaces_through_wants_draw() {
reset_tooltip_test_state();
reset();
set_tooltip_test_clock(None); super::super::timings::set_tooltip_timings(
super::super::timings::TooltipTimings::from_initial_delay(Duration::from_millis(20)),
);
let _g = Guard;
let mut app = App::new(Box::new(Tipped::new("Bold")));
app.layout(Size::new(800.0, 600.0));
hover(&mut app, 400.0, 300.0);
crate::animation::clear_draw_request();
app.update_tooltips_for_test();
assert!(
crate::animation::peek_next_draw_deadline().is_some(),
"the tooltip pass armed a scheduled draw for the hover delay"
);
assert!(
!crate::animation::wants_draw(),
"the hover delay has not elapsed yet, so no immediate draw"
);
std::thread::sleep(Duration::from_millis(40));
assert!(
crate::animation::wants_draw(),
"once the hover delay is due, wants_draw() surfaces it so a \
reactive host draws the frame that shows the tip"
);
}
}