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#[derive(Debug, Clone, PartialEq, TypedBuilder)]
23#[non_exhaustive]
24pub struct LinearGradient {
25 #[builder(default)]
27 pub repeating: bool,
28 #[builder(default)]
30 pub direction: LinearGradientDirection,
31 #[builder(default)]
33 pub interpolation: ColorInterpolationMethod,
34 #[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#[derive(Debug, Clone)]
47pub struct LinearGradientTile {
48 pub width: u32,
50 pub height: u32,
52 pub dir_x: f32,
54 pub dir_y: f32,
56 pub axis_length: f32,
58 pub repeating: bool,
60 pub repeat_start: f32,
62 pub repeat_period: f32,
64 pub projection_bias: f32,
66 pub position_to_lut_scale: f32,
68 pub fully_opaque: bool,
70 pub color_lut: Vec<PremultipliedColorU8>,
73 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 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#[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#[derive(Debug, Clone, PartialEq)]
339#[non_exhaustive]
340pub enum GradientStop {
341 ColorHint {
343 color: ColorInput,
345 hint: Option<StopPosition>,
347 },
348 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
361pub 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#[derive(Debug, Clone, PartialEq)]
374#[non_exhaustive]
375pub struct ResolvedGradientStop {
376 pub color: Color,
378 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 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#[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 pub const fn zero() -> Self {
497 Angle(0.0)
498 }
499
500 pub fn new(value: f32) -> Self {
502 Angle(value.rem_euclid(360.0))
503 }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq)]
508#[non_exhaustive]
509pub enum HorizontalKeyword {
510 Left,
512 Right,
514}
515
516#[derive(Debug, Clone, Copy, PartialEq)]
518#[non_exhaustive]
519pub enum VerticalKeyword {
520 Top,
522 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#[derive(Debug, Clone, Copy, PartialEq)]
540pub struct GradientKeywordDirection {
541 pub horizontal: Option<HorizontalKeyword>,
543 pub vertical: Option<VerticalKeyword>,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq)]
549pub enum LinearGradientDirection {
550 Angle(Angle),
552 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 pub fn degrees(&self) -> f32 {
565 match self {
566 HorizontalKeyword::Left => 270.0, HorizontalKeyword::Right => 90.0, }
569 }
570
571 pub fn vertical_mixed_degrees(&self) -> f32 {
573 match self {
574 HorizontalKeyword::Left => -45.0, HorizontalKeyword::Right => 45.0, }
577 }
578}
579
580impl VerticalKeyword {
581 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 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 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 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 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 assert_eq!(Angle::degrees_from_keywords(None, None), Angle::new(180.0));
1163
1164 assert_eq!(
1166 Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), None),
1167 Angle::new(270.0) );
1169 assert_eq!(
1170 Angle::degrees_from_keywords(Some(HorizontalKeyword::Right), None),
1171 Angle::new(90.0) );
1173
1174 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 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 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 let color_bottom = tile.sample_pixel(50, 100).demultiply();
1257 assert_eq!(color_bottom, ColorU8::from_rgba(0, 0, 255, 255));
1258
1259 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 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 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(), hint: None,
1321 }]
1322 .into(),
1323 };
1324
1325 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 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 let c0 = tile.sample_pixel(0, 0).demultiply();
1402 assert_eq!(c0, ColorU8::from_rgba(128, 128, 128, 255));
1403
1404 let c1 = tile.sample_pixel(1, 0).demultiply();
1406 assert_eq!(c1, ColorU8::from_rgba(0, 0, 0, 0));
1407
1408 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 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}