//! 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, /// When the armed one is due. pub(crate) due: Option, /// The open tooltip's overlay. pub(crate) overlay: Option, } impl WidgetTree { /// Give a widget a tooltip, or take it away. pub fn set_tooltip(&mut self, id: WidgetId, tooltip: Option) { 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 { 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;