Skip to main content

teksilo_core/styles/
recipe.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tier-2 paint-recipe primitives — Shape, Fill, Border, Shadow,
5//! per-state envelope, and the [`WidgetState`] enum the envelope
6//! resolves against.
7//!
8//! These types are **pure data**: every field is plain (no signals, no
9//! closures, no `Rc`-wrapped reactive state). That gives them three
10//! properties the rest of the styling system relies on:
11//!
12//! - **`Send + Sync + 'static`** so recipes can be held in
13//!   `Arc`-based serialization contexts (Inspector JSON Export,
14//!   future `ImageTheme` TOML manifest).
15//! - **`Serialize` / `Deserialize`** so themes can round-trip through
16//!   the inspector's JSON Export/Import and through the future
17//!   `ImageTheme` TOML manifest.
18//! - **Cheap `Clone`** so the chrome composer can pull out
19//!   `PerStateRecipe::resolve(state).clone()` without thinking about
20//!   it.
21//!
22//! Reactivity is layered on top, not baked in: when a widget's state
23//! signal changes, the chrome composer calls
24//! [`PerStateRecipe::resolve`] for the new state and re-paints with
25//! the resolved recipe. Theme swaps go through
26//! [`RecipeColor::resolve`] which reads the live `ColorTokens` from
27//! the theme signal — so a recipe holding `RecipeColor::Surface(Hover)`
28//! repaints automatically when the theme changes, even though the
29//! recipe itself never moved.
30
31use 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// ─── WidgetState ────────────────────────────────────────────────────────────
39
40/// Discrete interaction state used to index a [`PerStateRecipe`]. The
41/// chrome composer derives the active state from the widget's
42/// boolean signals (priority chain: Disabled > Pressed > Focused >
43/// Hovered > Idle).
44#[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// ─── RecipeColor ────────────────────────────────────────────────────────────
55
56/// Send+Sync, serializable color value usable inside a recipe.
57///
58/// Three flavours, all `Copy`:
59/// - **`Static(Color)`** — frozen literal color.
60/// - **`Surface(SurfaceRole)`** / `Border(BorderRole)` / `Text(TextRole)` —
61///   theme-aware role; resolves against the current `ColorTokens` at
62///   paint time (after a theme swap, the same recipe paints the new
63///   role-resolved color without rebuilding).
64///
65/// Distinct from [`crate::ColorProp`] (which carries a `Signal` and is
66/// `!Send`). Recipes use `RecipeColor`; widget builders that accept
67/// `impl Into<ColorProp>` continue to use `ColorProp` directly.
68#[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    /// Variant of [`Self::resolve`] that takes [`ColorTokens`]
82    /// directly. Useful for tight inner loops where the caller has a
83    /// borrow on the color tokens already.
84    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// ─── ShapeRecipe ────────────────────────────────────────────────────────────
116
117/// The outline a recipe paints into. `Pill` / `Circle` resolve their
118/// corner radius against the bounding rect at paint time. `CustomPath`
119/// is intentionally absent for now: the path-builder closure can't be
120/// `Send + Sync + Serialize`, and the IntUI default recipes don't need
121/// it. A future variant will land if app code grows the need.
122#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
123pub enum ShapeRecipe {
124    /// Axis-aligned rectangle with per-corner radii. Use
125    /// [`CornerRadius::uniform`] for a single-radius rounded rect.
126    Rect { corner_radius: CornerRadius },
127    /// `corner_radius = min(width, height) / 2` — fully-rounded ends.
128    Pill,
129    /// Force `width == height` and `corner_radius = width / 2`.
130    Circle,
131}
132
133impl ShapeRecipe {
134    /// Convenience: rounded rect with a uniform corner radius.
135    pub fn rounded(radius: f32) -> Self {
136        Self::Rect {
137            corner_radius: CornerRadius::uniform(radius),
138        }
139    }
140
141    /// Convenience: sharp-cornered rect.
142    pub fn rect() -> Self {
143        Self::Rect {
144            corner_radius: CornerRadius::uniform(0.0),
145        }
146    }
147}
148
149// ─── FillRecipe ─────────────────────────────────────────────────────────────
150
151/// What the inside of the [`ShapeRecipe`] is filled with.
152///
153/// `Solid` and `StateLayer` both resolve to a flat [`Color`] (the latter
154/// by compositing an `overlay` over a `base` at a given alpha — the
155/// Material-3 / Fluent "state layer" model). `LinearGradient` /
156/// `RadialGradient` describe true gradients; the renderer paints them via
157/// the SDF gradient pipeline once a `PaintProp` carries them (see
158/// `resolve_fill_to_paint`).
159#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
160pub enum FillRecipe {
161    /// Single flat color.
162    Solid(RecipeColor),
163    /// `overlay` composited over `base` at `alpha` — a translucent
164    /// "state layer" (M3 hover = 8 %, pressed = 12 % on-color over the
165    /// base fill). Resolves to a flat [`Color`], so it flows through the
166    /// solid paint path with no gradient support required.
167    StateLayer {
168        base: RecipeColor,
169        overlay: RecipeColor,
170        alpha: f32,
171    },
172    /// Linear gradient at `angle_deg` (0° = top→bottom, 90° = leading→trailing).
173    LinearGradient {
174        stops: Vec<GradientStop>,
175        angle_deg: f32,
176    },
177    /// Radial gradient with normalized center (`0.0..=1.0`) and radius
178    /// in normalized units of the bounding rect's longer side.
179    RadialGradient {
180        stops: Vec<GradientStop>,
181        center: (f32, f32),
182        radius: f32,
183    },
184    /// No fill (transparent).
185    None,
186}
187
188#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
189pub struct GradientStop {
190    /// Position along the gradient axis, `0.0..=1.0`.
191    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    /// A translucent state layer: `overlay` composited over `base` at
201    /// `alpha` (clamped to `0.0..=1.0`). Resolves to a flat [`Color`].
202    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    /// Resolve the flat-color variants (`Solid`, `StateLayer`, `None`)
215    /// against `colors`. Gradient variants return `None` here — they are
216    /// resolved to a `Paint` by `resolve_fill_to_paint`, not to a flat
217    /// color. `FillRecipe::None` maps to `Some(Color::TRANSPARENT)`.
218    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// ─── BorderRecipe ───────────────────────────────────────────────────────────
236
237#[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    /// Border drawn entirely inside the shape (default — matches the
253    /// existing IntUI-style `RectWidget` border).
254    #[default]
255    Inside,
256    /// Centerline of the border lies on the shape edge.
257    Center,
258    /// Border drawn entirely outside the shape (used for focus rings
259    /// that sit in the gap outside the control).
260    Outside,
261}
262
263/// Per-side border widths (logical px). When a [`BorderRecipe`] carries
264/// `sides: Some(BorderSides)`, the four widths override the uniform
265/// `BorderRecipe::width` — letting a recipe draw e.g. a bottom-only
266/// underline (Material 3 / Fluent / Adwaita filled fields). `Leading` /
267/// `Trailing` are RTL-resolved by the paint site, not here.
268#[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    /// All four sides at `w`.
278    pub fn uniform(w: f32) -> Self {
279        Self {
280            top: w,
281            trailing: w,
282            bottom: w,
283            leading: w,
284        }
285    }
286
287    /// Bottom edge only — the underline case.
288    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    /// Optional per-side widths. `None` = a uniform `width` border on all
303    /// four sides (the common case). `Some(..)` overrides with per-side
304    /// widths (e.g. a bottom-only underline).
305    #[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    /// Convenience for "no border" — width 0, transparent color.
321    pub fn none() -> Self {
322        Self::solid(0.0, RecipeColor::Static(Color::TRANSPARENT))
323    }
324
325    /// A bottom-only underline of `width` in `color` (M3 / Fluent /
326    /// Adwaita filled-field underline). `position` is `Inside`.
327    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
338/// Offset a stroke rect to honour a [`BorderPosition`].
339///
340/// The SDF stroke is centered on the rect edge ([`BorderPosition::Center`]),
341/// so `Inside` shrinks the rect inward by `width / 2` and `Outside`
342/// expands it outward by the same — placing the whole stroke inside or
343/// outside the original `bounds`. Used by recipe paint sites and
344/// `RectWidget` so a focus ring can sit in the gap outside a control.
345pub 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// ─── ShadowRecipe ───────────────────────────────────────────────────────────
360
361#[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// ─── PerStateRecipe ─────────────────────────────────────────────────────────
381
382/// Five-slot envelope that maps each [`WidgetState`] to a `T` with an
383/// explicit fallback chain. Teksilo's answer to Flutter's
384/// `WidgetStateProperty<T>` — no closures, no virtual dispatch, the
385/// fallback graph is always knowable from the data.
386///
387/// Resolution order:
388/// - `Idle` → `idle` (always present)
389/// - `Hovered` → `hover` ?? `idle`
390/// - `Pressed` → `pressed` ?? `hover` ?? `idle`
391/// - `Focused` → `focused` ?? `hover` ?? `idle`
392/// - `Disabled` → `disabled` ?? `idle`
393#[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    /// All five states share the same value.
404    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    /// Look up the value for `state`, walking the fallback chain.
418    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        // Same recipe → different colors after theme swap.
455        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); // → hover
478        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); // → idle
486    }
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), // intentionally NOT used by disabled
505            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        // 50 % white over black = mid-grey.
522        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        // alpha 0 → base unchanged.
532        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        // Inside shrinks by width/2 on each edge.
578        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        // Center is unchanged.
584        let center = apply_border_position(bounds, 4.0, BorderPosition::Center);
585        assert_eq!((center.x, center.width), (0.0, 100.0));
586        // Outside expands.
587        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        // Compile-time check: recipes must be Send + Sync to satisfy
609        // serialization contexts (inspector JSON export, future TOML
610        // manifest) and to stay storable in Arc-based caches.
611        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}