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::{
24 BorderRecipe, ButtonRecipe, ButtonStyle, ButtonStyleConfig, ButtonVariant, FillRecipe,
25 PerStateRecipe, RecipeColor, ShapeRecipe, WidgetState,
26};
27use teksilo_core::widget_id::WidgetId;
28use teksilo_tokens::{BorderRole, Color, CornerRadius, SurfaceRole, TextRole};
29
30use super::window_resolution_colors;
31use crate::primitives::{MinSize, Padding, RectWidget, ZStack};
32
33pub const BUTTON_HEIGHT: f32 = 24.0;
39pub const BUTTON_MIN_WIDTH: f32 = 72.0;
40pub const BUTTON_PADDING_HORIZONTAL: f32 = 14.0;
41pub const BUTTON_PADDING_VERTICAL: f32 = 0.0;
42pub const BUTTON_CORNER_RADIUS: f32 = 4.0;
43pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
44pub const BUTTON_ICON_SIZE: f32 = 16.0;
45pub const BUTTON_ICON_LABEL_GAP: f32 = 4.0;
46
47#[derive(Debug, Clone)]
59pub struct RecipeButtonStyle {
60 pub recipes: HashMap<ButtonVariant, ButtonRecipe>,
61 pub label_roles: HashMap<ButtonVariant, TextRole>,
62}
63
64impl RecipeButtonStyle {
65 pub fn intui() -> Self {
67 let mut recipes = HashMap::new();
68 recipes.insert(ButtonVariant::Filled, intui_filled_recipe());
69 recipes.insert(ButtonVariant::Destructive, intui_filled_recipe());
72 recipes.insert(ButtonVariant::Plain, intui_plain_recipe());
73 recipes.insert(ButtonVariant::Tinted, intui_plain_recipe());
75 recipes.insert(ButtonVariant::Outlined, intui_plain_recipe());
76 recipes.insert(ButtonVariant::Ghost, intui_ghost_recipe());
77 recipes.insert(ButtonVariant::Link, intui_ghost_recipe());
79 Self {
81 recipes,
82 label_roles: HashMap::new(),
83 }
84 }
85}
86
87impl Default for RecipeButtonStyle {
88 fn default() -> Self {
89 Self::intui()
90 }
91}
92
93impl ButtonStyle for RecipeButtonStyle {
94 fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
95 let recipe = self
97 .recipes
98 .get(&cfg.variant)
99 .or_else(|| self.recipes.get(&ButtonVariant::Plain))
100 .expect("RecipeButtonStyle must define at least Plain")
101 .clone();
102
103 let state_signal = derive_state_signal(cfg);
104
105 let bg_color = bind_fill(&state_signal, &recipe.fill, ctx);
107 let (border_color, border_width) = bind_border(&state_signal, &recipe.border, ctx);
108
109 let radius = match recipe.shape {
110 ShapeRecipe::Rect { corner_radius } => corner_radius,
111 ShapeRecipe::Pill | ShapeRecipe::Circle => CornerRadius::uniform(9999.0),
112 };
113
114 let padding_id = ctx.add(
115 Padding::new(
116 recipe.padding.top,
117 recipe.padding.trailing,
118 recipe.padding.bottom,
119 recipe.padding.leading,
120 )
121 .child_id(cfg.label),
122 );
123
124 let border_position = recipe.border.idle.position;
127 let border_sides = recipe.border.idle.sides;
128 let mut rect = RectWidget::new()
129 .background(bg_color)
130 .border_color(border_color)
131 .border_width(border_width)
132 .corner_radius(radius)
133 .border_position(border_position);
134 if border_sides.is_some() {
135 rect = rect.border_sides(border_sides);
136 }
137 let rect_id = ctx.add(rect);
138
139 let zstack_id = ctx.add(ZStack::new().add_child(rect_id).add_child(padding_id));
140
141 ctx.add(MinSize::new(recipe.min_size.width, recipe.min_size.height).child_id(zstack_id))
142 }
143
144 fn label_text_role(&self, variant: ButtonVariant) -> Option<TextRole> {
145 self.label_roles.get(&variant).copied()
146 }
147}
148
149fn derive_state_signal(cfg: &ButtonStyleConfig) -> Signal<WidgetState> {
153 cfg.is_disabled
154 .zip3(&cfg.is_pressed, &cfg.is_hovered)
155 .zip(&cfg.is_focused)
156 .map(|((disabled, pressed, hovered), focused)| {
157 if *disabled {
158 WidgetState::Disabled
159 } else if *pressed {
160 WidgetState::Pressed
161 } else if *focused {
162 WidgetState::Focused
163 } else if *hovered {
164 WidgetState::Hovered
165 } else {
166 WidgetState::Idle
167 }
168 })
169}
170
171fn bind_fill(
172 state: &Signal<WidgetState>,
173 recipe: &PerStateRecipe<FillRecipe>,
174 ctx: &BuildContext,
175) -> PaintProp {
176 let is_gradient = |f: &FillRecipe| {
182 matches!(
183 f,
184 FillRecipe::LinearGradient { .. } | FillRecipe::RadialGradient { .. }
185 )
186 };
187 let any_gradient = is_gradient(&recipe.idle)
188 || recipe.hover.as_ref().is_some_and(is_gradient)
189 || recipe.pressed.as_ref().is_some_and(is_gradient)
190 || recipe.focused.as_ref().is_some_and(is_gradient)
191 || recipe.disabled.as_ref().is_some_and(is_gradient);
192 if any_gradient {
193 let colors = if ctx.window_active() {
198 ctx.theme().colors.clone()
199 } else {
200 ctx.theme().colors.for_inactive_window()
201 };
202 return PaintProp::from_fill(&recipe.idle, &colors);
203 }
204 let recipe = recipe.clone();
205 let colors_sig = window_resolution_colors(ctx);
206 let sig: Signal<Color> = state
207 .zip(&colors_sig)
208 .map(move |(s, colors)| resolve_fill_to_color(recipe.resolve(*s), colors));
209 PaintProp::Solid(ColorProp::Bound(sig))
210}
211
212fn bind_border(
213 state: &Signal<WidgetState>,
214 recipe: &PerStateRecipe<BorderRecipe>,
215 ctx: &BuildContext,
216) -> (ColorProp, Signal<f32>) {
217 let recipe_for_color = recipe.clone();
218 let recipe_for_width = recipe.clone();
219 let colors_sig = window_resolution_colors(ctx);
220 let color: ColorProp = state
221 .zip(&colors_sig)
222 .map(move |(s, colors)| recipe_for_color.resolve(*s).color.resolve_with(colors))
223 .into();
224 let width = state.map(move |s| recipe_for_width.resolve(*s).width);
225 (color, width)
226}
227
228fn resolve_fill_to_color(fill: &FillRecipe, colors: &teksilo_tokens::ColorTokens) -> Color {
229 fill.resolve_flat(colors).unwrap_or(Color::TRANSPARENT)
234}
235
236fn intui_filled_recipe() -> ButtonRecipe {
239 ButtonRecipe {
240 shape: ShapeRecipe::rounded(4.0),
241 fill: PerStateRecipe {
242 idle: FillRecipe::solid(SurfaceRole::Accent),
243 hover: Some(FillRecipe::solid(SurfaceRole::AccentHover)),
244 pressed: None,
252 focused: None,
253 disabled: Some(FillRecipe::solid(SurfaceRole::AccentDisabled)),
254 },
255 border: PerStateRecipe {
256 idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
257 hover: None,
258 pressed: None,
259 focused: Some(BorderRecipe::solid(
260 2.0,
261 RecipeColor::Border(BorderRole::Focused),
262 )),
263 disabled: None,
264 },
265 shadow: PerStateRecipe::uniform(None),
266 padding: EdgeInsets::symmetric(14.0, 0.0),
267 min_size: Size::new(72.0, 24.0),
268 }
269}
270
271fn intui_plain_recipe() -> ButtonRecipe {
272 ButtonRecipe {
273 shape: ShapeRecipe::rounded(4.0),
274 fill: PerStateRecipe {
275 idle: FillRecipe::solid(SurfaceRole::Main),
276 hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
277 pressed: None,
279 focused: None,
280 disabled: None,
281 },
282 border: PerStateRecipe {
283 idle: BorderRecipe::solid(1.0, RecipeColor::Border(BorderRole::Default)),
284 hover: Some(BorderRecipe::solid(
285 1.0,
286 RecipeColor::Border(BorderRole::Strong),
287 )),
288 pressed: None,
290 focused: Some(BorderRecipe::solid(
291 2.0,
292 RecipeColor::Border(BorderRole::Focused),
293 )),
294 disabled: None,
295 },
296 shadow: PerStateRecipe::uniform(None),
297 padding: EdgeInsets::symmetric(14.0, 0.0),
298 min_size: Size::new(72.0, 24.0),
299 }
300}
301
302fn intui_ghost_recipe() -> ButtonRecipe {
303 ButtonRecipe {
304 shape: ShapeRecipe::rounded(4.0),
305 fill: PerStateRecipe {
306 idle: FillRecipe::solid(SurfaceRole::Transparent),
307 hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
308 pressed: None,
310 focused: None,
311 disabled: None,
312 },
313 border: PerStateRecipe {
314 idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
315 hover: None,
316 pressed: None,
317 focused: Some(BorderRecipe::solid(
318 2.0,
319 RecipeColor::Border(BorderRole::Focused),
320 )),
321 disabled: None,
322 },
323 shadow: PerStateRecipe::uniform(None),
324 padding: EdgeInsets::symmetric(14.0, 0.0),
325 min_size: Size::new(72.0, 24.0),
326 }
327}
328
329pub fn shared_intui() -> Rc<dyn ButtonStyle> {
333 Rc::new(RecipeButtonStyle::default())
334}