Skip to main content

takumi_css/style/properties/
line_height.rs

1use cssparser::{Parser, match_ignore_ascii_case};
2
3use crate::style::{
4  CssSyntaxKind, CssToken, FromCss, Length, MakeComputed, ParseResult, SizingContext, ToCss,
5  parse_calc_number_expression, tw::TailwindPropertyParser,
6};
7
8/// Represents a line height value.
9#[derive(Debug, Clone, PartialEq, Copy, Default)]
10#[non_exhaustive]
11pub enum LineHeight {
12  /// Normal line height.
13  #[default]
14  Normal,
15  /// A unitless line height which is relative to the font size.
16  Unitless(f32),
17  /// A specific line height.
18  Length(Length),
19}
20
21impl From<Length> for LineHeight {
22  fn from(value: Length) -> Self {
23    Self::Length(value)
24  }
25}
26
27impl TailwindPropertyParser for LineHeight {
28  fn parse_tw(token: &str) -> Option<Self> {
29    match_ignore_ascii_case! {&token,
30      "none" => Some(LineHeight::Unitless(1.0)),
31      "tight" => Some(LineHeight::Unitless(1.25)),
32      "snug" => Some(LineHeight::Unitless(1.375)),
33      "normal" => Some(LineHeight::Unitless(1.5)),
34      "relaxed" => Some(LineHeight::Unitless(1.625)),
35      "loose" => Some(LineHeight::Unitless(2.0)),
36      _ => {
37        let Ok(value) = token.parse::<f32>() else {
38          return None;
39        };
40
41        Some(LineHeight::Length(Length::from_spacing(value)))
42      }
43    }
44  }
45}
46
47impl<'i> FromCss<'i> for LineHeight {
48  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
49    if input
50      .try_parse(|input| input.expect_ident_matching("normal"))
51      .is_ok()
52    {
53      return Ok(Self::Normal);
54    }
55
56    if let Ok(number) = input.try_parse(parse_calc_number_expression) {
57      return Ok(LineHeight::Unitless(number));
58    }
59
60    if let Ok(percent) = input.try_parse(Parser::expect_percentage) {
61      return Ok(LineHeight::Length(Length::Percentage(percent * 100.0)));
62    }
63
64    let Ok(number) = input.try_parse(Parser::expect_number) else {
65      return Length::from_css(input).map(Into::into);
66    };
67
68    Ok(LineHeight::Unitless(number))
69  }
70
71  const VALID_TOKENS: &'static [CssToken] = &[
72    CssToken::Syntax(CssSyntaxKind::Number),
73    CssToken::Syntax(CssSyntaxKind::Length),
74    CssToken::Syntax(CssSyntaxKind::Percentage),
75  ];
76}
77
78impl LineHeight {
79  // Match Blink text-fit line-height scaling: non-fixed line heights scale, fixed and percentage line heights do not.
80  // Reference: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/layout/inline/inline_box_state.cc;l=137
81  pub const fn scales_with_text_fit(self) -> bool {
82    matches!(self, Self::Normal | Self::Unitless(_))
83  }
84
85  pub fn into_parley(self, sizing: &SizingContext) -> parley::LineHeight {
86    match self {
87      Self::Normal => parley::LineHeight::MetricsRelative(1.0),
88      Self::Length(length) => parley::LineHeight::Absolute(length.to_px(sizing, sizing.font_size)),
89      Self::Unitless(value) => parley::LineHeight::FontSizeRelative(value),
90    }
91  }
92
93  pub fn to_px(self, sizing: &SizingContext, normal_basis: f32) -> f32 {
94    match self {
95      Self::Normal => normal_basis,
96      Self::Unitless(value) => value * sizing.font_size,
97      Self::Length(length) => length.to_px(sizing, sizing.font_size),
98    }
99  }
100}
101
102impl MakeComputed for LineHeight {
103  fn make_computed(&mut self, sizing: &SizingContext) {
104    match self {
105      Self::Length(Length::Percentage(value)) => {
106        let dpr = sizing.viewport.device_pixel_ratio;
107        let font_size = if dpr > 0.0 {
108          sizing.font_size / dpr
109        } else {
110          sizing.font_size
111        };
112
113        *self = Self::Length(Length::Px((*value / 100.0) * font_size));
114      }
115      Self::Length(length) => length.make_computed(sizing),
116      Self::Normal | Self::Unitless(_) => {}
117    }
118  }
119}
120
121impl ToCss for LineHeight {
122  fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
123    match self {
124      Self::Normal => dest.write_str("normal"),
125      Self::Unitless(v) => write!(dest, "{}", v),
126      Self::Length(l) => l.to_css(dest),
127    }
128  }
129}
130
131#[cfg(test)]
132mod tests {
133  use crate::style::{FromCss, Length, LineHeight, tw::TailwindPropertyParser};
134
135  #[test]
136  fn parses_unitless_calc_expression() {
137    assert_eq!(
138      LineHeight::from_str("calc(1.75 / 1.125)"),
139      Ok(LineHeight::Unitless(1.75 / 1.125))
140    );
141  }
142
143  #[test]
144  fn parses_percentage_as_font_size_relative() {
145    assert_eq!(
146      LineHeight::from_str("90%"),
147      Ok(LineHeight::Length(Length::Percentage(90.0)))
148    );
149  }
150
151  #[test]
152  fn tailwind_spacing_scale_uses_absolute_length() {
153    assert_eq!(
154      LineHeight::parse_tw("7"),
155      Some(LineHeight::Length(Length::Rem(1.75)))
156    );
157  }
158
159  #[test]
160  fn tailwind_arbitrary_percentage_is_supported() {
161    assert_eq!(
162      LineHeight::parse_tw_with_arbitrary("[90%]"),
163      Some(LineHeight::Length(Length::Percentage(90.0)))
164    );
165  }
166}