tooltip.rs
raw
//! Tooltips: `:tooltip "text"` on any widget.
//!
//! This is **tree policy**, not a widget. What the pointer is resting on is
//! the hover chain's knowledge — where `:hover` and the menu highlight
//! already live — and a widget cannot reach the overlay stack anyway. So the
//! tree watches the chain, arms a wake on the innermost widget with a
//! tooltip, and opens an anchored panel when it fires. Moving cancels.
//!
//! A tooltip is text, so it needs no `ContentBuilder`: the deferred-content
//! seam is for content an author *writes*, and there is none here. Reaching
//! for it because it is new would be the wrong instinct.
use guiduck_scene::Fragment;
use guiduck_scene::geom::{Rect, Size};
use guiduck_scene::paint::{Brush, Color};
use super::overlay::{AnchorSide, OverlayOptions};
use super::{Text, Widget, WidgetId, WidgetTree};
use crate::dirty::Dirt;
use crate::style::{ComputedStyle, StyleReader};
/// How long the pointer must rest before a tooltip appears.
pub(crate) const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis(600);
/// The appearance tokens a tooltip panel reads from the theme.
struct Tokens {
background: Brush,
border_color: Brush,
border_width: f64,
corner_radius: f64,
}
impl Tokens {
fn read(&mut self, style: &ComputedStyle) -> bool {
let mut reader = StyleReader::new(style);
reader.brush(&mut self.background, "background");
reader.brush(&mut self.border_color, "border-color");
reader.number(&mut self.border_width, "border-width");
reader.number(&mut self.corner_radius, "corner-radius");
reader.changed()
}
}
impl Default for Tokens {
fn default() -> Self {
let mut tokens = Self {
background: Color::TRANSPARENT.into(),
border_color: Color::TRANSPARENT.into(),
border_width: 0.0,
corner_radius: 0.0,
};
tokens.read(&super::fallback_style("tooltip"));
tokens
}
}
/// The panel a tooltip's text sits in. Built by the tree, nameable in a
/// theme, never written in a `.gdc` — like `menu-panel`.
pub struct TooltipPanel {
tokens: Tokens,
dirt: Dirt,
}
impl TooltipPanel {
pub fn new() -> Self {
Self {
tokens: Tokens::default(),
dirt: Dirt::CLEAN,
}
}
}
impl Default for TooltipPanel {
fn default() -> Self {
Self::new()
}
}
impl Widget for TooltipPanel {
fn adjust_style(&self, style: &mut taffy::Style) {
style.padding = taffy::Rect::length(6.0_f32);
}
fn paint(&mut self, fragment: &mut Fragment, size: Size) {
let t = &self.tokens;
let bounds = Rect::from_origin_size((0.0, 0.0), size);
fragment.fill(
bounds.to_rounded_rect(t.corner_radius),
t.background.clone(),
);
if t.border_width > 0.0 {
fragment.stroke(
bounds
.inset(-t.border_width / 2.0)
.to_rounded_rect((t.corner_radius - t.border_width / 2.0).max(0.0)),
t.border_color.clone(),
guiduck_scene::geom::Stroke::new(t.border_width),
);
}
}
fn role(&self) -> accesskit::Role {
accesskit::Role::Tooltip
}
fn take_dirt(&mut self) -> Dirt {
std::mem::take(&mut self.dirt)
}
fn type_name(&self) -> &'static str {
"tooltip"
}
fn apply_style(&mut self, style: &ComputedStyle) {
if self.tokens.read(style) {
self.dirt.mark_paint();
}
}
}
/// The tree's tooltip state: what is armed, and what is showing.
#[derive(Default)]
pub(crate) struct TooltipState {
/// The widget the pointer is resting on that has a tooltip.
pub(crate) armed: Option<WidgetId>,
/// When the armed one is due.
pub(crate) due: Option<std::time::Instant>,
/// The open tooltip's overlay.
pub(crate) overlay: Option<WidgetId>,
}
impl WidgetTree {
/// Give a widget a tooltip, or take it away.
pub fn set_tooltip(&mut self, id: WidgetId, tooltip: Option<String>) {
if let Some(node) = self.nodes.get_mut(id) {
node.tooltip = tooltip;
}
}
/// The tooltip text of the innermost hovered widget that has one.
fn hovered_tooltip(&self) -> Option<WidgetId> {
self.hover_chain_for_tooltip()
.iter()
.rev()
.copied()
.find(|id| self.nodes.get(*id).is_some_and(|n| n.tooltip.is_some()))
}
/// The hover chain changed: re-arm, and drop anything showing.
///
/// Called from `set_hover_chain`, beside `:hover` and the menu highlight,
/// because all three answer the same question.
pub(crate) fn update_tooltip(&mut self) {
let target = self.hovered_tooltip();
if target == self.tooltip.armed {
return;
}
// The pointer moved to something else: whatever was showing is about
// something you are no longer pointing at.
self.close_tooltip();
self.tooltip.armed = target;
// The deadline is anchored when the platform next supplies a `now`
// (`next_wake`), keeping the no-hidden-clock rule: the tree never
// reads a clock, it only says "in this long".
self.tooltip.due = None;
}
/// Close any open tooltip.
pub(crate) fn close_tooltip(&mut self) {
if let Some(overlay) = self.tooltip.overlay.take() {
self.close_overlay(overlay);
}
}
/// Show the armed tooltip.
pub(crate) fn open_tooltip(&mut self) {
let Some(anchor) = self.tooltip.armed else {
return;
};
let Some(text) = self.nodes.get(anchor).and_then(|node| node.tooltip.clone()) else {
return;
};
let mut options = OverlayOptions::popup(anchor, AnchorSide::Below);
// A tooltip is not a thing you interact with: it must not eat a click
// or a key, and it is dismissed by the pointer leaving, which the
// hover chain already tells us about.
options.dismiss_on_outside_click = false;
options.dismiss_on_escape = false;
// The pointer passes through it. Otherwise it would take hover from
// the very widget it describes — and losing that hover is what takes
// it away, so it would flicker itself out of existence.
options.hit_testable = false;
let overlay = self.open_overlay(taffy::Style::default(), options);
let panel = self.insert(TooltipPanel::new(), taffy::Style::default(), Some(overlay));
self.insert(Text::new(text), taffy::Style::default(), Some(panel));
self.tooltip.overlay = Some(overlay);
}
}
#[cfg(test)]
mod tests;