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            // The TooltipWidget node itself already carries this string as
199            // its own name (see `accessibility` below); an unhidden Label
200            // here would announce the body twice.
201            .a11y_hidden();
202        let text_id = ctx.add(text);
203
204        let style: SharedTooltipStyle = self
205            .style_override
206            .clone()
207            .or_else(|| ctx.theme().style_slots.tooltip.clone())
208            .unwrap_or_else(|| {
209                Rc::new(crate::styles::RecipeTooltipStyle::for_tokens(
210                    &ctx.theme().input,
211                ))
212            });
213        let cfg = TooltipStyleConfig { content: text_id };
214        let root_id = style.make_body(&cfg, ctx);
215        self.root_child_id = Some(root_id);
216        vec![root_id]
217    }
218
219    fn layout_response(
220        &self,
221        proposal: SizeProposal,
222        ctx: &LayoutContext,
223    ) -> teksilo_core::widget::LayoutResponse {
224        // Clamp the proposal to the tooltip max-width token, mirroring
225        // `RichTooltipWidget::layout_response`. The overlay content pass
226        // measures with `width: None`, so without this the body has no width
227        // to wrap against and the surface stretches to the full length of the
228        // string.
229        let max_w = crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH;
230        let clamped = SizeProposal {
231            width: Some(proposal.width.map(|w| w.min(max_w)).unwrap_or(max_w)),
232            height: proposal.height,
233        };
234        if let Some(root) = self.root_child_id
235            && let Some(size) = ctx.child_size(root, clamped)
236        {
237            return size.into();
238        }
239        proposal.resolve(0.0, 0.0).into()
240    }
241
242    fn place_children(
243        &self,
244        bounds: Rect,
245        _proposal: SizeProposal,
246        children: &mut [WidgetPlacement],
247        _ctx: &LayoutContext,
248    ) {
249        for child in children.iter_mut() {
250            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
251            child.size = Size::new(bounds.width, bounds.height);
252        }
253    }
254
255    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
256        builder.set_role(teksilo_core::accesskit::Role::Tooltip);
257        // Read the current value at walk time — the AT tree re-walks on a
258        // locale change (Bound text) and on signal mutation (scene reuse),
259        // so the announced name stays in sync with what's painted.
260        builder.set_name(self.text.get());
261    }
262
263    fn children(&self) -> Vec<WidgetId> {
264        self.root_child_id.into_iter().collect()
265    }
266
267    /// A plain tooltip is entirely its string, so an empty or whitespace-only
268    /// body — an unresolved i18n key, a `Signal<String>` not yet filled in —
269    /// has nothing to show and must not open a blank bubble. Read at show
270    /// time, so a bound tooltip that gains text later shows normally.
271    fn tooltip_has_content(&self) -> bool {
272        !self.text.get().trim().is_empty()
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use teksilo_canvas::SizeProposal;
280    use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
281    use teksilo_core::widget_tree::WidgetTree;
282
283    /// A long single-word-free string that would run far past the cap if the
284    /// body did not wrap.
285    const LONG_BODY: &str = "This tooltip body is deliberately long enough that \
286         it must wrap onto several lines instead of stretching the surface into \
287         one endless ribbon that runs straight off the edge of the window.";
288
289    /// Show a plain tooltip through the real overlay path (attach + hover +
290    /// delay) and return the surface's laid-out bounds. The overlay content
291    /// pass measures with an *unbounded* proposal, which is exactly the
292    /// condition the wrapping fix has to survive — measuring the widget as a
293    /// tree root instead would just hand it the root proposal.
294    fn shown_tooltip_bounds(text: &str) -> teksilo_canvas::Rect {
295        let mut tree = WidgetTree::new()
296            .with_theme(teksilo_core::presets::intui::light())
297            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
298                teksilo_canvas::MockTextBackend::new(),
299            )));
300        let anchor = tree.add(crate::button::Button::new(lit!("Anchor")).tooltip(lit!(text)));
301        tree.layout(SizeProposal::exact(2000.0, 600.0));
302        tree.pointer_move(tree.bounds(anchor).center());
303        tree.advance_time(std::time::Duration::from_secs(1));
304        tree.layout(SizeProposal::exact(2000.0, 600.0));
305        let overlay = *tree
306            .active_overlays()
307            .first()
308            .expect("the tooltip is shown");
309        tree.overlay_content_bounds(overlay)
310            .expect("the shown overlay has content bounds")
311    }
312
313    #[test]
314    fn a_long_plain_tooltip_wraps_at_the_max_width() {
315        // Regression: the body was `single_line()` (ellipsis), which only
316        // truncates against a *bounded* width — and the overlay measures its
317        // content with `width: None`. So a long body rendered as one
318        // unwrapped line running off the window, and TOOLTIP_MAX_WIDTH was
319        // dead code for this tier (RichTooltipWidget clamps; plain did not).
320        let long = shown_tooltip_bounds(LONG_BODY);
321        assert!(
322            long.width <= crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH + 0.5,
323            "a long tooltip must wrap at TOOLTIP_MAX_WIDTH, got {}",
324            long.width
325        );
326
327        // ...and it wrapped rather than being truncated to one row.
328        let short = shown_tooltip_bounds("short");
329        assert!(
330            long.height > short.height,
331            "the wrapped body must occupy more than one line ({} vs {})",
332            long.height,
333            short.height
334        );
335    }
336
337    #[test]
338    fn an_empty_tooltip_has_no_content_to_show() {
339        // A blank or unresolved string must not pop an empty chromed bubble.
340        assert!(!TooltipWidget::new(lit!("")).tooltip_has_content());
341        assert!(!TooltipWidget::new(lit!("   ")).tooltip_has_content());
342        assert!(TooltipWidget::new(lit!("real")).tooltip_has_content());
343    }
344
345    #[test]
346    fn an_empty_tooltip_never_opens_an_overlay() {
347        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
348        let anchor = tree.add(crate::button::Button::new(lit!("Go")).tooltip(lit!("  ")));
349        tree.layout(SizeProposal::exact(400.0, 200.0));
350
351        tree.pointer_move(tree.bounds(anchor).center());
352        tree.advance_time(std::time::Duration::from_secs(1));
353        assert!(
354            tree.active_overlays().is_empty(),
355            "a whitespace-only tooltip must not open an empty bubble"
356        );
357    }
358
359    #[test]
360    fn tooltip_widget_emits_shadow() {
361        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
362        let _ = tree.add(TooltipWidget::new(lit!("hello")));
363        tree.layout(SizeProposal::exact(200.0, 80.0));
364        let frame = tree.render();
365        assert!(
366            !frame.shadows.is_empty(),
367            "tooltip should emit at least one shadow"
368        );
369    }
370
371    #[test]
372    fn tooltip_overlay_emits_shadow_through_fade() {
373        // End-to-end-ish: anchor + tooltip overlay with a fade scope
374        // applied (the production overlay path). Shadow must still
375        // land in the rendered frame.
376        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
377        let anchor = tree.add(TooltipWidget::new(lit!("anchor")));
378        let tip = tree.add(TooltipWidget::new(lit!("hello")));
379        tree.set_dormant(tip);
380        tree.layout(SizeProposal::exact(800.0, 600.0));
381
382        tree.show_overlay(
383            OverlayRequest::new(
384                tip,
385                anchor,
386                OverlayPlacement::NearAnchor {
387                    offset: teksilo_canvas::Vec2::new(0.0, 8.0),
388                },
389                DismissBehavior::PointerLeave {
390                    delay: std::time::Duration::from_millis(100),
391                },
392            )
393            .with_fade(std::time::Duration::from_millis(120)),
394        );
395        tree.layout(SizeProposal::exact(800.0, 600.0));
396        let frame = tree.render();
397        assert!(
398            !frame.shadows.is_empty(),
399            "tooltip overlay should emit at least one shadow even under fade scope"
400        );
401    }
402
403    #[test]
404    fn tooltip_body_label_is_hidden_from_at_since_the_tooltip_node_owns_the_name() {
405        // The TooltipWidget's own accessibility() sets its Role::Tooltip
406        // name to the body text; the inner TextWidget must not also expose
407        // a Role::Label carrying that same string, or a screen reader
408        // announces the body twice.
409        use teksilo_core::accessibility::widget_id_to_node_id;
410        use teksilo_core::accesskit::Role;
411        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
412        let id = tree.add(TooltipWidget::new(lit!("Save the current file")));
413        tree.layout(SizeProposal::exact(400.0, 200.0));
414        let update = tree.sync_accessibility();
415
416        let (_, node) = update
417            .nodes
418            .iter()
419            .find(|(nid, _)| *nid == widget_id_to_node_id(id))
420            .expect("tooltip node present");
421        assert_eq!(node.role(), Role::Tooltip);
422        assert_eq!(
423            node.label().unwrap_or_default(),
424            "Save the current file",
425            "hiding the body Label must not take the tooltip's own name away with it"
426        );
427
428        assert!(
429            !update.nodes.iter().any(|(_, n)| n.role() == Role::Label
430                && n.label().unwrap_or_default() == "Save the current file"),
431            "no separate Label node should duplicate the tooltip's own announced name"
432        );
433    }
434}