Skip to main content

takumi_css/style/properties/
linear_gradient.rs

1use cssparser::{Parser, Token, match_ignore_ascii_case};
2use std::{
3  fmt,
4  ops::{Deref, Neg},
5};
6use tiny_skia::PremultipliedColorU8;
7
8use typed_builder::TypedBuilder;
9
10use super::gradient_utils::{
11  GradientOverlayTile, adaptive_lut_size, adaptive_lut_size_with_visible_samples,
12  build_color_lut_with_interpolation, compute_repeat_setup, gradient_tile_accessors,
13  parse_gradient_stops, resolve_stops_along_axis, write_gradient_css,
14};
15use crate::style::{
16  Animatable, Color, ColorInterpolationMethod, CssDescriptorKind, CssSyntaxKind, CssToken, FromCss,
17  Length, MakeComputed, ParseResult, SizingContext, ToCss, declare_enum_from_css_impl,
18  properties::ColorInput, tw::TailwindPropertyParser, unexpected_token,
19};
20
21/// Represents a linear gradient.
22#[derive(Debug, Clone, PartialEq, TypedBuilder)]
23#[non_exhaustive]
24pub struct LinearGradient {
25  /// Whether the gradient repeats beyond the last stop.
26  #[builder(default)]
27  pub repeating: bool,
28  /// The gradient direction.
29  #[builder(default)]
30  pub direction: LinearGradientDirection,
31  /// The color interpolation method used between stops.
32  #[builder(default)]
33  pub interpolation: ColorInterpolationMethod,
34  /// The steps of the gradient.
35  #[builder(setter(into))]
36  pub stops: Box<[GradientStop]>,
37}
38
39impl MakeComputed for LinearGradient {
40  fn make_computed(&mut self, sizing: &SizingContext) {
41    self.stops.make_computed(sizing);
42  }
43}
44
45/// Precomputed drawing context for repeated sampling of a `LinearGradient`.
46#[derive(Debug, Clone)]
47pub struct LinearGradientTile {
48  /// Target width in pixels.
49  pub width: u32,
50  /// Target height in pixels.
51  pub height: u32,
52  /// Direction vector X component derived from angle.
53  pub dir_x: f32,
54  /// Direction vector Y component derived from angle.
55  pub dir_y: f32,
56  /// Full axis length along gradient direction in pixels.
57  pub axis_length: f32,
58  /// Whether this gradient repeats.
59  pub repeating: bool,
60  /// First resolved stop position in pixels, used as repeating origin.
61  pub repeat_start: f32,
62  /// Repeat period in pixels.
63  pub repeat_period: f32,
64  /// Projection bias for `x * dir_x + y * dir_y + projection_bias`.
65  pub projection_bias: f32,
66  /// Scale converting axis-space position in pixels into LUT index space.
67  pub position_to_lut_scale: f32,
68  /// Whether every sampled pixel in this gradient is fully opaque.
69  pub fully_opaque: bool,
70  /// Pre-computed color lookup table for fast gradient sampling.
71  /// Maps normalized position [0.0, 1.0] to color.
72  pub color_lut: Vec<PremultipliedColorU8>,
73  /// Precomputed axis samples for fast horizontal/vertical fills.
74  pub axis_aligned_fast_path: Option<LinearGradientFastPathData>,
75}
76
77#[derive(Debug, Clone, Copy)]
78pub struct LinearGradientRowState {
79  projection: f32,
80  projection_step: f32,
81  max_lut_index: usize,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum LinearGradientFastPathKind {
86  Horizontal,
87  Vertical,
88}
89
90#[derive(Debug, Clone)]
91pub struct LinearGradientFastPathData {
92  pub kind: LinearGradientFastPathKind,
93  pub axis_samples: Box<[PremultipliedColorU8]>,
94}
95
96#[derive(Debug, Clone, Copy)]
97pub struct LinearGradientFastPath<'a> {
98  pub kind: LinearGradientFastPathKind,
99  pub axis_samples: &'a [PremultipliedColorU8],
100  pub fully_opaque: bool,
101}
102
103impl LinearGradientTile {
104  const AXIS_ALIGNMENT_EPSILON: f32 = 1e-4;
105
106  fn direction_components(gradient: &LinearGradient, width: u32, height: u32) -> (f32, f32) {
107    match gradient.direction {
108      LinearGradientDirection::Angle(angle) => {
109        let rad = angle.0.to_radians();
110        (rad.sin(), -rad.cos())
111      }
112      LinearGradientDirection::Keyword(keyword_direction) => {
113        if let (Some(horizontal), Some(vertical)) =
114          (keyword_direction.horizontal, keyword_direction.vertical)
115        {
116          let dir_x = match horizontal {
117            HorizontalKeyword::Left => -(height as f32),
118            HorizontalKeyword::Right => height as f32,
119          };
120          let dir_y = match vertical {
121            VerticalKeyword::Top => -(width as f32),
122            VerticalKeyword::Bottom => width as f32,
123          };
124          let magnitude = dir_x.hypot(dir_y);
125          if magnitude > f32::EPSILON {
126            return (dir_x / magnitude, dir_y / magnitude);
127          }
128        }
129
130        let angle = keyword_direction.to_angle();
131        let rad = angle.0.to_radians();
132        (rad.sin(), -rad.cos())
133      }
134    }
135  }
136
137  #[inline(always)]
138  pub fn projection_at(&self, x: f32, y: f32) -> f32 {
139    x * self.dir_x + y * self.dir_y + self.projection_bias
140  }
141
142  #[inline(always)]
143  pub fn lut_index_for_projection_with_len(&self, projection: f32, lut_len: usize) -> usize {
144    if lut_len <= 1 {
145      return 0;
146    }
147
148    let position_px = projection.clamp(0.0, self.axis_length);
149    ((position_px * self.position_to_lut_scale).round() as usize).min(lut_len - 1)
150  }
151
152  fn classify_axis_aligned(dir_x: f32, dir_y: f32) -> Option<LinearGradientFastPathKind> {
153    if dir_y.abs() <= Self::AXIS_ALIGNMENT_EPSILON
154      && (dir_x.abs() - 1.0).abs() <= Self::AXIS_ALIGNMENT_EPSILON
155    {
156      return Some(LinearGradientFastPathKind::Horizontal);
157    }
158
159    if dir_x.abs() <= Self::AXIS_ALIGNMENT_EPSILON
160      && (dir_y.abs() - 1.0).abs() <= Self::AXIS_ALIGNMENT_EPSILON
161    {
162      return Some(LinearGradientFastPathKind::Vertical);
163    }
164
165    None
166  }
167
168  fn build_axis_samples(&self, kind: LinearGradientFastPathKind) -> Box<[PremultipliedColorU8]> {
169    match kind {
170      LinearGradientFastPathKind::Horizontal => {
171        (0..self.width).map(|x| self.sample_pixel(x, 0)).collect()
172      }
173      LinearGradientFastPathKind::Vertical => {
174        (0..self.height).map(|y| self.sample_pixel(0, y)).collect()
175      }
176    }
177  }
178
179  pub fn fast_path(&self) -> Option<LinearGradientFastPath<'_>> {
180    let fast_path = self.axis_aligned_fast_path.as_ref()?;
181    Some(LinearGradientFastPath {
182      kind: fast_path.kind,
183      axis_samples: &fast_path.axis_samples,
184      fully_opaque: self.fully_opaque,
185    })
186  }
187
188  /// Builds a drawing context from a gradient and a target viewport.
189  pub fn new(
190    gradient: &LinearGradient,
191    width: u32,
192    height: u32,
193    sizing: &SizingContext,
194    current_color: Color,
195  ) -> Self {
196    let (dir_x, dir_y) = Self::direction_components(gradient, width, height);
197    let axis_aligned_kind = Self::classify_axis_aligned(dir_x, dir_y);
198
199    let cx = width as f32 / 2.0;
200    let cy = height as f32 / 2.0;
201    let max_extent = ((width as f32 * dir_x.abs()) + (height as f32 * dir_y.abs())) / 2.0;
202    let axis_length = 2.0 * max_extent;
203    let projection_bias = max_extent - cx * dir_x - cy * dir_y;
204
205    let resolved_stops = resolve_stops_along_axis(
206      &gradient.stops,
207      axis_length.max(1e-6),
208      sizing,
209      current_color,
210    );
211
212    let (repeating, repeat_start, repeat_period, lut_axis_length, lut_resolved_stops) =
213      compute_repeat_setup(gradient.repeating, resolved_stops, axis_length);
214
215    let visible_lut_samples = match axis_aligned_kind {
216      Some(LinearGradientFastPathKind::Horizontal) => width as usize + 1,
217      Some(LinearGradientFastPathKind::Vertical) => height as usize + 1,
218      None => (lut_axis_length.ceil() as usize).saturating_add(1),
219    };
220    let lut_size = if axis_aligned_kind.is_some() {
221      adaptive_lut_size_with_visible_samples(
222        visible_lut_samples,
223        lut_axis_length,
224        &lut_resolved_stops,
225      )
226    } else {
227      adaptive_lut_size(lut_axis_length, &lut_resolved_stops)
228    };
229    let color_lut = build_color_lut_with_interpolation(
230      &lut_resolved_stops,
231      lut_axis_length,
232      lut_size,
233      gradient.interpolation.color_space,
234      gradient.interpolation.hue_direction,
235    );
236    let lut_len = color_lut.len();
237    let position_to_lut_scale = if lut_axis_length.abs() <= f32::EPSILON || lut_len <= 1 {
238      0.0
239    } else {
240      (lut_len - 1) as f32 / lut_axis_length
241    };
242    let fully_opaque = lut_resolved_stops
243      .iter()
244      .all(|stop| stop.color.0[3] == u8::MAX);
245
246    let mut tile = LinearGradientTile {
247      width,
248      height,
249      dir_x,
250      dir_y,
251      axis_length,
252      repeating,
253      repeat_start,
254      repeat_period,
255      projection_bias,
256      position_to_lut_scale,
257      fully_opaque,
258      color_lut,
259      axis_aligned_fast_path: None,
260    };
261
262    if !tile.repeating
263      && let Some(kind) = axis_aligned_kind
264    {
265      tile.axis_aligned_fast_path = Some(LinearGradientFastPathData {
266        kind,
267        axis_samples: tile.build_axis_samples(kind),
268      });
269    }
270
271    tile
272  }
273}
274
275impl GradientOverlayTile for LinearGradientTile {
276  type RowState = LinearGradientRowState;
277
278  gradient_tile_accessors!();
279
280  #[inline(always)]
281  fn sample_pixel(&self, x: u32, y: u32) -> PremultipliedColorU8 {
282    if self.color_lut.is_empty() {
283      return PremultipliedColorU8::TRANSPARENT;
284    }
285
286    if self.color_lut.len() == 1 {
287      return self.color_lut[0];
288    }
289
290    let projection = self.projection_at(x as f32, y as f32);
291    let lut_idx = if self.repeating && self.repeat_period > 1e-6 {
292      let wrapped = (projection - self.repeat_start).rem_euclid(self.repeat_period);
293      ((wrapped * self.position_to_lut_scale).round() as usize).min(self.color_lut.len() - 1)
294    } else {
295      self.lut_index_for_projection_with_len(projection, self.color_lut.len())
296    };
297
298    self.color_lut[lut_idx]
299  }
300
301  #[inline(always)]
302  fn begin_row(&self, src_x_start: u32, src_y: u32, lut_len: usize) -> Self::RowState {
303    let projection = self.projection_at(src_x_start as f32, src_y as f32);
304    LinearGradientRowState {
305      projection,
306      projection_step: self.dir_x,
307      max_lut_index: lut_len.saturating_sub(1),
308    }
309  }
310
311  #[inline(always)]
312  fn next_lut_index(&self, row_state: &mut Self::RowState) -> usize {
313    let lut_idx = if self.repeating && self.repeat_period > 1e-6 {
314      let wrapped = (row_state.projection - self.repeat_start).rem_euclid(self.repeat_period);
315      ((wrapped * self.position_to_lut_scale).round() as usize).min(row_state.max_lut_index)
316    } else {
317      let position_px = row_state.projection.clamp(0.0, self.axis_length);
318      ((position_px * self.position_to_lut_scale).round() as usize).min(row_state.max_lut_index)
319    };
320
321    row_state.projection += row_state.projection_step;
322    lut_idx
323  }
324}
325
326/// Represents a gradient stop position.
327/// If a percentage or number (0.0-1.0) is provided, it is treated as a percentage.
328#[derive(Debug, Clone, Copy, PartialEq)]
329pub struct StopPosition(pub Length);
330
331impl MakeComputed for StopPosition {
332  fn make_computed(&mut self, sizing: &SizingContext) {
333    self.0.make_computed(sizing);
334  }
335}
336
337/// Represents a gradient stop.
338#[derive(Debug, Clone, PartialEq)]
339#[non_exhaustive]
340pub enum GradientStop {
341  /// A color gradient stop.
342  ColorHint {
343    /// The color of the gradient stop.
344    color: ColorInput,
345    /// The position of the gradient stop.
346    hint: Option<StopPosition>,
347  },
348  /// A numeric gradient stop.
349  Hint(StopPosition),
350}
351
352impl MakeComputed for GradientStop {
353  fn make_computed(&mut self, sizing: &SizingContext) {
354    match self {
355      GradientStop::ColorHint { hint, .. } => hint.make_computed(sizing),
356      GradientStop::Hint(hint) => hint.make_computed(sizing),
357    }
358  }
359}
360
361/// A list of gradient color stops, handling CSS double-stop syntax.
362pub type GradientStops = Vec<GradientStop>;
363
364impl<'i> FromCss<'i> for GradientStops {
365  const VALID_TOKENS: &'static [CssToken] = GradientStop::VALID_TOKENS;
366
367  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
368    parse_gradient_stops(input, StopPosition::from_css)
369  }
370}
371
372/// Represents a resolved gradient stop with a position.
373#[derive(Debug, Clone, PartialEq)]
374#[non_exhaustive]
375pub struct ResolvedGradientStop {
376  /// The color of the gradient stop.
377  pub color: Color,
378  /// The position of the gradient stop in pixels from the start of the axis.
379  pub position: f32,
380}
381
382impl<'i> FromCss<'i> for StopPosition {
383  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, StopPosition> {
384    if let Ok(num) = input.try_parse(Parser::expect_number) {
385      return Ok(StopPosition(Length::Percentage(
386        num.clamp(0.0, 1.0) * 100.0,
387      )));
388    }
389
390    if let Ok(unit_value) = input.try_parse(Parser::expect_percentage) {
391      return Ok(StopPosition(Length::Percentage(unit_value * 100.0)));
392    }
393
394    let Ok(length) = input.try_parse(Length::from_css) else {
395      return Err(unexpected_token!(
396        input.current_source_location(),
397        input.next()?,
398      ));
399    };
400
401    Ok(StopPosition(length))
402  }
403
404  const VALID_TOKENS: &'static [CssToken] = Length::<true>::VALID_TOKENS;
405}
406
407impl<'i> FromCss<'i> for GradientStop {
408  /// Parses a gradient hint from the input.
409  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, GradientStop> {
410    if let Ok(hint) = input.try_parse(StopPosition::from_css) {
411      return Ok(GradientStop::Hint(hint));
412    };
413
414    let color = ColorInput::from_css(input)?;
415    let hint = input.try_parse(StopPosition::from_css).ok();
416
417    Ok(GradientStop::ColorHint { color, hint })
418  }
419
420  const VALID_TOKENS: &'static [CssToken] = &[
421    CssToken::Syntax(CssSyntaxKind::Color),
422    CssToken::Syntax(CssSyntaxKind::Length),
423  ];
424}
425
426/// Represents an angle value in degrees.
427#[derive(Debug, Default, Clone, Copy, PartialEq)]
428pub struct Angle(f32);
429
430impl MakeComputed for Angle {}
431
432impl ToCss for Angle {
433  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
434    write!(dest, "{}deg", **self)
435  }
436}
437
438impl Animatable for Angle {
439  fn missing_value() -> Option<Self> {
440    Some(Angle::zero())
441  }
442
443  fn interpolate(
444    &mut self,
445    from: &Self,
446    to: &Self,
447    progress: f32,
448    _sizing: &SizingContext,
449    _current_color: Color,
450  ) {
451    let from_degrees = **from;
452    let to_degrees = **to;
453    let delta = (to_degrees - from_degrees + 180.0).rem_euclid(360.0) - 180.0;
454    *self = Angle::new(from_degrees + delta * progress);
455  }
456}
457
458impl TailwindPropertyParser for Angle {
459  fn parse_tw(token: &str) -> Option<Self> {
460    match token.to_ascii_lowercase().as_str() {
461      "none" => return Some(Angle::zero()),
462      "to-t" => return Some(Angle::new(0.0)),
463      "to-tr" => return Some(Angle::new(45.0)),
464      "to-r" => return Some(Angle::new(90.0)),
465      "to-br" => return Some(Angle::new(135.0)),
466      "to-b" => return Some(Angle::new(180.0)),
467      "to-bl" => return Some(Angle::new(225.0)),
468      "to-l" => return Some(Angle::new(270.0)),
469      "to-tl" => return Some(Angle::new(315.0)),
470      _ => {}
471    }
472
473    let angle = token.parse::<f32>().ok()?;
474
475    Some(Angle::new(angle))
476  }
477}
478
479impl Neg for Angle {
480  type Output = Self;
481
482  fn neg(self) -> Self::Output {
483    Angle::new(-self.0)
484  }
485}
486
487impl Deref for Angle {
488  type Target = f32;
489  fn deref(&self) -> &Self::Target {
490    &self.0
491  }
492}
493
494impl Angle {
495  /// Returns a zero angle.
496  pub const fn zero() -> Self {
497    Angle(0.0)
498  }
499
500  /// Creates a new angle value, normalizing it to the range [0, 360).
501  pub fn new(value: f32) -> Self {
502    Angle(value.rem_euclid(360.0))
503  }
504}
505
506/// Represents a horizontal keyword.
507#[derive(Debug, Clone, Copy, PartialEq)]
508#[non_exhaustive]
509pub enum HorizontalKeyword {
510  /// The left keyword.
511  Left,
512  /// The right keyword.
513  Right,
514}
515
516/// Represents a vertical keyword.
517#[derive(Debug, Clone, Copy, PartialEq)]
518#[non_exhaustive]
519pub enum VerticalKeyword {
520  /// The top keyword.
521  Top,
522  /// The bottom keyword.
523  Bottom,
524}
525
526declare_enum_from_css_impl!(
527  HorizontalKeyword,
528  "left" => HorizontalKeyword::Left,
529  "right" => HorizontalKeyword::Right,
530);
531
532declare_enum_from_css_impl!(
533  VerticalKeyword,
534  "top" => VerticalKeyword::Top,
535  "bottom" => VerticalKeyword::Bottom,
536);
537
538/// The original `to <side-or-corner>` direction parsed from CSS.
539#[derive(Debug, Clone, Copy, PartialEq)]
540pub struct GradientKeywordDirection {
541  /// Horizontal keyword, if one was provided.
542  pub horizontal: Option<HorizontalKeyword>,
543  /// Vertical keyword, if one was provided.
544  pub vertical: Option<VerticalKeyword>,
545}
546
547/// The direction of a linear gradient.
548#[derive(Debug, Clone, Copy, PartialEq)]
549pub enum LinearGradientDirection {
550  /// An explicit numeric angle.
551  Angle(Angle),
552  /// A box-relative side-or-corner direction.
553  Keyword(GradientKeywordDirection),
554}
555
556impl Default for LinearGradientDirection {
557  fn default() -> Self {
558    Self::Angle(Angle::new(180.0))
559  }
560}
561
562impl HorizontalKeyword {
563  /// Returns the angle in degrees.
564  pub fn degrees(&self) -> f32 {
565    match self {
566      HorizontalKeyword::Left => 270.0, // "to left" = 270deg
567      HorizontalKeyword::Right => 90.0, // "to right" = 90deg
568    }
569  }
570
571  /// Returns the mixed angle in degrees.
572  pub fn vertical_mixed_degrees(&self) -> f32 {
573    match self {
574      HorizontalKeyword::Left => -45.0, // For diagonals with left
575      HorizontalKeyword::Right => 45.0, // For diagonals with right
576    }
577  }
578}
579
580impl VerticalKeyword {
581  /// Returns the angle in degrees.
582  pub fn degrees(&self) -> f32 {
583    match self {
584      VerticalKeyword::Top => 0.0,
585      VerticalKeyword::Bottom => 180.0,
586    }
587  }
588}
589
590impl GradientKeywordDirection {
591  /// Converts a side-or-corner direction into the matching CSS angle.
592  pub fn to_angle(self) -> Angle {
593    Angle::degrees_from_keywords(self.horizontal, self.vertical)
594  }
595}
596
597impl<'i> FromCss<'i> for GradientKeywordDirection {
598  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
599    input.expect_ident_matching("to")?;
600
601    if let Ok(vertical) = input.try_parse(VerticalKeyword::from_css) {
602      if let Ok(horizontal) = input.try_parse(HorizontalKeyword::from_css) {
603        return Ok(Self {
604          horizontal: Some(horizontal),
605          vertical: Some(vertical),
606        });
607      }
608
609      return Ok(Self {
610        horizontal: None,
611        vertical: Some(vertical),
612      });
613    }
614
615    if let Ok(horizontal) = input.try_parse(HorizontalKeyword::from_css) {
616      return Ok(Self {
617        horizontal: Some(horizontal),
618        vertical: None,
619      });
620    }
621
622    Err(input.new_error_for_next_token())
623  }
624
625  const VALID_TOKENS: &'static [CssToken] = &[
626    CssToken::Keyword("to"),
627    CssToken::Keyword("top"),
628    CssToken::Keyword("bottom"),
629    CssToken::Keyword("left"),
630    CssToken::Keyword("right"),
631  ];
632}
633
634impl<'i> FromCss<'i> for LinearGradientDirection {
635  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
636    if let Ok(direction) = input.try_parse(GradientKeywordDirection::from_css) {
637      return Ok(Self::Keyword(direction));
638    }
639
640    Angle::from_css(input).map(Self::Angle)
641  }
642
643  const VALID_TOKENS: &'static [CssToken] = Angle::VALID_TOKENS;
644}
645
646impl<'i> FromCss<'i> for LinearGradient {
647  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, LinearGradient> {
648    let location = input.current_source_location();
649    let name = input.expect_function()?;
650    let repeating = match_ignore_ascii_case! { &name,
651      "linear-gradient" => false,
652      "repeating-linear-gradient" => true,
653      _ => return Err(unexpected_token!(location, &Token::Function(name.clone()))),
654    };
655
656    input.parse_nested_block(|input| {
657      let mut direction = LinearGradientDirection::default();
658      let mut interpolation = ColorInterpolationMethod::default();
659      let mut saw_direction = false;
660
661      loop {
662        if let Ok(parsed_direction) = input.try_parse(LinearGradientDirection::from_css) {
663          if saw_direction {
664            return Err(input.new_error_for_next_token());
665          }
666
667          direction = parsed_direction;
668          saw_direction = true;
669          continue;
670        }
671
672        if let Ok(parsed_interpolation) = input.try_parse(ColorInterpolationMethod::from_css) {
673          interpolation = parsed_interpolation;
674          continue;
675        }
676
677        break;
678      }
679
680      input.try_parse(Parser::expect_comma).ok();
681
682      Ok(LinearGradient {
683        repeating,
684        direction,
685        interpolation,
686        stops: GradientStops::from_css(input)?.into_boxed_slice(),
687      })
688    })
689  }
690
691  const VALID_TOKENS: &'static [CssToken] =
692    &[CssToken::Descriptor(CssDescriptorKind::LinearGradientFn)];
693}
694
695impl Angle {
696  /// Calculates the angle from horizontal and vertical keywords.
697  pub fn degrees_from_keywords(
698    horizontal: Option<HorizontalKeyword>,
699    vertical: Option<VerticalKeyword>,
700  ) -> Angle {
701    match (horizontal, vertical) {
702      (None, None) => Angle::new(180.0),
703      (Some(horizontal), None) => Angle::new(horizontal.degrees()),
704      (None, Some(vertical)) => Angle::new(vertical.degrees()),
705      (Some(horizontal), Some(VerticalKeyword::Top)) => {
706        Angle::new(horizontal.vertical_mixed_degrees())
707      }
708      (Some(horizontal), Some(VerticalKeyword::Bottom)) => {
709        Angle::new(180.0 - horizontal.vertical_mixed_degrees())
710      }
711    }
712  }
713}
714
715impl<'i> FromCss<'i> for Angle {
716  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Angle> {
717    if input
718      .try_parse(|input| input.expect_ident_matching("none"))
719      .is_ok()
720    {
721      return Ok(Angle::zero());
722    }
723
724    let location = input.current_source_location();
725    let token = input.next()?;
726
727    match token {
728      Token::Number { value, .. } => Ok(Angle::new(*value)),
729      Token::Dimension { value, unit, .. } => match unit.as_ref() {
730        "deg" => Ok(Angle::new(*value)),
731        "grad" => Ok(Angle::new(*value / 400.0 * 360.0)),
732        "turn" => Ok(Angle::new(*value * 360.0)),
733        "rad" => Ok(Angle::new(value.to_degrees())),
734        _ => Err(unexpected_token!(location, token)),
735      },
736      _ => Err(unexpected_token!(location, token)),
737    }
738  }
739
740  const VALID_TOKENS: &'static [CssToken] = &[
741    CssToken::Syntax(CssSyntaxKind::Angle),
742    CssToken::Keyword("none"),
743  ];
744}
745
746impl ToCss for StopPosition {
747  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
748    self.0.to_css(dest)
749  }
750}
751
752impl ToCss for GradientStop {
753  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
754    match self {
755      Self::ColorHint { color, hint } => {
756        color.to_css(dest)?;
757        if let Some(h) = hint {
758          dest.write_char(' ')?;
759          h.to_css(dest)?;
760        }
761        Ok(())
762      }
763      Self::Hint(h) => h.to_css(dest),
764    }
765  }
766}
767
768impl ToCss for GradientKeywordDirection {
769  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
770    dest.write_str("to")?;
771    if let Some(v) = self.vertical {
772      dest.write_char(' ')?;
773      v.to_css(dest)?;
774    }
775    if let Some(h) = self.horizontal {
776      dest.write_char(' ')?;
777      h.to_css(dest)?;
778    }
779    Ok(())
780  }
781}
782
783impl ToCss for LinearGradientDirection {
784  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
785    match self {
786      Self::Angle(a) => a.to_css(dest),
787      Self::Keyword(kw) => kw.to_css(dest),
788    }
789  }
790}
791
792impl ToCss for LinearGradient {
793  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
794    let name = if self.repeating {
795      "repeating-linear-gradient"
796    } else {
797      "linear-gradient"
798    };
799
800    let mut dir_buf = String::new();
801    self.direction.to_css(&mut dir_buf)?;
802    if dir_buf == "180deg" || dir_buf == "to bottom" {
803      dir_buf.clear();
804    }
805
806    write_gradient_css(dest, name, &dir_buf, &self.interpolation, &self.stops)
807  }
808}
809
810#[cfg(test)]
811mod tests {
812  use color::{ColorSpaceTag, HueDirection};
813  use std::rc::Rc;
814  use taffy::Size;
815  use tiny_skia::ColorU8;
816
817  use crate::style::properties::gradient_utils::red_blue_stops;
818  use crate::{Viewport, style::CalcArena};
819
820  use super::*;
821  fn sizing() -> SizingContext {
822    SizingContext {
823      viewport: Viewport::new((200, 100)),
824      container_size: Size::NONE,
825      font_size: 16.0,
826      root_font_size: None,
827      line_height: 0.0,
828      root_line_height: None,
829      calc_arena: Rc::new(CalcArena::default()),
830    }
831  }
832
833  #[test]
834  fn test_parse_linear_gradient() {
835    assert_eq!(
836      LinearGradient::from_str("linear-gradient(to top right, #ff0000, #0000ff)"),
837      Ok(LinearGradient {
838        repeating: false,
839        direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
840          horizontal: Some(HorizontalKeyword::Right),
841          vertical: Some(VerticalKeyword::Top),
842        }),
843        interpolation: ColorInterpolationMethod::default(),
844        stops: red_blue_stops(None, None).into(),
845      })
846    )
847  }
848
849  #[test]
850  fn test_parse_angle() {
851    for (input, expected) in [
852      ("45deg", Angle::new(45.0)),
853      ("200grad", Angle::new(180.0)),
854      ("0.5turn", Angle::new(180.0)),
855      ("90", Angle::new(90.0)),
856    ] {
857      assert_eq!(Angle::from_str(input), Ok(expected), "input: {input}");
858    }
859    // rad requires approximate comparison
860    assert!(Angle::from_str("3.14159rad").is_ok_and(|a| (a.0 - 180.0).abs() < 0.001));
861  }
862
863  #[test]
864  fn test_parse_direction_keywords() {
865    use HorizontalKeyword::{Left, Right};
866    use VerticalKeyword::{Bottom, Top};
867    for (input, expected) in [
868      (
869        "to top",
870        GradientKeywordDirection {
871          horizontal: None,
872          vertical: Some(Top),
873        },
874      ),
875      (
876        "to right",
877        GradientKeywordDirection {
878          horizontal: Some(Right),
879          vertical: None,
880        },
881      ),
882      (
883        "to bottom",
884        GradientKeywordDirection {
885          horizontal: None,
886          vertical: Some(Bottom),
887        },
888      ),
889      (
890        "to left",
891        GradientKeywordDirection {
892          horizontal: Some(Left),
893          vertical: None,
894        },
895      ),
896      (
897        "to top right",
898        GradientKeywordDirection {
899          horizontal: Some(Right),
900          vertical: Some(Top),
901        },
902      ),
903      (
904        "to bottom left",
905        GradientKeywordDirection {
906          horizontal: Some(Left),
907          vertical: Some(Bottom),
908        },
909      ),
910      (
911        "to top left",
912        GradientKeywordDirection {
913          horizontal: Some(Left),
914          vertical: Some(Top),
915        },
916      ),
917      (
918        "to bottom right",
919        GradientKeywordDirection {
920          horizontal: Some(Right),
921          vertical: Some(Bottom),
922        },
923      ),
924    ] {
925      assert_eq!(
926        GradientKeywordDirection::from_str(input),
927        Ok(expected),
928        "input: {input}"
929      );
930    }
931  }
932
933  #[test]
934  fn test_angle_interpolate_uses_shortest_path_across_zero() {
935    let from = Angle::new(0.0);
936    let to = Angle::new(-3.0);
937    let mut interpolated = from;
938
939    interpolated.interpolate(&from, &to, 0.5, &sizing(), Color::transparent());
940
941    assert!((*interpolated - 358.5).abs() < 0.001);
942  }
943
944  #[test]
945  fn test_angle_interpolate_uses_shortest_path_forward_across_zero() {
946    let from = Angle::new(-3.0);
947    let to = Angle::new(0.0);
948    let mut interpolated = from;
949
950    interpolated.interpolate(&from, &to, 0.5, &sizing(), Color::transparent());
951
952    assert!((*interpolated - 358.5).abs() < 0.001);
953  }
954
955  #[test]
956  fn test_parse_linear_gradient_with_angle() {
957    assert_eq!(
958      LinearGradient::from_str("linear-gradient(45deg, #ff0000, #0000ff)"),
959      Ok(LinearGradient {
960        repeating: false,
961        direction: LinearGradientDirection::Angle(Angle::new(45.0)),
962        interpolation: ColorInterpolationMethod::default(),
963        stops: red_blue_stops(None, None).into(),
964      })
965    )
966  }
967
968  #[test]
969  fn test_parse_linear_gradient_with_interpolation_color_space() {
970    assert_eq!(
971      LinearGradient::from_str("linear-gradient(in oklab, #ff0000, #0000ff)"),
972      Ok(LinearGradient {
973        repeating: false,
974        direction: LinearGradientDirection::default(),
975        interpolation: ColorInterpolationMethod {
976          color_space: ColorSpaceTag::Oklab,
977          hue_direction: HueDirection::Shorter,
978        },
979        stops: red_blue_stops(None, None).into(),
980      })
981    );
982  }
983
984  #[test]
985  fn test_parse_linear_gradient_with_interpolation_hue_direction() {
986    assert_eq!(
987      LinearGradient::from_str("linear-gradient(to right in oklch longer hue, red, blue)"),
988      Ok(LinearGradient {
989        repeating: false,
990        direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
991          horizontal: Some(HorizontalKeyword::Right),
992          vertical: None,
993        }),
994        interpolation: ColorInterpolationMethod {
995          color_space: ColorSpaceTag::Oklch,
996          hue_direction: HueDirection::Longer,
997        },
998        stops: [
999          GradientStop::ColorHint {
1000            color: ColorInput::Value(Color::from_rgb(0xff0000)),
1001            hint: None,
1002          },
1003          GradientStop::ColorHint {
1004            color: ColorInput::Value(Color::from_rgb(0x0000ff)),
1005            hint: None,
1006          },
1007        ]
1008        .into(),
1009      })
1010    );
1011  }
1012
1013  #[test]
1014  fn test_parse_linear_gradient_rejects_multiple_directions() {
1015    assert!(LinearGradient::from_str("linear-gradient(to right 45deg, red, blue)").is_err());
1016    assert!(LinearGradient::from_str("linear-gradient(45deg to right, red, blue)").is_err());
1017  }
1018
1019  #[test]
1020  fn test_parse_linear_gradient_with_stops() {
1021    assert_eq!(
1022      LinearGradient::from_str("linear-gradient(to right, #ff0000 0%, #0000ff 100%)"),
1023      Ok(LinearGradient {
1024        repeating: false,
1025        direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
1026          horizontal: Some(HorizontalKeyword::Right),
1027          vertical: None,
1028        }),
1029        interpolation: ColorInterpolationMethod::default(),
1030        stops: red_blue_stops(
1031          Some(StopPosition(Length::Percentage(0.0))),
1032          Some(StopPosition(Length::Percentage(100.0))),
1033        )
1034        .into(),
1035      })
1036    );
1037  }
1038
1039  #[test]
1040  fn test_parse_linear_gradient_with_double_position_color_stop() {
1041    assert_eq!(
1042      LinearGradient::from_str("linear-gradient(to right, red 10% 20%, blue)"),
1043      Ok(LinearGradient {
1044        repeating: false,
1045        direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
1046          horizontal: Some(HorizontalKeyword::Right),
1047          vertical: None,
1048        }),
1049        interpolation: ColorInterpolationMethod::default(),
1050        stops: [
1051          GradientStop::ColorHint {
1052            color: ColorInput::Value(Color::from_rgb(0xff0000)),
1053            hint: Some(StopPosition(Length::Percentage(10.0))),
1054          },
1055          GradientStop::ColorHint {
1056            color: ColorInput::Value(Color::from_rgb(0xff0000)),
1057            hint: Some(StopPosition(Length::Percentage(20.0))),
1058          },
1059          GradientStop::ColorHint {
1060            color: ColorInput::Value(Color::from_rgb(0x0000ff)),
1061            hint: None,
1062          },
1063        ]
1064        .into(),
1065      })
1066    );
1067  }
1068
1069  #[test]
1070  fn test_parse_linear_gradient_with_hint() {
1071    assert_eq!(
1072      LinearGradient::from_str("linear-gradient(to right, #ff0000, 50%, #0000ff)"),
1073      Ok(LinearGradient {
1074        repeating: false,
1075        direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
1076          horizontal: Some(HorizontalKeyword::Right),
1077          vertical: None,
1078        }),
1079        interpolation: ColorInterpolationMethod::default(),
1080        stops: [
1081          GradientStop::ColorHint {
1082            color: ColorInput::Value(Color([255, 0, 0, 255])),
1083            hint: None,
1084          },
1085          GradientStop::Hint(StopPosition(Length::Percentage(50.0))),
1086          GradientStop::ColorHint {
1087            color: ColorInput::Value(Color([0, 0, 255, 255])),
1088            hint: None,
1089          },
1090        ]
1091        .into(),
1092      })
1093    );
1094  }
1095
1096  #[test]
1097  fn test_parse_linear_gradient_single_color() {
1098    assert_eq!(
1099      LinearGradient::from_str("linear-gradient(to bottom, #ff0000)"),
1100      Ok(LinearGradient {
1101        repeating: false,
1102        direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
1103          horizontal: None,
1104          vertical: Some(VerticalKeyword::Bottom),
1105        }),
1106        interpolation: ColorInterpolationMethod::default(),
1107        stops: [GradientStop::ColorHint {
1108          color: ColorInput::Value(Color([255, 0, 0, 255])),
1109          hint: None,
1110        }]
1111        .into(),
1112      })
1113    );
1114  }
1115
1116  #[test]
1117  fn test_parse_linear_gradient_default_angle() {
1118    // Default angle is 180 degrees (to bottom)
1119    assert_eq!(
1120      LinearGradient::from_str("linear-gradient(#ff0000, #0000ff)"),
1121      Ok(LinearGradient {
1122        repeating: false,
1123        direction: LinearGradientDirection::default(),
1124        interpolation: ColorInterpolationMethod::default(),
1125        stops: [
1126          GradientStop::ColorHint {
1127            color: ColorInput::Value(Color::from_rgb(0xff0000)),
1128            hint: None,
1129          },
1130          GradientStop::ColorHint {
1131            color: ColorInput::Value(Color::from_rgb(0x0000ff)),
1132            hint: None,
1133          },
1134        ]
1135        .into(),
1136      })
1137    );
1138  }
1139
1140  #[test]
1141  fn test_parse_gradient_hint_color() {
1142    assert_eq!(
1143      GradientStop::from_str("#ff0000"),
1144      Ok(GradientStop::ColorHint {
1145        color: ColorInput::Value(Color([255, 0, 0, 255])),
1146        hint: None,
1147      })
1148    );
1149  }
1150
1151  #[test]
1152  fn test_parse_gradient_hint_numeric() {
1153    assert_eq!(
1154      GradientStop::from_str("50%"),
1155      Ok(GradientStop::Hint(StopPosition(Length::Percentage(50.0))))
1156    );
1157  }
1158
1159  #[test]
1160  fn test_angle_degrees_from_keywords() {
1161    // None, None
1162    assert_eq!(Angle::degrees_from_keywords(None, None), Angle::new(180.0));
1163
1164    // Some horizontal, None
1165    assert_eq!(
1166      Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), None),
1167      Angle::new(270.0) // "to left" = 270deg
1168    );
1169    assert_eq!(
1170      Angle::degrees_from_keywords(Some(HorizontalKeyword::Right), None),
1171      Angle::new(90.0) // "to right" = 90deg
1172    );
1173
1174    // None, Some vertical
1175    assert_eq!(
1176      Angle::degrees_from_keywords(None, Some(VerticalKeyword::Top)),
1177      Angle::new(0.0)
1178    );
1179    assert_eq!(
1180      Angle::degrees_from_keywords(None, Some(VerticalKeyword::Bottom)),
1181      Angle::new(180.0)
1182    );
1183
1184    // Some horizontal, Some vertical
1185    assert_eq!(
1186      Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), Some(VerticalKeyword::Top)),
1187      Angle::new(315.0)
1188    );
1189    assert_eq!(
1190      Angle::degrees_from_keywords(Some(HorizontalKeyword::Right), Some(VerticalKeyword::Top)),
1191      Angle::new(45.0)
1192    );
1193    assert_eq!(
1194      Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), Some(VerticalKeyword::Bottom)),
1195      Angle::new(225.0)
1196    );
1197    assert_eq!(
1198      Angle::degrees_from_keywords(
1199        Some(HorizontalKeyword::Right),
1200        Some(VerticalKeyword::Bottom)
1201      ),
1202      Angle::new(135.0)
1203    );
1204  }
1205
1206  #[test]
1207  fn test_parse_linear_gradient_mixed_hints_and_colors() {
1208    assert_eq!(
1209      LinearGradient::from_str("linear-gradient(45deg, #ff0000, 25%, #00ff00, 75%, #0000ff)"),
1210      Ok(LinearGradient {
1211        repeating: false,
1212        direction: LinearGradientDirection::Angle(Angle::new(45.0)),
1213        interpolation: ColorInterpolationMethod::default(),
1214        stops: [
1215          GradientStop::ColorHint {
1216            color: Color([255, 0, 0, 255]).into(),
1217            hint: None,
1218          },
1219          GradientStop::Hint(StopPosition(Length::Percentage(25.0))),
1220          GradientStop::ColorHint {
1221            color: Color([0, 255, 0, 255]).into(),
1222            hint: None,
1223          },
1224          GradientStop::Hint(StopPosition(Length::Percentage(75.0))),
1225          GradientStop::ColorHint {
1226            color: Color([0, 0, 255, 255]).into(),
1227            hint: None,
1228          },
1229        ]
1230        .into(),
1231      })
1232    );
1233  }
1234
1235  #[test]
1236  fn test_linear_gradient_at_simple() {
1237    let gradient = LinearGradient {
1238      repeating: false,
1239      direction: LinearGradientDirection::default(),
1240      interpolation: ColorInterpolationMethod::default(),
1241      stops: red_blue_stops(
1242        Some(StopPosition(Length::Percentage(0.0))),
1243        Some(StopPosition(Length::Percentage(100.0))),
1244      )
1245      .into(),
1246    };
1247
1248    // Test at the top (should be red)
1249    let sizing = SizingContext::new_test(Viewport::new((100, 100)));
1250    let tile = LinearGradientTile::new(&gradient, 100, 100, &sizing, Color::black());
1251
1252    let color_top = tile.sample_pixel(50, 0).demultiply();
1253    assert_eq!(color_top, ColorU8::from_rgba(255, 0, 0, 255));
1254
1255    // Test at the bottom (should be blue)
1256    let color_bottom = tile.sample_pixel(50, 100).demultiply();
1257    assert_eq!(color_bottom, ColorU8::from_rgba(0, 0, 255, 255));
1258
1259    // Test in the middle (should be purple)
1260    let color_middle = tile.sample_pixel(50, 50).demultiply();
1261    assert_eq!(color_middle, ColorU8::from_rgba(140, 83, 162, 255));
1262  }
1263
1264  #[test]
1265  fn test_linear_gradient_at_horizontal() {
1266    let gradient = LinearGradient {
1267      repeating: false,
1268      direction: LinearGradientDirection::Angle(Angle::new(90.0)),
1269      interpolation: ColorInterpolationMethod::default(),
1270      stops: red_blue_stops(
1271        Some(StopPosition(Length::Percentage(0.0))),
1272        Some(StopPosition(Length::Percentage(100.0))),
1273      )
1274      .into(),
1275    };
1276
1277    // Test at the left (should be red)
1278    let sizing = SizingContext::new_test(Viewport::new((100, 100)));
1279
1280    let tile = LinearGradientTile::new(&gradient, 100, 100, &sizing, Color::black());
1281    let color_left = tile.sample_pixel(0, 50).demultiply();
1282    assert_eq!(color_left, ColorU8::from_rgba(255, 0, 0, 255));
1283
1284    // Test at the right (should be blue)
1285    let color_right = tile.sample_pixel(100, 50).demultiply();
1286    assert_eq!(color_right, ColorU8::from_rgba(0, 0, 255, 255));
1287  }
1288
1289  #[test]
1290  fn test_keyword_corner_direction_uses_aspect_ratio() {
1291    let gradient = LinearGradient {
1292      repeating: false,
1293      direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
1294        horizontal: Some(HorizontalKeyword::Right),
1295        vertical: Some(VerticalKeyword::Bottom),
1296      }),
1297      interpolation: ColorInterpolationMethod::default(),
1298      stops: red_blue_stops(
1299        Some(StopPosition(Length::Percentage(0.0))),
1300        Some(StopPosition(Length::Percentage(100.0))),
1301      )
1302      .into(),
1303    };
1304
1305    let sizing = SizingContext::new_test(Viewport::new((200, 100)));
1306    let tile = LinearGradientTile::new(&gradient, 200, 100, &sizing, Color::black());
1307
1308    assert!((tile.dir_x - 0.4472136).abs() < 0.001);
1309    assert!((tile.dir_y - 0.8944272).abs() < 0.001);
1310  }
1311
1312  #[test]
1313  fn test_linear_gradient_at_single_color() {
1314    let gradient = LinearGradient {
1315      repeating: false,
1316      direction: LinearGradientDirection::Angle(Angle::new(0.0)),
1317      interpolation: ColorInterpolationMethod::default(),
1318      stops: [GradientStop::ColorHint {
1319        color: Color([255, 0, 0, 255]).into(), // Red
1320        hint: None,
1321      }]
1322      .into(),
1323    };
1324
1325    // Should always return the same color
1326    let sizing = SizingContext::new_test(Viewport::new((100, 100)));
1327    let tile = LinearGradientTile::new(&gradient, 100, 100, &sizing, Color::black());
1328    let color = tile.sample_pixel(50, 50).demultiply();
1329    assert_eq!(color, ColorU8::from_rgba(255, 0, 0, 255));
1330  }
1331
1332  #[test]
1333  fn test_linear_gradient_at_no_steps() {
1334    let gradient = LinearGradient {
1335      repeating: false,
1336      direction: LinearGradientDirection::Angle(Angle::new(0.0)),
1337      interpolation: ColorInterpolationMethod::default(),
1338      stops: [].into(),
1339    };
1340
1341    // Should return transparent
1342    let sizing = SizingContext::new_test(Viewport::new((100, 100)));
1343    let tile = LinearGradientTile::new(&gradient, 100, 100, &sizing, Color::black());
1344    let color = tile.sample_pixel(50, 50).demultiply();
1345    assert_eq!(color, ColorU8::from_rgba(0, 0, 0, 0));
1346  }
1347
1348  #[test]
1349  fn test_repeating_linear_gradient_stripes() {
1350    let gradient = LinearGradient::builder()
1351      .repeating(true)
1352      .direction(LinearGradientDirection::Angle(Angle::new(90.0)))
1353      .stops([
1354        GradientStop::ColorHint {
1355          color: Color([255, 0, 0, 255]).into(),
1356          hint: Some(StopPosition(Length::Px(0.0))),
1357        },
1358        GradientStop::ColorHint {
1359          color: Color([255, 0, 0, 255]).into(),
1360          hint: Some(StopPosition(Length::Px(5.0))),
1361        },
1362        GradientStop::ColorHint {
1363          color: Color([0, 0, 255, 255]).into(),
1364          hint: Some(StopPosition(Length::Px(5.0))),
1365        },
1366        GradientStop::ColorHint {
1367          color: Color([0, 0, 255, 255]).into(),
1368          hint: Some(StopPosition(Length::Px(10.0))),
1369        },
1370      ])
1371      .build();
1372
1373    let sizing = SizingContext::new_test(Viewport::new((40, 1)));
1374    let tile = LinearGradientTile::new(&gradient, 40, 1, &sizing, Color::black());
1375
1376    assert_eq!(
1377      [
1378        tile.sample_pixel(2, 0).demultiply(),
1379        tile.sample_pixel(7, 0).demultiply(),
1380        tile.sample_pixel(12, 0).demultiply(),
1381        tile.sample_pixel(17, 0).demultiply(),
1382      ],
1383      [
1384        ColorU8::from_rgba(255, 0, 0, 255),
1385        ColorU8::from_rgba(0, 0, 255, 255),
1386        ColorU8::from_rgba(255, 0, 0, 255),
1387        ColorU8::from_rgba(0, 0, 255, 255),
1388      ]
1389    );
1390  }
1391
1392  #[test]
1393  fn test_linear_gradient_px_stops_crisp_line() -> ParseResult<'static, ()> {
1394    let gradient =
1395      LinearGradient::from_str("linear-gradient(to right, grey 1px, transparent 1px)")?;
1396
1397    let sizing = SizingContext::new_test(Viewport::new((40, 40)));
1398    let tile = LinearGradientTile::new(&gradient, 40, 40, &sizing, Color::black());
1399
1400    // grey at 0,0
1401    let c0 = tile.sample_pixel(0, 0).demultiply();
1402    assert_eq!(c0, ColorU8::from_rgba(128, 128, 128, 255));
1403
1404    // transparent at 1,0
1405    let c1 = tile.sample_pixel(1, 0).demultiply();
1406    assert_eq!(c1, ColorU8::from_rgba(0, 0, 0, 0));
1407
1408    // transparent till the end
1409    let c2 = tile.sample_pixel(40, 0).demultiply();
1410    assert_eq!(c2, ColorU8::from_rgba(0, 0, 0, 0));
1411
1412    Ok(())
1413  }
1414
1415  #[test]
1416  fn test_linear_gradient_vertical_px_stops_top_pixel() -> ParseResult<'static, ()> {
1417    let gradient =
1418      LinearGradient::from_str("linear-gradient(to bottom, grey 1px, transparent 1px)")?;
1419
1420    let sizing = SizingContext::new_test(Viewport::new((40, 40)));
1421    let tile = LinearGradientTile::new(&gradient, 40, 40, &sizing, Color::black());
1422
1423    // color at top-left (0, 0) should be grey (1px hard stop)
1424    assert_eq!(
1425      tile.sample_pixel(0, 0).demultiply(),
1426      ColorU8::from_rgba(128, 128, 128, 255)
1427    );
1428
1429    Ok(())
1430  }
1431
1432  #[test]
1433  fn test_stop_position_parsing() {
1434    for (input, expected) in [
1435      ("0.25", StopPosition(Length::Percentage(25.0))),
1436      ("75%", StopPosition(Length::Percentage(75.0))),
1437      ("50%", StopPosition(Length::Percentage(50.0))),
1438      ("12px", StopPosition(Length::Px(12.0))),
1439      ("8px", StopPosition(Length::Px(8.0))),
1440    ] {
1441      assert_eq!(
1442        StopPosition::from_str(input),
1443        Ok(expected),
1444        "input: {input}"
1445      );
1446    }
1447  }
1448
1449  #[test]
1450  fn resolve_stops_percentage_and_px_linear() {
1451    let gradient = LinearGradient::builder()
1452      .direction(LinearGradientDirection::Angle(Angle::new(0.0)))
1453      .stops([
1454        GradientStop::ColorHint {
1455          color: Color::black().into(),
1456          hint: Some(StopPosition(Length::Percentage(0.0))),
1457        },
1458        GradientStop::ColorHint {
1459          color: Color::black().into(),
1460          hint: Some(StopPosition(Length::Percentage(50.0))),
1461        },
1462        GradientStop::ColorHint {
1463          color: Color::black().into(),
1464          hint: Some(StopPosition(Length::Px(100.0))),
1465        },
1466      ])
1467      .build();
1468
1469    let sizing = SizingContext::new_test(Viewport::new((200, 100)));
1470
1471    let resolved = resolve_stops_along_axis(
1472      &gradient.stops,
1473      sizing.viewport.size.width.unwrap_or_default() as f32,
1474      &sizing,
1475      Color::black(),
1476    );
1477    assert_eq!(resolved.len(), 3);
1478    assert!((resolved[0].position - 0.0).abs() < 1e-3);
1479    assert!((resolved[1].position - 100.0).abs() < 1e-3);
1480    assert!((resolved[2].position - 100.0).abs() < 1e-3);
1481  }
1482
1483  #[test]
1484  fn resolve_stops_equal_positions_allowed_linear() {
1485    let gradient = LinearGradient::builder()
1486      .direction(LinearGradientDirection::Angle(Angle::new(0.0)))
1487      .stops([
1488        GradientStop::ColorHint {
1489          color: Color::black().into(),
1490          hint: Some(StopPosition(Length::Px(0.0))),
1491        },
1492        GradientStop::ColorHint {
1493          color: Color::black().into(),
1494          hint: Some(StopPosition(Length::Px(0.0))),
1495        },
1496      ])
1497      .build();
1498    let sizing = SizingContext::new_test(Viewport::new((200, 100)));
1499
1500    let resolved = resolve_stops_along_axis(
1501      &gradient.stops,
1502      sizing.viewport.size.width.unwrap_or_default() as f32,
1503      &sizing,
1504      Color::black(),
1505    );
1506    assert_eq!(resolved.len(), 2);
1507    assert!((resolved[0].position - 0.0).abs() < 1e-3);
1508    assert!((resolved[1].position - 0.0).abs() < 1e-3);
1509  }
1510}