teksilo_core/styles/button_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tier-3 style protocol for `Button`.
5//!
6//! See `docs/styling-system.md`. The trait is object-safe so
7//! `Rc<dyn ButtonStyle>` can be stored in a theme slot or attached
8//! per-call via `Button::style(...)`.
9
10use std::rc::Rc;
11
12use serde::{Deserialize, Serialize};
13use teksilo_canvas::{EdgeInsets, Size};
14
15use crate::build_context::BuildContext;
16use crate::signal::Signal;
17use crate::styles::recipe::{BorderRecipe, FillRecipe, PerStateRecipe, ShadowRecipe, ShapeRecipe};
18use crate::widget_id::WidgetId;
19
20/// Closed enum naming the design-language variants of `Button`. Set
21/// per-call via `Button::variant(ButtonVariant::Outlined)` or
22/// per-app default via a `ComponentDefaults` extension.
23///
24/// Variants are *hints* the active [`ButtonStyle`] may honour or
25/// remap. The IntUI default `RecipeButtonStyle` collapses some pairs:
26/// Tinted/Outlined → Plain look, Link → Ghost, Destructive → Filled
27/// (the warning lives in the dialog title, not the button).
28#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
29pub enum ButtonVariant {
30 Filled,
31 Tinted,
32 Outlined,
33 #[default]
34 Plain,
35 Ghost,
36 Link,
37 Destructive,
38}
39
40/// Inputs handed to a [`ButtonStyle::make_body`] call.
41///
42/// `label` is a pre-built subtree (the active style only arranges
43/// chrome around it; it never builds the label itself). The four
44/// boolean signals carry the live interaction state — the style can
45/// `.zip` / `.map` them to derive a [`crate::styles::WidgetState`] if
46/// it wants to pick between per-state recipes.
47#[derive(Clone, Debug)]
48pub struct ButtonStyleConfig {
49 pub label: WidgetId,
50 pub is_pressed: Signal<bool>,
51 pub is_hovered: Signal<bool>,
52 pub is_focused: Signal<bool>,
53 pub is_disabled: Signal<bool>,
54 pub variant: ButtonVariant,
55}
56
57/// Style protocol for `Button`. The active style owns *all* paint and
58/// layering — it receives the label subtree pre-built and arranges
59/// background, border, focus ring, padding, etc. around it.
60///
61/// `'static` (no `Send + Sync`) because the rest of teksilo-core is
62/// already single-threaded (`Signal` uses `Rc`); enforcing thread
63/// safety here would be inconsistent and pay no benefit.
64pub trait ButtonStyle: 'static {
65 fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId;
66
67 /// Optional per-variant override of the label/icon text role.
68 ///
69 /// The `Button` picks its label color from its built-in
70 /// variant→role mapping (`OnAccent` for accent-filled variants,
71 /// `Primary` otherwise, `Link` for `Link`) *before* the style runs.
72 /// Returning `Some(role)` here lets a design-language style redirect
73 /// it — e.g. Material 3 paints text/outlined buttons in the accent
74 /// color (`TextRole::Accent`) rather than `Primary`.
75 ///
76 /// Default `None` preserves the built-in mapping, so existing styles
77 /// (and the IntUI default) are unaffected. A per-call
78 /// `Button::text_role(...)` still wins over this.
79 fn label_text_role(&self, _variant: ButtonVariant) -> Option<teksilo_tokens::TextRole> {
80 None
81 }
82}
83
84/// Shared handle for a `ButtonStyle` impl. Cheap to clone; one shared
85/// `Rc` is used per theme slot and per-call override.
86pub type SharedButtonStyle = Rc<dyn ButtonStyle>;
87
88/// Tier-2 paint-recipe for one variant of `Button`. The default
89/// [`crate::styles::ButtonStyle`] impl shipped in `teksilo-widgets`
90/// (`RecipeButtonStyle`) holds a `HashMap<ButtonVariant, ButtonRecipe>`
91/// and looks up the recipe at paint time.
92///
93/// Custom `ButtonStyle` impls can ignore recipes entirely (paint a
94/// glassmorphism gradient, run their own canvas code, etc.); the
95/// recipe layer is the *default* surface, not an obligation.
96#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
97pub struct ButtonRecipe {
98 pub shape: ShapeRecipe,
99 pub fill: PerStateRecipe<FillRecipe>,
100 pub border: PerStateRecipe<BorderRecipe>,
101 pub shadow: PerStateRecipe<Option<ShadowRecipe>>,
102 pub padding: EdgeInsets,
103 pub min_size: Size,
104}