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