Skip to main content

densa_paragraph/
style.rs

1use densa_core::Color;
2use std::borrow::Cow;
3use std::fmt;
4use std::ops::Range;
5
6/// Largest font size accepted, in pixels per em. Far beyond any UI text; it keeps every
7/// line metric finite (sizes near `f32::MAX` turn layout geometry into NaN).
8pub const MAX_FONT_PX: f32 = 4096.0;
9
10/// Largest line height accepted, as a multiple of the font size.
11pub const MAX_LINE_HEIGHT: f32 = 16.0;
12
13/// Horizontal alignment of each line within the paragraph's width.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[non_exhaustive]
16pub enum TextAlign {
17    /// Left for left-to-right paragraphs, right for right-to-left ones.
18    #[default]
19    Start,
20    /// Right for left-to-right paragraphs, left for right-to-left ones.
21    End,
22    /// Flush left.
23    Left,
24    /// Centered.
25    Center,
26    /// Flush right.
27    Right,
28    /// Stretched to the full width, except a paragraph's last line.
29    Justify,
30}
31
32/// How a whole paragraph is set; [`Span`]s override parts of it.
33///
34/// Sizes and widths are in physical pixels: multiply logical sizes by the display's scale
35/// factor, and lay out again when it changes.
36#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub struct ParagraphStyle {
39    /// CSS-style family list, for example `"Inter, sans-serif"`.
40    pub family: Cow<'static, str>,
41    /// Font size in pixels per em.
42    pub size_px: f32,
43    /// Line height as a multiple of the font size.
44    pub line_height: f32,
45    /// Text color.
46    pub color: Color,
47    /// Line alignment.
48    pub align: TextAlign,
49    /// Width lines wrap at, in pixels; `None` breaks only at explicit newlines.
50    pub max_width: Option<f32>,
51}
52
53impl ParagraphStyle {
54    /// White `monospace` text of `size_px` pixels, 1.3 line height, start-aligned, unwrapped.
55    pub fn new(size_px: f32) -> Self {
56        Self {
57            family: Cow::Borrowed("monospace"),
58            size_px,
59            line_height: 1.3,
60            color: Color::WHITE,
61            align: TextAlign::Start,
62            max_width: None,
63        }
64    }
65
66    /// The style with the family list `family`.
67    #[must_use]
68    pub fn with_family(mut self, family: impl Into<Cow<'static, str>>) -> Self {
69        self.family = family.into();
70        self
71    }
72
73    /// The style with text color `color`.
74    #[must_use]
75    pub fn with_color(mut self, color: Color) -> Self {
76        self.color = color;
77        self
78    }
79
80    /// The style with line alignment `align`.
81    #[must_use]
82    pub fn with_align(mut self, align: TextAlign) -> Self {
83        self.align = align;
84        self
85    }
86
87    /// The style with a line height of `line_height` times the font size.
88    #[must_use]
89    pub fn with_line_height(mut self, line_height: f32) -> Self {
90        self.line_height = line_height;
91        self
92    }
93
94    /// The style wrapping lines at `max_width` pixels (`None`: no wrapping).
95    #[must_use]
96    pub fn with_max_width(mut self, max_width: Option<f32>) -> Self {
97        self.max_width = max_width;
98        self
99    }
100}
101
102/// A styled part of a paragraph's text.
103#[derive(Debug, Clone, PartialEq)]
104#[non_exhaustive]
105pub struct Span {
106    /// Byte range of the text it styles; must lie on character boundaries.
107    pub range: Range<usize>,
108    /// Text color, if different from the paragraph's.
109    pub color: Option<Color>,
110    /// Font size in pixels per em, if different from the paragraph's.
111    pub size_px: Option<f32>,
112}
113
114impl Span {
115    /// A span over `range` that changes nothing yet.
116    pub fn new(range: Range<usize>) -> Self {
117        Self {
118            range,
119            color: None,
120            size_px: None,
121        }
122    }
123
124    /// The span coloring its text `color`.
125    #[must_use]
126    pub fn with_color(mut self, color: Color) -> Self {
127        self.color = Some(color);
128        self
129    }
130
131    /// The span setting its text at `size_px` pixels per em.
132    #[must_use]
133    pub fn with_size(mut self, size_px: f32) -> Self {
134        self.size_px = Some(size_px);
135        self
136    }
137}
138
139/// Why a paragraph could not be laid out.
140#[derive(Debug, Clone, PartialEq)]
141#[non_exhaustive]
142pub enum ParagraphError {
143    /// A font size outside `(0, MAX_FONT_PX]`, or a line height outside
144    /// `(0, MAX_LINE_HEIGHT]`.
145    InvalidSize(f32),
146    /// The maximum width is not a finite, non-negative number.
147    InvalidWidth(f32),
148    /// A span's range is reversed, outside the text, or splits a character.
149    InvalidSpan(Range<usize>),
150    /// A byte range (such as an input method's cursor in its preedit text) is reversed,
151    /// outside its text, or splits a character.
152    InvalidRange(Range<usize>),
153    /// The shaper chose a font whose data cannot be read for drawing.
154    UnreadableFont,
155}
156
157impl fmt::Display for ParagraphError {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            Self::InvalidSize(size) => write!(f, "size {size} is out of the supported range"),
161            Self::InvalidWidth(width) => {
162                write!(f, "width {width} is not a finite, non-negative number")
163            }
164            Self::InvalidSpan(range) => write!(
165                f,
166                "span {range:?} is reversed, outside the text, or splits a character"
167            ),
168            Self::InvalidRange(range) => write!(
169                f,
170                "range {range:?} is reversed, outside its text, or splits a character"
171            ),
172            Self::UnreadableFont => f.write_str("a font chosen for the text cannot be read"),
173        }
174    }
175}
176
177impl std::error::Error for ParagraphError {}
178
179pub(crate) fn check_font_size(size: f32) -> Result<f32, ParagraphError> {
180    in_range(size, MAX_FONT_PX)
181}
182
183pub(crate) fn check_line_height(line_height: f32) -> Result<f32, ParagraphError> {
184    in_range(line_height, MAX_LINE_HEIGHT)
185}
186
187/// `value` if it lies in `(0, max]` (NaN does not).
188fn in_range(value: f32, max: f32) -> Result<f32, ParagraphError> {
189    if value > 0.0 && value <= max {
190        Ok(value)
191    } else {
192        Err(ParagraphError::InvalidSize(value))
193    }
194}
195
196pub(crate) fn check_width(width: Option<f32>) -> Result<Option<f32>, ParagraphError> {
197    match width {
198        Some(w) if !(w.is_finite() && w >= 0.0) => Err(ParagraphError::InvalidWidth(w)),
199        _ => Ok(width),
200    }
201}
202
203pub(crate) fn check_span(text: &str, span: &Span) -> Result<(), ParagraphError> {
204    if !is_char_range(text, &span.range) {
205        return Err(ParagraphError::InvalidSpan(span.range.clone()));
206    }
207    span.size_px.map(check_font_size).transpose()?;
208    Ok(())
209}
210
211/// Whether `range` is in order, within `text` and on character boundaries.
212pub(crate) fn is_char_range(text: &str, range: &Range<usize>) -> bool {
213    range.start <= range.end
214        && range.end <= text.len()
215        && text.is_char_boundary(range.start)
216        && text.is_char_boundary(range.end)
217}