use cssparser::{Parser, Token, match_ignore_ascii_case};
use std::{
fmt,
ops::{Deref, Neg},
};
use tiny_skia::PremultipliedColorU8;
use typed_builder::TypedBuilder;
use super::gradient_utils::{
GradientOverlayTile, adaptive_lut_size, adaptive_lut_size_with_visible_samples,
build_color_lut_with_interpolation, resolve_stops_along_axis,
};
use crate::layout::style::{
Animatable, Color, ColorInterpolationMethod, CssDescriptorKind, CssSyntaxKind, CssToken, FromCss,
Length, MakeComputed, ParseResult, ToCss, declare_enum_from_css_impl, properties::ColorInput,
tw::TailwindPropertyParser, unexpected_token,
};
use crate::rendering::{RenderContext, Sizing};
#[derive(Debug, Clone, PartialEq, TypedBuilder)]
#[non_exhaustive]
pub struct LinearGradient {
#[builder(default)]
pub repeating: bool,
#[builder(default)]
pub direction: LinearGradientDirection,
#[builder(default)]
pub interpolation: ColorInterpolationMethod,
#[builder(setter(into))]
pub stops: Box<[GradientStop]>,
}
impl MakeComputed for LinearGradient {
fn make_computed(&mut self, sizing: &Sizing) {
self.stops.make_computed(sizing);
}
}
#[derive(Debug, Clone)]
pub(crate) struct LinearGradientTile {
pub width: u32,
pub height: u32,
pub dir_x: f32,
pub dir_y: f32,
pub axis_length: f32,
pub repeating: bool,
pub repeat_start: f32,
pub repeat_period: f32,
pub projection_bias: f32,
pub position_to_lut_scale: f32,
pub fully_opaque: bool,
pub color_lut: Vec<PremultipliedColorU8>,
pub axis_aligned_fast_path: Option<LinearGradientFastPathData>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LinearGradientRowState {
projection: f32,
projection_step: f32,
max_lut_index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LinearGradientFastPathKind {
Horizontal,
Vertical,
}
#[derive(Debug, Clone)]
pub(crate) struct LinearGradientFastPathData {
pub kind: LinearGradientFastPathKind,
pub axis_samples: Box<[PremultipliedColorU8]>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LinearGradientFastPath<'a> {
pub kind: LinearGradientFastPathKind,
pub axis_samples: &'a [PremultipliedColorU8],
pub fully_opaque: bool,
}
impl LinearGradientTile {
const AXIS_ALIGNMENT_EPSILON: f32 = 1e-4;
fn direction_components(gradient: &LinearGradient, width: u32, height: u32) -> (f32, f32) {
match gradient.direction {
LinearGradientDirection::Angle(angle) => {
let rad = angle.0.to_radians();
(rad.sin(), -rad.cos())
}
LinearGradientDirection::Keyword(keyword_direction) => {
if let (Some(horizontal), Some(vertical)) =
(keyword_direction.horizontal, keyword_direction.vertical)
{
let dir_x = match horizontal {
HorizontalKeyword::Left => -(height as f32),
HorizontalKeyword::Right => height as f32,
};
let dir_y = match vertical {
VerticalKeyword::Top => -(width as f32),
VerticalKeyword::Bottom => width as f32,
};
let magnitude = dir_x.hypot(dir_y);
if magnitude > f32::EPSILON {
return (dir_x / magnitude, dir_y / magnitude);
}
}
let angle = keyword_direction.to_angle();
let rad = angle.0.to_radians();
(rad.sin(), -rad.cos())
}
}
}
#[inline(always)]
pub(crate) fn projection_at(&self, x: f32, y: f32) -> f32 {
x * self.dir_x + y * self.dir_y + self.projection_bias
}
#[inline(always)]
pub(crate) fn lut_index_for_projection_with_len(&self, projection: f32, lut_len: usize) -> usize {
if lut_len <= 1 {
return 0;
}
let position_px = projection.clamp(0.0, self.axis_length);
((position_px * self.position_to_lut_scale).round() as usize).min(lut_len - 1)
}
fn classify_axis_aligned(dir_x: f32, dir_y: f32) -> Option<LinearGradientFastPathKind> {
if dir_y.abs() <= Self::AXIS_ALIGNMENT_EPSILON
&& (dir_x.abs() - 1.0).abs() <= Self::AXIS_ALIGNMENT_EPSILON
{
return Some(LinearGradientFastPathKind::Horizontal);
}
if dir_x.abs() <= Self::AXIS_ALIGNMENT_EPSILON
&& (dir_y.abs() - 1.0).abs() <= Self::AXIS_ALIGNMENT_EPSILON
{
return Some(LinearGradientFastPathKind::Vertical);
}
None
}
fn build_axis_samples(&self, kind: LinearGradientFastPathKind) -> Box<[PremultipliedColorU8]> {
match kind {
LinearGradientFastPathKind::Horizontal => {
(0..self.width).map(|x| self.sample_pixel(x, 0)).collect()
}
LinearGradientFastPathKind::Vertical => {
(0..self.height).map(|y| self.sample_pixel(0, y)).collect()
}
}
}
pub(crate) fn fast_path(&self) -> Option<LinearGradientFastPath<'_>> {
let fast_path = self.axis_aligned_fast_path.as_ref()?;
Some(LinearGradientFastPath {
kind: fast_path.kind,
axis_samples: &fast_path.axis_samples,
fully_opaque: self.fully_opaque,
})
}
pub fn new(gradient: &LinearGradient, width: u32, height: u32, context: &RenderContext) -> Self {
let (dir_x, dir_y) = Self::direction_components(gradient, width, height);
let axis_aligned_kind = Self::classify_axis_aligned(dir_x, dir_y);
let cx = width as f32 / 2.0;
let cy = height as f32 / 2.0;
let max_extent = ((width as f32 * dir_x.abs()) + (height as f32 * dir_y.abs())) / 2.0;
let axis_length = 2.0 * max_extent;
let projection_bias = max_extent - cx * dir_x - cy * dir_y;
let resolved_stops = resolve_stops_along_axis(&gradient.stops, axis_length.max(1e-6), context);
let (repeating, repeat_start, repeat_period, lut_axis_length, lut_resolved_stops) = if gradient
.repeating
&& let (Some(first), Some(last)) = (resolved_stops.first(), resolved_stops.last())
{
let repeat_start = first.position;
let repeat_period = (last.position - first.position).max(0.0);
if repeat_period > 1e-6 {
let shifted = resolved_stops
.iter()
.map(|stop| ResolvedGradientStop {
color: stop.color,
position: stop.position - repeat_start,
})
.collect();
(true, repeat_start, repeat_period, repeat_period, shifted)
} else {
(false, 0.0, 0.0, axis_length, resolved_stops)
}
} else {
(false, 0.0, 0.0, axis_length, resolved_stops)
};
let visible_lut_samples = match axis_aligned_kind {
Some(LinearGradientFastPathKind::Horizontal) => width as usize + 1,
Some(LinearGradientFastPathKind::Vertical) => height as usize + 1,
None => (lut_axis_length.ceil() as usize).saturating_add(1),
};
let lut_size = if axis_aligned_kind.is_some() {
adaptive_lut_size_with_visible_samples(
visible_lut_samples,
lut_axis_length,
&lut_resolved_stops,
)
} else {
adaptive_lut_size(lut_axis_length, &lut_resolved_stops)
};
let color_lut = build_color_lut_with_interpolation(
&lut_resolved_stops,
lut_axis_length,
lut_size,
gradient.interpolation.color_space,
gradient.interpolation.hue_direction,
);
let lut_len = color_lut.len();
let position_to_lut_scale = if lut_axis_length.abs() <= f32::EPSILON || lut_len <= 1 {
0.0
} else {
(lut_len - 1) as f32 / lut_axis_length
};
let fully_opaque = lut_resolved_stops
.iter()
.all(|stop| stop.color.0[3] == u8::MAX);
let mut tile = LinearGradientTile {
width,
height,
dir_x,
dir_y,
axis_length,
repeating,
repeat_start,
repeat_period,
projection_bias,
position_to_lut_scale,
fully_opaque,
color_lut,
axis_aligned_fast_path: None,
};
if !tile.repeating
&& let Some(kind) = axis_aligned_kind
{
tile.axis_aligned_fast_path = Some(LinearGradientFastPathData {
kind,
axis_samples: tile.build_axis_samples(kind),
});
}
tile
}
}
impl GradientOverlayTile for LinearGradientTile {
type RowState = LinearGradientRowState;
#[inline(always)]
fn width(&self) -> u32 {
self.width
}
#[inline(always)]
fn height(&self) -> u32 {
self.height
}
#[inline(always)]
fn lut_len(&self) -> usize {
self.color_lut.len()
}
#[inline(always)]
fn sample_at(&self, lut_idx: usize) -> PremultipliedColorU8 {
self.color_lut[lut_idx]
}
#[inline(always)]
fn sample_pixel(&self, x: u32, y: u32) -> PremultipliedColorU8 {
if self.color_lut.is_empty() {
return PremultipliedColorU8::TRANSPARENT;
}
if self.color_lut.len() == 1 {
return self.color_lut[0];
}
let projection = self.projection_at(x as f32, y as f32);
let lut_idx = if self.repeating && self.repeat_period > 1e-6 {
let wrapped = (projection - self.repeat_start).rem_euclid(self.repeat_period);
((wrapped * self.position_to_lut_scale).round() as usize).min(self.color_lut.len() - 1)
} else {
self.lut_index_for_projection_with_len(projection, self.color_lut.len())
};
self.color_lut[lut_idx]
}
#[inline(always)]
fn begin_row(&self, src_x_start: u32, src_y: u32, lut_len: usize) -> Self::RowState {
let projection = self.projection_at(src_x_start as f32, src_y as f32);
LinearGradientRowState {
projection,
projection_step: self.dir_x,
max_lut_index: lut_len.saturating_sub(1),
}
}
#[inline(always)]
fn next_lut_index(&self, row_state: &mut Self::RowState) -> usize {
let lut_idx = if self.repeating && self.repeat_period > 1e-6 {
let wrapped = (row_state.projection - self.repeat_start).rem_euclid(self.repeat_period);
((wrapped * self.position_to_lut_scale).round() as usize).min(row_state.max_lut_index)
} else {
let position_px = row_state.projection.clamp(0.0, self.axis_length);
((position_px * self.position_to_lut_scale).round() as usize).min(row_state.max_lut_index)
};
row_state.projection += row_state.projection_step;
lut_idx
}
#[inline(always)]
fn fully_opaque(&self) -> bool {
self.fully_opaque
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct StopPosition(pub Length);
impl MakeComputed for StopPosition {
fn make_computed(&mut self, sizing: &Sizing) {
self.0.make_computed(sizing);
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum GradientStop {
ColorHint {
color: ColorInput,
hint: Option<StopPosition>,
},
Hint(StopPosition),
}
impl MakeComputed for GradientStop {
fn make_computed(&mut self, sizing: &Sizing) {
match self {
GradientStop::ColorHint { hint, .. } => hint.make_computed(sizing),
GradientStop::Hint(hint) => hint.make_computed(sizing),
}
}
}
pub type GradientStops = Vec<GradientStop>;
impl<'i> FromCss<'i> for GradientStops {
const VALID_TOKENS: &'static [CssToken] = GradientStop::VALID_TOKENS;
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
let mut stops = Vec::new();
loop {
if let Ok(hint) = input.try_parse(StopPosition::from_css) {
stops.push(GradientStop::Hint(hint));
} else {
let color = ColorInput::from_css(input)?;
let first_position = input.try_parse(StopPosition::from_css).ok();
let second_position = if first_position.is_some() {
input.try_parse(StopPosition::from_css).ok()
} else {
None
};
match (first_position, second_position) {
(Some(first_position), Some(second_position)) => {
stops.push(GradientStop::ColorHint {
color,
hint: Some(first_position),
});
stops.push(GradientStop::ColorHint {
color,
hint: Some(second_position),
});
}
(first_position, None) | (first_position, Some(_)) => {
stops.push(GradientStop::ColorHint {
color,
hint: first_position,
});
}
}
}
if input.try_parse(Parser::expect_comma).is_err() {
break;
}
}
Ok(stops)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ResolvedGradientStop {
pub color: Color,
pub position: f32,
}
impl<'i> FromCss<'i> for StopPosition {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, StopPosition> {
if let Ok(num) = input.try_parse(Parser::expect_number) {
return Ok(StopPosition(Length::Percentage(
num.clamp(0.0, 1.0) * 100.0,
)));
}
if let Ok(unit_value) = input.try_parse(Parser::expect_percentage) {
return Ok(StopPosition(Length::Percentage(unit_value * 100.0)));
}
let Ok(length) = input.try_parse(Length::from_css) else {
return Err(unexpected_token!(
input.current_source_location(),
input.next()?,
));
};
Ok(StopPosition(length))
}
const VALID_TOKENS: &'static [CssToken] = Length::<true>::VALID_TOKENS;
}
impl<'i> FromCss<'i> for GradientStop {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, GradientStop> {
if let Ok(hint) = input.try_parse(StopPosition::from_css) {
return Ok(GradientStop::Hint(hint));
};
let color = ColorInput::from_css(input)?;
let hint = input.try_parse(StopPosition::from_css).ok();
Ok(GradientStop::ColorHint { color, hint })
}
const VALID_TOKENS: &'static [CssToken] = &[
CssToken::Syntax(CssSyntaxKind::Color),
CssToken::Syntax(CssSyntaxKind::Length),
];
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct Angle(f32);
impl MakeComputed for Angle {}
impl ToCss for Angle {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
write!(dest, "{}deg", **self)
}
}
impl Animatable for Angle {
fn missing_value() -> Option<Self> {
Some(Angle::zero())
}
fn interpolate(
&mut self,
from: &Self,
to: &Self,
progress: f32,
_sizing: &Sizing,
_current_color: Color,
) {
let from_degrees = **from;
let to_degrees = **to;
let delta = (to_degrees - from_degrees + 180.0).rem_euclid(360.0) - 180.0;
*self = Angle::new(from_degrees + delta * progress);
}
}
impl TailwindPropertyParser for Angle {
fn parse_tw(token: &str) -> Option<Self> {
match token.to_ascii_lowercase().as_str() {
"none" => return Some(Angle::zero()),
"to-t" => return Some(Angle::new(0.0)),
"to-tr" => return Some(Angle::new(45.0)),
"to-r" => return Some(Angle::new(90.0)),
"to-br" => return Some(Angle::new(135.0)),
"to-b" => return Some(Angle::new(180.0)),
"to-bl" => return Some(Angle::new(225.0)),
"to-l" => return Some(Angle::new(270.0)),
"to-tl" => return Some(Angle::new(315.0)),
_ => {}
}
let angle = token.parse::<f32>().ok()?;
Some(Angle::new(angle))
}
}
impl Neg for Angle {
type Output = Self;
fn neg(self) -> Self::Output {
Angle::new(-self.0)
}
}
impl Deref for Angle {
type Target = f32;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Angle {
pub const fn zero() -> Self {
Angle(0.0)
}
pub fn new(value: f32) -> Self {
Angle(value.rem_euclid(360.0))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum HorizontalKeyword {
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum VerticalKeyword {
Top,
Bottom,
}
declare_enum_from_css_impl!(
HorizontalKeyword,
"left" => HorizontalKeyword::Left,
"right" => HorizontalKeyword::Right,
);
declare_enum_from_css_impl!(
VerticalKeyword,
"top" => VerticalKeyword::Top,
"bottom" => VerticalKeyword::Bottom,
);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GradientKeywordDirection {
pub horizontal: Option<HorizontalKeyword>,
pub vertical: Option<VerticalKeyword>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LinearGradientDirection {
Angle(Angle),
Keyword(GradientKeywordDirection),
}
impl Default for LinearGradientDirection {
fn default() -> Self {
Self::Angle(Angle::new(180.0))
}
}
impl HorizontalKeyword {
pub fn degrees(&self) -> f32 {
match self {
HorizontalKeyword::Left => 270.0, HorizontalKeyword::Right => 90.0, }
}
pub fn vertical_mixed_degrees(&self) -> f32 {
match self {
HorizontalKeyword::Left => -45.0, HorizontalKeyword::Right => 45.0, }
}
}
impl VerticalKeyword {
pub fn degrees(&self) -> f32 {
match self {
VerticalKeyword::Top => 0.0,
VerticalKeyword::Bottom => 180.0,
}
}
}
impl GradientKeywordDirection {
pub fn to_angle(self) -> Angle {
Angle::degrees_from_keywords(self.horizontal, self.vertical)
}
}
impl<'i> FromCss<'i> for GradientKeywordDirection {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
input.expect_ident_matching("to")?;
if let Ok(vertical) = input.try_parse(VerticalKeyword::from_css) {
if let Ok(horizontal) = input.try_parse(HorizontalKeyword::from_css) {
return Ok(Self {
horizontal: Some(horizontal),
vertical: Some(vertical),
});
}
return Ok(Self {
horizontal: None,
vertical: Some(vertical),
});
}
if let Ok(horizontal) = input.try_parse(HorizontalKeyword::from_css) {
return Ok(Self {
horizontal: Some(horizontal),
vertical: None,
});
}
Err(input.new_error_for_next_token())
}
const VALID_TOKENS: &'static [CssToken] = &[
CssToken::Keyword("to"),
CssToken::Keyword("top"),
CssToken::Keyword("bottom"),
CssToken::Keyword("left"),
CssToken::Keyword("right"),
];
}
impl<'i> FromCss<'i> for LinearGradientDirection {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
if let Ok(direction) = input.try_parse(GradientKeywordDirection::from_css) {
return Ok(Self::Keyword(direction));
}
Angle::from_css(input).map(Self::Angle)
}
const VALID_TOKENS: &'static [CssToken] = Angle::VALID_TOKENS;
}
impl<'i> FromCss<'i> for LinearGradient {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, LinearGradient> {
let location = input.current_source_location();
let name = input.expect_function()?;
let repeating = match_ignore_ascii_case! { &name,
"linear-gradient" => false,
"repeating-linear-gradient" => true,
_ => return Err(unexpected_token!(location, &Token::Function(name.clone()))),
};
input.parse_nested_block(|input| {
let mut direction = LinearGradientDirection::default();
let mut interpolation = ColorInterpolationMethod::default();
let mut saw_direction = false;
loop {
if let Ok(parsed_direction) = input.try_parse(LinearGradientDirection::from_css) {
if saw_direction {
return Err(input.new_error_for_next_token());
}
direction = parsed_direction;
saw_direction = true;
continue;
}
if let Ok(parsed_interpolation) = input.try_parse(ColorInterpolationMethod::from_css) {
interpolation = parsed_interpolation;
continue;
}
break;
}
input.try_parse(Parser::expect_comma).ok();
Ok(LinearGradient {
repeating,
direction,
interpolation,
stops: GradientStops::from_css(input)?.into_boxed_slice(),
})
})
}
const VALID_TOKENS: &'static [CssToken] =
&[CssToken::Descriptor(CssDescriptorKind::LinearGradientFn)];
}
impl Angle {
pub fn degrees_from_keywords(
horizontal: Option<HorizontalKeyword>,
vertical: Option<VerticalKeyword>,
) -> Angle {
match (horizontal, vertical) {
(None, None) => Angle::new(180.0),
(Some(horizontal), None) => Angle::new(horizontal.degrees()),
(None, Some(vertical)) => Angle::new(vertical.degrees()),
(Some(horizontal), Some(VerticalKeyword::Top)) => {
Angle::new(horizontal.vertical_mixed_degrees())
}
(Some(horizontal), Some(VerticalKeyword::Bottom)) => {
Angle::new(180.0 - horizontal.vertical_mixed_degrees())
}
}
}
}
impl<'i> FromCss<'i> for Angle {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Angle> {
if input
.try_parse(|input| input.expect_ident_matching("none"))
.is_ok()
{
return Ok(Angle::zero());
}
let location = input.current_source_location();
let token = input.next()?;
match token {
Token::Number { value, .. } => Ok(Angle::new(*value)),
Token::Dimension { value, unit, .. } => match unit.as_ref() {
"deg" => Ok(Angle::new(*value)),
"grad" => Ok(Angle::new(*value / 400.0 * 360.0)),
"turn" => Ok(Angle::new(*value * 360.0)),
"rad" => Ok(Angle::new(value.to_degrees())),
_ => Err(unexpected_token!(location, token)),
},
_ => Err(unexpected_token!(location, token)),
}
}
const VALID_TOKENS: &'static [CssToken] = &[
CssToken::Syntax(CssSyntaxKind::Angle),
CssToken::Keyword("none"),
];
}
impl ToCss for StopPosition {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
self.0.to_css(dest)
}
}
impl ToCss for GradientStop {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
match self {
Self::ColorHint { color, hint } => {
color.to_css(dest)?;
if let Some(h) = hint {
dest.write_char(' ')?;
h.to_css(dest)?;
}
Ok(())
}
Self::Hint(h) => h.to_css(dest),
}
}
}
impl ToCss for GradientKeywordDirection {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
dest.write_str("to")?;
if let Some(v) = self.vertical {
dest.write_char(' ')?;
v.to_css(dest)?;
}
if let Some(h) = self.horizontal {
dest.write_char(' ')?;
h.to_css(dest)?;
}
Ok(())
}
}
impl ToCss for LinearGradientDirection {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
match self {
Self::Angle(a) => a.to_css(dest),
Self::Keyword(kw) => kw.to_css(dest),
}
}
}
impl ToCss for LinearGradient {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
let name = if self.repeating {
"repeating-linear-gradient"
} else {
"linear-gradient"
};
dest.write_str(name)?;
dest.write_char('(')?;
let mut first = true;
let mut dir_buf = String::new();
self.direction.to_css(&mut dir_buf)?;
if dir_buf != "180deg" && dir_buf != "to bottom" {
dest.write_str(&dir_buf)?;
first = false;
}
let mut interp_buf = String::new();
self.interpolation.to_css(&mut interp_buf)?;
if !interp_buf.is_empty() {
if !first {
dest.write_str(", ")?;
}
dest.write_str(&interp_buf)?;
first = false;
}
for stop in self.stops.iter() {
if !first {
dest.write_str(", ")?;
}
stop.to_css(dest)?;
first = false;
}
dest.write_char(')')
}
}
#[cfg(test)]
mod tests {
use color::{ColorSpaceTag, HueDirection};
use std::rc::Rc;
use taffy::Size;
use tiny_skia::ColorU8;
use crate::{
GlobalContext,
layout::{Viewport, style::CalcArena},
};
use super::*;
fn sizing() -> Sizing {
Sizing {
viewport: Viewport::new((200, 100)),
container_size: Size::NONE,
font_size: 16.0,
root_font_size: None,
line_height: 0.0,
root_line_height: None,
calc_arena: Rc::new(CalcArena::default()),
}
}
#[test]
fn test_parse_linear_gradient() {
assert_eq!(
LinearGradient::from_str("linear-gradient(to top right, #ff0000, #0000ff)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: Some(VerticalKeyword::Top),
}),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: None,
},
GradientStop::ColorHint {
color: ColorInput::Value(Color([0, 0, 255, 255])),
hint: None,
},
]
.into(),
})
)
}
#[test]
fn test_parse_angle() {
assert_eq!(Angle::from_str("45deg"), Ok(Angle::new(45.0)));
}
#[test]
fn test_parse_angle_grad() {
assert_eq!(Angle::from_str("200grad"), Ok(Angle::new(180.0)));
}
#[test]
fn test_parse_angle_turn() {
assert_eq!(Angle::from_str("0.5turn"), Ok(Angle::new(180.0)));
}
#[test]
fn test_parse_angle_rad() {
assert!(Angle::from_str("3.14159rad").is_ok_and(|angle| (angle.0 - 180.0).abs() < 0.001));
}
#[test]
fn test_parse_angle_number() {
assert_eq!(Angle::from_str("90"), Ok(Angle::new(90.0)));
}
#[test]
fn test_parse_direction_keywords_top() {
assert_eq!(
GradientKeywordDirection::from_str("to top"),
Ok(GradientKeywordDirection {
horizontal: None,
vertical: Some(VerticalKeyword::Top),
})
);
}
#[test]
fn test_parse_direction_keywords_right() {
assert_eq!(
GradientKeywordDirection::from_str("to right"),
Ok(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: None,
})
);
}
#[test]
fn test_parse_direction_keywords_bottom() {
assert_eq!(
GradientKeywordDirection::from_str("to bottom"),
Ok(GradientKeywordDirection {
horizontal: None,
vertical: Some(VerticalKeyword::Bottom),
})
);
}
#[test]
fn test_parse_direction_keywords_left() {
assert_eq!(
GradientKeywordDirection::from_str("to left"),
Ok(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Left),
vertical: None,
})
);
}
#[test]
fn test_parse_direction_keywords_top_right() {
assert_eq!(
GradientKeywordDirection::from_str("to top right"),
Ok(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: Some(VerticalKeyword::Top),
})
);
}
#[test]
fn test_parse_direction_keywords_bottom_left() {
assert_eq!(
GradientKeywordDirection::from_str("to bottom left"),
Ok(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Left),
vertical: Some(VerticalKeyword::Bottom),
})
);
}
#[test]
fn test_parse_direction_keywords_top_left() {
assert_eq!(
GradientKeywordDirection::from_str("to top left"),
Ok(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Left),
vertical: Some(VerticalKeyword::Top),
})
);
}
#[test]
fn test_parse_direction_keywords_bottom_right() {
assert_eq!(
GradientKeywordDirection::from_str("to bottom right"),
Ok(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: Some(VerticalKeyword::Bottom),
})
);
}
#[test]
fn test_angle_interpolate_uses_shortest_path_across_zero() {
let from = Angle::new(0.0);
let to = Angle::new(-3.0);
let mut interpolated = from;
interpolated.interpolate(&from, &to, 0.5, &sizing(), Color::transparent());
assert!((*interpolated - 358.5).abs() < 0.001);
}
#[test]
fn test_angle_interpolate_uses_shortest_path_forward_across_zero() {
let from = Angle::new(-3.0);
let to = Angle::new(0.0);
let mut interpolated = from;
interpolated.interpolate(&from, &to, 0.5, &sizing(), Color::transparent());
assert!((*interpolated - 358.5).abs() < 0.001);
}
#[test]
fn test_parse_linear_gradient_with_angle() {
assert_eq!(
LinearGradient::from_str("linear-gradient(45deg, #ff0000, #0000ff)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Angle(Angle::new(45.0)),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: None,
},
GradientStop::ColorHint {
color: ColorInput::Value(Color([0, 0, 255, 255])),
hint: None,
},
]
.into(),
})
)
}
#[test]
fn test_parse_linear_gradient_with_interpolation_color_space() {
assert_eq!(
LinearGradient::from_str("linear-gradient(in oklab, #ff0000, #0000ff)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::default(),
interpolation: ColorInterpolationMethod {
color_space: ColorSpaceTag::Oklab,
hue_direction: HueDirection::Shorter,
},
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: None,
},
GradientStop::ColorHint {
color: ColorInput::Value(Color([0, 0, 255, 255])),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_linear_gradient_with_interpolation_hue_direction() {
assert_eq!(
LinearGradient::from_str("linear-gradient(to right in oklch longer hue, red, blue)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: None,
}),
interpolation: ColorInterpolationMethod {
color_space: ColorSpaceTag::Oklch,
hue_direction: HueDirection::Longer,
},
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0xff0000)),
hint: None,
},
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0x0000ff)),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_linear_gradient_rejects_multiple_directions() {
assert!(LinearGradient::from_str("linear-gradient(to right 45deg, red, blue)").is_err());
assert!(LinearGradient::from_str("linear-gradient(45deg to right, red, blue)").is_err());
}
#[test]
fn test_parse_linear_gradient_with_stops() {
assert_eq!(
LinearGradient::from_str("linear-gradient(to right, #ff0000 0%, #0000ff 100%)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: None,
}),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: ColorInput::Value(Color([0, 0, 255, 255])),
hint: Some(StopPosition(Length::Percentage(100.0))),
},
]
.into(),
})
);
}
#[test]
fn test_parse_linear_gradient_with_double_position_color_stop() {
assert_eq!(
LinearGradient::from_str("linear-gradient(to right, red 10% 20%, blue)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: None,
}),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0xff0000)),
hint: Some(StopPosition(Length::Percentage(10.0))),
},
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0xff0000)),
hint: Some(StopPosition(Length::Percentage(20.0))),
},
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0x0000ff)),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_linear_gradient_with_hint() {
assert_eq!(
LinearGradient::from_str("linear-gradient(to right, #ff0000, 50%, #0000ff)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: None,
}),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: None,
},
GradientStop::Hint(StopPosition(Length::Percentage(50.0))),
GradientStop::ColorHint {
color: ColorInput::Value(Color([0, 0, 255, 255])),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_linear_gradient_single_color() {
assert_eq!(
LinearGradient::from_str("linear-gradient(to bottom, #ff0000)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: None,
vertical: Some(VerticalKeyword::Bottom),
}),
interpolation: ColorInterpolationMethod::default(),
stops: [GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: None,
}]
.into(),
})
);
}
#[test]
fn test_parse_linear_gradient_default_angle() {
assert_eq!(
LinearGradient::from_str("linear-gradient(#ff0000, #0000ff)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::default(),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0xff0000)),
hint: None,
},
GradientStop::ColorHint {
color: ColorInput::Value(Color::from_rgb(0x0000ff)),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_gradient_hint_color() {
assert_eq!(
GradientStop::from_str("#ff0000"),
Ok(GradientStop::ColorHint {
color: ColorInput::Value(Color([255, 0, 0, 255])),
hint: None,
})
);
}
#[test]
fn test_parse_gradient_hint_numeric() {
assert_eq!(
GradientStop::from_str("50%"),
Ok(GradientStop::Hint(StopPosition(Length::Percentage(50.0))))
);
}
#[test]
fn test_angle_degrees_from_keywords() {
assert_eq!(Angle::degrees_from_keywords(None, None), Angle::new(180.0));
assert_eq!(
Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), None),
Angle::new(270.0) );
assert_eq!(
Angle::degrees_from_keywords(Some(HorizontalKeyword::Right), None),
Angle::new(90.0) );
assert_eq!(
Angle::degrees_from_keywords(None, Some(VerticalKeyword::Top)),
Angle::new(0.0)
);
assert_eq!(
Angle::degrees_from_keywords(None, Some(VerticalKeyword::Bottom)),
Angle::new(180.0)
);
assert_eq!(
Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), Some(VerticalKeyword::Top)),
Angle::new(315.0)
);
assert_eq!(
Angle::degrees_from_keywords(Some(HorizontalKeyword::Right), Some(VerticalKeyword::Top)),
Angle::new(45.0)
);
assert_eq!(
Angle::degrees_from_keywords(Some(HorizontalKeyword::Left), Some(VerticalKeyword::Bottom)),
Angle::new(225.0)
);
assert_eq!(
Angle::degrees_from_keywords(
Some(HorizontalKeyword::Right),
Some(VerticalKeyword::Bottom)
),
Angle::new(135.0)
);
}
#[test]
fn test_parse_linear_gradient_mixed_hints_and_colors() {
assert_eq!(
LinearGradient::from_str("linear-gradient(45deg, #ff0000, 25%, #00ff00, 75%, #0000ff)"),
Ok(LinearGradient {
repeating: false,
direction: LinearGradientDirection::Angle(Angle::new(45.0)),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: None,
},
GradientStop::Hint(StopPosition(Length::Percentage(25.0))),
GradientStop::ColorHint {
color: Color([0, 255, 0, 255]).into(),
hint: None,
},
GradientStop::Hint(StopPosition(Length::Percentage(75.0))),
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_linear_gradient_at_simple() {
let gradient = LinearGradient {
repeating: false,
direction: LinearGradientDirection::default(),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(), hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(), hint: Some(StopPosition(Length::Percentage(100.0))),
},
]
.into(),
};
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((100, 100)));
let tile = LinearGradientTile::new(&gradient, 100, 100, &dummy_context);
let color_top = tile.sample_pixel(50, 0).demultiply();
assert_eq!(color_top, ColorU8::from_rgba(255, 0, 0, 255));
let color_bottom = tile.sample_pixel(50, 100).demultiply();
assert_eq!(color_bottom, ColorU8::from_rgba(0, 0, 255, 255));
let color_middle = tile.sample_pixel(50, 50).demultiply();
assert_eq!(color_middle, ColorU8::from_rgba(140, 83, 162, 255));
}
#[test]
fn test_linear_gradient_at_horizontal() {
let gradient = LinearGradient {
repeating: false,
direction: LinearGradientDirection::Angle(Angle::new(90.0)),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(), hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(), hint: Some(StopPosition(Length::Percentage(100.0))),
},
]
.into(),
};
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((100, 100)));
let tile = LinearGradientTile::new(&gradient, 100, 100, &dummy_context);
let color_left = tile.sample_pixel(0, 50).demultiply();
assert_eq!(color_left, ColorU8::from_rgba(255, 0, 0, 255));
let color_right = tile.sample_pixel(100, 50).demultiply();
assert_eq!(color_right, ColorU8::from_rgba(0, 0, 255, 255));
}
#[test]
fn test_keyword_corner_direction_uses_aspect_ratio() {
let gradient = LinearGradient {
repeating: false,
direction: LinearGradientDirection::Keyword(GradientKeywordDirection {
horizontal: Some(HorizontalKeyword::Right),
vertical: Some(VerticalKeyword::Bottom),
}),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Percentage(100.0))),
},
]
.into(),
};
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((200, 100)));
let tile = LinearGradientTile::new(&gradient, 200, 100, &dummy_context);
assert!((tile.dir_x - 0.4472136).abs() < 0.001);
assert!((tile.dir_y - 0.8944272).abs() < 0.001);
}
#[test]
fn test_linear_gradient_at_single_color() {
let gradient = LinearGradient {
repeating: false,
direction: LinearGradientDirection::Angle(Angle::new(0.0)),
interpolation: ColorInterpolationMethod::default(),
stops: [GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(), hint: None,
}]
.into(),
};
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((100, 100)));
let tile = LinearGradientTile::new(&gradient, 100, 100, &dummy_context);
let color = tile.sample_pixel(50, 50).demultiply();
assert_eq!(color, ColorU8::from_rgba(255, 0, 0, 255));
}
#[test]
fn test_linear_gradient_at_no_steps() {
let gradient = LinearGradient {
repeating: false,
direction: LinearGradientDirection::Angle(Angle::new(0.0)),
interpolation: ColorInterpolationMethod::default(),
stops: [].into(),
};
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((100, 100)));
let tile = LinearGradientTile::new(&gradient, 100, 100, &dummy_context);
let color = tile.sample_pixel(50, 50).demultiply();
assert_eq!(color, ColorU8::from_rgba(0, 0, 0, 0));
}
#[test]
fn test_repeating_linear_gradient_stripes() {
let gradient = LinearGradient::builder()
.repeating(true)
.direction(LinearGradientDirection::Angle(Angle::new(90.0)))
.stops([
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Px(0.0))),
},
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Px(5.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Px(5.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Px(10.0))),
},
])
.build();
let context = GlobalContext::default();
let render_context = RenderContext::new_test(&context, Viewport::new((40, 1)));
let tile = LinearGradientTile::new(&gradient, 40, 1, &render_context);
assert_eq!(
[
tile.sample_pixel(2, 0).demultiply(),
tile.sample_pixel(7, 0).demultiply(),
tile.sample_pixel(12, 0).demultiply(),
tile.sample_pixel(17, 0).demultiply(),
],
[
ColorU8::from_rgba(255, 0, 0, 255),
ColorU8::from_rgba(0, 0, 255, 255),
ColorU8::from_rgba(255, 0, 0, 255),
ColorU8::from_rgba(0, 0, 255, 255),
]
);
}
#[test]
fn test_linear_gradient_px_stops_crisp_line() -> ParseResult<'static, ()> {
let gradient =
LinearGradient::from_str("linear-gradient(to right, grey 1px, transparent 1px)")?;
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((40, 40)));
let tile = LinearGradientTile::new(&gradient, 40, 40, &dummy_context);
let c0 = tile.sample_pixel(0, 0).demultiply();
assert_eq!(c0, ColorU8::from_rgba(128, 128, 128, 255));
let c1 = tile.sample_pixel(1, 0).demultiply();
assert_eq!(c1, ColorU8::from_rgba(0, 0, 0, 0));
let c2 = tile.sample_pixel(40, 0).demultiply();
assert_eq!(c2, ColorU8::from_rgba(0, 0, 0, 0));
Ok(())
}
#[test]
fn test_linear_gradient_vertical_px_stops_top_pixel() -> ParseResult<'static, ()> {
let gradient =
LinearGradient::from_str("linear-gradient(to bottom, grey 1px, transparent 1px)")?;
let context = GlobalContext::default();
let dummy_context = RenderContext::new_test(&context, Viewport::new((40, 40)));
let tile = LinearGradientTile::new(&gradient, 40, 40, &dummy_context);
assert_eq!(
tile.sample_pixel(0, 0).demultiply(),
ColorU8::from_rgba(128, 128, 128, 255)
);
Ok(())
}
#[test]
fn test_stop_position_parsing_fraction_number() {
assert_eq!(
StopPosition::from_str("0.25"),
Ok(StopPosition(Length::Percentage(25.0)))
);
}
#[test]
fn test_stop_position_parsing_percentage() {
assert_eq!(
StopPosition::from_str("75%"),
Ok(StopPosition(Length::Percentage(75.0)))
);
}
#[test]
fn test_stop_position_parsing_length_px() {
assert_eq!(
StopPosition::from_str("12px"),
Ok(StopPosition(Length::Px(12.0)))
);
}
#[test]
fn test_stop_position_value_css_roundtrip() {
assert_eq!(
StopPosition::from_str("50%"),
Ok(StopPosition(Length::Percentage(50.0)))
);
assert_eq!(
StopPosition::from_str("8px"),
Ok(StopPosition(Length::Px(8.0)))
);
}
#[test]
fn resolve_stops_percentage_and_px_linear() {
let gradient = LinearGradient::builder()
.direction(LinearGradientDirection::Angle(Angle::new(0.0)))
.stops([
GradientStop::ColorHint {
color: Color::black().into(),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color::black().into(),
hint: Some(StopPosition(Length::Percentage(50.0))),
},
GradientStop::ColorHint {
color: Color::black().into(),
hint: Some(StopPosition(Length::Px(100.0))),
},
])
.build();
let context = GlobalContext::default();
let ctx = RenderContext::new_test(&context, Viewport::new((200, 100)));
let resolved = resolve_stops_along_axis(
&gradient.stops,
ctx.sizing.viewport.size.width.unwrap_or_default() as f32,
&ctx,
);
assert_eq!(resolved.len(), 3);
assert!((resolved[0].position - 0.0).abs() < 1e-3);
assert!((resolved[1].position - 100.0).abs() < 1e-3);
assert!((resolved[2].position - 100.0).abs() < 1e-3);
}
#[test]
fn resolve_stops_equal_positions_allowed_linear() {
let gradient = LinearGradient::builder()
.direction(LinearGradientDirection::Angle(Angle::new(0.0)))
.stops([
GradientStop::ColorHint {
color: Color::black().into(),
hint: Some(StopPosition(Length::Px(0.0))),
},
GradientStop::ColorHint {
color: Color::black().into(),
hint: Some(StopPosition(Length::Px(0.0))),
},
])
.build();
let context = GlobalContext::default();
let ctx = RenderContext::new_test(&context, Viewport::new((200, 100)));
let resolved = resolve_stops_along_axis(
&gradient.stops,
ctx.sizing.viewport.size.width.unwrap_or_default() as f32,
&ctx,
);
assert_eq!(resolved.len(), 2);
assert!((resolved[0].position - 0.0).abs() < 1e-3);
assert!((resolved[1].position - 0.0).abs() < 1e-3);
}
}