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;