Skip to main content

teksilo_widgets/styles/
recipe_tooltip_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `TooltipStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeTooltipStyle` paints the IntUI tooltip chrome — `shadow_xs`
7//! pair + dark `tooltip_bg` (intentionally dark even in light theme,
8//! the JetBrains house style). Used by all three tooltip tiers (plain,
9//! rich, composite — though composite ships its own larger-shadow
10//! variant via `RecipeCompositeTooltipStyle`).
11//!
12//! Apps that want a different look (light tooltip, branded chrome,
13//! glassmorphism) write their own `impl TooltipStyle` block.
14
15use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
16use teksilo_core::accessibility::AccessNodeBuilder;
17use teksilo_core::build_context::BuildContext;
18use teksilo_core::styles::{TooltipStyle, TooltipStyleConfig};
19use teksilo_core::widget::{
20    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
21};
22use teksilo_core::widget_id::WidgetId;
23use teksilo_tokens::CornerRadius;
24
25// IntUI design tokens for plain Tooltip + CompositeTooltip. The recipe
26// and tooltip widget own these constants.
27pub const TOOLTIP_PADDING_HORIZONTAL: f32 = 10.0;
28pub const TOOLTIP_PADDING_VERTICAL: f32 = 6.0;
29pub const TOOLTIP_CORNER_RADIUS: f32 = 8.0;
30pub const TOOLTIP_MAX_WIDTH: f32 = 320.0;
31/// 0..=1 multiplier on `shape.shadow_inner_xs.color.a` at paint time.
32pub const TOOLTIP_SHADOW_DENSITY: f32 = 1.0;
33
34pub const COMPOSITE_TOOLTIP_PADDING_HORIZONTAL: f32 = 12.0;
35pub const COMPOSITE_TOOLTIP_PADDING_VERTICAL: f32 = 12.0;
36pub const COMPOSITE_TOOLTIP_CORNER_RADIUS: f32 = 8.0;
37pub const COMPOSITE_TOOLTIP_MAX_WIDTH: f32 = 480.0;
38pub const COMPOSITE_TOOLTIP_MAX_HEIGHT: f32 = 480.0;
39/// 0..=1 multiplier on `shape.shadow_inner_md.color.a` at paint time.
40pub const COMPOSITE_TOOLTIP_SHADOW_DENSITY: f32 = 0.7;
41
42/// Configurable dimensions for [`RecipeTooltipStyle`].
43///
44/// Fields mirror the `TOOLTIP_*` constants defined in this module.
45/// Construct via `Default` (reads the constants) or override individual
46/// fields for a custom look.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct TooltipRecipe {
49    pub padding_horizontal: f32,
50    pub padding_vertical: f32,
51    pub corner_radius: f32,
52    pub max_width: f32,
53    /// 0..=1 multiplier on `shape.shadow_inner_xs.color.a` at paint time.
54    pub shadow_density: f32,
55}
56
57impl Default for TooltipRecipe {
58    fn default() -> Self {
59        Self {
60            padding_horizontal: TOOLTIP_PADDING_HORIZONTAL,
61            padding_vertical: TOOLTIP_PADDING_VERTICAL,
62            corner_radius: TOOLTIP_CORNER_RADIUS,
63            max_width: TOOLTIP_MAX_WIDTH,
64            shadow_density: TOOLTIP_SHADOW_DENSITY,
65        }
66    }
67}
68
69/// Default `TooltipStyle` shipped with Teksilo. Chrome from
70/// `theme.colors.tooltip_bg` + the `xs` shadow tier.
71#[derive(Debug, Default, Clone, Copy)]
72pub struct RecipeTooltipStyle {
73    pub recipe: TooltipRecipe,
74}
75
76impl RecipeTooltipStyle {
77    pub fn new(recipe: TooltipRecipe) -> Self {
78        Self { recipe }
79    }
80}
81
82impl TooltipStyle for RecipeTooltipStyle {
83    fn make_body(&self, cfg: &TooltipStyleConfig, ctx: &mut BuildContext) -> WidgetId {
84        let frame = TooltipFrame {
85            child_id: None,
86            pending_child: Some(PendingChild::Id(cfg.content)),
87            recipe: self.recipe,
88        };
89        ctx.add(frame)
90    }
91}
92
93/// Internal container that paints the tooltip chrome (shadow + dark
94/// background + corner radius) and lays out the content with the
95/// tooltip padding inset. Sizing reads fields from [`TooltipRecipe`].
96struct TooltipFrame {
97    child_id: Option<WidgetId>,
98    pending_child: Option<PendingChild>,
99    recipe: TooltipRecipe,
100}
101
102impl std::fmt::Debug for TooltipFrame {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("TooltipFrame").finish()
105    }
106}
107
108impl Widget for TooltipFrame {
109    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
110        if let Some(pending) = self.pending_child.take() {
111            self.child_id = Some(match pending {
112                PendingChild::Id(id) => id,
113                PendingChild::Deferred(w) => ctx.add_boxed(w),
114            });
115        }
116        self.child_id.into_iter().collect()
117    }
118
119    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
120        let pad_h = self.recipe.padding_horizontal;
121        let pad_v = self.recipe.padding_vertical;
122        let inset_w = pad_h * 2.0;
123        let inset_h = pad_v * 2.0;
124        if let Some(child_id) = self.child_id {
125            // Cap the content width at the recipe's `max_width` even when the
126            // proposal is unbounded (the overlay measurement pass proposes
127            // `None`), so the body wraps at the token instead of running on.
128            let bounded_w = proposal
129                .width
130                .map(|w| w.min(self.recipe.max_width))
131                .unwrap_or(self.recipe.max_width);
132            let inner = SizeProposal {
133                width: Some((bounded_w - inset_w).max(0.0)),
134                height: proposal.height.map(|h| (h - inset_h).max(0.0)),
135            };
136            if let Some(child_size) = ctx.child_size(child_id, inner) {
137                return Size::new(child_size.width + inset_w, child_size.height + inset_h).into();
138            }
139        }
140        proposal.resolve(inset_w, inset_h).into()
141    }
142
143    fn place_children(
144        &self,
145        bounds: Rect,
146        _proposal: SizeProposal,
147        children: &mut [WidgetPlacement],
148        _ctx: &LayoutContext,
149    ) {
150        let pad_h = self.recipe.padding_horizontal;
151        let pad_v = self.recipe.padding_vertical;
152        for child in children.iter_mut() {
153            child.origin = teksilo_canvas::Point::new(bounds.x + pad_h, bounds.y + pad_v);
154            child.size = Size::new(
155                (bounds.width - pad_h * 2.0).max(0.0),
156                (bounds.height - pad_v * 2.0).max(0.0),
157            );
158        }
159    }
160
161    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
162        let radius = CornerRadius::uniform(self.recipe.corner_radius);
163        crate::tooltip::paint_tooltip_shadows(canvas, bounds, radius, ctx);
164        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
165    }
166
167    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
168        // Presentational — the parent TooltipWidget emits Role::Tooltip.
169        builder.set_hidden();
170    }
171
172    fn children(&self) -> Vec<WidgetId> {
173        self.child_id.into_iter().collect()
174    }
175}