Skip to main content

i_slint_core/graphics/
brush.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4/*!
5This module contains brush related types for the run-time library.
6*/
7
8use super::Color;
9use crate::SharedVector;
10use crate::lengths::{PhysicalPx, ScaleFactor};
11use crate::properties::InterpolatedPropertyValue;
12use alloc::borrow::Cow;
13use euclid::default::{Point2D, Size2D};
14
15#[cfg(not(feature = "std"))]
16use num_traits::float::Float;
17
18/// A brush is a data structure that is used to describe how
19/// a shape, such as a rectangle, path or even text, shall be filled.
20/// A brush can also be applied to the outline of a shape, that means
21/// the fill of the outline itself.
22#[derive(Clone, PartialEq, Debug, derive_more::From)]
23#[repr(C)]
24#[non_exhaustive]
25pub enum Brush {
26    /// The color variant of brush is a plain color that is to be used for the fill.
27    SolidColor(Color),
28    /// The linear gradient variant of a brush describes the gradient stops for a fill
29    /// where all color stops are along a line that's rotated by the specified angle.
30    LinearGradient(LinearGradientBrush),
31    /// The radial gradient variant of a brush describes a circular gradient.
32    /// The center defaults to the middle of the bounding box.
33    RadialGradient(RadialGradientBrush),
34    /// The conical gradient variant of a brush describes a gradient that rotates around
35    /// a center point, like the hands of a clock
36    ConicGradient(ConicGradientBrush),
37}
38
39/// Construct a brush with transparent color
40impl Default for Brush {
41    fn default() -> Self {
42        Self::SolidColor(Color::default())
43    }
44}
45
46impl Brush {
47    /// If the brush is SolidColor, the contained color is returned.
48    /// If the brush is a LinearGradient, the color of the first stop is returned.
49    pub fn color(&self) -> Color {
50        match self {
51            Brush::SolidColor(col) => *col,
52            Brush::LinearGradient(gradient) => {
53                gradient.stops().next().map(|stop| stop.color).unwrap_or_default()
54            }
55            Brush::RadialGradient(gradient) => {
56                gradient.stops().next().map(|stop| stop.color).unwrap_or_default()
57            }
58            Brush::ConicGradient(gradient) => {
59                gradient.stops().next().map(|stop| stop.color).unwrap_or_default()
60            }
61        }
62    }
63
64    /// Returns true if this brush contains a fully transparent color (alpha value is zero)
65    ///
66    /// ```
67    /// # use i_slint_core::graphics::*;
68    /// assert!(Brush::default().is_transparent());
69    /// assert!(Brush::SolidColor(Color::from_argb_u8(0, 255, 128, 140)).is_transparent());
70    /// assert!(!Brush::SolidColor(Color::from_argb_u8(25, 128, 140, 210)).is_transparent());
71    /// ```
72    pub fn is_transparent(&self) -> bool {
73        match self {
74            Brush::SolidColor(c) => c.alpha() == 0,
75            Brush::LinearGradient(_) => false,
76            Brush::RadialGradient(_) => false,
77            Brush::ConicGradient(_) => false,
78        }
79    }
80
81    /// Returns true if this brush is fully opaque
82    ///
83    /// ```
84    /// # use i_slint_core::graphics::*;
85    /// assert!(!Brush::default().is_opaque());
86    /// assert!(!Brush::SolidColor(Color::from_argb_u8(25, 255, 128, 140)).is_opaque());
87    /// assert!(Brush::SolidColor(Color::from_rgb_u8(128, 140, 210)).is_opaque());
88    /// ```
89    pub fn is_opaque(&self) -> bool {
90        match self {
91            Brush::SolidColor(c) => c.alpha() == 255,
92            Brush::LinearGradient(g) => g.stops().all(|s| s.color.alpha() == 255),
93            Brush::RadialGradient(g) => g.stops().all(|s| s.color.alpha() == 255),
94            Brush::ConicGradient(g) => g.stops().all(|s| s.color.alpha() == 255),
95        }
96    }
97
98    /// Returns a new version of this brush that has the brightness increased
99    /// by the specified factor. This is done by calling [`Color::brighter`] on
100    /// all the colors of this brush.
101    #[must_use]
102    pub fn brighter(&self, factor: f32) -> Self {
103        match self {
104            Brush::SolidColor(c) => Brush::SolidColor(c.brighter(factor)),
105            Brush::LinearGradient(g) => Brush::LinearGradient(LinearGradientBrush::new(
106                g.angle(),
107                g.stops().map(|s| GradientStop {
108                    color: s.color.brighter(factor),
109                    position: s.position,
110                }),
111            )),
112            Brush::RadialGradient(g) => {
113                let mut new_grad = g.clone();
114                for s in new_grad.0.make_mut_slice().iter_mut().skip(RadialGradientBrush::HEADER) {
115                    s.color = s.color.brighter(factor);
116                }
117                Brush::RadialGradient(new_grad)
118            }
119            Brush::ConicGradient(g) => {
120                let mut new_grad = g.clone();
121                for x in new_grad.0.make_mut_slice().iter_mut().skip(ConicGradientBrush::HEADER) {
122                    x.color = x.color.brighter(factor);
123                }
124                Brush::ConicGradient(new_grad)
125            }
126        }
127    }
128
129    /// Returns a new version of this brush that has the brightness decreased
130    /// by the specified factor. This is done by calling [`Color::darker`] on
131    /// all the color of this brush.
132    #[must_use]
133    pub fn darker(&self, factor: f32) -> Self {
134        match self {
135            Brush::SolidColor(c) => Brush::SolidColor(c.darker(factor)),
136            Brush::LinearGradient(g) => Brush::LinearGradient(LinearGradientBrush::new(
137                g.angle(),
138                g.stops()
139                    .map(|s| GradientStop { color: s.color.darker(factor), position: s.position }),
140            )),
141            Brush::RadialGradient(g) => {
142                let mut new_grad = g.clone();
143                for s in new_grad.0.make_mut_slice().iter_mut().skip(RadialGradientBrush::HEADER) {
144                    s.color = s.color.darker(factor);
145                }
146                Brush::RadialGradient(new_grad)
147            }
148            Brush::ConicGradient(g) => {
149                let mut new_grad = g.clone();
150                for x in new_grad.0.make_mut_slice().iter_mut().skip(ConicGradientBrush::HEADER) {
151                    x.color = x.color.darker(factor);
152                }
153                Brush::ConicGradient(new_grad)
154            }
155        }
156    }
157
158    /// Returns a new version of this brush with the opacity decreased by `factor`.
159    ///
160    /// The transparency is obtained by multiplying the alpha channel by `(1 - factor)`.
161    ///
162    /// See also [`Color::transparentize`]
163    #[must_use]
164    pub fn transparentize(&self, amount: f32) -> Self {
165        match self {
166            Brush::SolidColor(c) => Brush::SolidColor(c.transparentize(amount)),
167            Brush::LinearGradient(g) => Brush::LinearGradient(LinearGradientBrush::new(
168                g.angle(),
169                g.stops().map(|s| GradientStop {
170                    color: s.color.transparentize(amount),
171                    position: s.position,
172                }),
173            )),
174            Brush::RadialGradient(g) => {
175                let mut new_grad = g.clone();
176                for s in new_grad.0.make_mut_slice().iter_mut().skip(RadialGradientBrush::HEADER) {
177                    s.color = s.color.transparentize(amount);
178                }
179                Brush::RadialGradient(new_grad)
180            }
181            Brush::ConicGradient(g) => {
182                let mut new_grad = g.clone();
183                for x in new_grad.0.make_mut_slice().iter_mut().skip(ConicGradientBrush::HEADER) {
184                    x.color = x.color.transparentize(amount);
185                }
186                Brush::ConicGradient(new_grad)
187            }
188        }
189    }
190
191    /// Returns a new version of this brush with the related color's opacities
192    /// set to `alpha`.
193    #[must_use]
194    pub fn with_alpha(&self, alpha: f32) -> Self {
195        match self {
196            Brush::SolidColor(c) => Brush::SolidColor(c.with_alpha(alpha)),
197            Brush::LinearGradient(g) => Brush::LinearGradient(LinearGradientBrush::new(
198                g.angle(),
199                g.stops().map(|s| GradientStop {
200                    color: s.color.with_alpha(alpha),
201                    position: s.position,
202                }),
203            )),
204            Brush::RadialGradient(g) => {
205                let mut new_grad = g.clone();
206                for s in new_grad.0.make_mut_slice().iter_mut().skip(RadialGradientBrush::HEADER) {
207                    s.color = s.color.with_alpha(alpha);
208                }
209                Brush::RadialGradient(new_grad)
210            }
211            Brush::ConicGradient(g) => {
212                let mut new_grad = g.clone();
213                for x in new_grad.0.make_mut_slice().iter_mut().skip(ConicGradientBrush::HEADER) {
214                    x.color = x.color.with_alpha(alpha);
215                }
216                Brush::ConicGradient(new_grad)
217            }
218        }
219    }
220}
221
222/// The LinearGradientBrush describes a way of filling a shape with different colors, which
223/// are interpolated between different stops. The colors are aligned with a line that's rotated
224/// by the LinearGradient's angle.
225#[derive(Clone, PartialEq, Debug)]
226#[repr(transparent)]
227pub struct LinearGradientBrush(SharedVector<GradientStop>);
228
229impl LinearGradientBrush {
230    /// Creates a new linear gradient, described by the specified angle and the provided color stops.
231    ///
232    /// The angle need to be specified in degrees.
233    /// The stops don't need to be sorted as this function will sort them.
234    pub fn new(angle: f32, stops: impl IntoIterator<Item = GradientStop>) -> Self {
235        let stop_iter = stops.into_iter();
236        let mut encoded_angle_and_stops = SharedVector::with_capacity(stop_iter.size_hint().0 + 1);
237        // The gradient's first stop is a fake stop to store the angle
238        encoded_angle_and_stops.push(GradientStop { color: Default::default(), position: angle });
239        encoded_angle_and_stops.extend(stop_iter);
240        Self(encoded_angle_and_stops)
241    }
242    /// Returns the angle of the linear gradient in degrees.
243    pub fn angle(&self) -> f32 {
244        self.0[0].position
245    }
246    /// Returns the color stops of the linear gradient.
247    /// The stops are sorted by positions.
248    pub fn stops(&self) -> impl Iterator<Item = &GradientStop> {
249        // skip the first fake stop that just contains the angle
250        self.0.iter().skip(1)
251    }
252
253    /// The color stops as a slice, without the angle header stop.
254    fn stops_slice(&self) -> &[GradientStop] {
255        self.0.as_slice().get(1..).unwrap_or_default()
256    }
257}
258
259/// NaN-aware float equality: two NaNs compare equal (unlike IEEE 754).
260#[inline]
261fn nan_eq(a: f32, b: f32) -> bool {
262    a == b || (a.is_nan() && b.is_nan())
263}
264
265/// Shared center resolution for the logical and scaled radial/conic methods.
266/// When `scale_factor` is 1.0 this is identical to the unscaled case.
267#[inline]
268fn center_or_bbox(cx: f32, cy: f32, width: f32, height: f32, scale_factor: f32) -> (f32, f32) {
269    if cx.is_nan() { (width / 2.0, height / 2.0) } else { (cx * scale_factor, cy * scale_factor) }
270}
271
272/// The RadialGradientBrush describes a way of filling a shape with a circular gradient.
273///
274/// The center defaults to the middle of the bounding box; the radius defaults to half the
275/// bounding box diagonal. Use [`with_center`](Self::with_center) and
276/// [`with_radius`](Self::with_radius) to override these defaults.
277///
278/// Internally the brush encodes center and radius as the first three fake
279/// [`GradientStop`] entries (indices 0–2), following the same pattern as
280/// [`LinearGradientBrush`] (which stores the angle as stop 0).
281#[derive(Clone, Debug)]
282#[repr(transparent)]
283pub struct RadialGradientBrush(SharedVector<GradientStop>);
284
285impl RadialGradientBrush {
286    const HEADER: usize = 3;
287
288    /// Creates a new circle radial gradient centered in the element's bounding box,
289    /// described by the provided color stops.
290    pub fn new_circle(stops: impl IntoIterator<Item = GradientStop>) -> Self {
291        let stop_iter = stops.into_iter();
292        let mut v = SharedVector::with_capacity(Self::HEADER + stop_iter.size_hint().0);
293        // Header stops: center_x (NaN=bbox), center_y (NaN=bbox), radius (negative=bbox diagonal/2)
294        v.push(GradientStop { color: Default::default(), position: f32::NAN });
295        v.push(GradientStop { color: Default::default(), position: f32::NAN });
296        v.push(GradientStop { color: Default::default(), position: -1.0 });
297        v.extend(stop_iter);
298        Self(v)
299    }
300
301    #[inline]
302    fn center_x(&self) -> f32 {
303        self.0[0].position
304    }
305    #[inline]
306    fn center_y(&self) -> f32 {
307        self.0[1].position
308    }
309    #[inline]
310    fn radius(&self) -> f32 {
311        self.0[2].position
312    }
313
314    /// Returns the color stops of the radial gradient.
315    pub fn stops(&self) -> impl Iterator<Item = &GradientStop> {
316        self.0.iter().skip(Self::HEADER)
317    }
318
319    /// The color stops as a slice, without the header stops.
320    fn stops_slice(&self) -> &[GradientStop] {
321        self.0.as_slice().get(Self::HEADER..).unwrap_or_default()
322    }
323
324    /// Sets an explicit center, returning `self` for chaining. `cx` and `cy` are in the
325    /// element's local logical coordinate space.
326    pub fn with_center(mut self, cx: f32, cy: f32) -> Self {
327        let s = self.0.make_mut_slice();
328        s[0].position = cx;
329        s[1].position = cy;
330        self
331    }
332
333    /// Sets an explicit radius, returning `self` for chaining. `r` is in the element's local
334    /// logical coordinate space.
335    pub fn with_radius(mut self, r: f32) -> Self {
336        self.0.make_mut_slice()[2].position = r;
337        self
338    }
339
340    /// Returns the gradient center, falling back to the bounding box center when not explicitly set.
341    ///
342    /// `width` and `height` are the element's logical dimensions.
343    pub fn center_or_default(&self, width: f32, height: f32) -> (f32, f32) {
344        debug_assert!(
345            self.center_x().is_nan() == self.center_y().is_nan(),
346            "center_x and center_y must both be NaN or both finite"
347        );
348        center_or_bbox(self.center_x(), self.center_y(), width, height, 1.0)
349    }
350
351    /// Returns the gradient center in a scaled coordinate space.
352    ///
353    /// `width` and `height` are the dimensions in the target coordinate space. Explicit center
354    /// values are local logical lengths and are multiplied by `scale_factor`; default centers are
355    /// derived from the dimensions directly.
356    pub fn center_or_default_scaled(
357        &self,
358        width: f32,
359        height: f32,
360        scale_factor: f32,
361    ) -> (f32, f32) {
362        debug_assert!(
363            self.center_x().is_nan() == self.center_y().is_nan(),
364            "center_x and center_y must both be NaN or both finite"
365        );
366        center_or_bbox(self.center_x(), self.center_y(), width, height, scale_factor)
367    }
368
369    /// Returns the gradient radius, falling back to half of the bounding box diagonal when not
370    /// explicitly set.
371    ///
372    /// `width` and `height` are the element's logical dimensions.
373    pub fn radius_or_default(&self, width: f32, height: f32) -> f32 {
374        let r = self.radius();
375        if r < 0.0 { 0.5 * (width * width + height * height).sqrt() } else { r }
376    }
377
378    /// Returns the gradient radius in a scaled coordinate space.
379    ///
380    /// `width` and `height` are the dimensions in the target coordinate space. Explicit radius
381    /// values are local logical lengths and are multiplied by `scale_factor`; the default radius is
382    /// derived from the dimensions directly.
383    pub fn radius_or_default_scaled(&self, width: f32, height: f32, scale_factor: f32) -> f32 {
384        let r = self.radius();
385        if r < 0.0 { 0.5 * (width * width + height * height).sqrt() } else { r * scale_factor }
386    }
387}
388
389/// Equality is render-equivalence: two NaN center fields compare equal because both use the
390/// bounding box center. Any two negative radii compare equal because both use the default radius.
391impl PartialEq for RadialGradientBrush {
392    fn eq(&self, other: &Self) -> bool {
393        if self.0.len() != other.0.len() {
394            return false;
395        }
396        nan_eq(self.center_x(), other.center_x())
397            && nan_eq(self.center_y(), other.center_y())
398            && (self.radius() == other.radius() || (self.radius() < 0.0 && other.radius() < 0.0))
399            && self.0.iter().skip(Self::HEADER).eq(other.0.iter().skip(Self::HEADER))
400    }
401}
402
403/// The ConicGradientBrush describes a way of filling a shape with a gradient
404/// that rotates around a center point.
405///
406/// The center defaults to the middle of the bounding box. Use
407/// [`with_center`](Self::with_center) to override.
408///
409/// Internally the first three fake [`GradientStop`] entries encode the starting angle
410/// (index 0), center_x (index 1), and center_y (index 2). Real color stops begin at index 3.
411#[derive(Clone, Debug)]
412#[repr(transparent)]
413pub struct ConicGradientBrush(SharedVector<GradientStop>);
414
415/// Equality is render-equivalence: two NaN center fields compare equal because both use the
416/// bounding box center.
417impl PartialEq for ConicGradientBrush {
418    fn eq(&self, other: &Self) -> bool {
419        if self.0.len() != other.0.len() {
420            return false;
421        }
422        // angle (index 0) uses plain f32 equality (not NaN-aware)
423        self.0[0].position == other.0[0].position
424            && nan_eq(self.center_x(), other.center_x())
425            && nan_eq(self.center_y(), other.center_y())
426            && self.0.iter().skip(Self::HEADER).eq(other.0.iter().skip(Self::HEADER))
427    }
428}
429
430impl ConicGradientBrush {
431    const HEADER: usize = 3;
432
433    /// Creates a new conic gradient, described by the specified angle and the provided color stops.
434    ///
435    /// The angle need to be specified in degrees (CSS `from <angle>` syntax).
436    /// The stops don't need to be sorted as this function will normalize and process them.
437    pub fn new(angle: f32, stops: impl IntoIterator<Item = GradientStop>) -> Self {
438        let stop_iter = stops.into_iter();
439        let mut v = SharedVector::with_capacity(Self::HEADER + stop_iter.size_hint().0);
440        // Header stops: angle, center_x (NaN=bbox), center_y (NaN=bbox)
441        v.push(GradientStop { color: Default::default(), position: angle });
442        v.push(GradientStop { color: Default::default(), position: f32::NAN });
443        v.push(GradientStop { color: Default::default(), position: f32::NAN });
444        v.extend(stop_iter);
445        let mut result = Self(v);
446        result.normalize_stops();
447        if angle.abs() > f32::EPSILON {
448            result.apply_rotation(angle);
449        }
450        result
451    }
452
453    /// Normalizes the gradient stops to be within [0, 1] range with proper boundary stops.
454    fn normalize_stops(&mut self) {
455        // Check if we need to make any changes
456        let stops_slice = &self.0[Self::HEADER..];
457        let has_stop_at_0 = stops_slice.iter().any(|s| s.position.abs() < f32::EPSILON);
458        let has_stop_at_1 = stops_slice.iter().any(|s| (s.position - 1.0).abs() < f32::EPSILON);
459        let has_stops_outside = stops_slice.iter().any(|s| s.position < 0.0 || s.position > 1.0);
460        let is_empty = stops_slice.is_empty();
461
462        // If no changes needed, return early
463        if has_stop_at_0 && has_stop_at_1 && !has_stops_outside && !is_empty {
464            return;
465        }
466
467        // Need to make changes, so copy
468        let mut stops: alloc::vec::Vec<_> = stops_slice.to_vec();
469
470        // Add interpolated boundary stop at 0.0 if needed
471        if !has_stop_at_0 {
472            let stop_below_0 = stops.iter().filter(|s| s.position < 0.0).max_by(|a, b| {
473                a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
474            });
475            let stop_above_0 = stops.iter().filter(|s| s.position > 0.0).min_by(|a, b| {
476                a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
477            });
478            if let (Some(below), Some(above)) = (stop_below_0, stop_above_0) {
479                let t = (0.0 - below.position) / (above.position - below.position);
480                let color_at_0 = Self::interpolate_color(&below.color, &above.color, t);
481                stops.insert(0, GradientStop { position: 0.0, color: color_at_0 });
482            } else if let Some(above) = stop_above_0 {
483                stops.insert(0, GradientStop { position: 0.0, color: above.color });
484            } else if let Some(below) = stop_below_0 {
485                stops.insert(0, GradientStop { position: 0.0, color: below.color });
486            }
487        }
488
489        // Add interpolated boundary stop at 1.0 if needed
490        if !has_stop_at_1 {
491            let stop_below_1 = stops.iter().filter(|s| s.position < 1.0).max_by(|a, b| {
492                a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
493            });
494            let stop_above_1 = stops.iter().filter(|s| s.position > 1.0).min_by(|a, b| {
495                a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
496            });
497
498            if let (Some(below), Some(above)) = (stop_below_1, stop_above_1) {
499                let t = (1.0 - below.position) / (above.position - below.position);
500                let color_at_1 = Self::interpolate_color(&below.color, &above.color, t);
501                stops.push(GradientStop { position: 1.0, color: color_at_1 });
502            } else if let Some(below) = stop_below_1 {
503                stops.push(GradientStop { position: 1.0, color: below.color });
504            } else if let Some(above) = stop_above_1 {
505                stops.push(GradientStop { position: 1.0, color: above.color });
506            }
507        }
508
509        // Drop stops outside [0, 1] range
510        if has_stops_outside {
511            stops.retain(|s| 0.0 <= s.position && s.position <= 1.0);
512        }
513
514        // Handle empty gradients
515        if stops.is_empty() {
516            stops.push(GradientStop { position: 0.0, color: Color::default() });
517            stops.push(GradientStop { position: 1.0, color: Color::default() });
518        }
519
520        // Rebuild internal storage, preserving the full header (angle, center_x, center_y)
521        let angle = self.angle();
522        let cx = self.center_x();
523        let cy = self.center_y();
524        self.0 = SharedVector::with_capacity(stops.len() + Self::HEADER);
525        self.0.push(GradientStop { color: Default::default(), position: angle });
526        self.0.push(GradientStop { color: Default::default(), position: cx });
527        self.0.push(GradientStop { color: Default::default(), position: cy });
528        self.0.extend(stops);
529    }
530
531    /// Apply rotation to the gradient (CSS `from <angle>` syntax).
532    ///
533    /// The `from_angle` parameter is specified in degrees and rotates the entire gradient clockwise.
534    fn apply_rotation(&mut self, from_angle: f32) {
535        // Convert degrees to normalized 0-1 range
536        let normalized_from_angle = (from_angle / 360.0) - (from_angle / 360.0).floor();
537
538        // If no rotation needed, just update the stored angle
539        if normalized_from_angle.abs() < f32::EPSILON {
540            self.0.make_mut_slice()[0].position = from_angle;
541            return;
542        }
543
544        // Update the stored angle
545        self.0.make_mut_slice()[0].position = from_angle;
546
547        // Need to rotate, so copy
548        let mut stops: alloc::vec::Vec<_> = self.0.iter().skip(Self::HEADER).copied().collect();
549
550        // Adjust first stop (at 0.0) to avoid duplicate with stop at 1.0
551        if let Some(first) = stops.first_mut()
552            && first.position.abs() < f32::EPSILON
553        {
554            first.position = f32::EPSILON;
555        }
556
557        // Step 1: Apply rotation by adding from_angle and wrapping to [0, 1) range
558        stops = stops
559            .iter()
560            .map(|stop| {
561                // f32::rem_euclid is not always available, and when it is it has a
562                // different signature than num_traits::Euclid::rem_euclid (issue #11333).
563                let rotated_position =
564                    num_traits::Euclid::rem_euclid(&(stop.position + normalized_from_angle), &1.0);
565                GradientStop { position: rotated_position, color: stop.color }
566            })
567            .collect();
568
569        // Step 2: Separate duplicate positions with different colors to avoid flickering
570        for i in 0..stops.len() {
571            let j = (i + 1) % stops.len();
572            if (stops[i].position - stops[j].position).abs() < f32::EPSILON
573                && stops[i].color != stops[j].color
574            {
575                stops[i].position = (stops[i].position - f32::EPSILON).max(0.0);
576                stops[j].position = (stops[j].position + f32::EPSILON).min(1.0);
577            }
578        }
579
580        // Step 3: Sort by rotated position
581        stops.sort_by(|a, b| {
582            a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
583        });
584
585        // Step 4: Add boundary stops at 0.0 and 1.0 if missing
586        let has_stop_at_0 = stops.iter().any(|s| s.position.abs() < f32::EPSILON);
587        if !has_stop_at_0 && let (Some(last), Some(first)) = (stops.last(), stops.first()) {
588            let gap = 1.0 - last.position + first.position;
589            let color_at_0 = if gap > f32::EPSILON {
590                let t = (1.0 - last.position) / gap;
591                Self::interpolate_color(&last.color, &first.color, t)
592            } else {
593                last.color
594            };
595            stops.insert(0, GradientStop { position: 0.0, color: color_at_0 });
596        }
597
598        let has_stop_at_1 = stops.iter().any(|s| (s.position - 1.0).abs() < f32::EPSILON);
599        if !has_stop_at_1 && let Some(first) = stops.first() {
600            stops.push(GradientStop { position: 1.0, color: first.color });
601        }
602
603        // Rebuild internal storage, preserving the full header (angle, center_x, center_y)
604        let cx = self.center_x();
605        let cy = self.center_y();
606        self.0 = SharedVector::with_capacity(stops.len() + Self::HEADER);
607        self.0.push(GradientStop { color: Default::default(), position: from_angle });
608        self.0.push(GradientStop { color: Default::default(), position: cx });
609        self.0.push(GradientStop { color: Default::default(), position: cy });
610        self.0.extend(stops);
611    }
612
613    /// Returns the starting angle (rotation) of the conic gradient in degrees.
614    fn angle(&self) -> f32 {
615        self.0[0].position
616    }
617
618    #[inline]
619    fn center_x(&self) -> f32 {
620        self.0[1].position
621    }
622    #[inline]
623    fn center_y(&self) -> f32 {
624        self.0[2].position
625    }
626
627    /// Returns the color stops of the conic gradient.
628    /// The stops are already rotated according to the `from_angle` specified in `new()`.
629    pub fn stops(&self) -> impl Iterator<Item = &GradientStop> {
630        self.0.iter().skip(Self::HEADER)
631    }
632
633    /// The color stops as a slice, without the header stops.
634    fn stops_slice(&self) -> &[GradientStop] {
635        self.0.as_slice().get(Self::HEADER..).unwrap_or_default()
636    }
637
638    /// Sets an explicit center, returning `self` for chaining. `cx` and `cy` are in the
639    /// element's local logical coordinate space.
640    pub fn with_center(mut self, cx: f32, cy: f32) -> Self {
641        let s = self.0.make_mut_slice();
642        s[1].position = cx;
643        s[2].position = cy;
644        self
645    }
646
647    /// Returns the gradient center, falling back to the bounding box center when not explicitly set.
648    ///
649    /// `width` and `height` are the element's logical dimensions.
650    pub fn center_or_default(&self, width: f32, height: f32) -> (f32, f32) {
651        debug_assert!(
652            self.center_x().is_nan() == self.center_y().is_nan(),
653            "center_x and center_y must both be NaN or both finite"
654        );
655        center_or_bbox(self.center_x(), self.center_y(), width, height, 1.0)
656    }
657
658    /// Returns the gradient center in a scaled coordinate space.
659    ///
660    /// `width` and `height` are the dimensions in the target coordinate space. Explicit center
661    /// values are local logical lengths and are multiplied by `scale_factor`; default centers are
662    /// derived from the dimensions directly.
663    pub fn center_or_default_scaled(
664        &self,
665        width: f32,
666        height: f32,
667        scale_factor: f32,
668    ) -> (f32, f32) {
669        debug_assert!(
670            self.center_x().is_nan() == self.center_y().is_nan(),
671            "center_x and center_y must both be NaN or both finite"
672        );
673        center_or_bbox(self.center_x(), self.center_y(), width, height, scale_factor)
674    }
675
676    /// Helper: Linearly interpolate between two colors using premultiplied alpha.
677    ///
678    /// This is used for interpolating gradient boundary colors in CSS-style gradients.
679    /// We cannot use Color::mix() here because it implements Sass color mixing algorithm,
680    /// which is different from CSS gradient color interpolation.
681    ///
682    /// CSS gradients interpolate in premultiplied RGBA space:
683    /// https://www.w3.org/TR/css-color-4/#interpolation-alpha
684    fn interpolate_color(c1: &Color, c2: &Color, factor: f32) -> Color {
685        let argb1 = c1.to_argb_u8();
686        let argb2 = c2.to_argb_u8();
687
688        // Convert to premultiplied alpha
689        let a1 = argb1.alpha as f32 / 255.0;
690        let a2 = argb2.alpha as f32 / 255.0;
691        let r1 = argb1.red as f32 * a1;
692        let g1 = argb1.green as f32 * a1;
693        let b1 = argb1.blue as f32 * a1;
694        let r2 = argb2.red as f32 * a2;
695        let g2 = argb2.green as f32 * a2;
696        let b2 = argb2.blue as f32 * a2;
697
698        // Interpolate in premultiplied space
699        let alpha = (1.0 - factor) * a1 + factor * a2;
700        let red = (1.0 - factor) * r1 + factor * r2;
701        let green = (1.0 - factor) * g1 + factor * g2;
702        let blue = (1.0 - factor) * b1 + factor * b2;
703
704        // Convert back from premultiplied alpha
705        if alpha > 0.0 {
706            Color::from_argb_u8(
707                (alpha * 255.0) as u8,
708                (red / alpha).min(255.0) as u8,
709                (green / alpha).min(255.0) as u8,
710                (blue / alpha).min(255.0) as u8,
711            )
712        } else {
713            Color::from_argb_u8(0, 0, 0, 0)
714        }
715    }
716}
717
718/// C FFI function to normalize the gradient stops to be within [0, 1] range
719#[cfg(feature = "ffi")]
720#[unsafe(no_mangle)]
721pub extern "C" fn slint_conic_gradient_normalize_stops(gradient: &mut ConicGradientBrush) {
722    gradient.normalize_stops();
723}
724
725/// C FFI function to apply rotation to a ConicGradientBrush
726#[cfg(feature = "ffi")]
727#[unsafe(no_mangle)]
728pub extern "C" fn slint_conic_gradient_apply_rotation(
729    gradient: &mut ConicGradientBrush,
730    angle_degrees: f32,
731) {
732    gradient.apply_rotation(angle_degrees);
733}
734
735/// Compare two brushes using Rust's render-equivalent equality.
736#[cfg(feature = "ffi")]
737#[unsafe(no_mangle)]
738pub extern "C" fn slint_brush_compare_equal(brush1: &Brush, brush2: &Brush) -> bool {
739    brush1.eq(brush2)
740}
741
742/// GradientStop describes a single color stop in a gradient. The colors between multiple
743/// stops are interpolated.
744#[repr(C)]
745#[derive(Copy, Clone, Debug, PartialEq)]
746pub struct GradientStop {
747    /// The color to draw at this stop.
748    pub color: Color,
749    /// The position of this stop on the entire shape, as a normalized value between 0 and 1.
750    pub position: f32,
751}
752
753/// Returns the start / end points of a gradient within a rectangle of the given size, based on the angle (in degree).
754pub fn line_for_angle(angle: f32, size: Size2D<f32>) -> (Point2D<f32>, Point2D<f32>) {
755    let angle = (angle + 90.).to_radians();
756    let (s, c) = angle.sin_cos();
757
758    let (a, b) = if s.abs() < f32::EPSILON {
759        let y = size.height / 2.;
760        return if c < 0. {
761            (Point2D::new(0., y), Point2D::new(size.width, y))
762        } else {
763            (Point2D::new(size.width, y), Point2D::new(0., y))
764        };
765    } else if c * s < 0. {
766        // Intersection between the gradient line, and an orthogonal line that goes through (height, 0)
767        let x = (s * size.width + c * size.height) * s / 2.;
768        let y = -c * x / s + size.height;
769        (Point2D::new(x, y), Point2D::new(size.width - x, size.height - y))
770    } else {
771        // Intersection between the gradient line, and an orthogonal line that goes through (0, 0)
772        let x = (s * size.width - c * size.height) * s / 2.;
773        let y = -c * x / s;
774        (Point2D::new(size.width - x, size.height - y), Point2D::new(x, y))
775    };
776
777    if s > 0. { (a, b) } else { (b, a) }
778}
779
780impl InterpolatedPropertyValue for Brush {
781    fn interpolate(&self, target_value: &Self, t: f32) -> Self {
782        match (self, target_value) {
783            (Brush::SolidColor(source_col), Brush::SolidColor(target_col)) => {
784                Brush::SolidColor(source_col.interpolate(target_col, t))
785            }
786            (Brush::SolidColor(col), Brush::LinearGradient(grad)) => {
787                let mut new_grad = grad.clone();
788                for x in new_grad.0.make_mut_slice().iter_mut().skip(1) {
789                    x.color = col.interpolate(&x.color, t);
790                }
791                Brush::LinearGradient(new_grad)
792            }
793            (a @ Brush::LinearGradient(_), b @ Brush::SolidColor(_)) => {
794                Self::interpolate(b, a, 1. - t)
795            }
796            (Brush::LinearGradient(lhs), Brush::LinearGradient(rhs)) => {
797                if lhs.0.len() < rhs.0.len() {
798                    Self::interpolate(target_value, self, 1. - t)
799                } else {
800                    let mut new_grad = lhs.clone();
801                    let mut iter = new_grad.0.make_mut_slice().iter_mut();
802                    {
803                        let angle = &mut iter.next().unwrap().position;
804                        *angle = angle.interpolate(&rhs.angle(), t);
805                    }
806                    for s2 in rhs.stops() {
807                        let s1 = iter.next().unwrap();
808                        s1.color = s1.color.interpolate(&s2.color, t);
809                        s1.position = s1.position.interpolate(&s2.position, t);
810                    }
811                    for x in iter {
812                        x.position = x.position.interpolate(&1.0, t);
813                    }
814                    Brush::LinearGradient(new_grad)
815                }
816            }
817            (Brush::SolidColor(col), Brush::RadialGradient(grad)) => {
818                let mut new_grad = grad.clone();
819                for x in new_grad.0.make_mut_slice().iter_mut().skip(RadialGradientBrush::HEADER) {
820                    x.color = col.interpolate(&x.color, t);
821                }
822                Brush::RadialGradient(new_grad)
823            }
824            (a @ Brush::RadialGradient(_), b @ Brush::SolidColor(_)) => {
825                Self::interpolate(b, a, 1. - t)
826            }
827            (Brush::RadialGradient(lhs), Brush::RadialGradient(rhs)) => {
828                if lhs.0.len() < rhs.0.len() {
829                    Self::interpolate(target_value, self, 1. - t)
830                } else {
831                    let mut new_grad = lhs.clone();
832                    {
833                        let s = new_grad.0.make_mut_slice();
834                        // Center: interpolate when both sides are explicit. When one side is the
835                        // default (NaN), lhs wins for t < 1 and snaps to rhs at t == 1.
836                        if !lhs.center_x().is_nan() && !rhs.center_x().is_nan() {
837                            s[0].position = lhs.center_x().interpolate(&rhs.center_x(), t);
838                            s[1].position = lhs.center_y().interpolate(&rhs.center_y(), t);
839                        } else if t >= 1.0 {
840                            s[0].position = rhs.center_x();
841                            s[1].position = rhs.center_y();
842                        }
843                        // Radius: same snap behavior when one side is the default (negative).
844                        if lhs.radius() >= 0.0 && rhs.radius() >= 0.0 {
845                            s[2].position = lhs.radius().interpolate(&rhs.radius(), t);
846                        } else if t >= 1.0 {
847                            s[2].position = rhs.radius();
848                        }
849                        let mut rhs_stops = rhs.stops();
850                        let mut iter = s.iter_mut().skip(RadialGradientBrush::HEADER);
851                        let mut last_color = Color::default();
852                        for s2 in &mut rhs_stops {
853                            let s1 = iter.next().unwrap();
854                            last_color = s2.color;
855                            s1.color = s1.color.interpolate(&s2.color, t);
856                            s1.position = s1.position.interpolate(&s2.position, t);
857                        }
858                        for x in iter {
859                            x.position = x.position.interpolate(&1.0, t);
860                            x.color = x.color.interpolate(&last_color, t);
861                        }
862                    }
863                    Brush::RadialGradient(new_grad)
864                }
865            }
866            (Brush::SolidColor(col), Brush::ConicGradient(grad)) => {
867                let mut new_grad = grad.clone();
868                for x in new_grad.0.make_mut_slice().iter_mut().skip(ConicGradientBrush::HEADER) {
869                    x.color = col.interpolate(&x.color, t);
870                }
871                Brush::ConicGradient(new_grad)
872            }
873            (a @ Brush::ConicGradient(_), b @ Brush::SolidColor(_)) => {
874                Self::interpolate(b, a, 1. - t)
875            }
876            (Brush::ConicGradient(lhs), Brush::ConicGradient(rhs)) => {
877                if lhs.0.len() < rhs.0.len() {
878                    Self::interpolate(target_value, self, 1. - t)
879                } else {
880                    let mut new_grad = lhs.clone();
881                    {
882                        let s = new_grad.0.make_mut_slice();
883                        // angle (s[0])
884                        s[0].position = lhs.angle().interpolate(&rhs.angle(), t);
885                        // Center: interpolate when both sides are explicit. When one side is the
886                        // default (NaN), lhs wins for t < 1 and snaps to rhs at t == 1.
887                        if !lhs.center_x().is_nan() && !rhs.center_x().is_nan() {
888                            s[1].position = lhs.center_x().interpolate(&rhs.center_x(), t);
889                            s[2].position = lhs.center_y().interpolate(&rhs.center_y(), t);
890                        } else if t >= 1.0 {
891                            s[1].position = rhs.center_x();
892                            s[2].position = rhs.center_y();
893                        }
894                        let mut rhs_stops = rhs.stops();
895                        let mut iter = s.iter_mut().skip(ConicGradientBrush::HEADER);
896                        for s2 in &mut rhs_stops {
897                            let s1 = iter.next().unwrap();
898                            s1.color = s1.color.interpolate(&s2.color, t);
899                            s1.position = s1.position.interpolate(&s2.position, t);
900                        }
901                        for x in iter {
902                            x.position = x.position.interpolate(&1.0, t);
903                        }
904                    }
905                    Brush::ConicGradient(new_grad)
906                }
907            }
908            (a @ Brush::LinearGradient(_), b @ Brush::RadialGradient(_))
909            | (a @ Brush::RadialGradient(_), b @ Brush::LinearGradient(_))
910            | (a @ Brush::LinearGradient(_), b @ Brush::ConicGradient(_))
911            | (a @ Brush::ConicGradient(_), b @ Brush::LinearGradient(_))
912            | (a @ Brush::RadialGradient(_), b @ Brush::ConicGradient(_))
913            | (a @ Brush::ConicGradient(_), b @ Brush::RadialGradient(_)) => {
914                // Just go to an intermediate color.
915                let color = Color::interpolate(&b.color(), &a.color(), t);
916                if t < 0.5 {
917                    Self::interpolate(a, &Brush::SolidColor(color), t * 2.)
918                } else {
919                    Self::interpolate(&Brush::SolidColor(color), b, (t - 0.5) * 2.)
920                }
921            }
922        }
923    }
924}
925
926/// A [`Brush`] resolved by [`resolve_brush`]: gradient geometry in physical pixels
927/// plus sanitized color stops, so that all renderers share one interpretation of
928/// the brush model.
929#[derive(Clone, Debug, PartialEq)]
930pub enum ResolvedBrush<'a> {
931    /// A plain color fill.
932    SolidColor(Color),
933    /// See [`ResolvedLinearGradient`].
934    LinearGradient(ResolvedLinearGradient<'a>),
935    /// See [`ResolvedRadialGradient`].
936    RadialGradient(ResolvedRadialGradient<'a>),
937    /// See [`ResolvedConicGradient`].
938    ConicGradient(ResolvedConicGradient<'a>),
939}
940
941/// A linear gradient whose stops span the line from `start` to `end`.
942#[derive(Clone, Debug, PartialEq)]
943pub struct ResolvedLinearGradient<'a> {
944    /// The point stop position 0 lies on.
945    pub start: euclid::Point2D<f32, PhysicalPx>,
946    /// The point stop position 1 lies on.
947    pub end: euclid::Point2D<f32, PhysicalPx>,
948    /// The sanitized color stops.
949    pub stops: Cow<'a, [GradientStop]>,
950}
951
952/// A radial gradient whose stops span from `center` to `radius`.
953#[derive(Clone, Debug, PartialEq)]
954pub struct ResolvedRadialGradient<'a> {
955    /// The center of the gradient.
956    pub center: euclid::Point2D<f32, PhysicalPx>,
957    /// The radius stop position 1 lies on.
958    pub radius: euclid::Length<f32, PhysicalPx>,
959    /// The sanitized color stops.
960    pub stops: Cow<'a, [GradientStop]>,
961}
962
963/// A conic gradient whose stops run one full clockwise turn around `center`,
964/// starting at 12 o'clock (Slint's 0°).
965#[derive(Clone, Debug, PartialEq)]
966pub struct ResolvedConicGradient<'a> {
967    /// The center of the gradient.
968    pub center: euclid::Point2D<f32, PhysicalPx>,
969    /// The sanitized color stops.
970    pub stops: Cow<'a, [GradientStop]>,
971}
972
973/// Resolves a brush against the shape it fills: `size` is the shape's size in
974/// physical pixels; explicit gradient center and radius values are local logical
975/// lengths converted through `scale_factor`. Returns `None` for a fully
976/// transparent brush.
977///
978/// The returned stops are canonical - sorted, strictly increasing, within [0, 1] -
979/// with out-of-range linear/radial stops folded into the gradient geometry and
980/// conic stops clamped (a conic gradient cannot extend past a full turn), so they
981/// are directly usable by backends that render non-canonical stops incorrectly
982/// (vello draws them as a solid fill of the first color). Already-canonical stops,
983/// which is all the compiler produces, are borrowed rather than copied.
984///
985/// A free function rather than a `Brush` method so that it stays out of the public
986/// API that `Brush` is re-exported into.
987pub fn resolve_brush<'a>(
988    brush: &'a Brush,
989    size: euclid::Size2D<f32, PhysicalPx>,
990    scale_factor: ScaleFactor,
991) -> Option<ResolvedBrush<'a>> {
992    if brush.is_transparent() {
993        return None;
994    }
995    Some(match brush {
996        Brush::SolidColor(color) => ResolvedBrush::SolidColor(*color),
997        Brush::LinearGradient(gradient) => {
998            let (stops, extent) = sanitize_color_stops(gradient.stops_slice(), true);
999            let (start, mut end) = line_for_angle(gradient.angle(), size.to_untyped());
1000            if extent != 1.0 {
1001                // sanitize_color_stops scaled the offsets down into [0, 1]; scale
1002                // the gradient line to match, or the ramp comes out compressed.
1003                end = start + (end - start) * extent;
1004            }
1005            ResolvedBrush::LinearGradient(ResolvedLinearGradient {
1006                start: start.cast_unit(),
1007                end: end.cast_unit(),
1008                stops,
1009            })
1010        }
1011        Brush::RadialGradient(gradient) => {
1012            let (stops, extent) = sanitize_color_stops(gradient.stops_slice(), true);
1013            let (center_x, center_y) =
1014                gradient.center_or_default_scaled(size.width, size.height, scale_factor.get());
1015            let radius =
1016                gradient.radius_or_default_scaled(size.width, size.height, scale_factor.get())
1017                    * extent;
1018            ResolvedBrush::RadialGradient(ResolvedRadialGradient {
1019                center: euclid::point2(center_x, center_y),
1020                radius: euclid::Length::new(radius),
1021                stops,
1022            })
1023        }
1024        Brush::ConicGradient(gradient) => {
1025            let (stops, _) = sanitize_color_stops(gradient.stops_slice(), false);
1026            let (center_x, center_y) =
1027                gradient.center_or_default_scaled(size.width, size.height, scale_factor.get());
1028            ResolvedBrush::ConicGradient(ResolvedConicGradient {
1029                center: euclid::point2(center_x, center_y),
1030                stops,
1031            })
1032        }
1033    })
1034}
1035
1036/// Massage gradient color stops into a canonical form (see [`resolve_brush`]).
1037/// Stops below 0 are replaced by the interpolated color at 0 (the CSS behavior).
1038/// For stops beyond 1: with `can_extend`, all positions are divided by the maximum
1039/// and that maximum is returned as the second tuple element, so the caller can grow
1040/// the gradient geometry by the same factor; without it, they are clamped to the
1041/// interpolated color at 1. Duplicate positions (hard color steps) are separated by
1042/// the smallest representable amount.
1043fn sanitize_color_stops(
1044    stops: &[GradientStop],
1045    can_extend: bool,
1046) -> (Cow<'_, [GradientStop]>, f32) {
1047    /// Plain per-channel interpolation, matching how the gradient ramp itself blends.
1048    fn color_at(position: f32, a: &GradientStop, b: &GradientStop) -> Color {
1049        let t = if b.position > a.position {
1050            ((position - a.position) / (b.position - a.position)).clamp(0., 1.)
1051        } else {
1052            0.
1053        };
1054        let (ca, cb) = (a.color.to_argb_u8(), b.color.to_argb_u8());
1055        let lerp = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
1056        Color::from_argb_u8(
1057            lerp(ca.alpha, cb.alpha),
1058            lerp(ca.red, cb.red),
1059            lerp(ca.green, cb.green),
1060            lerp(ca.blue, cb.blue),
1061        )
1062    }
1063
1064    // The compiler always produces canonical stop lists, so this fast path is the
1065    // common case. A NaN position fails these comparisons: slow path.
1066    if stops.first().is_none_or(|first| first.position >= 0.)
1067        && stops.last().is_none_or(|last| last.position <= 1.)
1068        && stops.windows(2).all(|pair| pair[0].position < pair[1].position)
1069    {
1070        return (Cow::Borrowed(stops), 1.0);
1071    }
1072
1073    let mut stops: alloc::vec::Vec<GradientStop> = stops.to_vec();
1074    stops.sort_by(|a, b| a.position.total_cmp(&b.position));
1075
1076    // Replace everything below 0 with the interpolated color at 0.
1077    while stops.len() >= 2 && stops[1].position <= 0. {
1078        stops.remove(0);
1079    }
1080    if let [first, second, ..] = stops.as_slice()
1081        && first.position < 0.
1082    {
1083        stops[0] = GradientStop { color: color_at(0., first, second), position: 0. };
1084    } else if let [only] = stops.as_slice()
1085        && only.position < 0.
1086    {
1087        stops[0].position = 0.;
1088    }
1089
1090    // Handle stops beyond 1: normalize (the caller extends the geometry) or clamp to
1091    // the interpolated color at 1.
1092    let mut extent = 1.0f32;
1093    if stops.last().is_some_and(|last| last.position > 1.) {
1094        if can_extend {
1095            extent = stops.last().unwrap().position;
1096            for stop in &mut stops {
1097                stop.position /= extent;
1098            }
1099        } else {
1100            while stops.len() >= 2 && stops[stops.len() - 2].position >= 1. {
1101                stops.pop();
1102            }
1103            let clamped_last = match stops.as_slice() {
1104                [.., second_to_last, last] if last.position > 1. => {
1105                    Some(GradientStop { color: color_at(1., second_to_last, last), position: 1. })
1106                }
1107                [only] if only.position > 1. => {
1108                    Some(GradientStop { color: only.color, position: 1. })
1109                }
1110                _ => None,
1111            };
1112            if let Some(stop) = clamped_last {
1113                *stops.last_mut().unwrap() = stop;
1114            }
1115        }
1116    }
1117
1118    // Make positions strictly increasing: separate duplicates (hard color steps) by the
1119    // smallest representable amount, then push back anything that got nudged past 1.
1120    let mut previous = f32::NEG_INFINITY;
1121    for stop in &mut stops {
1122        if stop.position <= previous {
1123            stop.position = previous.next_up();
1124        }
1125        previous = stop.position;
1126    }
1127    let mut next = 1.0f32.next_up();
1128    for stop in stops.iter_mut().rev() {
1129        if stop.position >= next {
1130            stop.position = next.next_down();
1131        }
1132        next = stop.position;
1133    }
1134
1135    (Cow::Owned(stops), extent)
1136}
1137
1138#[test]
1139fn test_resolve_sanitizes_out_of_range_stops() {
1140    // Stops beyond 1 extend the gradient line instead of being clamped.
1141    let brush = Brush::LinearGradient(LinearGradientBrush::new(
1142        180.,
1143        [
1144            GradientStop { position: 0.0, color: Color::from_rgb_u8(255, 0, 0) },
1145            GradientStop { position: 2.0, color: Color::from_rgb_u8(0, 0, 255) },
1146        ],
1147    ));
1148    let Some(ResolvedBrush::LinearGradient(gradient)) =
1149        resolve_brush(&brush, [100., 50.].into(), ScaleFactor::new(1.0))
1150    else {
1151        panic!("expected a resolved linear gradient");
1152    };
1153    assert_eq!(gradient.stops.last().unwrap().position, 1.0);
1154    // A 180° gradient runs from the top edge down; the end extends past the shape.
1155    assert_eq!(gradient.start.y, 0.);
1156    assert_eq!(gradient.end.y, 100.);
1157
1158    // A conic gradient cannot extend, so out-of-range stops are clamped to the
1159    // interpolated color at position 1 instead. Note that ConicGradientBrush::new
1160    // already normalizes, so resolving keeps its stops within [0, 1].
1161    let brush = Brush::ConicGradient(ConicGradientBrush::new(
1162        0.,
1163        [
1164            GradientStop { position: 0.0, color: Color::from_rgb_u8(255, 0, 0) },
1165            GradientStop { position: 2.0, color: Color::from_rgb_u8(0, 0, 255) },
1166        ],
1167    ));
1168    let Some(ResolvedBrush::ConicGradient(gradient)) =
1169        resolve_brush(&brush, [100., 50.].into(), ScaleFactor::new(1.0))
1170    else {
1171        panic!("expected a resolved conic gradient");
1172    };
1173    assert!(gradient.stops.iter().all(|stop| (0. ..=1.).contains(&stop.position)));
1174    assert_eq!(gradient.center, euclid::point2(50., 25.));
1175}
1176
1177#[test]
1178fn test_resolve_makes_stops_strictly_increasing() {
1179    let brush = Brush::LinearGradient(LinearGradientBrush::new(
1180        0.,
1181        [
1182            GradientStop { position: 0.5, color: Color::from_rgb_u8(255, 0, 0) },
1183            GradientStop { position: 0.5, color: Color::from_rgb_u8(0, 255, 0) },
1184            GradientStop { position: 0.2, color: Color::from_rgb_u8(0, 0, 255) },
1185        ],
1186    ));
1187    let Some(ResolvedBrush::LinearGradient(gradient)) =
1188        resolve_brush(&brush, [100., 100.].into(), ScaleFactor::new(1.0))
1189    else {
1190        panic!("expected a resolved linear gradient");
1191    };
1192    // Sorted and strictly increasing: the duplicate hard step is separated minimally.
1193    assert!(gradient.stops.windows(2).all(|pair| pair[0].position < pair[1].position));
1194    assert_eq!(gradient.stops[0].color, Color::from_rgb_u8(0, 0, 255));
1195}
1196
1197#[test]
1198fn test_resolve_replaces_stops_below_zero() {
1199    let brush = Brush::LinearGradient(LinearGradientBrush::new(
1200        0.,
1201        [
1202            GradientStop { position: -1.0, color: Color::from_rgb_u8(0, 0, 0) },
1203            GradientStop { position: 1.0, color: Color::from_rgb_u8(200, 200, 200) },
1204        ],
1205    ));
1206    let Some(ResolvedBrush::LinearGradient(gradient)) =
1207        resolve_brush(&brush, [100., 100.].into(), ScaleFactor::new(1.0))
1208    else {
1209        panic!("expected a resolved linear gradient");
1210    };
1211    // The first stop is replaced by the interpolated color at position 0.
1212    assert_eq!(gradient.stops[0].position, 0.0);
1213    assert_eq!(gradient.stops[0].color, Color::from_rgb_u8(100, 100, 100));
1214}
1215
1216#[test]
1217fn test_resolve_transparent_brush() {
1218    assert_eq!(resolve_brush(&Brush::default(), [100., 100.].into(), ScaleFactor::new(1.0)), None);
1219}
1220
1221#[test]
1222fn test_resolve_borrows_canonical_stops() {
1223    // Already canonical stops are borrowed from the brush, not copied.
1224    let brush = Brush::LinearGradient(LinearGradientBrush::new(
1225        90.,
1226        [
1227            GradientStop { position: 0.0, color: Color::from_rgb_u8(255, 0, 0) },
1228            GradientStop { position: 1.0, color: Color::from_rgb_u8(0, 0, 255) },
1229        ],
1230    ));
1231    let Some(ResolvedBrush::LinearGradient(gradient)) =
1232        resolve_brush(&brush, [100., 100.].into(), ScaleFactor::new(1.0))
1233    else {
1234        panic!("expected a resolved linear gradient");
1235    };
1236    assert!(matches!(gradient.stops, Cow::Borrowed(_)));
1237    assert_eq!(gradient.stops.len(), 2);
1238}
1239
1240#[test]
1241#[allow(clippy::float_cmp)] // We want bit-wise equality here
1242fn test_linear_gradient_encoding() {
1243    let stops: SharedVector<GradientStop> = [
1244        GradientStop { position: 0.0, color: Color::from_argb_u8(255, 255, 0, 0) },
1245        GradientStop { position: 0.5, color: Color::from_argb_u8(255, 0, 255, 0) },
1246        GradientStop { position: 1.0, color: Color::from_argb_u8(255, 0, 0, 255) },
1247    ]
1248    .into();
1249    let grad = LinearGradientBrush::new(256., stops.clone());
1250    assert_eq!(grad.angle(), 256.);
1251    assert!(grad.stops().eq(stops.iter()));
1252}
1253
1254#[test]
1255fn test_conic_gradient_basic() {
1256    // Test basic conic gradient with no rotation
1257    let grad = ConicGradientBrush::new(
1258        0.0,
1259        [
1260            GradientStop { position: 0.0, color: Color::from_rgb_u8(255, 0, 0) },
1261            GradientStop { position: 0.5, color: Color::from_rgb_u8(0, 255, 0) },
1262            GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 0, 0) },
1263        ],
1264    );
1265    assert_eq!(grad.angle(), 0.0);
1266    assert_eq!(grad.stops().count(), 3);
1267}
1268
1269#[test]
1270fn test_conic_gradient_with_rotation() {
1271    // Test conic gradient with 90 degree rotation
1272    let grad = ConicGradientBrush::new(
1273        90.0,
1274        [
1275            GradientStop { position: 0.0, color: Color::from_rgb_u8(255, 0, 0) },
1276            GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 0, 0) },
1277        ],
1278    );
1279    assert_eq!(grad.angle(), 90.0);
1280    // After rotation, stops should still be present and sorted
1281    assert!(grad.stops().count() >= 2);
1282}
1283
1284#[test]
1285fn test_conic_gradient_negative_angle() {
1286    // Test with negative angle - should be normalized
1287    let grad = ConicGradientBrush::new(
1288        -90.0,
1289        [GradientStop { position: 0.5, color: Color::from_rgb_u8(255, 0, 0) }],
1290    );
1291    assert_eq!(grad.angle(), -90.0); // Angle is stored as-is
1292    assert!(grad.stops().count() >= 2); // Should have boundary stops added
1293}
1294
1295#[test]
1296fn test_conic_gradient_stops_outside_range() {
1297    // Test with stops outside [0, 1] range
1298    let grad = ConicGradientBrush::new(
1299        0.0,
1300        [
1301            GradientStop { position: -0.2, color: Color::from_rgb_u8(255, 0, 0) },
1302            GradientStop { position: 0.5, color: Color::from_rgb_u8(0, 255, 0) },
1303            GradientStop { position: 1.2, color: Color::from_rgb_u8(0, 0, 255) },
1304        ],
1305    );
1306    // All stops should be within [0, 1] after processing
1307    for stop in grad.stops() {
1308        assert!(stop.position >= 0.0 && stop.position <= 1.0);
1309    }
1310}
1311
1312#[test]
1313fn test_conic_gradient_all_stops_below_zero() {
1314    // Test edge case: all stops are below 0
1315    let grad = ConicGradientBrush::new(
1316        0.0,
1317        [
1318            GradientStop { position: -0.5, color: Color::from_rgb_u8(255, 0, 0) },
1319            GradientStop { position: -0.3, color: Color::from_rgb_u8(0, 255, 0) },
1320        ],
1321    );
1322    // Should create valid boundary stops
1323    assert!(grad.stops().count() >= 2);
1324    // First stop should be at or near 0.0
1325    let first = grad.stops().next().unwrap();
1326    assert!(first.position >= 0.0 && first.position < 0.1);
1327}
1328
1329#[test]
1330fn test_conic_gradient_all_stops_above_one() {
1331    // Test edge case: all stops are above 1
1332    let grad = ConicGradientBrush::new(
1333        0.0,
1334        [
1335            GradientStop { position: 1.2, color: Color::from_rgb_u8(255, 0, 0) },
1336            GradientStop { position: 1.5, color: Color::from_rgb_u8(0, 255, 0) },
1337        ],
1338    );
1339    // Should create valid boundary stops
1340    assert!(grad.stops().count() >= 2);
1341    // Last stop should be at or near 1.0
1342    let last = grad.stops().last().unwrap();
1343    assert!(last.position > 0.9 && last.position <= 1.0);
1344}
1345
1346#[test]
1347fn test_conic_gradient_empty() {
1348    // Test edge case: no stops provided
1349    let grad = ConicGradientBrush::new(0.0, []);
1350    // Should create default transparent stops
1351    assert_eq!(grad.stops().count(), 2);
1352}
1353
1354#[test]
1355fn test_radial_gradient_preserves_center_on_brighter() {
1356    let grad = RadialGradientBrush::new_circle([
1357        GradientStop { position: 0.0, color: Color::from_rgb_u8(200, 100, 50) },
1358        GradientStop { position: 1.0, color: Color::from_rgb_u8(50, 200, 100) },
1359    ])
1360    .with_center(10.0, 20.0)
1361    .with_radius(30.0);
1362    let brighter = Brush::RadialGradient(grad.clone()).brighter(0.5);
1363    if let Brush::RadialGradient(b) = brighter {
1364        assert_eq!(b.center_x(), 10.0);
1365        assert_eq!(b.center_y(), 20.0);
1366        assert_eq!(b.radius(), 30.0);
1367    } else {
1368        panic!("Expected RadialGradient");
1369    }
1370}
1371
1372#[test]
1373fn test_radial_gradient_default_center() {
1374    let grad = RadialGradientBrush::new_circle([]);
1375    assert!(grad.center_x().is_nan());
1376    assert!(grad.center_y().is_nan());
1377    assert!(grad.radius() < 0.0);
1378    assert_eq!(grad.center_or_default(100.0, 80.0), (50.0, 40.0));
1379    assert!((grad.radius_or_default(60.0, 80.0) - 50.0).abs() < 0.01);
1380}
1381
1382#[test]
1383fn test_radial_gradient_scaled_explicit_values() {
1384    let grad = RadialGradientBrush::new_circle([]).with_center(10.0, 20.0).with_radius(30.0);
1385
1386    assert_eq!(grad.center_or_default_scaled(200.0, 160.0, 2.0), (20.0, 40.0));
1387    assert_eq!(grad.radius_or_default_scaled(200.0, 160.0, 2.0), 60.0);
1388}
1389
1390#[test]
1391fn test_radial_gradient_scaled_defaults_use_physical_frame() {
1392    let grad = RadialGradientBrush::new_circle([]);
1393
1394    assert_eq!(grad.center_or_default_scaled(200.0, 160.0, 2.0), (100.0, 80.0));
1395    assert!((grad.radius_or_default_scaled(120.0, 160.0, 2.0) - 100.0).abs() < 0.01);
1396}
1397
1398#[test]
1399fn test_radial_gradient_interpolation_reaches_explicit_metadata() {
1400    let source = Brush::RadialGradient(RadialGradientBrush::new_circle([
1401        GradientStop { position: 0.0, color: Color::from_rgb_u8(0, 0, 0) },
1402        GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 255, 255) },
1403    ]));
1404    let target_grad = RadialGradientBrush::new_circle([
1405        GradientStop { position: 0.0, color: Color::from_rgb_u8(0, 0, 0) },
1406        GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 255, 255) },
1407    ])
1408    .with_center(10.0, 20.0)
1409    .with_radius(30.0);
1410    let target = Brush::RadialGradient(target_grad.clone());
1411
1412    if let Brush::RadialGradient(result) = source.interpolate(&target, 1.0) {
1413        assert_eq!(result.center_x(), target_grad.center_x());
1414        assert_eq!(result.center_y(), target_grad.center_y());
1415        assert_eq!(result.radius(), target_grad.radius());
1416    } else {
1417        panic!("Expected RadialGradient");
1418    }
1419}
1420
1421#[test]
1422fn test_radial_gradient_interpolation_reaches_default_metadata() {
1423    let source_grad = RadialGradientBrush::new_circle([
1424        GradientStop { position: 0.0, color: Color::from_rgb_u8(0, 0, 0) },
1425        GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 255, 255) },
1426    ])
1427    .with_center(10.0, 20.0)
1428    .with_radius(30.0);
1429    let source = Brush::RadialGradient(source_grad);
1430    let target = Brush::RadialGradient(RadialGradientBrush::new_circle([
1431        GradientStop { position: 0.0, color: Color::from_rgb_u8(0, 0, 0) },
1432        GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 255, 255) },
1433    ]));
1434
1435    if let Brush::RadialGradient(result) = source.interpolate(&target, 1.0) {
1436        assert!(result.center_x().is_nan());
1437        assert!(result.center_y().is_nan());
1438        assert!(result.radius() < 0.0);
1439    } else {
1440        panic!("Expected RadialGradient");
1441    }
1442}
1443
1444#[test]
1445fn test_conic_gradient_interpolation_reaches_explicit_center() {
1446    let source = Brush::ConicGradient(ConicGradientBrush::new(
1447        0.0,
1448        [
1449            GradientStop { position: 0.0, color: Color::from_rgb_u8(0, 0, 0) },
1450            GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 255, 255) },
1451        ],
1452    ));
1453    let target_grad = ConicGradientBrush::new(
1454        0.0,
1455        [
1456            GradientStop { position: 0.0, color: Color::from_rgb_u8(0, 0, 0) },
1457            GradientStop { position: 1.0, color: Color::from_rgb_u8(255, 255, 255) },
1458        ],
1459    )
1460    .with_center(40.0, 50.0);
1461    let target = Brush::ConicGradient(target_grad.clone());
1462
1463    if let Brush::ConicGradient(result) = source.interpolate(&target, 1.0) {
1464        assert_eq!(result.center_x(), target_grad.center_x());
1465        assert_eq!(result.center_y(), target_grad.center_y());
1466    } else {
1467        panic!("Expected ConicGradient");
1468    }
1469}