1use 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
30pub 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#[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 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#[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 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 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 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 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 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 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 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 #[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 #[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}