use peniko::Color;
use crate::tokens::{FamilyRole, TextSize, Weight};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Length {
Px(f32),
Pct(f32),
Ch(f32),
#[default]
Auto,
}
impl Length {
pub(crate) fn resolved(self, ch_px: f32) -> Length {
match self {
Length::Ch(n) => Length::Px(n * ch_px),
other => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Display {
#[default]
Flex,
Grid,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Direction {
#[default]
Row,
Column,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlignItems {
#[default]
Stretch,
Start,
Center,
End,
Baseline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JustifyContent {
#[default]
Start,
Center,
End,
SpaceBetween,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlignContent {
#[default]
Start,
Center,
End,
Stretch,
SpaceBetween,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Position {
#[default]
Relative,
Absolute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Overflow {
#[default]
Visible,
Hidden,
Scroll,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Edges {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
impl Edges {
pub const fn all(v: f32) -> Self {
Self {
top: v,
right: v,
bottom: v,
left: v,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Inset {
pub top: Option<f32>,
pub right: Option<f32>,
pub bottom: Option<f32>,
pub left: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Track {
Px(f32),
Fr(f32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GridPlace {
pub start: Option<i16>,
pub span: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GradientStop {
pub offset: f32,
pub color: Color,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Paint {
Solid(Color),
LinearGradient {
angle_deg: f32,
stops: Vec<GradientStop>,
},
RadialGradient {
center: (f32, f32),
radius: f32,
stops: Vec<GradientStop>,
},
}
impl From<Color> for Paint {
fn from(c: Color) -> Self {
Self::Solid(c)
}
}
#[must_use]
pub fn oklch_stops(anchors: &[(f32, Color)], steps: usize) -> Vec<GradientStop> {
if anchors.is_empty() {
return Vec::new();
}
let mut sorted = anchors.to_vec();
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
if sorted.len() == 1 || steps == 0 {
return sorted
.iter()
.map(|&(offset, color)| GradientStop { offset, color })
.collect();
}
let mut out = Vec::with_capacity((sorted.len() - 1) * steps + 1);
for (seg, pair) in sorted.windows(2).enumerate() {
let (o0, c0) = pair[0];
let (o1, c1) = pair[1];
let first = usize::from(seg != 0);
for j in first..=steps {
#[expect(clippy::cast_precision_loss, reason = "gradient step counts are tiny")]
let t = j as f32 / steps as f32;
out.push(GradientStop {
offset: o0 + (o1 - o0) * t,
color: crate::anim::lerp_color(c0, c1, t),
});
}
}
out
}
fn even_anchors(colors: impl IntoIterator<Item = Color>) -> Vec<(f32, Color)> {
let colors: Vec<Color> = colors.into_iter().collect();
let last = colors.len().saturating_sub(1);
if last == 0 {
return colors.into_iter().map(|c| (0.0, c)).collect();
}
colors
.into_iter()
.enumerate()
.map(|(i, c)| {
#[expect(clippy::cast_precision_loss, reason = "color counts are tiny")]
let offset = i as f32 / last as f32;
(offset, c)
})
.collect()
}
fn degenerate_solid(colors: &[Color]) -> Option<Paint> {
match colors {
[] => Some(Paint::Solid(Color::new([0.0, 0.0, 0.0, 0.0]))),
[c] => Some(Paint::Solid(*c)),
_ => None,
}
}
#[must_use]
pub fn linear_gradient(angle_deg: f32, colors: impl IntoIterator<Item = Color>) -> Paint {
let colors: Vec<Color> = colors.into_iter().collect();
degenerate_solid(&colors).unwrap_or_else(|| Paint::LinearGradient {
angle_deg,
stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
})
}
#[must_use]
pub fn radial_gradient(
center: (f32, f32),
radius: f32,
colors: impl IntoIterator<Item = Color>,
) -> Paint {
let colors: Vec<Color> = colors.into_iter().collect();
degenerate_solid(&colors).unwrap_or_else(|| Paint::RadialGradient {
center,
radius,
stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Border {
pub width: f32,
pub color: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct CornerRadius {
pub tl: f32,
pub tr: f32,
pub br: f32,
pub bl: f32,
}
impl CornerRadius {
pub const fn all(r: f32) -> Self {
Self {
tl: r,
tr: r,
br: r,
bl: r,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Shadow {
pub dx: f32,
pub dy: f32,
pub blur: f32,
pub spread: f32,
pub color: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TextAlign {
#[default]
Start,
Center,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TextWrap {
#[default]
Normal,
Balance,
Pretty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum FigureStyle {
#[default]
Default,
Lining,
OldStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum NumericSpacing {
#[default]
Default,
Proportional,
Tabular,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct FontFeatures {
pub figures: FigureStyle,
pub spacing: NumericSpacing,
pub small_caps: bool,
pub ligatures: Option<bool>,
pub fractions: bool,
}
impl FontFeatures {
pub(crate) fn feature_string(&self) -> Option<String> {
let mut parts: Vec<&'static str> = Vec::new();
match self.figures {
FigureStyle::Default => {}
FigureStyle::Lining => parts.push("\"lnum\" 1"),
FigureStyle::OldStyle => parts.push("\"onum\" 1"),
}
match self.spacing {
NumericSpacing::Default => {}
NumericSpacing::Proportional => parts.push("\"pnum\" 1"),
NumericSpacing::Tabular => parts.push("\"tnum\" 1"),
}
if self.small_caps {
parts.push("\"smcp\" 1");
}
match self.ligatures {
None => {}
Some(true) => parts.push("\"liga\" 1"),
Some(false) => parts.push("\"liga\" 0"),
}
if self.fractions {
parts.push("\"frac\" 1");
}
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct TextStyle {
pub size: TextSize,
pub size_px: Option<f32>,
pub weight: Weight,
pub color: Option<Color>,
pub line_height: Option<f32>,
pub letter_spacing: Option<f32>,
pub family: FamilyRole,
pub align: TextAlign,
pub max_lines: Option<u32>,
pub features: FontFeatures,
pub wrap: TextWrap,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Style {
pub display: Display,
pub direction: Direction,
pub wrap: bool,
pub align_items: AlignItems,
pub align_self: Option<AlignItems>,
pub justify_content: JustifyContent,
pub align_content: AlignContent,
pub gap: f32,
pub padding: Edges,
pub margin: Edges,
pub inset: Inset,
pub position: Position,
pub width: Length,
pub height: Length,
pub min_width: Length,
pub max_width: Length,
pub min_height: Length,
pub max_height: Length,
pub flex_grow: f32,
pub flex_shrink: f32,
pub flex_basis: Length,
pub grid_template_columns: Vec<Track>,
pub grid_template_rows: Vec<Track>,
pub grid_column: GridPlace,
pub grid_row: GridPlace,
pub overflow_x: Overflow,
pub overflow_y: Overflow,
pub fill: Option<Paint>,
pub border: Option<Border>,
pub corner_radius: CornerRadius,
pub corner_smoothing: f32,
pub shadow_token: Option<crate::tokens::ShadowToken>,
pub shadows: Vec<Shadow>,
pub highlight_top: Option<Color>,
pub opacity: f32,
pub scale: f32,
pub clip: bool,
pub path_trim: f32,
pub text: TextStyle,
}
impl Default for Style {
fn default() -> Self {
Self {
display: Display::default(),
direction: Direction::default(),
wrap: false,
align_items: AlignItems::default(),
align_self: None,
justify_content: JustifyContent::default(),
align_content: AlignContent::default(),
gap: 0.0,
padding: Edges::default(),
margin: Edges::default(),
inset: Inset::default(),
position: Position::default(),
width: Length::Auto,
height: Length::Auto,
min_width: Length::Auto,
max_width: Length::Auto,
min_height: Length::Auto,
max_height: Length::Auto,
flex_grow: 0.0,
flex_shrink: 1.0,
flex_basis: Length::Auto,
grid_template_columns: Vec::new(),
grid_template_rows: Vec::new(),
grid_column: GridPlace::default(),
grid_row: GridPlace::default(),
overflow_x: Overflow::Visible,
overflow_y: Overflow::Visible,
fill: None,
border: None,
corner_radius: CornerRadius::default(),
corner_smoothing: 0.0,
shadow_token: None,
shadows: Vec::new(),
highlight_top: None,
opacity: 1.0,
scale: 1.0,
clip: false,
path_trim: 1.0,
text: TextStyle::default(),
}
}
}
pub type ThemedFn = Box<dyn Fn(&crate::theme::Theme, Style) -> Style>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpringSpec {
pub stiffness: f32,
pub damping: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transition {
pub colors: bool,
pub opacity: bool,
pub lengths: bool,
pub offsets: bool,
pub shadows: bool,
pub duration_ms: f32,
pub easing: crate::tokens::CubicBezier,
pub spring: Option<SpringSpec>,
}
pub struct Keyframes {
pub(crate) stops: Vec<(f32, ThemedFn)>,
pub(crate) duration_ms: f32,
pub(crate) easing: crate::tokens::CubicBezier,
}
impl Keyframes {
pub fn new(duration_ms: f32) -> Self {
Self {
stops: Vec::new(),
duration_ms,
easing: crate::tokens::EASE_STANDARD,
}
}
pub fn stop(self, at: f32, f: impl Fn(Style) -> Style + 'static) -> Self {
self.themed_stop(at, move |_, s| f(s))
}
pub fn themed_stop(
mut self,
at: f32,
f: impl Fn(&crate::theme::Theme, Style) -> Style + 'static,
) -> Self {
self.stops.push((at.clamp(0.0, 1.0), Box::new(f)));
self.stops.sort_by(|a, b| a.0.total_cmp(&b.0));
self
}
pub fn ease(mut self, easing: crate::tokens::CubicBezier) -> Self {
self.easing = easing;
self
}
}
impl Transition {
pub fn colors() -> Self {
Self {
colors: true,
opacity: false,
lengths: false,
offsets: false,
shadows: true,
duration_ms: crate::tokens::MotionDuration::Fast.ms(),
easing: crate::tokens::EASE_STANDARD,
spring: None,
}
}
pub fn all() -> Self {
Self {
colors: true,
opacity: true,
lengths: true,
offsets: true,
shadows: true,
duration_ms: crate::tokens::MotionDuration::Base.ms(),
easing: crate::tokens::EASE_STANDARD,
spring: None,
}
}
pub fn spring() -> Self {
Self {
spring: Some(SpringSpec {
stiffness: 380.0,
damping: 26.0,
}),
..Self::all()
}
}
pub fn with_spring(mut self, stiffness: f32, damping: f32) -> Self {
self.spring = Some(SpringSpec { stiffness, damping });
self
}
pub fn duration(mut self, d: crate::tokens::MotionDuration) -> Self {
self.duration_ms = d.ms();
self
}
pub fn duration_ms(mut self, ms: f32) -> Self {
self.duration_ms = ms;
self
}
pub fn lengths(mut self, on: bool) -> Self {
self.lengths = on;
self
}
pub fn offsets(mut self, on: bool) -> Self {
self.offsets = on;
self
}
pub fn opacity(mut self, on: bool) -> Self {
self.opacity = on;
self
}
pub fn easing(mut self, e: crate::tokens::CubicBezier) -> Self {
self.easing = e;
self
}
}
impl From<f32> for Length {
fn from(v: f32) -> Self {
Self::Px(v)
}
}
impl Style {
pub fn p(mut self, v: f32) -> Self {
self.padding = Edges::all(v);
self
}
pub fn px(mut self, v: f32) -> Self {
self.padding.left = v;
self.padding.right = v;
self
}
pub fn py(mut self, v: f32) -> Self {
self.padding.top = v;
self.padding.bottom = v;
self
}
pub fn pt(mut self, v: f32) -> Self {
self.padding.top = v;
self
}
pub fn pr(mut self, v: f32) -> Self {
self.padding.right = v;
self
}
pub fn pb(mut self, v: f32) -> Self {
self.padding.bottom = v;
self
}
pub fn pl(mut self, v: f32) -> Self {
self.padding.left = v;
self
}
pub fn m(mut self, v: f32) -> Self {
self.margin = Edges::all(v);
self
}
pub fn mx(mut self, v: f32) -> Self {
self.margin.left = v;
self.margin.right = v;
self
}
pub fn my(mut self, v: f32) -> Self {
self.margin.top = v;
self.margin.bottom = v;
self
}
pub fn mt(mut self, v: f32) -> Self {
self.margin.top = v;
self
}
pub fn mr(mut self, v: f32) -> Self {
self.margin.right = v;
self
}
pub fn mb(mut self, v: f32) -> Self {
self.margin.bottom = v;
self
}
pub fn ml(mut self, v: f32) -> Self {
self.margin.left = v;
self
}
pub fn gap(mut self, v: f32) -> Self {
self.gap = v;
self
}
pub fn w(mut self, v: impl Into<Length>) -> Self {
self.width = v.into();
self
}
pub fn h(mut self, v: impl Into<Length>) -> Self {
self.height = v.into();
self
}
pub fn min_w(mut self, v: impl Into<Length>) -> Self {
self.min_width = v.into();
self
}
pub fn max_w(mut self, v: impl Into<Length>) -> Self {
self.max_width = v.into();
self
}
pub fn min_h(mut self, v: impl Into<Length>) -> Self {
self.min_height = v.into();
self
}
pub fn max_h(mut self, v: impl Into<Length>) -> Self {
self.max_height = v.into();
self
}
pub fn w_full(mut self) -> Self {
self.width = Length::Pct(100.0);
self
}
pub fn h_full(mut self) -> Self {
self.height = Length::Pct(100.0);
self
}
pub fn measure(mut self, chars: f32) -> Self {
self.max_width = Length::Ch(chars);
self
}
pub fn w_ch(mut self, chars: f32) -> Self {
self.width = Length::Ch(chars);
self
}
pub fn min_w_ch(mut self, chars: f32) -> Self {
self.min_width = Length::Ch(chars);
self
}
pub fn max_w_ch(mut self, chars: f32) -> Self {
self.max_width = Length::Ch(chars);
self
}
pub fn grow(mut self) -> Self {
self.flex_grow = 1.0;
self
}
pub fn shrink0(mut self) -> Self {
self.flex_shrink = 0.0;
self
}
pub fn items_start(mut self) -> Self {
self.align_items = AlignItems::Start;
self
}
pub fn items_center(mut self) -> Self {
self.align_items = AlignItems::Center;
self
}
pub fn items_end(mut self) -> Self {
self.align_items = AlignItems::End;
self
}
pub fn items_baseline(mut self) -> Self {
self.align_items = AlignItems::Baseline;
self
}
pub fn justify_start(mut self) -> Self {
self.justify_content = JustifyContent::Start;
self
}
pub fn justify_center(mut self) -> Self {
self.justify_content = JustifyContent::Center;
self
}
pub fn justify_end(mut self) -> Self {
self.justify_content = JustifyContent::End;
self
}
pub fn justify_between(mut self) -> Self {
self.justify_content = JustifyContent::SpaceBetween;
self
}
pub fn wrap(mut self) -> Self {
self.wrap = true;
self
}
pub fn absolute(mut self) -> Self {
self.position = Position::Absolute;
self
}
pub fn top(mut self, v: f32) -> Self {
self.inset.top = Some(v);
self
}
pub fn right(mut self, v: f32) -> Self {
self.inset.right = Some(v);
self
}
pub fn bottom(mut self, v: f32) -> Self {
self.inset.bottom = Some(v);
self
}
pub fn left(mut self, v: f32) -> Self {
self.inset.left = Some(v);
self
}
pub fn overflow_hidden(mut self) -> Self {
self.overflow_x = Overflow::Hidden;
self.overflow_y = Overflow::Hidden;
self.clip = true;
self
}
pub fn scroll_y(mut self) -> Self {
self.overflow_y = Overflow::Scroll;
self.clip = true;
self
}
pub fn bg(mut self, paint: impl Into<Paint>) -> Self {
self.fill = Some(paint.into());
self
}
pub fn border(mut self, width: f32, color: Color) -> Self {
self.border = Some(Border { width, color });
self
}
pub fn rounded(mut self, r: f32) -> Self {
self.corner_radius = CornerRadius::all(r);
self
}
pub fn rounded_full(mut self) -> Self {
self.corner_radius = CornerRadius::all(crate::tokens::R_FULL);
self
}
pub fn corner_smoothing(mut self, s: f32) -> Self {
self.corner_smoothing = s.clamp(0.0, 1.0);
self
}
pub fn shadow(mut self, token: crate::tokens::ShadowToken) -> Self {
self.shadow_token = Some(token);
self
}
pub fn highlight_top(mut self, color: Color) -> Self {
self.highlight_top = Some(color);
self
}
pub fn opacity(mut self, v: f32) -> Self {
self.opacity = v;
self
}
pub fn scale(mut self, v: f32) -> Self {
self.scale = v;
self
}
pub fn trim(mut self, v: f32) -> Self {
self.path_trim = v.clamp(0.0, 1.0);
self
}
pub fn size_px(mut self, px: f32) -> Self {
self.text.size_px = Some(px);
self
}
pub fn tracking(mut self, em: f32) -> Self {
self.text.letter_spacing = Some(em);
self
}
pub fn leading(mut self, multiple: f32) -> Self {
self.text.line_height = Some(multiple);
self
}
pub fn tabular(mut self) -> Self {
self.text.features.spacing = NumericSpacing::Tabular;
self
}
pub fn proportional_nums(mut self) -> Self {
self.text.features.spacing = NumericSpacing::Proportional;
self
}
pub fn oldstyle_nums(mut self) -> Self {
self.text.features.figures = FigureStyle::OldStyle;
self
}
pub fn lining_nums(mut self) -> Self {
self.text.features.figures = FigureStyle::Lining;
self
}
pub fn small_caps(mut self) -> Self {
self.text.features.small_caps = true;
self
}
pub fn ligatures(mut self, on: bool) -> Self {
self.text.features.ligatures = Some(on);
self
}
pub fn fractions(mut self) -> Self {
self.text.features.fractions = true;
self
}
pub fn family(mut self, family: crate::tokens::FamilyRole) -> Self {
self.text.family = family;
self
}
pub fn size(mut self, size: crate::tokens::TextSize) -> Self {
self.text.size = size;
self
}
pub fn weight(mut self, weight: crate::tokens::Weight) -> Self {
self.text.weight = weight;
self
}
pub fn color(mut self, color: Color) -> Self {
self.text.color = Some(color);
self
}
pub fn mono(mut self) -> Self {
self.text.family = crate::tokens::FamilyRole::Mono;
self
}
pub fn truncate(mut self) -> Self {
self.text.max_lines = Some(1);
self
}
pub fn text_align(mut self, align: TextAlign) -> Self {
self.text.align = align;
self
}
pub fn balance(mut self) -> Self {
self.text.wrap = TextWrap::Balance;
self
}
pub fn pretty(mut self) -> Self {
self.text.wrap = TextWrap::Pretty;
self
}
pub fn text_wrap(mut self, wrap: TextWrap) -> Self {
self.text.wrap = wrap;
self
}
}
impl Style {
pub fn grid_cols(mut self, tracks: impl IntoIterator<Item = Track>) -> Self {
self.display = Display::Grid;
self.grid_template_columns = tracks.into_iter().collect();
self
}
pub fn grid_rows(mut self, tracks: impl IntoIterator<Item = Track>) -> Self {
self.display = Display::Grid;
self.grid_template_rows = tracks.into_iter().collect();
self
}
pub fn grid_col(mut self, start: i16, span: u16) -> Self {
self.grid_column = GridPlace {
start: Some(start),
span: (span > 1).then_some(span),
};
self
}
pub fn grid_row(mut self, start: i16, span: u16) -> Self {
self.grid_row = GridPlace {
start: Some(start),
span: (span > 1).then_some(span),
};
self
}
}
impl Style {
pub(crate) fn has_ch(&self) -> bool {
matches!(self.width, Length::Ch(_))
|| matches!(self.min_width, Length::Ch(_))
|| matches!(self.max_width, Length::Ch(_))
|| matches!(self.height, Length::Ch(_))
|| matches!(self.min_height, Length::Ch(_))
|| matches!(self.max_height, Length::Ch(_))
|| matches!(self.flex_basis, Length::Ch(_))
}
pub(crate) fn resolve_ch(&mut self, ch_px: f32) {
self.width = self.width.resolved(ch_px);
self.min_width = self.min_width.resolved(ch_px);
self.max_width = self.max_width.resolved(ch_px);
self.height = self.height.resolved(ch_px);
self.min_height = self.min_height.resolved(ch_px);
self.max_height = self.max_height.resolved(ch_px);
self.flex_basis = self.flex_basis.resolved(ch_px);
}
}
#[cfg(test)]
mod corner_smoothing_tests {
use super::*;
#[test]
fn corner_smoothing_defaults_zero_and_clamps() {
assert_eq!(Style::default().corner_smoothing, 0.0);
assert_eq!(Style::default().corner_smoothing(0.6).corner_smoothing, 0.6);
assert_eq!(Style::default().corner_smoothing(5.0).corner_smoothing, 1.0);
assert_eq!(
Style::default().corner_smoothing(-1.0).corner_smoothing,
0.0
);
}
}
#[cfg(test)]
mod feature_tests {
use super::*;
fn fs(style: Style) -> Option<String> {
style.text.features.feature_string()
}
#[test]
fn default_features_emit_nothing() {
assert_eq!(FontFeatures::default().feature_string(), None);
}
#[test]
fn tabular_unchanged() {
assert_eq!(
fs(Style::default().tabular()),
Some("\"tnum\" 1".to_owned())
);
}
#[test]
fn oldstyle_and_smcp() {
let s = fs(Style::default().oldstyle_nums().small_caps()).unwrap();
assert!(s.contains("\"onum\" 1"), "{s}");
assert!(s.contains("\"smcp\" 1"), "{s}");
assert!(!s.contains("\"lnum\""), "{s}");
assert!(!s.contains("\"tnum\""), "{s}");
assert!(!s.contains("\"pnum\""), "{s}");
}
#[test]
fn tnum_onum_mutually_consistent() {
let s = fs(Style::default().tabular().oldstyle_nums()).unwrap();
assert!(s.contains("\"tnum\" 1"), "{s}");
assert!(s.contains("\"onum\" 1"), "{s}");
}
#[test]
fn ligatures_off_and_on() {
assert!(
fs(Style::default().ligatures(false))
.unwrap()
.contains("\"liga\" 0")
);
assert!(
fs(Style::default().ligatures(true))
.unwrap()
.contains("\"liga\" 1")
);
}
#[test]
fn fractions_and_proportional() {
assert!(
fs(Style::default().fractions())
.unwrap()
.contains("\"frac\" 1")
);
assert!(
fs(Style::default().proportional_nums())
.unwrap()
.contains("\"pnum\" 1")
);
}
#[test]
fn figure_axis_is_exclusive() {
let style = Style::default().oldstyle_nums().lining_nums();
assert_eq!(style.text.features.figures, FigureStyle::Lining);
let s = fs(style).unwrap();
assert!(s.contains("\"lnum\""), "{s}");
assert!(!s.contains("\"onum\""), "{s}");
}
}
#[cfg(test)]
mod gradient_tests {
use super::*;
use crate::oklch_of;
use crate::theme::Theme;
use crate::tokens::GRADIENT_STEPS;
#[test]
fn midpoint_keeps_chroma_no_gray_deadzone() {
let theme = Theme::light();
let a = theme.accent;
let b = theme.warning.solid;
let stops = oklch_stops(&[(0.0, a), (1.0, b)], GRADIENT_STEPS);
let mid = stops
.iter()
.min_by(|x, y| (x.offset - 0.5).abs().total_cmp(&(y.offset - 0.5).abs()))
.unwrap();
let ca = a.components;
let cb = b.components;
let srgb_mid = Color::new([
(ca[0] + cb[0]) / 2.0,
(ca[1] + cb[1]) / 2.0,
(ca[2] + cb[2]) / 2.0,
(ca[3] + cb[3]) / 2.0,
]);
let c_oklch = oklch_of(mid.color)[1];
let c_srgb = oklch_of(srgb_mid)[1];
assert!(
c_oklch > 1.5 * c_srgb,
"OKLCH mid chroma {c_oklch} should far exceed sRGB mid chroma {c_srgb}"
);
}
#[test]
fn lightness_is_monotonic_across_stops() {
let theme = Theme::light();
let a = theme.accents.step(7);
let b = theme.accents.step(10);
let stops = oklch_stops(&[(0.0, a), (1.0, b)], GRADIENT_STEPS);
for w in stops.windows(2) {
let l0 = oklch_of(w[0].color)[0];
let l1 = oklch_of(w[1].color)[0];
assert!(l1 <= l0 + 1e-3, "lightness rose mid-ramp: {l0} -> {l1}");
}
}
#[test]
fn offsets_sorted_and_span_anchors() {
let theme = Theme::light();
let a = theme.accent;
let b = theme.warning.solid;
let stops = oklch_stops(&[(0.0, a), (1.0, b)], 16);
assert_eq!(stops.len(), 17);
assert_eq!(stops.first().unwrap().offset, 0.0);
assert_eq!(stops.last().unwrap().offset, 1.0);
for w in stops.windows(2) {
assert!(w[1].offset > w[0].offset, "offsets must strictly increase");
}
let unsorted = oklch_stops(&[(1.0, b), (0.0, a)], 16);
assert_eq!(unsorted, stops);
}
#[test]
fn endpoints_are_exact() {
let theme = Theme::light();
let a = theme.accent;
let b = theme.warning.solid;
let stops = oklch_stops(&[(0.0, a), (1.0, b)], 16);
assert_eq!(stops.first().unwrap().color.to_rgba8(), a.to_rgba8());
assert_eq!(stops.last().unwrap().color.to_rgba8(), b.to_rgba8());
}
#[test]
fn linear_gradient_even_spacing() {
let theme = Theme::light();
let (a, b, c) = (theme.accent, theme.warning.solid, theme.success.solid);
let paint = linear_gradient(90.0, [a, b, c]);
let Paint::LinearGradient { angle_deg, stops } = paint else {
panic!("linear_gradient must build a LinearGradient");
};
assert_eq!(angle_deg, 90.0);
for (off, color) in [(0.0, a), (0.5, b), (1.0, c)] {
let found = stops
.iter()
.find(|s| (s.offset - off).abs() < 1e-4)
.unwrap_or_else(|| panic!("no stop at offset {off}"));
assert_eq!(
found.color.to_rgba8(),
color.to_rgba8(),
"anchor color at offset {off}"
);
}
}
#[test]
fn degenerate_inputs() {
let theme = Theme::light();
let a = theme.accent;
let b = theme.warning.solid;
assert!(oklch_stops(&[], 16).is_empty());
let single = oklch_stops(&[(0.3, a)], 16);
assert_eq!(single.len(), 1);
assert_eq!(single[0].offset, 0.3);
assert_eq!(single[0].color.to_rgba8(), a.to_rgba8());
let zero_steps = oklch_stops(&[(0.0, a), (1.0, b)], 0);
assert_eq!(zero_steps.len(), 2);
assert_eq!(zero_steps[0].color.to_rgba8(), a.to_rgba8());
assert_eq!(zero_steps[1].color.to_rgba8(), b.to_rgba8());
}
}