Skip to main content

teksilo_widgets/styles/
recipe_icon_button_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `IconButtonStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeIconButtonStyle` ships the IntUI flat-square treatment:
7//! transparent at rest, surface tint on hover / pressed, accent border
8//! on focus, optional `Selected` surface tint when a `toggled` signal
9//! is bound.
10//!
11//! Apps that want a different treatment (Material 3 elevated icon
12//! button, glassmorphism, brutalist square frame) write their own
13//! `impl IconButtonStyle` block and install it per-call
14//! (`IconButton::style(...)`) or theme-wide
15//! (`theme.style_slots.icon_button = Some(Rc::new(MyIconButton))`).
16
17use teksilo_core::build_context::BuildContext;
18use teksilo_core::color_prop::ColorProp;
19use teksilo_core::signal::Signal;
20use teksilo_core::styles::{IconButtonSize, IconButtonStyle, IconButtonStyleConfig};
21use teksilo_core::widget_id::WidgetId;
22use teksilo_tokens::CornerRadius;
23use teksilo_tokens::{BorderRole, SurfaceRole};
24
25use crate::primitives::{Center, FixedSize, RectWidget, ZStack};
26
27// IntUI design tokens for IconButton. The recipe owns its own dimensions.
28// Sizes follow the IntelliJ IntUI scale (Compact < Default < Toolbar
29// < Large < Hero).
30// WCAG 2.5.8 Target Size (Minimum) requires >= 24x24 CSS px. Compact was 22px
31// (below the floor); raised to 24 to match Default. Still the densest size.
32pub const ICON_BUTTON_SIZE_COMPACT: f32 = 24.0;
33pub const ICON_BUTTON_SIZE_DEFAULT: f32 = 24.0;
34pub const ICON_BUTTON_SIZE_TOOLBAR: f32 = 30.0;
35pub const ICON_BUTTON_SIZE_LARGE: f32 = 40.0;
36pub const ICON_BUTTON_SIZE_HERO: f32 = 50.0;
37pub const ICON_BUTTON_ICON_SIZE: f32 = 16.0;
38pub const ICON_BUTTON_ICON_SIZE_TOOLBAR: f32 = 18.0;
39pub const ICON_BUTTON_ICON_SIZE_LARGE: f32 = 24.0;
40pub const ICON_BUTTON_ICON_SIZE_HERO: f32 = 32.0;
41pub const ICON_BUTTON_CORNER_RADIUS: f32 = 8.0;
42
43/// Dimension recipe for [`RecipeIconButtonStyle`]. All fields correspond to
44/// the `ICON_BUTTON_*` module constants and can be overridden per call-site
45/// or theme-wide by constructing a custom `IconButtonRecipe`.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct IconButtonRecipe {
48    pub size_compact: f32,
49    pub size_default: f32,
50    pub size_toolbar: f32,
51    pub size_large: f32,
52    pub size_hero: f32,
53    pub icon_size: f32,
54    pub icon_size_toolbar: f32,
55    pub icon_size_large: f32,
56    pub icon_size_hero: f32,
57    pub corner_radius: f32,
58}
59
60impl Default for IconButtonRecipe {
61    fn default() -> Self {
62        Self {
63            size_compact: ICON_BUTTON_SIZE_COMPACT,
64            size_default: ICON_BUTTON_SIZE_DEFAULT,
65            size_toolbar: ICON_BUTTON_SIZE_TOOLBAR,
66            size_large: ICON_BUTTON_SIZE_LARGE,
67            size_hero: ICON_BUTTON_SIZE_HERO,
68            icon_size: ICON_BUTTON_ICON_SIZE,
69            icon_size_toolbar: ICON_BUTTON_ICON_SIZE_TOOLBAR,
70            icon_size_large: ICON_BUTTON_ICON_SIZE_LARGE,
71            icon_size_hero: ICON_BUTTON_ICON_SIZE_HERO,
72            corner_radius: ICON_BUTTON_CORNER_RADIUS,
73        }
74    }
75}
76
77/// Default `IconButtonStyle` shipped with Teksilo. Surface roles come
78/// from the active theme's role resolver (so theme-swap repaints for
79/// free).
80#[derive(Debug, Default, Clone, Copy)]
81pub struct RecipeIconButtonStyle {
82    pub recipe: IconButtonRecipe,
83}
84
85impl RecipeIconButtonStyle {
86    pub fn new(recipe: IconButtonRecipe) -> Self {
87        Self { recipe }
88    }
89}
90
91impl IconButtonStyle for RecipeIconButtonStyle {
92    fn make_body(&self, cfg: &IconButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
93        let focus_ring_width = ctx.theme().shape.focus_ring_width;
94        let corner_radius = self.recipe.corner_radius;
95        let button_dim = resolve_size(cfg.size, &self.recipe);
96
97        // Background — `Selected` flavor when `is_on == true`, plain
98        // flat treatment otherwise. Pressed always wins (the press
99        // flash overrides Selected).
100        let bg_role: ColorProp = match cfg.is_on.clone() {
101            Some(on) => {
102                bistate_bg_role(&cfg.is_pressed, &cfg.is_hovered, &cfg.is_disabled, &on).into()
103            }
104            None => plain_bg_role(&cfg.is_pressed, &cfg.is_hovered, &cfg.is_disabled).into(),
105        };
106
107        // Border — focus uses accent border at `focus_ring_width`,
108        // every other state is transparent + 0 dp. The button's own
109        // border IS the focus indicator (Int UI convention).
110        let border_role: ColorProp = cfg
111            .is_focused
112            .map(|focused| {
113                if *focused {
114                    BorderRole::Focused
115                } else {
116                    BorderRole::Transparent
117                }
118            })
119            .into();
120        let border_width = cfg
121            .is_focused
122            .map(move |focused| if *focused { focus_ring_width } else { 0.0 });
123
124        let bg_id = ctx.add(
125            RectWidget::new()
126                .background(bg_role)
127                .border_color(border_role)
128                .border_width(border_width)
129                .corner_radius(CornerRadius::uniform(corner_radius)),
130        );
131
132        let centered_id = ctx.add(Center::new().child_id(cfg.icon));
133        let zstack_id = ctx.add(ZStack::new().add_child(bg_id).add_child(centered_id));
134        ctx.add(
135            FixedSize::new()
136                .width(button_dim)
137                .height(button_dim)
138                .child_id(zstack_id),
139        )
140    }
141}
142
143fn plain_bg_role(
144    is_pressed: &Signal<bool>,
145    is_hovered: &Signal<bool>,
146    is_disabled: &Signal<bool>,
147) -> Signal<SurfaceRole> {
148    is_pressed
149        .zip3(is_hovered, is_disabled)
150        .map(|(pressed, hovered, disabled)| {
151            // Int UI icon buttons DO have a distinct pressed (mouse-down)
152            // state — unlike regular buttons. The shared helper now feeds
153            // Pressed on pointer-down, so this renders on mouse-down too,
154            // not just keyboard activation.
155            if *disabled {
156                SurfaceRole::Transparent
157            } else if *pressed {
158                SurfaceRole::Pressed
159            } else if *hovered {
160                SurfaceRole::Hover
161            } else {
162                SurfaceRole::Transparent
163            }
164        })
165}
166
167fn bistate_bg_role(
168    is_pressed: &Signal<bool>,
169    is_hovered: &Signal<bool>,
170    is_disabled: &Signal<bool>,
171    is_on: &Signal<bool>,
172) -> Signal<SurfaceRole> {
173    let combined = is_pressed.zip3(is_hovered, is_disabled);
174    combined
175        .zip(is_on)
176        .map(|((pressed, hovered, disabled), on)| {
177            if *disabled {
178                SurfaceRole::Transparent
179            } else if *on {
180                if *pressed {
181                    SurfaceRole::Pressed
182                } else {
183                    SurfaceRole::Selected
184                }
185            } else if *pressed {
186                SurfaceRole::Pressed
187            } else if *hovered {
188                SurfaceRole::Hover
189            } else {
190                SurfaceRole::Transparent
191            }
192        })
193}
194
195fn resolve_size(size: IconButtonSize, recipe: &IconButtonRecipe) -> f32 {
196    match size {
197        IconButtonSize::Compact => recipe.size_compact,
198        IconButtonSize::Default => recipe.size_default,
199        IconButtonSize::Toolbar => recipe.size_toolbar,
200        IconButtonSize::Large => recipe.size_large,
201        IconButtonSize::Hero => recipe.size_hero,
202    }
203}