Skip to main content

teksilo_widgets/
tooltip.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tooltip system — hover-triggered overlays with configurable delay.
5//!
6//! Three tiers, increasing in expressive power:
7//!
8//! - [`TooltipWidget`] — single line of localized text in a themed
9//!   rounded rect. Attached via the per-widget `.tooltip(...)` setter.
10//! - [`RichTooltipWidget`] — `TooltipContent`-driven (body + optional
11//!   long-form "more" disclosure + shortcut chip), inline-markup body
12//!   so `[label](:key)` cascade links resolve against
13//!   [`TooltipRegistry`]. Attached via `.rich_tooltip(key)` /
14//!   `.rich_tooltip_content(content)`. On dwell it flips its AT role
15//!   to `Role::Dialog` and advertises a `Focus` action — keyboard
16//!   focus is not auto-transferred; the user Tabs in (the correct
17//!   non-modal-panel a11y pattern).
18//! - [`composite::CompositeTooltipWidget`] — hosts an arbitrary
19//!   `impl Widget + 'static` body inside the same chrome with a
20//!   larger surface budget. Crusader Kings 3-style: tabbed sections,
21//!   charts, progress bars, conditional rows. Attached via
22//!   `.composite_tooltip(content)`. "Primary-only" by construction —
23//!   has no inline-markup body and no registry key, so it cannot be
24//!   the target of a `[label](:key)` cascade. Child widgets *inside*
25//!   the body keep their own tooltip setters and cascade normally.
26//!
27//! All three tiers share the same overlay machinery, hover/focus
28//! tracking, and dwell-promotion timer in `teksilo-core`. The per-widget
29//! setters (`.tooltip` / `.rich_tooltip` / `.composite_tooltip`) are
30//! mutually exclusive (last-one-wins): each setter clears the others.
31//!
32//! ## Example — plain tooltip
33//!
34//! ```rust
35//! # use teksilo_widgets::tooltip::TooltipWidget;
36//! # use teksilo_i18n::lit;
37//! let _tip = TooltipWidget::new(lit!("Save the current file"));
38//! ```
39
40pub mod attach;
41pub mod composite;
42pub(crate) mod dwell_indicator;
43pub mod registry;
44pub mod rich;
45
46pub use attach::{
47    RichTooltipSource, attach_composite_tooltip, attach_composite_tooltip_boxed,
48    attach_composite_tooltip_boxed_with_placement, attach_composite_tooltip_widget_with_placement,
49    attach_plain_tooltip, attach_plain_tooltip_with_placement, attach_rich_tooltip,
50    attach_rich_tooltip_content, attach_rich_tooltip_content_with_placement,
51    attach_rich_tooltip_source, attach_rich_tooltip_source_with_placement,
52    attach_rich_tooltip_with_placement,
53};
54pub use composite::CompositeTooltipWidget;
55pub use registry::{
56    TooltipContent, TooltipRegistry, install_tooltip_registry, with_tooltip_registry,
57};
58pub use rich::RichTooltipWidget;
59/// Where a tooltip opens relative to its anchor — re-exported from
60/// `teksilo-core` so widgets can request `Side` placement in a vertical
61/// list without naming the core path.
62pub use teksilo_core::overlay::TooltipPlacement;
63
64use std::rc::Rc;
65use teksilo_i18n::lit;
66
67use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
68use teksilo_core::accessibility::AccessNodeBuilder;
69use teksilo_core::build_context::BuildContext;
70use teksilo_core::signal::Prop;
71use teksilo_core::styles::{SharedTooltipStyle, TooltipStyleConfig};
72use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
73use teksilo_core::widget_id::WidgetId;
74use teksilo_tokens::{CornerRadius, TextRole, TextStyleRole};
75
76use crate::primitives::TextWidget;
77use crate::shadow::paint_layered_shadow;
78use teksilo_i18n::LocalizedString;
79
80/// Tooltip-specific wrapper around [`paint_layered_shadow`] — pulls the
81/// xs outer + inner shadow tokens and the per-component
82/// `shadow_density` from the theme.
83pub(crate) fn paint_tooltip_shadows(
84    canvas: &mut Canvas,
85    bounds: Rect,
86    radius: CornerRadius,
87    ctx: &PaintContext,
88) {
89    paint_layered_shadow(
90        canvas,
91        bounds,
92        radius,
93        &ctx.theme.shape.shadow_xs,
94        &ctx.theme.shape.shadow_inner_xs,
95        crate::styles::recipe_tooltip_style::TOOLTIP_SHADOW_DENSITY,
96        None,
97    );
98}
99
100/// Composite-tooltip variant of [`paint_tooltip_shadows`] — uses the
101/// medium shadow tier (the larger CK3-style surface deserves more
102/// presence than the punchy `xs` rim of plain tooltips).
103pub(crate) fn paint_composite_tooltip_shadows(
104    canvas: &mut Canvas,
105    bounds: Rect,
106    radius: CornerRadius,
107    ctx: &PaintContext,
108) {
109    paint_layered_shadow(
110        canvas,
111        bounds,
112        radius,
113        &ctx.theme.shape.shadow_md,
114        &ctx.theme.shape.shadow_inner_md,
115        crate::styles::recipe_tooltip_style::COMPOSITE_TOOLTIP_SHADOW_DENSITY,
116        None,
117    );
118}
119
120/// A tooltip content widget — a themed rounded rect with text.
121///
122/// Composes a `TextWidget` with `Small` typography in `tooltip_text` color,
123/// then delegates the chrome (shadow, dark background, corner radius,
124/// padding) to the active `TooltipStyle` (default
125/// [`crate::styles::RecipeTooltipStyle`]). Apps install per-call
126/// (`TooltipWidget::new(...).style(impl TooltipStyle)`) or theme-wide
127/// via `theme.style_slots.tooltip = Some(Rc::new(MyTooltip))`.
128pub struct TooltipWidget {
129    /// The tooltip body as a `Prop<String>`. A `tr!(...)` / `lit!(...)`
130    /// source enters via [`TooltipWidget::new`] (locale-reactive when an
131    /// `I18nManager` is installed); a `Signal<String>` source enters via
132    /// [`TooltipWidget::bound`] for callers that swap the text at runtime
133    /// (e.g. a single reusable tooltip surface reused across many
134    /// hover targets, as `teksilo-scene` does for lightweight items).
135    /// Either way the inner `TextWidget` re-renders on change without a
136    /// rebuild.
137    text: Prop<String>,
138    style_override: Option<SharedTooltipStyle>,
139    root_child_id: Option<WidgetId>,
140}
141
142impl std::fmt::Debug for TooltipWidget {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("TooltipWidget")
145            .field("text", &self.text.get())
146            .finish()
147    }
148}
149
150impl TooltipWidget {
151    /// Construct a tooltip from a localized string. With an `I18nManager`
152    /// installed the body stays locale-reactive (re-resolves on locale
153    /// change); otherwise it's a static snapshot.
154    pub fn new(text: impl Into<LocalizedString>) -> Self {
155        let ls: LocalizedString = text.into();
156        Self {
157            text: Prop::from(ls),
158            style_override: None,
159            root_child_id: None,
160        }
161    }
162
163    /// Construct a tooltip whose body is driven by a `Signal<String>`
164    /// (or any `Prop<String>`). Mutating the signal re-renders the
165    /// tooltip in place — used when a single dormant tooltip surface is
166    /// reused across many anchors and its text is set just before each
167    /// show. Callers wanting locale reactivity should resolve their
168    /// `LocalizedString` against the active locale when setting the
169    /// signal.
170    pub fn bound(text: impl Into<Prop<String>>) -> Self {
171        Self {
172            text: text.into(),
173            style_override: None,
174            root_child_id: None,
175        }
176    }
177
178    /// Per-call style override. Replaces the theme-wide default
179    /// `TooltipStyle` for just this TooltipWidget instance.
180    pub fn style(mut self, style: impl teksilo_core::styles::TooltipStyle) -> Self {
181        self.style_override = Some(Rc::new(style));
182        self
183    }
184}
185
186impl Widget for TooltipWidget {
187    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
188        // Wrap, don't ellipsize. `single_line()` only truncates against a
189        // *bounded* width, and the overlay measures its content with an
190        // unbounded proposal — so a long body used to render as one endless
191        // line running off the window rather than as the capped, wrapped block
192        // the `TOOLTIP_MAX_WIDTH` token describes. `layout_response` below
193        // supplies the bound; `TextOverflow::Wrap` is the default.
194        let text = TextWidget::new(lit!(""))
195            .text(self.text.clone())
196            .style(TextStyleRole::Small)
197            .color(TextRole::TooltipText);
198        let text_id = ctx.add(text);
199
200        let style: SharedTooltipStyle = self
201            .style_override
202            .clone()
203            .or_else(|| ctx.theme().style_slots.tooltip.clone())
204            .unwrap_or_else(|| Rc::new(crate::styles::RecipeTooltipStyle::default()));
205        let cfg = TooltipStyleConfig { content: text_id };
206        let root_id = style.make_body(&cfg, ctx);
207        self.root_child_id = Some(root_id);
208        vec![root_id]
209    }
210
211    fn layout_response(
212        &self,
213        proposal: SizeProposal,
214        ctx: &LayoutContext,
215    ) -> teksilo_core::widget::LayoutResponse {
216        // Clamp the proposal to the tooltip max-width token, mirroring
217        // `RichTooltipWidget::layout_response`. The overlay content pass
218        // measures with `width: None`, so without this the body has no width
219        // to wrap against and the surface stretches to the full length of the
220        // string.
221        let max_w = crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH;
222        let clamped = SizeProposal {
223            width: Some(proposal.width.map(|w| w.min(max_w)).unwrap_or(max_w)),
224            height: proposal.height,
225        };
226        if let Some(root) = self.root_child_id
227            && let Some(size) = ctx.child_size(root, clamped)
228        {
229            return size.into();
230        }
231        proposal.resolve(0.0, 0.0).into()
232    }
233
234    fn place_children(
235        &self,
236        bounds: Rect,
237        _proposal: SizeProposal,
238        children: &mut [WidgetPlacement],
239        _ctx: &LayoutContext,
240    ) {
241        for child in children.iter_mut() {
242            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
243            child.size = Size::new(bounds.width, bounds.height);
244        }
245    }
246
247    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
248        builder.set_role(teksilo_core::accesskit::Role::Tooltip);
249        // Read the current value at walk time — the AT tree re-walks on a
250        // locale change (Bound text) and on signal mutation (scene reuse),
251        // so the announced name stays in sync with what's painted.
252        builder.set_name(self.text.get());
253    }
254
255    fn children(&self) -> Vec<WidgetId> {
256        self.root_child_id.into_iter().collect()
257    }
258
259    /// A plain tooltip is entirely its string, so an empty or whitespace-only
260    /// body — an unresolved i18n key, a `Signal<String>` not yet filled in —
261    /// has nothing to show and must not open a blank bubble. Read at show
262    /// time, so a bound tooltip that gains text later shows normally.
263    fn tooltip_has_content(&self) -> bool {
264        !self.text.get().trim().is_empty()
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use teksilo_canvas::SizeProposal;
272    use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
273    use teksilo_core::widget_tree::WidgetTree;
274
275    /// A long single-word-free string that would run far past the cap if the
276    /// body did not wrap.
277    const LONG_BODY: &str = "This tooltip body is deliberately long enough that \
278         it must wrap onto several lines instead of stretching the surface into \
279         one endless ribbon that runs straight off the edge of the window.";
280
281    /// Show a plain tooltip through the real overlay path (attach + hover +
282    /// delay) and return the surface's laid-out bounds. The overlay content
283    /// pass measures with an *unbounded* proposal, which is exactly the
284    /// condition the wrapping fix has to survive — measuring the widget as a
285    /// tree root instead would just hand it the root proposal.
286    fn shown_tooltip_bounds(text: &str) -> teksilo_canvas::Rect {
287        let mut tree = WidgetTree::new()
288            .with_theme(teksilo_core::presets::intui::light())
289            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
290                teksilo_canvas::MockTextBackend::new(),
291            )));
292        let anchor = tree.add(crate::button::Button::new(lit!("Anchor")).tooltip(lit!(text)));
293        tree.layout(SizeProposal::exact(2000.0, 600.0));
294        tree.pointer_move(tree.bounds(anchor).center());
295        tree.advance_time(std::time::Duration::from_secs(1));
296        tree.layout(SizeProposal::exact(2000.0, 600.0));
297        let overlay = *tree
298            .active_overlays()
299            .first()
300            .expect("the tooltip is shown");
301        tree.overlay_content_bounds(overlay)
302            .expect("the shown overlay has content bounds")
303    }
304
305    #[test]
306    fn a_long_plain_tooltip_wraps_at_the_max_width() {
307        // Regression: the body was `single_line()` (ellipsis), which only
308        // truncates against a *bounded* width — and the overlay measures its
309        // content with `width: None`. So a long body rendered as one
310        // unwrapped line running off the window, and TOOLTIP_MAX_WIDTH was
311        // dead code for this tier (RichTooltipWidget clamps; plain did not).
312        let long = shown_tooltip_bounds(LONG_BODY);
313        assert!(
314            long.width <= crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH + 0.5,
315            "a long tooltip must wrap at TOOLTIP_MAX_WIDTH, got {}",
316            long.width
317        );
318
319        // ...and it wrapped rather than being truncated to one row.
320        let short = shown_tooltip_bounds("short");
321        assert!(
322            long.height > short.height,
323            "the wrapped body must occupy more than one line ({} vs {})",
324            long.height,
325            short.height
326        );
327    }
328
329    #[test]
330    fn an_empty_tooltip_has_no_content_to_show() {
331        // A blank or unresolved string must not pop an empty chromed bubble.
332        assert!(!TooltipWidget::new(lit!("")).tooltip_has_content());
333        assert!(!TooltipWidget::new(lit!("   ")).tooltip_has_content());
334        assert!(TooltipWidget::new(lit!("real")).tooltip_has_content());
335    }
336
337    #[test]
338    fn an_empty_tooltip_never_opens_an_overlay() {
339        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
340        let anchor = tree.add(crate::button::Button::new(lit!("Go")).tooltip(lit!("  ")));
341        tree.layout(SizeProposal::exact(400.0, 200.0));
342
343        tree.pointer_move(tree.bounds(anchor).center());
344        tree.advance_time(std::time::Duration::from_secs(1));
345        assert!(
346            tree.active_overlays().is_empty(),
347            "a whitespace-only tooltip must not open an empty bubble"
348        );
349    }
350
351    #[test]
352    fn tooltip_widget_emits_shadow() {
353        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
354        let _ = tree.add(TooltipWidget::new(lit!("hello")));
355        tree.layout(SizeProposal::exact(200.0, 80.0));
356        let frame = tree.render();
357        assert!(
358            !frame.shadows.is_empty(),
359            "tooltip should emit at least one shadow"
360        );
361    }
362
363    #[test]
364    fn tooltip_overlay_emits_shadow_through_fade() {
365        // End-to-end-ish: anchor + tooltip overlay with a fade scope
366        // applied (the production overlay path). Shadow must still
367        // land in the rendered frame.
368        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
369        let anchor = tree.add(TooltipWidget::new(lit!("anchor")));
370        let tip = tree.add(TooltipWidget::new(lit!("hello")));
371        tree.set_dormant(tip);
372        tree.layout(SizeProposal::exact(800.0, 600.0));
373
374        tree.show_overlay(OverlayRequest {
375            content_id: tip,
376            anchor,
377            placement: OverlayPlacement::NearAnchor {
378                offset: teksilo_canvas::Vec2::new(0.0, 8.0),
379            },
380            dismiss: DismissBehavior::PointerLeave {
381                delay: std::time::Duration::from_millis(100),
382            },
383            layer: OverlayLayer::InTree,
384            parent_overlay: None,
385            on_dismiss: None,
386            fade_duration: Some(std::time::Duration::from_millis(120)),
387        });
388        tree.layout(SizeProposal::exact(800.0, 600.0));
389        let frame = tree.render();
390        assert!(
391            !frame.shadows.is_empty(),
392            "tooltip overlay should emit at least one shadow even under fade scope"
393        );
394    }
395}