1use serde::{Deserialize, Serialize};
32
33use teksilo_canvas::{Rect, Vec2};
34use teksilo_tokens::{BorderRole, Color, ColorTokens, CornerRadius, SurfaceRole, TextRole};
35
36use crate::styles::Theme;
37
38#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default, Serialize, Deserialize)]
45pub enum WidgetState {
46 #[default]
47 Idle,
48 Hovered,
49 Pressed,
50 Focused,
51 Disabled,
52}
53
54#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
69pub enum RecipeColor {
70 Static(Color),
71 Surface(SurfaceRole),
72 Border(BorderRole),
73 Text(TextRole),
74}
75
76impl RecipeColor {
77 pub fn resolve(self, theme: &Theme) -> Color {
78 self.resolve_with(&theme.colors)
79 }
80
81 pub fn resolve_with(self, colors: &ColorTokens) -> Color {
85 match self {
86 RecipeColor::Static(c) => c,
87 RecipeColor::Surface(r) => r.resolve(colors),
88 RecipeColor::Border(r) => r.resolve(colors),
89 RecipeColor::Text(r) => r.resolve(colors),
90 }
91 }
92}
93
94impl From<Color> for RecipeColor {
95 fn from(c: Color) -> Self {
96 Self::Static(c)
97 }
98}
99impl From<SurfaceRole> for RecipeColor {
100 fn from(r: SurfaceRole) -> Self {
101 Self::Surface(r)
102 }
103}
104impl From<BorderRole> for RecipeColor {
105 fn from(r: BorderRole) -> Self {
106 Self::Border(r)
107 }
108}
109impl From<TextRole> for RecipeColor {
110 fn from(r: TextRole) -> Self {
111 Self::Text(r)
112 }
113}
114
115#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
123pub enum ShapeRecipe {
124 Rect { corner_radius: CornerRadius },
127 Pill,
129 Circle,
131}
132
133impl ShapeRecipe {
134 pub fn rounded(radius: f32) -> Self {
136 Self::Rect {
137 corner_radius: CornerRadius::uniform(radius),
138 }
139 }
140
141 pub fn rect() -> Self {
143 Self::Rect {
144 corner_radius: CornerRadius::uniform(0.0),
145 }
146 }
147}
148
149#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
160pub enum FillRecipe {
161 Solid(RecipeColor),
163 StateLayer {
168 base: RecipeColor,
169 overlay: RecipeColor,
170 alpha: f32,
171 },
172 LinearGradient {
174 stops: Vec<GradientStop>,
175 angle_deg: f32,
176 },
177 RadialGradient {
180 stops: Vec<GradientStop>,
181 center: (f32, f32),
182 radius: f32,
183 },
184 None,
186}
187
188#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
189pub struct GradientStop {
190 pub offset: f32,
192 pub color: RecipeColor,
193}
194
195impl FillRecipe {
196 pub fn solid(color: impl Into<RecipeColor>) -> Self {
197 Self::Solid(color.into())
198 }
199
200 pub fn state_layer(
203 base: impl Into<RecipeColor>,
204 overlay: impl Into<RecipeColor>,
205 alpha: f32,
206 ) -> Self {
207 Self::StateLayer {
208 base: base.into(),
209 overlay: overlay.into(),
210 alpha: alpha.clamp(0.0, 1.0),
211 }
212 }
213
214 pub fn resolve_flat(&self, colors: &ColorTokens) -> Option<Color> {
219 match self {
220 FillRecipe::Solid(c) => Some(c.resolve_with(colors)),
221 FillRecipe::StateLayer {
222 base,
223 overlay,
224 alpha,
225 } => Some(
226 base.resolve_with(colors)
227 .mix(overlay.resolve_with(colors), *alpha),
228 ),
229 FillRecipe::None => Some(Color::TRANSPARENT),
230 FillRecipe::LinearGradient { .. } | FillRecipe::RadialGradient { .. } => None,
231 }
232 }
233}
234
235#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
238pub enum BorderStyle {
239 #[default]
240 Solid,
241 Dashed {
242 dash: f32,
243 gap: f32,
244 },
245 Dotted {
246 gap: f32,
247 },
248}
249
250#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub enum BorderPosition {
252 #[default]
255 Inside,
256 Center,
258 Outside,
261}
262
263#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
269pub struct BorderSides {
270 pub top: f32,
271 pub trailing: f32,
272 pub bottom: f32,
273 pub leading: f32,
274}
275
276impl BorderSides {
277 pub fn uniform(w: f32) -> Self {
279 Self {
280 top: w,
281 trailing: w,
282 bottom: w,
283 leading: w,
284 }
285 }
286
287 pub fn bottom(w: f32) -> Self {
289 Self {
290 bottom: w,
291 ..Self::default()
292 }
293 }
294}
295
296#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
297pub struct BorderRecipe {
298 pub width: f32,
299 pub color: RecipeColor,
300 pub style: BorderStyle,
301 pub position: BorderPosition,
302 #[serde(default)]
306 pub sides: Option<BorderSides>,
307}
308
309impl BorderRecipe {
310 pub fn solid(width: f32, color: impl Into<RecipeColor>) -> Self {
311 Self {
312 width,
313 color: color.into(),
314 style: BorderStyle::Solid,
315 position: BorderPosition::Inside,
316 sides: None,
317 }
318 }
319
320 pub fn none() -> Self {
322 Self::solid(0.0, RecipeColor::Static(Color::TRANSPARENT))
323 }
324
325 pub fn underline(width: f32, color: impl Into<RecipeColor>) -> Self {
328 Self {
329 width,
330 color: color.into(),
331 style: BorderStyle::Solid,
332 position: BorderPosition::Inside,
333 sides: Some(BorderSides::bottom(width)),
334 }
335 }
336}
337
338pub fn apply_border_position(bounds: Rect, width: f32, position: BorderPosition) -> Rect {
346 let offset = match position {
347 BorderPosition::Inside => width / 2.0,
348 BorderPosition::Center => 0.0,
349 BorderPosition::Outside => -width / 2.0,
350 };
351 Rect::new(
352 bounds.x + offset,
353 bounds.y + offset,
354 bounds.width - offset * 2.0,
355 bounds.height - offset * 2.0,
356 )
357}
358
359#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
362pub struct ShadowRecipe {
363 pub offset: Vec2,
364 pub blur: f32,
365 pub spread: f32,
366 pub color: RecipeColor,
367}
368
369impl ShadowRecipe {
370 pub fn drop(offset: Vec2, blur: f32, color: impl Into<RecipeColor>) -> Self {
371 Self {
372 offset,
373 blur,
374 spread: 0.0,
375 color: color.into(),
376 }
377 }
378}
379
380#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
394pub struct PerStateRecipe<T> {
395 pub idle: T,
396 pub hover: Option<T>,
397 pub pressed: Option<T>,
398 pub focused: Option<T>,
399 pub disabled: Option<T>,
400}
401
402impl<T> PerStateRecipe<T> {
403 pub fn uniform(value: T) -> Self
405 where
406 T: Clone,
407 {
408 Self {
409 idle: value,
410 hover: None,
411 pressed: None,
412 focused: None,
413 disabled: None,
414 }
415 }
416
417 pub fn resolve(&self, state: WidgetState) -> &T {
419 match state {
420 WidgetState::Idle => &self.idle,
421 WidgetState::Hovered => self.hover.as_ref().unwrap_or(&self.idle),
422 WidgetState::Pressed => self
423 .pressed
424 .as_ref()
425 .or(self.hover.as_ref())
426 .unwrap_or(&self.idle),
427 WidgetState::Focused => self
428 .focused
429 .as_ref()
430 .or(self.hover.as_ref())
431 .unwrap_or(&self.idle),
432 WidgetState::Disabled => self.disabled.as_ref().unwrap_or(&self.idle),
433 }
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::presets::intui;
441
442 #[test]
443 fn recipe_color_static_round_trips() {
444 let theme = intui::light();
445 let red = RecipeColor::Static(Color::from_hex("#FF0000"));
446 assert_eq!(red.resolve(&theme), Color::from_hex("#FF0000"));
447 }
448
449 #[test]
450 fn recipe_color_surface_resolves_against_theme() {
451 let light = intui::light();
452 let dark = intui::dark();
453 let main = RecipeColor::Surface(SurfaceRole::Main);
454 assert_ne!(main.resolve(&light), main.resolve(&dark));
456 }
457
458 #[test]
459 fn per_state_resolves_idle_fallback() {
460 let r = PerStateRecipe::<u32>::uniform(7);
461 assert_eq!(*r.resolve(WidgetState::Idle), 7);
462 assert_eq!(*r.resolve(WidgetState::Hovered), 7);
463 assert_eq!(*r.resolve(WidgetState::Pressed), 7);
464 assert_eq!(*r.resolve(WidgetState::Focused), 7);
465 assert_eq!(*r.resolve(WidgetState::Disabled), 7);
466 }
467
468 #[test]
469 fn pressed_falls_back_to_hover_then_idle() {
470 let r = PerStateRecipe {
471 idle: 1,
472 hover: Some(2),
473 pressed: None,
474 focused: None,
475 disabled: None,
476 };
477 assert_eq!(*r.resolve(WidgetState::Pressed), 2); let r2 = PerStateRecipe {
479 idle: 1,
480 hover: None,
481 pressed: None,
482 focused: None,
483 disabled: None,
484 };
485 assert_eq!(*r2.resolve(WidgetState::Pressed), 1); }
487
488 #[test]
489 fn focused_falls_back_to_hover_then_idle() {
490 let r = PerStateRecipe {
491 idle: 1,
492 hover: Some(2),
493 pressed: None,
494 focused: None,
495 disabled: None,
496 };
497 assert_eq!(*r.resolve(WidgetState::Focused), 2);
498 }
499
500 #[test]
501 fn disabled_falls_back_to_idle_directly() {
502 let r = PerStateRecipe {
503 idle: 1,
504 hover: Some(2), pressed: None,
506 focused: None,
507 disabled: None,
508 };
509 assert_eq!(*r.resolve(WidgetState::Disabled), 1);
510 }
511
512 #[test]
513 fn fill_recipe_solid_constructor() {
514 let f = FillRecipe::solid(SurfaceRole::Accent);
515 assert!(matches!(f, FillRecipe::Solid(RecipeColor::Surface(_))));
516 }
517
518 #[test]
519 fn state_layer_composites_overlay_over_base() {
520 let colors = intui::light().colors;
521 let f = FillRecipe::state_layer(
523 RecipeColor::Static(Color::BLACK),
524 RecipeColor::Static(Color::WHITE),
525 0.5,
526 );
527 let c = f.resolve_flat(&colors).unwrap();
528 assert!((c.r() - 0.5).abs() < 1e-6);
529 assert!((c.g() - 0.5).abs() < 1e-6);
530 assert!((c.b() - 0.5).abs() < 1e-6);
531 let f0 = FillRecipe::state_layer(Color::BLACK, Color::WHITE, 0.0);
533 assert_eq!(f0.resolve_flat(&colors).unwrap(), Color::BLACK);
534 }
535
536 #[test]
537 fn state_layer_clamps_alpha() {
538 let f = FillRecipe::state_layer(Color::BLACK, Color::WHITE, 5.0);
539 match f {
540 FillRecipe::StateLayer { alpha, .. } => assert_eq!(alpha, 1.0),
541 _ => panic!("expected StateLayer"),
542 }
543 }
544
545 #[test]
546 fn gradient_has_no_flat_color() {
547 let colors = intui::light().colors;
548 let g = FillRecipe::LinearGradient {
549 stops: vec![],
550 angle_deg: 0.0,
551 };
552 assert!(g.resolve_flat(&colors).is_none());
553 }
554
555 #[test]
556 fn underline_is_bottom_only() {
557 let b = BorderRecipe::underline(2.0, BorderRole::Focused);
558 let sides = b.sides.expect("underline sets per-side widths");
559 assert_eq!(sides.bottom, 2.0);
560 assert_eq!(sides.top, 0.0);
561 assert_eq!(sides.leading, 0.0);
562 assert_eq!(sides.trailing, 0.0);
563 }
564
565 #[test]
566 fn solid_border_has_no_per_side() {
567 assert!(
568 BorderRecipe::solid(1.0, BorderRole::Default)
569 .sides
570 .is_none()
571 );
572 }
573
574 #[test]
575 fn border_position_offsets_stroke_rect() {
576 let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
577 let inside = apply_border_position(bounds, 4.0, BorderPosition::Inside);
579 assert_eq!(
580 (inside.x, inside.y, inside.width, inside.height),
581 (2.0, 2.0, 96.0, 36.0)
582 );
583 let center = apply_border_position(bounds, 4.0, BorderPosition::Center);
585 assert_eq!((center.x, center.width), (0.0, 100.0));
586 let outside = apply_border_position(bounds, 4.0, BorderPosition::Outside);
588 assert_eq!(
589 (outside.x, outside.y, outside.width, outside.height),
590 (-2.0, -2.0, 104.0, 44.0)
591 );
592 }
593
594 #[test]
595 fn shape_recipe_rounded_constructor() {
596 let s = ShapeRecipe::rounded(4.0);
597 match s {
598 ShapeRecipe::Rect { corner_radius } => {
599 assert_eq!(corner_radius.top_left, 4.0);
600 assert_eq!(corner_radius.bottom_right, 4.0);
601 }
602 _ => panic!("expected Rect"),
603 }
604 }
605
606 #[test]
607 fn recipes_are_send_sync() {
608 fn assert_send_sync<T: Send + Sync>() {}
612 assert_send_sync::<ShapeRecipe>();
613 assert_send_sync::<FillRecipe>();
614 assert_send_sync::<BorderRecipe>();
615 assert_send_sync::<ShadowRecipe>();
616 assert_send_sync::<PerStateRecipe<FillRecipe>>();
617 assert_send_sync::<RecipeColor>();
618 assert_send_sync::<WidgetState>();
619 }
620}