1use 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::density::{density_min_size, spacing};
24use teksilo_core::styles::{
25 BorderRecipe, ButtonRecipe, ButtonStyle, ButtonStyleConfig, ButtonVariant, FillRecipe,
26 PerStateRecipe, RecipeColor, ShapeRecipe, WidgetState,
27};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{
30 BorderRole, Color, CornerRadius, InputTokens, SurfaceRole, TargetAxes, TextRole,
31};
32
33use super::window_resolution_colors;
34use crate::primitives::{MinSize, Padding, RectWidget, ZStack};
35
36pub const BUTTON_HEIGHT: f32 = 24.0;
42pub const BUTTON_MIN_WIDTH: f32 = 72.0;
43pub const BUTTON_PADDING_HORIZONTAL: f32 = 14.0;
44pub const BUTTON_PADDING_VERTICAL: f32 = 0.0;
45pub const BUTTON_CORNER_RADIUS: f32 = 4.0;
46pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
47pub const BUTTON_ICON_SIZE: f32 = 16.0;
48pub const BUTTON_ICON_LABEL_GAP: f32 = 4.0;
49
50#[derive(Debug, Clone)]
62pub struct RecipeButtonStyle {
63 pub recipes: HashMap<ButtonVariant, ButtonRecipe>,
64 pub label_roles: HashMap<ButtonVariant, TextRole>,
65}
66
67impl RecipeButtonStyle {
68 pub fn intui() -> Self {
70 Self::for_tokens(&InputTokens::default())
71 }
72
73 pub fn for_tokens(tokens: &InputTokens) -> Self {
82 let mut recipes = HashMap::new();
83 recipes.insert(ButtonVariant::Filled, intui_filled_recipe(tokens));
84 recipes.insert(ButtonVariant::Destructive, intui_filled_recipe(tokens));
87 recipes.insert(ButtonVariant::Plain, intui_plain_recipe(tokens));
88 recipes.insert(ButtonVariant::Tinted, intui_plain_recipe(tokens));
90 recipes.insert(ButtonVariant::Outlined, intui_plain_recipe(tokens));
91 recipes.insert(ButtonVariant::Ghost, intui_ghost_recipe(tokens));
92 recipes.insert(ButtonVariant::Link, intui_ghost_recipe(tokens));
94 Self {
96 recipes,
97 label_roles: HashMap::new(),
98 }
99 }
100}
101
102impl Default for RecipeButtonStyle {
103 fn default() -> Self {
104 Self::intui()
105 }
106}
107
108impl ButtonStyle for RecipeButtonStyle {
109 fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
110 let recipe = self
112 .recipes
113 .get(&cfg.variant)
114 .or_else(|| self.recipes.get(&ButtonVariant::Plain))
115 .expect("RecipeButtonStyle must define at least Plain")
116 .clone();
117
118 let state_signal = derive_state_signal(cfg);
119
120 let bg_color = bind_fill(&state_signal, &recipe.fill, ctx);
122 let (border_color, border_width) = bind_border(&state_signal, &recipe.border, ctx);
123
124 let radius = match recipe.shape {
125 ShapeRecipe::Rect { corner_radius } => corner_radius,
126 ShapeRecipe::Pill | ShapeRecipe::Circle => CornerRadius::uniform(9999.0),
127 };
128
129 let padding_id = ctx.add(
130 Padding::new(
131 recipe.padding.top,
132 recipe.padding.trailing,
133 recipe.padding.bottom,
134 recipe.padding.leading,
135 )
136 .child(cfg.label),
137 );
138
139 let border_position = recipe.border.idle.position;
142 let border_sides = recipe.border.idle.sides;
143 let mut rect = RectWidget::new()
144 .background(bg_color)
145 .border_color(border_color)
146 .border_width(border_width)
147 .corner_radius(radius)
148 .border_position(border_position);
149 if border_sides.is_some() {
150 rect = rect.border_sides(border_sides);
151 }
152 let rect_id = ctx.add(rect);
153
154 let zstack_id = ctx.add(ZStack::new().child(rect_id).child(padding_id));
155
156 ctx.add(MinSize::new(recipe.min_size.width, recipe.min_size.height).child(zstack_id))
157 }
158
159 fn label_text_role(&self, variant: ButtonVariant) -> Option<TextRole> {
160 self.label_roles.get(&variant).copied()
161 }
162}
163
164fn derive_state_signal(cfg: &ButtonStyleConfig) -> Signal<WidgetState> {
168 cfg.is_disabled
169 .zip3(&cfg.is_pressed, &cfg.is_hovered)
170 .zip(&cfg.is_focused)
171 .map(|((disabled, pressed, hovered), focused)| {
172 if *disabled {
173 WidgetState::Disabled
174 } else if *pressed {
175 WidgetState::Pressed
176 } else if *focused {
177 WidgetState::Focused
178 } else if *hovered {
179 WidgetState::Hovered
180 } else {
181 WidgetState::Idle
182 }
183 })
184}
185
186fn bind_fill(
187 state: &Signal<WidgetState>,
188 recipe: &PerStateRecipe<FillRecipe>,
189 ctx: &BuildContext,
190) -> PaintProp {
191 let is_gradient = |f: &FillRecipe| {
197 matches!(
198 f,
199 FillRecipe::LinearGradient { .. } | FillRecipe::RadialGradient { .. }
200 )
201 };
202 let any_gradient = is_gradient(&recipe.idle)
203 || recipe.hover.as_ref().is_some_and(is_gradient)
204 || recipe.pressed.as_ref().is_some_and(is_gradient)
205 || recipe.focused.as_ref().is_some_and(is_gradient)
206 || recipe.disabled.as_ref().is_some_and(is_gradient);
207 if any_gradient {
208 let colors = ctx.theme().paint_colors(ctx.paint_state());
214 return PaintProp::from_fill(&recipe.idle, &colors);
215 }
216 let recipe = recipe.clone();
217 let colors_sig = window_resolution_colors(ctx);
218 let sig: Signal<Color> = state
219 .zip(&colors_sig)
220 .map(move |(s, colors)| resolve_fill_to_color(recipe.resolve(*s), colors));
221 PaintProp::Solid(ColorProp::Bound(sig))
222}
223
224fn bind_border(
225 state: &Signal<WidgetState>,
226 recipe: &PerStateRecipe<BorderRecipe>,
227 ctx: &BuildContext,
228) -> (ColorProp, Signal<f32>) {
229 let recipe_for_color = recipe.clone();
230 let recipe_for_width = recipe.clone();
231 let colors_sig = window_resolution_colors(ctx);
232 let color: ColorProp = state
233 .zip(&colors_sig)
234 .map(move |(s, colors)| recipe_for_color.resolve(*s).color.resolve_with(colors))
235 .into();
236 let width = state.map(move |s| recipe_for_width.resolve(*s).width);
237 (color, width)
238}
239
240fn resolve_fill_to_color(fill: &FillRecipe, colors: &teksilo_tokens::ColorTokens) -> Color {
241 fill.resolve_flat(colors).unwrap_or(Color::TRANSPARENT)
246}
247
248fn intui_filled_recipe(tokens: &InputTokens) -> ButtonRecipe {
251 ButtonRecipe {
252 shape: ShapeRecipe::rounded(4.0),
253 fill: PerStateRecipe {
254 idle: FillRecipe::solid(SurfaceRole::Accent),
255 hover: Some(FillRecipe::solid(SurfaceRole::AccentHover)),
256 pressed: None,
264 focused: None,
265 disabled: Some(FillRecipe::solid(SurfaceRole::AccentDisabled)),
266 },
267 border: PerStateRecipe {
268 idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
269 hover: None,
270 pressed: None,
271 focused: Some(BorderRecipe::solid(
272 2.0,
273 RecipeColor::Border(BorderRole::Focused),
274 )),
275 disabled: None,
276 },
277 shadow: PerStateRecipe::uniform(None),
278 padding: EdgeInsets::symmetric(
279 spacing(BUTTON_PADDING_HORIZONTAL, tokens),
280 spacing(BUTTON_PADDING_VERTICAL, tokens),
281 ),
282 min_size: density_min_size(
283 Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT),
284 TargetAxes::BOTH,
285 tokens,
286 ),
287 }
288}
289
290fn intui_plain_recipe(tokens: &InputTokens) -> ButtonRecipe {
291 ButtonRecipe {
292 shape: ShapeRecipe::rounded(4.0),
293 fill: PerStateRecipe {
294 idle: FillRecipe::solid(SurfaceRole::Main),
295 hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
296 pressed: None,
298 focused: None,
299 disabled: None,
300 },
301 border: PerStateRecipe {
302 idle: BorderRecipe::solid(1.0, RecipeColor::Border(BorderRole::Default)),
303 hover: Some(BorderRecipe::solid(
304 1.0,
305 RecipeColor::Border(BorderRole::Strong),
306 )),
307 pressed: None,
309 focused: Some(BorderRecipe::solid(
310 2.0,
311 RecipeColor::Border(BorderRole::Focused),
312 )),
313 disabled: None,
314 },
315 shadow: PerStateRecipe::uniform(None),
316 padding: EdgeInsets::symmetric(
317 spacing(BUTTON_PADDING_HORIZONTAL, tokens),
318 spacing(BUTTON_PADDING_VERTICAL, tokens),
319 ),
320 min_size: density_min_size(
321 Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT),
322 TargetAxes::BOTH,
323 tokens,
324 ),
325 }
326}
327
328fn intui_ghost_recipe(tokens: &InputTokens) -> ButtonRecipe {
329 ButtonRecipe {
330 shape: ShapeRecipe::rounded(4.0),
331 fill: PerStateRecipe {
332 idle: FillRecipe::solid(SurfaceRole::Transparent),
333 hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
334 pressed: None,
336 focused: None,
337 disabled: None,
338 },
339 border: PerStateRecipe {
340 idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
341 hover: None,
342 pressed: None,
343 focused: Some(BorderRecipe::solid(
344 2.0,
345 RecipeColor::Border(BorderRole::Focused),
346 )),
347 disabled: None,
348 },
349 shadow: PerStateRecipe::uniform(None),
350 padding: EdgeInsets::symmetric(
351 spacing(BUTTON_PADDING_HORIZONTAL, tokens),
352 spacing(BUTTON_PADDING_VERTICAL, tokens),
353 ),
354 min_size: density_min_size(
355 Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT),
356 TargetAxes::BOTH,
357 tokens,
358 ),
359 }
360}
361
362pub fn shared_intui(tokens: &InputTokens) -> Rc<dyn ButtonStyle> {
366 Rc::new(RecipeButtonStyle::for_tokens(tokens))
367}