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