use std::{f32::consts::TAU, fmt};
use cssparser::{Parser, Token, match_ignore_ascii_case};
use tiny_skia::PremultipliedColorU8;
use typed_builder::TypedBuilder;
use super::gradient_utils::{
GradientOverlayTile, adaptive_lut_size_with_visible_samples, build_color_lut_with_interpolation,
resolve_stops_along_axis,
};
use crate::{
layout::style::{
Angle, BackgroundPosition, ColorInput, ColorInterpolationMethod, CssDescriptorKind, CssToken,
FromCss, GradientStop, Length, MakeComputed, ObjectPosition, ParseResult, ResolvedGradientStop,
StopPosition, ToCss, unexpected_token,
},
rendering::{RenderContext, Sizing},
};
const LUT_INDEX_BOUNDARY_EPSILON: f32 = 0.001;
#[derive(Debug, Clone, PartialEq, TypedBuilder)]
#[non_exhaustive]
pub struct ConicGradient {
#[builder(default)]
pub repeating: bool,
#[builder(default)]
pub from_angle: Angle,
#[builder(default)]
pub center: ObjectPosition,
#[builder(default)]
pub interpolation: ColorInterpolationMethod,
#[builder(setter(into))]
pub stops: Box<[GradientStop]>,
}
impl MakeComputed for ConicGradient {
fn make_computed(&mut self, sizing: &Sizing) {
self.center.make_computed(sizing);
self.stops.make_computed(sizing);
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConicGradientTile {
pub width: u32,
pub height: u32,
pub cx: f32,
pub cy: f32,
pub start_rad: f32,
pub repeating: bool,
pub repeat_start_deg: f32,
pub repeat_period_deg: f32,
pub angle_to_lut_scale: f32,
pub fully_opaque: bool,
pub color_lut: Vec<PremultipliedColorU8>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ConicGradientRowState {
dx: f32,
dy: f32,
lut_len: usize,
}
impl ConicGradientTile {
fn visible_angle_samples(width: u32, height: u32, cx: f32, cy: f32) -> usize {
let max_dx = cx.max(width as f32 - cx);
let max_dy = cy.max(height as f32 - cy);
(max_dx.hypot(max_dy) * TAU).ceil() as usize + 1
}
#[inline(always)]
fn angle_from_top_normalized(dx: f32, dy: f32) -> f32 {
let angle = dx.atan2(-dy);
if angle < 0.0 { angle + TAU } else { angle }
}
#[inline(always)]
fn adjusted_angle(&self, angle_from_top: f32) -> f32 {
let adjusted = angle_from_top - self.start_rad;
if adjusted < 0.0 {
adjusted + TAU
} else {
adjusted
}
}
#[inline(always)]
fn stable_floor_index(scaled_position: f32, max_index: usize) -> usize {
let nearest = scaled_position.round();
let sample = if (scaled_position - nearest).abs() <= LUT_INDEX_BOUNDARY_EPSILON {
nearest
} else {
scaled_position.floor()
};
(sample as usize).min(max_index)
}
#[inline(always)]
fn stable_round_index(scaled_position: f32, max_index: usize) -> usize {
let floor = scaled_position.floor();
let fraction = scaled_position - floor;
let sample = if fraction >= 0.5 - LUT_INDEX_BOUNDARY_EPSILON {
floor + 1.0
} else {
floor
};
(sample as usize).min(max_index)
}
#[inline(always)]
pub(crate) fn lut_index_for_adjusted_angle_with_len(
&self,
adjusted_angle: f32,
lut_len: usize,
) -> usize {
if lut_len <= 1 {
return 0;
}
let max_index = lut_len - 1;
if self.repeating && self.repeat_period_deg > 1e-6 {
let degrees = adjusted_angle / TAU * 360.0;
let wrapped = (degrees - self.repeat_start_deg).rem_euclid(self.repeat_period_deg);
Self::stable_round_index(wrapped * self.angle_to_lut_scale, max_index)
} else {
Self::stable_floor_index(adjusted_angle * self.angle_to_lut_scale, max_index)
}
}
pub fn new(gradient: &ConicGradient, width: u32, height: u32, context: &RenderContext) -> Self {
let cx = Length::from(gradient.center.0.x).to_px(&context.sizing, width as f32);
let cy = Length::from(gradient.center.0.y).to_px(&context.sizing, height as f32);
let start_rad = gradient.from_angle.to_radians().rem_euclid(TAU);
let resolved_stops = resolve_stops_along_axis(&gradient.stops, 360.0, context);
let (repeating, repeat_start_deg, repeat_period_deg, lut_axis_length_deg, lut_resolved_stops) =
if gradient.repeating
&& let (Some(first), Some(last)) = (resolved_stops.first(), resolved_stops.last())
{
let repeat_start_deg = first.position;
let repeat_period_deg = (last.position - first.position).max(0.0);
if repeat_period_deg > 1e-6 {
let shifted = resolved_stops
.iter()
.map(|stop| ResolvedGradientStop {
color: stop.color,
position: stop.position - repeat_start_deg,
})
.collect();
(
true,
repeat_start_deg,
repeat_period_deg,
repeat_period_deg,
shifted,
)
} else {
(false, 0.0, 0.0, 360.0, resolved_stops)
}
} else {
(false, 0.0, 0.0, 360.0, resolved_stops)
};
let lut_size = adaptive_lut_size_with_visible_samples(
Self::visible_angle_samples(width, height, cx, cy),
lut_axis_length_deg,
&lut_resolved_stops,
);
let color_lut = build_color_lut_with_interpolation(
&lut_resolved_stops,
lut_axis_length_deg,
lut_size,
gradient.interpolation.color_space,
gradient.interpolation.hue_direction,
);
let lut_len = color_lut.len();
let angle_to_lut_scale = if repeating && repeat_period_deg > 1e-6 && lut_len > 1 {
(lut_len - 1) as f32 / repeat_period_deg
} else if lut_len == 0 {
0.0
} else {
lut_len as f32 / TAU
};
let fully_opaque = color_lut.iter().all(|p| p.alpha() == u8::MAX);
ConicGradientTile {
width,
height,
cx,
cy,
start_rad,
repeating,
repeat_start_deg,
repeat_period_deg,
angle_to_lut_scale,
fully_opaque,
color_lut,
}
}
}
impl GradientOverlayTile for ConicGradientTile {
type RowState = ConicGradientRowState;
#[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 dx = x as f32 - self.cx;
let dy = y as f32 - self.cy;
if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
return self.color_lut[0];
}
let angle_from_top = Self::angle_from_top_normalized(dx, dy);
let adjusted = self.adjusted_angle(angle_from_top);
let lut_idx = self.lut_index_for_adjusted_angle_with_len(adjusted, 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 {
ConicGradientRowState {
dx: src_x_start as f32 - self.cx,
dy: src_y as f32 - self.cy,
lut_len,
}
}
#[inline(always)]
fn next_lut_index(&self, row_state: &mut Self::RowState) -> usize {
let lut_idx = if row_state.dx.abs() <= f32::EPSILON && row_state.dy.abs() <= f32::EPSILON {
0
} else {
let angle_from_top = Self::angle_from_top_normalized(row_state.dx, row_state.dy);
let adjusted_angle = self.adjusted_angle(angle_from_top);
self.lut_index_for_adjusted_angle_with_len(adjusted_angle, row_state.lut_len)
};
row_state.dx += 1.0;
lut_idx
}
#[inline(always)]
fn fully_opaque(&self) -> bool {
self.fully_opaque
}
}
fn parse_conic_stop_position<'i>(input: &mut Parser<'i, '_>) -> ParseResult<'i, StopPosition> {
let location = input.current_source_location();
let token = input.next()?;
match token {
Token::Percentage { unit_value, .. } => {
Ok(StopPosition(Length::Percentage(*unit_value * 100.0)))
}
Token::Number { value, .. } if (0.0..=1.0).contains(value) => {
Ok(StopPosition(Length::Percentage(*value * 100.0)))
}
Token::Dimension { value, unit, .. } => {
let degrees = match_ignore_ascii_case! { unit,
"deg" => *value,
"grad" => *value * 0.9,
"rad" => value.to_degrees(),
"turn" => *value * 360.0,
_ => return Err(unexpected_token!(StopPosition, location, token)),
};
Ok(StopPosition(Length::Percentage(degrees / 360.0 * 100.0)))
}
_ => Err(unexpected_token!(StopPosition, location, token)),
}
}
fn parse_conic_gradient_stops<'i>(
input: &mut Parser<'i, '_>,
) -> ParseResult<'i, Vec<GradientStop>> {
let mut stops = Vec::new();
loop {
if let Ok(hint) = input.try_parse(parse_conic_stop_position) {
stops.push(GradientStop::Hint(hint));
} else {
let color = ColorInput::from_css(input)?;
let first_position = input.try_parse(parse_conic_stop_position).ok();
let second_position = if first_position.is_some() {
input.try_parse(parse_conic_stop_position).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),
});
}
_ => {
stops.push(GradientStop::ColorHint {
color,
hint: first_position,
});
}
}
}
if input.try_parse(Parser::expect_comma).is_err() {
break;
}
}
Ok(stops)
}
impl<'i> FromCss<'i> for ConicGradient {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, ConicGradient> {
let location = input.current_source_location();
let name = input.expect_function()?.to_owned();
let repeating = match_ignore_ascii_case! { &name,
"conic-gradient" => false,
"repeating-conic-gradient" => true,
_ => return Err(unexpected_token!(location, &Token::Function(name))),
};
input.parse_nested_block(|input| {
let mut from_angle: Option<Angle> = None;
let mut center: Option<ObjectPosition> = None;
let mut interpolation = ColorInterpolationMethod::default();
loop {
if input.try_parse(|i| i.expect_ident_matching("from")).is_ok() {
from_angle = Some(Angle::from_css(input)?);
continue;
}
if input.try_parse(|i| i.expect_ident_matching("at")).is_ok() {
center = Some(BackgroundPosition::from_css(input)?);
continue;
}
if let Ok(parsed_interpolation) = input.try_parse(ColorInterpolationMethod::from_css) {
interpolation = parsed_interpolation;
continue;
}
input.try_parse(Parser::expect_comma).ok();
break;
}
let stops = parse_conic_gradient_stops(input)?;
Ok(ConicGradient {
repeating,
from_angle: from_angle.unwrap_or(Angle::zero()),
center: center.unwrap_or_default(),
interpolation,
stops: stops.into_boxed_slice(),
})
})
}
const VALID_TOKENS: &'static [CssToken] =
&[CssToken::Descriptor(CssDescriptorKind::ConicGradientFn)];
}
impl ToCss for ConicGradient {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
let name = if self.repeating {
"repeating-conic-gradient"
} else {
"conic-gradient"
};
dest.write_str(name)?;
dest.write_char('(')?;
let mut first = true;
let mut params_buf = String::new();
if self.from_angle != Angle::zero() {
params_buf.push_str("from ");
self.from_angle.to_css(&mut params_buf)?;
}
let mut center_buf = String::new();
self.center.to_css(&mut center_buf)?;
let is_center_default = center_buf == "center center" || center_buf == "50% 50%";
if !is_center_default {
if !params_buf.is_empty() {
params_buf.push(' ');
}
params_buf.push_str("at ");
params_buf.push_str(¢er_buf);
}
if !params_buf.is_empty() {
dest.write_str(¶ms_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 tiny_skia::ColorU8;
use super::*;
use crate::layout::Viewport;
use crate::layout::style::{Color, Length, SpacePair, StopPosition};
use crate::{GlobalContext, rendering::RenderContext};
#[test]
fn test_parse_conic_gradient_basic() {
let gradient = ConicGradient::from_str("conic-gradient(#ff0000, #0000ff)");
assert_eq!(
gradient,
Ok(ConicGradient {
repeating: false,
from_angle: Angle::zero(),
center: ObjectPosition::default(),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: None,
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_conic_gradient_with_interpolation_color_space() {
assert_eq!(
ConicGradient::from_str("conic-gradient(in oklab, red, blue)"),
Ok(ConicGradient {
repeating: false,
from_angle: Angle::zero(),
center: ObjectPosition::default(),
interpolation: ColorInterpolationMethod {
color_space: ColorSpaceTag::Oklab,
hue_direction: HueDirection::Shorter,
},
stops: [
GradientStop::ColorHint {
color: Color::from_rgb(0xff0000).into(),
hint: None,
},
GradientStop::ColorHint {
color: Color::from_rgb(0x0000ff).into(),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_conic_gradient_with_stops() {
assert_eq!(
ConicGradient::from_str("conic-gradient(#ff0000 0%, #00ff00 50%, #0000ff 100%)"),
Ok(ConicGradient {
repeating: false,
from_angle: Angle::zero(),
center: ObjectPosition::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, 255, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(50.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Percentage(100.0))),
},
]
.into(),
})
);
}
#[test]
fn test_parse_conic_gradient_with_double_position_color_stop() {
assert_eq!(
ConicGradient::from_str("conic-gradient(red 10% 20%, blue)"),
Ok(ConicGradient {
repeating: false,
from_angle: Angle::zero(),
center: ObjectPosition::default(),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color::from_rgb(0xff0000).into(),
hint: Some(StopPosition(Length::Percentage(10.0))),
},
GradientStop::ColorHint {
color: Color::from_rgb(0xff0000).into(),
hint: Some(StopPosition(Length::Percentage(20.0))),
},
GradientStop::ColorHint {
color: Color::from_rgb(0x0000ff).into(),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_conic_gradient_with_angle_stops() {
assert_eq!(
ConicGradient::from_str("conic-gradient(red 0deg, lime 180deg, blue 1turn)"),
Ok(ConicGradient {
repeating: false,
from_angle: Angle::zero(),
center: ObjectPosition::default(),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color::from_rgb(0xff0000).into(),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color::from_rgb(0x00ff00).into(),
hint: Some(StopPosition(Length::Percentage(50.0))),
},
GradientStop::ColorHint {
color: Color::from_rgb(0x0000ff).into(),
hint: Some(StopPosition(Length::Percentage(100.0))),
},
]
.into(),
})
);
}
#[test]
fn test_parse_conic_gradient_with_double_angle_stop() {
assert_eq!(
ConicGradient::from_str("conic-gradient(red 0deg 90deg, blue)"),
Ok(ConicGradient {
repeating: false,
from_angle: Angle::zero(),
center: ObjectPosition::default(),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color::from_rgb(0xff0000).into(),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color::from_rgb(0xff0000).into(),
hint: Some(StopPosition(Length::Percentage(25.0))),
},
GradientStop::ColorHint {
color: Color::from_rgb(0x0000ff).into(),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_parse_conic_gradient_complex() {
let gradient = ConicGradient::from_str("conic-gradient(from 90deg at 25% 75%, red, blue)");
assert_eq!(
gradient,
Ok(ConicGradient {
repeating: false,
from_angle: Angle::new(90.0),
center: BackgroundPosition::<false>(SpacePair::from_pair(
Length::Percentage(25.0).into(),
Length::Percentage(75.0).into()
)),
interpolation: ColorInterpolationMethod::default(),
stops: [
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: None,
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: None,
},
]
.into(),
})
);
}
#[test]
fn test_conic_gradient_top_pixel_is_first_color() {
let gradient = ConicGradient::builder()
.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))),
},
])
.build();
let context = GlobalContext::default();
let render_context = RenderContext::new_test(&context, Viewport::new((100, 100)));
let tile = ConicGradientTile::new(&gradient, 100, 100, &render_context);
let color_top = tile.sample_pixel(50, 0).demultiply();
assert_eq!(color_top, ColorU8::from_rgba(255, 0, 0, 255));
}
#[test]
fn test_conic_gradient_hard_stops() {
let gradient = ConicGradient::builder()
.stops([
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(33.0))),
},
GradientStop::ColorHint {
color: Color([0, 255, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(33.0))),
},
GradientStop::ColorHint {
color: Color([0, 255, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(66.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Percentage(66.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Percentage(100.0))),
},
])
.build();
let context = GlobalContext::default();
let render_context = RenderContext::new_test(&context, Viewport::new((100, 100)));
let tile = ConicGradientTile::new(&gradient, 100, 100, &render_context);
let top = tile.sample_pixel(50, 0).demultiply();
assert_eq!(top, ColorU8::from_rgba(255, 0, 0, 255));
let bottom = tile.sample_pixel(50, 99).demultiply();
assert_eq!(bottom, ColorU8::from_rgba(0, 255, 0, 255));
}
#[test]
fn test_repeating_conic_gradient_quadrants() {
let gradient = ConicGradient::builder()
.repeating(true)
.stops([
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(0.0))),
},
GradientStop::ColorHint {
color: Color([255, 0, 0, 255]).into(),
hint: Some(StopPosition(Length::Percentage(25.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Percentage(25.0))),
},
GradientStop::ColorHint {
color: Color([0, 0, 255, 255]).into(),
hint: Some(StopPosition(Length::Percentage(50.0))),
},
])
.build();
let context = GlobalContext::default();
let render_context = RenderContext::new_test(&context, Viewport::new((40, 40)));
let tile = ConicGradientTile::new(&gradient, 40, 40, &render_context);
assert_eq!(
[
tile.sample_pixel(25, 15).demultiply(),
tile.sample_pixel(25, 25).demultiply(),
tile.sample_pixel(15, 25).demultiply(),
tile.sample_pixel(15, 15).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_conic_gradient_lut_index_snaps_near_floor_boundary() {
assert_eq!(ConicGradientTile::stable_floor_index(127.9995, 360), 128);
assert_eq!(ConicGradientTile::stable_floor_index(127.998, 360), 127);
}
#[test]
fn test_conic_gradient_lut_index_snaps_near_round_boundary() {
assert_eq!(ConicGradientTile::stable_round_index(127.4995, 360), 128);
assert_eq!(ConicGradientTile::stable_round_index(127.498, 360), 127);
}
}