Skip to main content

teksilo_core/
paint_prop.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `PaintProp` — a fill input that may be a flat color *or* a gradient.
5//!
6//! [`ColorProp`] covers the flat case (static color / theme role /
7//! `Signal<Color>`). `PaintProp` is the superset that a fill-bearing
8//! widget (`RectWidget`) accepts so a theme recipe can describe a
9//! gradient (`FillRecipe::LinearGradient` / `RadialGradient`). Anything
10//! that is `Into<ColorProp>` is also `Into<PaintProp>` (as a solid), so
11//! callers that pass a `Color` / role / signal keep working unchanged.
12//!
13//! Gradient stop colors are themselves [`ColorProp`]s, so they resolve
14//! against the live theme at paint time. Gradient geometry is computed
15//! from the widget's **size** at paint (the canvas [`Paint`] gradient
16//! coordinates are rect-local), so a `PaintProp` never needs the widget's
17//! absolute position.
18
19use teksilo_canvas::{GradientStop, Paint, Point, Size};
20
21use crate::binding::{BindingLevel, BindingRegistry};
22use crate::color_prop::ColorProp;
23use crate::styles::{FillRecipe, Theme};
24use crate::widget_id::WidgetId;
25
26/// One stop of a gradient `PaintProp`. The color is a [`ColorProp`] so it
27/// tracks theme/role/signal changes.
28#[derive(Clone, Debug)]
29pub struct GradientStopProp {
30    /// Position along the gradient axis, `0.0..=1.0`.
31    pub offset: f32,
32    pub color: ColorProp,
33}
34
35/// A fill that resolves to a canvas [`Paint`] — flat or gradient.
36#[derive(Clone, Debug)]
37pub enum PaintProp {
38    /// Flat fill — delegates to [`ColorProp`] (the common case; fully
39    /// reactive via a bound color or role).
40    Solid(ColorProp),
41    /// Linear gradient. `angle_deg`: `0°` = top→bottom, `90°` =
42    /// leading→trailing.
43    Linear {
44        stops: Vec<GradientStopProp>,
45        angle_deg: f32,
46    },
47    /// Radial gradient. `center` is normalized (`0.0..=1.0`) within the
48    /// rect; `radius` is normalized to the rect's longer side.
49    Radial {
50        stops: Vec<GradientStopProp>,
51        center: (f32, f32),
52        radius: f32,
53    },
54}
55
56impl PaintProp {
57    /// A solid fill from anything `Into<ColorProp>`.
58    pub fn solid(color: impl Into<ColorProp>) -> Self {
59        PaintProp::Solid(color.into())
60    }
61
62    /// Build a `PaintProp` from a [`FillRecipe`]. Flat variants
63    /// (`Solid`/`StateLayer`/`None`) become a `Solid` `ColorProp`;
64    /// gradients map their `RecipeColor` stops to `ColorProp` stops.
65    /// `StateLayer` can't be a single role, so it resolves to a frozen
66    /// composited color (still re-resolved on theme swap only if the
67    /// caller rebuilds — recipe styles instead fold state layers into a
68    /// reactive `Solid` via their own state signal).
69    pub fn from_fill(fill: &FillRecipe, colors: &teksilo_tokens::ColorTokens) -> Self {
70        match fill {
71            FillRecipe::Solid(c) => PaintProp::Solid((*c).into()),
72            FillRecipe::None => {
73                PaintProp::Solid(ColorProp::Static(teksilo_tokens::Color::TRANSPARENT))
74            }
75            FillRecipe::StateLayer { .. } => {
76                // Pre-composite against the supplied tokens (frozen).
77                let flat = fill
78                    .resolve_flat(colors)
79                    .unwrap_or(teksilo_tokens::Color::TRANSPARENT);
80                PaintProp::Solid(ColorProp::Static(flat))
81            }
82            FillRecipe::LinearGradient { stops, angle_deg } => PaintProp::Linear {
83                stops: stops.iter().map(stop_to_prop).collect(),
84                angle_deg: *angle_deg,
85            },
86            FillRecipe::RadialGradient {
87                stops,
88                center,
89                radius,
90            } => PaintProp::Radial {
91                stops: stops.iter().map(stop_to_prop).collect(),
92                center: *center,
93                radius: *radius,
94            },
95        }
96    }
97
98    /// Resolve to a canvas [`Paint`]. `size` is the filled rect's size;
99    /// gradient endpoints are rect-local (`(0,0)` = top-left).
100    pub fn resolve(&self, theme: &Theme, enabled: bool, size: Size) -> Paint {
101        match self {
102            PaintProp::Solid(c) => Paint::Solid(c.resolve(theme, enabled)),
103            PaintProp::Linear { stops, angle_deg } => {
104                let (start, end) = angle_to_endpoints(*angle_deg, size);
105                Paint::LinearGradient {
106                    start,
107                    end,
108                    stops: resolve_stops(stops, theme, enabled),
109                }
110            }
111            PaintProp::Radial {
112                stops,
113                center,
114                radius,
115            } => Paint::RadialGradient {
116                center: Point::new(center.0 * size.width, center.1 * size.height),
117                radius: radius * size.width.max(size.height),
118                stops: resolve_stops(stops, theme, enabled),
119            },
120        }
121    }
122
123    /// Register dirty-tracking for any signal-bearing color (the solid
124    /// color or each gradient stop).
125    pub fn register_if_bound(
126        &self,
127        widget_id: WidgetId,
128        registry: &BindingRegistry,
129        level: BindingLevel,
130    ) {
131        match self {
132            PaintProp::Solid(c) => c.register_if_bound(widget_id, registry, level),
133            PaintProp::Linear { stops, .. } | PaintProp::Radial { stops, .. } => {
134                for s in stops {
135                    s.color.register_if_bound(widget_id, registry, level);
136                }
137            }
138        }
139    }
140}
141
142fn stop_to_prop(s: &crate::styles::GradientStop) -> GradientStopProp {
143    GradientStopProp {
144        offset: s.offset,
145        color: s.color.into(),
146    }
147}
148
149fn resolve_stops(stops: &[GradientStopProp], theme: &Theme, enabled: bool) -> Vec<GradientStop> {
150    stops
151        .iter()
152        .map(|s| GradientStop {
153            offset: s.offset,
154            color: s.color.resolve(theme, enabled),
155        })
156        .collect()
157}
158
159/// Map a gradient angle (degrees; `0°` = top→bottom, `90°` =
160/// leading→trailing) to rect-local start/end points through the rect
161/// centre. Axis-aligned angles land exactly on the conventional edges.
162pub fn angle_to_endpoints(angle_deg: f32, size: Size) -> (Point, Point) {
163    let rad = angle_deg.to_radians();
164    // dir: 0° → (0, 1) down, 90° → (1, 0) right.
165    let dx = rad.sin();
166    let dy = rad.cos();
167    let cx = size.width * 0.5;
168    let cy = size.height * 0.5;
169    let ex = dx * size.width * 0.5;
170    let ey = dy * size.height * 0.5;
171    (Point::new(cx - ex, cy - ey), Point::new(cx + ex, cy + ey))
172}
173
174// Anything `Into<ColorProp>` is a solid `PaintProp` — keeps existing
175// `.background(color/role/signal)` callers working after the field type
176// changes from `ColorProp` to `PaintProp`.
177impl<T: Into<ColorProp>> From<T> for PaintProp {
178    fn from(t: T) -> Self {
179        PaintProp::Solid(t.into())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::presets::intui;
187    use crate::styles::{GradientStop as RecipeStop, RecipeColor};
188    use teksilo_tokens::{Color, SurfaceRole};
189
190    #[test]
191    fn solid_from_color_resolves() {
192        let theme = intui::light();
193        let p = PaintProp::solid(Color::RED);
194        match p.resolve(&theme, true, Size::new(10.0, 10.0)) {
195            Paint::Solid(c) => assert_eq!(c, Color::RED),
196            _ => panic!("expected solid"),
197        }
198    }
199
200    #[test]
201    fn vertical_angle_endpoints() {
202        // 0° top→bottom over a 100×40 rect.
203        let (s, e) = angle_to_endpoints(0.0, Size::new(100.0, 40.0));
204        assert_eq!((s.x, s.y), (50.0, 0.0));
205        assert_eq!((e.x, e.y), (50.0, 40.0));
206        // 90° leading→trailing.
207        let (s, e) = angle_to_endpoints(90.0, Size::new(100.0, 40.0));
208        assert!((s.x - 0.0).abs() < 1e-4 && (s.y - 20.0).abs() < 1e-4);
209        assert!((e.x - 100.0).abs() < 1e-4 && (e.y - 20.0).abs() < 1e-4);
210    }
211
212    #[test]
213    fn linear_gradient_from_fill_resolves_role_stops() {
214        let theme = intui::light();
215        let fill = FillRecipe::LinearGradient {
216            stops: vec![
217                RecipeStop {
218                    offset: 0.0,
219                    color: RecipeColor::Surface(SurfaceRole::Accent),
220                },
221                RecipeStop {
222                    offset: 1.0,
223                    color: RecipeColor::Static(Color::WHITE),
224                },
225            ],
226            angle_deg: 0.0,
227        };
228        let p = PaintProp::from_fill(&fill, &theme.colors);
229        match p.resolve(&theme, true, Size::new(20.0, 20.0)) {
230            Paint::LinearGradient { stops, .. } => {
231                assert_eq!(stops.len(), 2);
232                assert_eq!(stops[0].color, theme.colors.accent);
233                assert_eq!(stops[1].color, Color::WHITE);
234            }
235            _ => panic!("expected linear gradient"),
236        }
237    }
238}