Skip to main content

teksilo_widgets/styles/
recipe_button_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ButtonStyle` impl driven by `ButtonRecipe` data per
5//! variant. Holds a `HashMap<ButtonVariant, ButtonRecipe>` and looks
6//! up the recipe at paint time.
7//!
8//! Apps retheme buttons by constructing a fresh map of recipes (e.g.
9//! a Material-3 ButtonRecipe set with rounded-edge filled buttons)
10//! and installing it per-call (`Button::style(MyStyle)`) or
11//! theme-wide (`theme.style_slots.button = Some(Rc::new(MyStyle))`).
12//! The Button widget never sees the recipe; it only knows about the
13//! trait.
14
15use std::collections::HashMap;
16use std::rc::Rc;
17
18use teksilo_canvas::{EdgeInsets, Size};
19use teksilo_core::build_context::BuildContext;
20use teksilo_core::color_prop::ColorProp;
21use teksilo_core::paint_prop::PaintProp;
22use teksilo_core::signal::Signal;
23use teksilo_core::styles::density::{density_min_size, spacing};
24use teksilo_core::styles::{
25    BorderRecipe, ButtonRecipe, ButtonStyle, ButtonStyleConfig, ButtonVariant, FillRecipe,
26    PerStateRecipe, RecipeColor, ShapeRecipe, WidgetState,
27};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{
30    BorderRole, Color, CornerRadius, InputTokens, SurfaceRole, TargetAxes, TextRole,
31};
32
33use super::window_resolution_colors;
34use crate::primitives::{MinSize, Padding, RectWidget, ZStack};
35
36// IntUI design tokens for Button. The recipe and its consumers own
37// these constants. Most button dimensions live inside per-variant
38// `ButtonRecipe`s (padding, min size, etc.); the constants below
39// cover globals (icon size, the icon ↔ label gap) that aren't
40// variant-specific.
41pub const BUTTON_HEIGHT: f32 = 24.0;
42pub const BUTTON_MIN_WIDTH: f32 = 72.0;
43pub const BUTTON_PADDING_HORIZONTAL: f32 = 14.0;
44pub const BUTTON_PADDING_VERTICAL: f32 = 0.0;
45pub const BUTTON_CORNER_RADIUS: f32 = 4.0;
46pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
47pub const BUTTON_ICON_SIZE: f32 = 16.0;
48pub const BUTTON_ICON_LABEL_GAP: f32 = 4.0;
49
50/// Default `ButtonStyle` shipped with Teksilo.
51///
52/// Holds a `HashMap<ButtonVariant, ButtonRecipe>`. Variants that
53/// aren't explicitly populated fall back to `ButtonVariant::Plain`
54/// (the Int UI house default).
55///
56/// `label_roles` optionally redirects the label/icon color per variant
57/// (see [`ButtonStyle::label_text_role`]). It is empty in the IntUI
58/// default — the `Button`'s built-in mapping is kept — but
59/// design-language presets (Material 3) populate it so e.g. text and
60/// outlined buttons read in the accent color.
61#[derive(Debug, Clone)]
62pub struct RecipeButtonStyle {
63    pub recipes: HashMap<ButtonVariant, ButtonRecipe>,
64    pub label_roles: HashMap<ButtonVariant, TextRole>,
65}
66
67impl RecipeButtonStyle {
68    /// IntUI's per-variant ButtonRecipe set at the Compact density.
69    pub fn intui() -> Self {
70        Self::for_tokens(&InputTokens::default())
71    }
72
73    /// IntUI's per-variant `ButtonRecipe` set with every dimension resolved
74    /// against a density's [`InputTokens`], as
75    /// `RecipeButtonStyle::for_tokens(&ctx.theme().input)` at `Button`'s own
76    /// build site.
77    ///
78    /// [`Default`] and [`intui`](Self::intui) are the `TargetDensity::Compact`
79    /// projection, so a Compact tree gets exactly the values this module
80    /// documents.
81    pub fn for_tokens(tokens: &InputTokens) -> Self {
82        let mut recipes = HashMap::new();
83        recipes.insert(ButtonVariant::Filled, intui_filled_recipe(tokens));
84        // IntUI maps Destructive → Filled (the warning lives in the
85        // dialog title/body, not the button).
86        recipes.insert(ButtonVariant::Destructive, intui_filled_recipe(tokens));
87        recipes.insert(ButtonVariant::Plain, intui_plain_recipe(tokens));
88        // IntUI maps Tinted/Outlined → Plain.
89        recipes.insert(ButtonVariant::Tinted, intui_plain_recipe(tokens));
90        recipes.insert(ButtonVariant::Outlined, intui_plain_recipe(tokens));
91        recipes.insert(ButtonVariant::Ghost, intui_ghost_recipe(tokens));
92        // IntUI maps Link → Ghost.
93        recipes.insert(ButtonVariant::Link, intui_ghost_recipe(tokens));
94        // IntUI keeps the Button's built-in label-role mapping.
95        Self {
96            recipes,
97            label_roles: HashMap::new(),
98        }
99    }
100}
101
102impl Default for RecipeButtonStyle {
103    fn default() -> Self {
104        Self::intui()
105    }
106}
107
108impl ButtonStyle for RecipeButtonStyle {
109    fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
110        // Recipe lookup: requested variant > Plain fallback.
111        let recipe = self
112            .recipes
113            .get(&cfg.variant)
114            .or_else(|| self.recipes.get(&ButtonVariant::Plain))
115            .expect("RecipeButtonStyle must define at least Plain")
116            .clone();
117
118        let state_signal = derive_state_signal(cfg);
119
120        // Fill — convert (state, recipe) → reactive Color signal.
121        let bg_color = bind_fill(&state_signal, &recipe.fill, ctx);
122        let (border_color, border_width) = bind_border(&state_signal, &recipe.border, ctx);
123
124        let radius = match recipe.shape {
125            ShapeRecipe::Rect { corner_radius } => corner_radius,
126            ShapeRecipe::Pill | ShapeRecipe::Circle => CornerRadius::uniform(9999.0),
127        };
128
129        let padding_id = ctx.add(
130            Padding::new(
131                recipe.padding.top,
132                recipe.padding.trailing,
133                recipe.padding.bottom,
134                recipe.padding.leading,
135            )
136            .child(cfg.label),
137        );
138
139        // Border geometry (position / per-side widths) is state-
140        // independent — read it from the idle border recipe.
141        let border_position = recipe.border.idle.position;
142        let border_sides = recipe.border.idle.sides;
143        let mut rect = RectWidget::new()
144            .background(bg_color)
145            .border_color(border_color)
146            .border_width(border_width)
147            .corner_radius(radius)
148            .border_position(border_position);
149        if border_sides.is_some() {
150            rect = rect.border_sides(border_sides);
151        }
152        let rect_id = ctx.add(rect);
153
154        let zstack_id = ctx.add(ZStack::new().child(rect_id).child(padding_id));
155
156        ctx.add(MinSize::new(recipe.min_size.width, recipe.min_size.height).child(zstack_id))
157    }
158
159    fn label_text_role(&self, variant: ButtonVariant) -> Option<TextRole> {
160        self.label_roles.get(&variant).copied()
161    }
162}
163
164/// Derive a `Signal<WidgetState>` from the four booleans in
165/// `ButtonStyleConfig`. Priority chain: Disabled > Pressed > Focused >
166/// Hovered > Idle.
167fn derive_state_signal(cfg: &ButtonStyleConfig) -> Signal<WidgetState> {
168    cfg.is_disabled
169        .zip3(&cfg.is_pressed, &cfg.is_hovered)
170        .zip(&cfg.is_focused)
171        .map(|((disabled, pressed, hovered), focused)| {
172            if *disabled {
173                WidgetState::Disabled
174            } else if *pressed {
175                WidgetState::Pressed
176            } else if *focused {
177                WidgetState::Focused
178            } else if *hovered {
179                WidgetState::Hovered
180            } else {
181                WidgetState::Idle
182            }
183        })
184}
185
186fn bind_fill(
187    state: &Signal<WidgetState>,
188    recipe: &PerStateRecipe<FillRecipe>,
189    ctx: &BuildContext,
190) -> PaintProp {
191    // Gradient fills can't fold into a single reactive flat color. If any
192    // state carries a gradient, paint the idle fill as a theme-reactive
193    // gradient `PaintProp` (per-state gradient *switching* is unsupported —
194    // use solid/state-layer for interactive feedback). Otherwise produce
195    // the reactive flat color, which covers Solid / StateLayer / None.
196    let is_gradient = |f: &FillRecipe| {
197        matches!(
198            f,
199            FillRecipe::LinearGradient { .. } | FillRecipe::RadialGradient { .. }
200        )
201    };
202    let any_gradient = is_gradient(&recipe.idle)
203        || recipe.hover.as_ref().is_some_and(is_gradient)
204        || recipe.pressed.as_ref().is_some_and(is_gradient)
205        || recipe.focused.as_ref().is_some_and(is_gradient)
206        || recipe.disabled.as_ref().is_some_and(is_gradient);
207    if any_gradient {
208        // Gradient fills bake to a non-reactive PaintProp. Resolve against the
209        // paint state at build so a window that *starts* inactive, or under
210        // high contrast, already paints the projected palette; live flips of
211        // a gradient fill are a known gap (no IntUI / Material accent button
212        // uses a gradient fill).
213        let colors = ctx.theme().paint_colors(ctx.paint_state());
214        return PaintProp::from_fill(&recipe.idle, &colors);
215    }
216    let recipe = recipe.clone();
217    let colors_sig = window_resolution_colors(ctx);
218    let sig: Signal<Color> = state
219        .zip(&colors_sig)
220        .map(move |(s, colors)| resolve_fill_to_color(recipe.resolve(*s), colors));
221    PaintProp::Solid(ColorProp::Bound(sig))
222}
223
224fn bind_border(
225    state: &Signal<WidgetState>,
226    recipe: &PerStateRecipe<BorderRecipe>,
227    ctx: &BuildContext,
228) -> (ColorProp, Signal<f32>) {
229    let recipe_for_color = recipe.clone();
230    let recipe_for_width = recipe.clone();
231    let colors_sig = window_resolution_colors(ctx);
232    let color: ColorProp = state
233        .zip(&colors_sig)
234        .map(move |(s, colors)| recipe_for_color.resolve(*s).color.resolve_with(colors))
235        .into();
236    let width = state.map(move |s| recipe_for_width.resolve(*s).width);
237    (color, width)
238}
239
240fn resolve_fill_to_color(fill: &FillRecipe, colors: &teksilo_tokens::ColorTokens) -> Color {
241    // Solid / StateLayer / None resolve to a flat color. Gradient
242    // variants have no flat form here and fall back to transparent —
243    // they are painted via the SDF gradient pipeline once a `PaintProp`
244    // carries them (see `resolve_fill_to_paint`).
245    fill.resolve_flat(colors).unwrap_or(Color::TRANSPARENT)
246}
247
248// ─── IntUI per-variant recipe constructors ──────────────────────────
249
250fn intui_filled_recipe(tokens: &InputTokens) -> ButtonRecipe {
251    ButtonRecipe {
252        shape: ShapeRecipe::rounded(4.0),
253        fill: PerStateRecipe {
254            idle: FillRecipe::solid(SurfaceRole::Accent),
255            hover: Some(FillRecipe::solid(SurfaceRole::AccentHover)),
256            // Int UI has no distinct pressed state — pressed falls back
257            // to hover (pressed → hover → idle). The button now provides
258            // the Pressed state on pointer-down (see
259            // `build_interaction_handlers`); whether to render it is a
260            // per-theme recipe decision, and IntUI declines. Other
261            // theme recipes (Material 3, macOS, …) set a distinct
262            // pressed fill here.
263            pressed: None,
264            focused: None,
265            disabled: Some(FillRecipe::solid(SurfaceRole::AccentDisabled)),
266        },
267        border: PerStateRecipe {
268            idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
269            hover: None,
270            pressed: None,
271            focused: Some(BorderRecipe::solid(
272                2.0,
273                RecipeColor::Border(BorderRole::Focused),
274            )),
275            disabled: None,
276        },
277        shadow: PerStateRecipe::uniform(None),
278        padding: EdgeInsets::symmetric(
279            spacing(BUTTON_PADDING_HORIZONTAL, tokens),
280            spacing(BUTTON_PADDING_VERTICAL, tokens),
281        ),
282        min_size: density_min_size(
283            Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT),
284            TargetAxes::BOTH,
285            tokens,
286        ),
287    }
288}
289
290fn intui_plain_recipe(tokens: &InputTokens) -> ButtonRecipe {
291    ButtonRecipe {
292        shape: ShapeRecipe::rounded(4.0),
293        fill: PerStateRecipe {
294            idle: FillRecipe::solid(SurfaceRole::Main),
295            hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
296            // Int UI has no distinct pressed state — falls back to hover.
297            pressed: None,
298            focused: None,
299            disabled: None,
300        },
301        border: PerStateRecipe {
302            idle: BorderRecipe::solid(1.0, RecipeColor::Border(BorderRole::Default)),
303            hover: Some(BorderRecipe::solid(
304                1.0,
305                RecipeColor::Border(BorderRole::Strong),
306            )),
307            // No distinct pressed border — falls back to the hover border.
308            pressed: None,
309            focused: Some(BorderRecipe::solid(
310                2.0,
311                RecipeColor::Border(BorderRole::Focused),
312            )),
313            disabled: None,
314        },
315        shadow: PerStateRecipe::uniform(None),
316        padding: EdgeInsets::symmetric(
317            spacing(BUTTON_PADDING_HORIZONTAL, tokens),
318            spacing(BUTTON_PADDING_VERTICAL, tokens),
319        ),
320        min_size: density_min_size(
321            Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT),
322            TargetAxes::BOTH,
323            tokens,
324        ),
325    }
326}
327
328fn intui_ghost_recipe(tokens: &InputTokens) -> ButtonRecipe {
329    ButtonRecipe {
330        shape: ShapeRecipe::rounded(4.0),
331        fill: PerStateRecipe {
332            idle: FillRecipe::solid(SurfaceRole::Transparent),
333            hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
334            // Int UI has no distinct pressed state — falls back to hover.
335            pressed: None,
336            focused: None,
337            disabled: None,
338        },
339        border: PerStateRecipe {
340            idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
341            hover: None,
342            pressed: None,
343            focused: Some(BorderRecipe::solid(
344                2.0,
345                RecipeColor::Border(BorderRole::Focused),
346            )),
347            disabled: None,
348        },
349        shadow: PerStateRecipe::uniform(None),
350        padding: EdgeInsets::symmetric(
351            spacing(BUTTON_PADDING_HORIZONTAL, tokens),
352            spacing(BUTTON_PADDING_VERTICAL, tokens),
353        ),
354        min_size: density_min_size(
355            Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT),
356            TargetAxes::BOTH,
357            tokens,
358        ),
359    }
360}
361
362// `Rc::new(RecipeButtonStyle::for_tokens(&ctx.theme().input))` is a common
363// allocation point for callers that need a `SharedButtonStyle`; expose a tiny
364// helper so they don't have to repeat the type name.
365pub fn shared_intui(tokens: &InputTokens) -> Rc<dyn ButtonStyle> {
366    Rc::new(RecipeButtonStyle::for_tokens(tokens))
367}