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_canvas::Size;
18use teksilo_core::build_context::BuildContext;
19use teksilo_core::color_prop::ColorProp;
20use teksilo_core::signal::Signal;
21use teksilo_core::styles::{IconButtonSize, IconButtonStyle, IconButtonStyleConfig};
22use teksilo_core::widget_id::WidgetId;
23use teksilo_tokens::CornerRadius;
24use teksilo_tokens::{BorderRole, InputTokens, SurfaceRole, TargetRole};
25
26use crate::common::conformance_box::conformance_box;
27use crate::primitives::{Center, FixedSize, RectWidget, ZStack};
28use teksilo_core::styles::density::dp;
29
30// IntUI design tokens for IconButton. The recipe owns its own dimensions.
31// Sizes follow the IntelliJ IntUI scale (Compact < Default < Toolbar
32// < Large < Hero).
33// WCAG 2.5.8 Target Size (Minimum) requires >= 24x24 CSS px. Compact was 22px
34// (below the floor); raised to 24 to match Default. Still the densest size.
35pub const ICON_BUTTON_SIZE_COMPACT: f32 = 24.0;
36pub const ICON_BUTTON_SIZE_DEFAULT: f32 = 24.0;
37pub const ICON_BUTTON_SIZE_TOOLBAR: f32 = 30.0;
38pub const ICON_BUTTON_SIZE_LARGE: f32 = 40.0;
39pub const ICON_BUTTON_SIZE_HERO: f32 = 50.0;
40pub const ICON_BUTTON_ICON_SIZE: f32 = 16.0;
41pub const ICON_BUTTON_ICON_SIZE_TOOLBAR: f32 = 18.0;
42pub const ICON_BUTTON_ICON_SIZE_LARGE: f32 = 24.0;
43pub const ICON_BUTTON_ICON_SIZE_HERO: f32 = 32.0;
44pub const ICON_BUTTON_CORNER_RADIUS: f32 = 8.0;
45
46/// Dimension recipe for [`RecipeIconButtonStyle`]. All fields correspond to
47/// the `ICON_BUTTON_*` module constants and can be overridden per call-site
48/// or theme-wide by constructing a custom `IconButtonRecipe`.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct IconButtonRecipe {
51    pub size_compact: f32,
52    pub size_default: f32,
53    pub size_toolbar: f32,
54    pub size_large: f32,
55    pub size_hero: f32,
56    pub icon_size: f32,
57    pub icon_size_toolbar: f32,
58    pub icon_size_large: f32,
59    pub icon_size_hero: f32,
60    pub corner_radius: f32,
61}
62
63impl IconButtonRecipe {
64    /// This recipe's dimensions resolved against a density's [`InputTokens`].
65    ///
66    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
67    /// ladder — so the shipped values below are the Compact column by
68    /// construction and cannot drift from it.
69    pub fn for_tokens(tokens: &InputTokens) -> Self {
70        Self {
71            size_compact: dp(ICON_BUTTON_SIZE_COMPACT, TargetRole::Target, tokens),
72            size_default: dp(ICON_BUTTON_SIZE_DEFAULT, TargetRole::Target, tokens),
73            size_toolbar: dp(ICON_BUTTON_SIZE_TOOLBAR, TargetRole::Target, tokens),
74            size_large: dp(ICON_BUTTON_SIZE_LARGE, TargetRole::Target, tokens),
75            size_hero: dp(ICON_BUTTON_SIZE_HERO, TargetRole::Target, tokens),
76            icon_size: ICON_BUTTON_ICON_SIZE,
77            icon_size_toolbar: ICON_BUTTON_ICON_SIZE_TOOLBAR,
78            icon_size_large: ICON_BUTTON_ICON_SIZE_LARGE,
79            icon_size_hero: ICON_BUTTON_ICON_SIZE_HERO,
80            corner_radius: ICON_BUTTON_CORNER_RADIUS,
81        }
82    }
83}
84
85impl Default for IconButtonRecipe {
86    fn default() -> Self {
87        Self::for_tokens(&InputTokens::default())
88    }
89}
90
91/// Default `IconButtonStyle` shipped with Teksilo. Surface roles come
92/// from the active theme's role resolver (so theme-swap repaints for
93/// free).
94#[derive(Debug, Default, Clone, Copy)]
95pub struct RecipeIconButtonStyle {
96    pub recipe: IconButtonRecipe,
97}
98
99impl RecipeIconButtonStyle {
100    pub fn new(recipe: IconButtonRecipe) -> Self {
101        Self { recipe }
102    }
103
104    /// This style with every dimension resolved against a density's
105    /// [`InputTokens`], as `RecipeIconButtonStyle::for_tokens(&ctx.theme().input)` at
106    /// the widget's own build site.
107    ///
108    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
109    /// tree gets exactly the values this module documents.
110    pub fn for_tokens(tokens: &InputTokens) -> Self {
111        Self {
112            recipe: IconButtonRecipe::for_tokens(tokens),
113        }
114    }
115}
116
117impl IconButtonStyle for RecipeIconButtonStyle {
118    fn make_body(&self, cfg: &IconButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
119        let focus_ring_width = ctx.theme().shape.focus_ring_width;
120        let corner_radius = self.recipe.corner_radius;
121        let button_dim = resolve_size(cfg.size, &self.recipe);
122
123        // Background — `Selected` flavor when `is_on == true`, plain
124        // flat treatment otherwise. Pressed always wins (the press
125        // flash overrides Selected).
126        let bg_role: ColorProp = match cfg.is_on.clone() {
127            Some(on) => {
128                bistate_bg_role(&cfg.is_pressed, &cfg.is_hovered, &cfg.is_disabled, &on).into()
129            }
130            None => plain_bg_role(&cfg.is_pressed, &cfg.is_hovered, &cfg.is_disabled).into(),
131        };
132
133        // Border — focus uses accent border at `focus_ring_width`,
134        // every other state is transparent + 0 dp. The button's own
135        // border IS the focus indicator (Int UI convention).
136        let border_role: ColorProp = cfg
137            .is_focused
138            .map(|focused| {
139                if *focused {
140                    BorderRole::Focused
141                } else {
142                    BorderRole::Transparent
143                }
144            })
145            .into();
146        let border_width = cfg
147            .is_focused
148            .map(move |focused| if *focused { focus_ring_width } else { 0.0 });
149
150        let bg_id = ctx.add(
151            RectWidget::new()
152                .background(bg_role)
153                .border_color(border_role)
154                .border_width(border_width)
155                .corner_radius(CornerRadius::uniform(corner_radius)),
156        );
157
158        let centered_id = ctx.add(Center::new().child(cfg.icon));
159        let zstack_id = ctx.add(ZStack::new().child(bg_id).child(centered_id));
160        let painted = ctx.add(
161            FixedSize::new()
162                .width(button_dim)
163                .height(button_dim)
164                .child(zstack_id),
165        );
166        // The conformance box (see `common::conformance_box` for the bargain).
167        // The shipped recipe routes every rung through `dp(.., Target, ..)`
168        // and is the identity — no box is built. What earns one is a preset
169        // pinning a rung below the floor, as macOS does at 22 dp (18 at
170        // `Compact`), and a row of icon buttons is exactly the flush-neighbour
171        // geometry where no hit mechanism could stand in for it.
172        let (root, _) = conformance_box(ctx, painted, Size::new(button_dim, button_dim));
173        root
174    }
175}
176
177fn plain_bg_role(
178    is_pressed: &Signal<bool>,
179    is_hovered: &Signal<bool>,
180    is_disabled: &Signal<bool>,
181) -> Signal<SurfaceRole> {
182    is_pressed
183        .zip3(is_hovered, is_disabled)
184        .map(|(pressed, hovered, disabled)| {
185            // Int UI icon buttons DO have a distinct pressed (mouse-down)
186            // state — unlike regular buttons. The shared helper now feeds
187            // Pressed on pointer-down, so this renders on mouse-down too,
188            // not just keyboard activation.
189            if *disabled {
190                SurfaceRole::Transparent
191            } else if *pressed {
192                SurfaceRole::Pressed
193            } else if *hovered {
194                SurfaceRole::Hover
195            } else {
196                SurfaceRole::Transparent
197            }
198        })
199}
200
201fn bistate_bg_role(
202    is_pressed: &Signal<bool>,
203    is_hovered: &Signal<bool>,
204    is_disabled: &Signal<bool>,
205    is_on: &Signal<bool>,
206) -> Signal<SurfaceRole> {
207    let combined = is_pressed.zip3(is_hovered, is_disabled);
208    combined
209        .zip(is_on)
210        .map(|((pressed, hovered, disabled), on)| {
211            if *disabled {
212                SurfaceRole::Transparent
213            } else if *on {
214                if *pressed {
215                    SurfaceRole::Pressed
216                } else {
217                    SurfaceRole::Selected
218                }
219            } else if *pressed {
220                SurfaceRole::Pressed
221            } else if *hovered {
222                SurfaceRole::Hover
223            } else {
224                SurfaceRole::Transparent
225            }
226        })
227}
228
229fn resolve_size(size: IconButtonSize, recipe: &IconButtonRecipe) -> f32 {
230    match size {
231        IconButtonSize::Compact => recipe.size_compact,
232        IconButtonSize::Default => recipe.size_default,
233        IconButtonSize::Toolbar => recipe.size_toolbar,
234        IconButtonSize::Large => recipe.size_large,
235        IconButtonSize::Hero => recipe.size_hero,
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use teksilo_canvas::SizeProposal;
242    use teksilo_core::widget_id::WidgetId;
243    use teksilo_core::widget_tree::WidgetTree;
244
245    use super::*;
246    use crate::icon_button::IconButton;
247    use crate::primitives::IconWidget;
248
249    /// Every `FixedSize` under `root`, as `(width, height)` sorted by width.
250    fn fixed_size_squares(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
251        let mut out = Vec::new();
252        let mut stack = vec![root];
253        while let Some(id) = stack.pop() {
254            if tree
255                .widget_type_name(id)
256                .is_some_and(|t| t.rsplit("::").next() == Some("FixedSize"))
257            {
258                let b = tree.bounds(id);
259                out.push((b.width, b.height));
260            }
261            for c in tree.children(id) {
262                stack.push(c);
263            }
264        }
265        out.sort_by(|a, b| a.0.total_cmp(&b.0));
266        out
267    }
268
269    fn mounted(button: IconButton) -> (WidgetTree, WidgetId) {
270        // Inside a stack, not bare at the root: a root child is stretched to
271        // the window proposal, which would hide the squares under measurement.
272        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
273        let id = tree.add(crate::primitives::HStack::new().child(button));
274        tree.layout(SizeProposal::exact(200.0, 200.0));
275        (tree, id)
276    }
277
278    /// The identity contract at this call site: the shipped recipe routes
279    /// every rung through `dp(.., Target, ..)`, so the conformance box is not
280    /// built at all — the painted square is the only `FixedSize` in the
281    /// button's chrome. Reddens if `make_body` goes back to wrapping
282    /// unconditionally (a second `FixedSize` appears).
283    #[test]
284    fn an_int_ui_icon_button_gains_no_conformance_wrapper() {
285        let (tree, id) = mounted(IconButton::new(IconWidget::checkmark(12.0)));
286        assert_eq!(
287            fixed_size_squares(&tree, id),
288            vec![(ICON_BUTTON_SIZE_DEFAULT, ICON_BUTTON_SIZE_DEFAULT)],
289            "one FixedSize — the painted square — and no box above it",
290        );
291    }
292
293    /// And the box contract at this call site: a recipe pinning a rung under
294    /// the floor (the macOS shape, reproduced here without the preset crate)
295    /// gets exactly one extra `FixedSize` at the floor around the untouched
296    /// painted square. Reddens if the `conformance_box` call is dropped from
297    /// `make_body` or its floor read is neutered.
298    #[test]
299    fn a_sub_floor_recipe_earns_exactly_the_conformance_box() {
300        let sub_floor = RecipeIconButtonStyle::new(IconButtonRecipe {
301            size_default: 18.0,
302            ..IconButtonRecipe::default()
303        });
304        let (tree, id) = mounted(IconButton::new(IconWidget::checkmark(12.0)).style(sub_floor));
305        let floor = teksilo_core::presets::intui::light()
306            .input
307            .min_target_conformance;
308        assert_eq!(
309            fixed_size_squares(&tree, id),
310            vec![(18.0, 18.0), (floor, floor)],
311            "the painted square keeps the recipe's number inside a box at the floor",
312        );
313    }
314}