Skip to main content

rosace_widgets/tree/
tooltip.rs

1use rosace_core::types::{Point, Size};
2use rosace_render::Color;
3
4use super::overlay::{FocusBehavior, InputBehavior, LayerPosition, OverlayEntry};
5use super::{BoxedWidget, Children, LayoutCtx, PaintCtx, Widget};
6
7/// Theme-driven tooltip appearance (D115/Phase 32). Set once on the theme
8/// via [`rosace_theme::ThemeData::with_ext`] and every `.tooltip(..)` /
9/// [`Tooltip`] in the app picks it up — shape, colors, font, padding all
10/// come from here, with a sensible default when the theme sets none. Same
11/// type-keyed extension mechanism `CursorStyle` uses (D105).
12#[derive(Debug, Clone, Copy)]
13pub struct TooltipStyle {
14    pub background: Color,
15    pub text_color: Color,
16    pub radius: f32,
17    pub font_size: f32,
18    /// Horizontal padding inside the bubble; vertical padding is derived
19    /// from the font size so the label always sits centered.
20    pub pad_h: f32,
21    /// Drop-shadow strength; `0.0` disables it.
22    pub elevation: f32,
23}
24
25impl Default for TooltipStyle {
26    fn default() -> Self {
27        Self {
28            background: Color::rgba(40, 42, 58, 245),
29            text_color: Color::rgb(228, 230, 244),
30            radius: 6.0,
31            font_size: 12.0,
32            pad_h: 10.0,
33            elevation: 1.0,
34        }
35    }
36}
37
38impl TooltipStyle {
39    /// Resolve from the active theme's extension, else the default.
40    fn resolve(theme: &rosace_theme::ThemeData) -> Self {
41        theme.ext::<TooltipStyle>().copied().unwrap_or_default()
42    }
43}
44
45/// Wraps a child and shows a floating label while the pointer hovers it.
46///
47/// Prefer the ergonomic `widget.tooltip("text")` ([`super::WidgetExt`])
48/// over constructing this directly — same result, no wrapping. Styling is
49/// theme-driven ([`TooltipStyle`]); an explicit `.style(..)` overrides it
50/// per-tooltip.
51pub struct Tooltip {
52    label: String,
53    style: Option<TooltipStyle>,
54    child: BoxedWidget,
55}
56
57impl Tooltip {
58    pub fn new(label: impl Into<String>, child: impl Widget + 'static) -> Self {
59        Self { label: label.into(), style: None, child: Box::new(child) }
60    }
61    /// Per-tooltip style override (otherwise the theme's `TooltipStyle`).
62    pub fn style(mut self, style: TooltipStyle) -> Self {
63        self.style = Some(style);
64        self
65    }
66    /// Convenience: override just the font size on the resolved style.
67    pub fn font_size(mut self, s: f32) -> Self {
68        let mut st = self.style.unwrap_or_default();
69        st.font_size = s;
70        self.style = Some(st);
71        self
72    }
73}
74
75impl Widget for Tooltip {
76    fn children(&self) -> Children<'_> { Children::One(&*self.child) }
77
78    fn paint(&self, ctx: &mut PaintCtx) {
79        let r = ctx.rect;
80        self.child.paint(&mut ctx.child(r));
81        ctx.hoverable();
82        if ctx.hovered() {
83            let style = self.style.unwrap_or_else(|| TooltipStyle::resolve(&ctx.theme));
84            let w = ctx.font.measure_text(&self.label, style.font_size) + style.pad_h * 2.0;
85            let h = style.font_size * 1.7;
86            let label = self.label.clone();
87            // TREE-ATTACHED so the engine's `AboveCentered` handling maps
88            // the anchor through `content_to_screen` — a tooltip on a
89            // widget inside a GPU scroll layer is remapped to window space
90            // and centred over its anchor (the legacy Absolute push path
91            // skipped that remap and dropped the label off-screen).
92            ctx.attach_overlay(
93                OverlayEntry::new(
94                    LayerPosition::AboveCentered(r),
95                    TooltipLabel { label, w, h, style },
96                )
97                .input(InputBehavior::PassThrough)
98                .focus(FocusBehavior::Inert),
99            );
100        }
101    }
102}
103
104struct TooltipLabel {
105    label: String,
106    w: f32,
107    h: f32,
108    style: TooltipStyle,
109}
110
111impl Widget for TooltipLabel {
112    fn layout(&self, ctx: &LayoutCtx) -> Size {
113        ctx.constraints.constrain(Size { width: self.w, height: self.h })
114    }
115    fn paint(&self, ctx: &mut PaintCtx) {
116        let r = ctx.rect;
117        if self.style.elevation > 0.0 {
118            ctx.fill_shadow_rrect(r, self.style.radius, Color::rgba(0, 0, 0, 90), 8.0);
119        }
120        ctx.fill_rrect(r, self.style.radius, self.style.background);
121        let ty = r.origin.y + (self.h - ctx.font.line_height(self.style.font_size)) / 2.0;
122        ctx.draw_text_at(
123            &self.label,
124            Point { x: r.origin.x + self.style.pad_h, y: ty },
125            self.style.text_color,
126            self.style.font_size,
127        );
128    }
129}
130
131/// Ergonomic extension available on EVERY widget (D115/Phase 32): attach a
132/// tooltip as a PROPERTY instead of wrapping — `Button::new("Save")
133/// .tooltip("Saves changes")`. Desktop shows it on hover; mobile has no
134/// hover, so it's naturally inert there (a long-press variant can hook the
135/// same path later). Styling comes from the theme's [`TooltipStyle`].
136pub trait WidgetExt: Widget + Sized + 'static {
137    /// Show `label` while the pointer hovers this widget.
138    fn tooltip(self, label: impl Into<String>) -> Tooltip {
139        Tooltip::new(label, self)
140    }
141}
142
143impl<W: Widget + Sized + 'static> WidgetExt for W {}