Skip to main content

model/
text.rs

1use crate::color::Color;
2
3/// A rich text value composed of styled segments.
4#[derive(Debug, Clone, PartialEq)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub struct RichText {
7    pub segments: Vec<RichSegment>,
8    pub color: Color,
9    pub font_size: f32,
10    /// Font family name (e.g. "Noto Sans"). Resolved by the renderer's text
11    /// stack: runtime-registered fonts → system fonts (native only) →
12    /// bundled Liberation Sans. Empty string = bundled font. A character the
13    /// resolved face cannot draw falls back per character to registered
14    /// fonts, the bundled face, then (native only) installed system fonts.
15    pub font: String,
16    /// Where each line of a multi-line text sits inside the block. Additive
17    /// schema: documents written before it existed parse as `Left` (the only
18    /// layout they could have had), and `Left` is not serialized, so their
19    /// JSON round-trips unchanged.
20    #[cfg_attr(
21        feature = "serde",
22        serde(default, skip_serializing_if = "TextAlign::is_left")
23    )]
24    pub align: TextAlign,
25}
26
27/// Horizontal alignment of the lines of a [`RichText`] inside its block.
28///
29/// The block is as wide as its widest line, and placement code positions that
30/// block; alignment only decides where each shorter line spends its slack
31/// (the block width minus its own width): none of it before the line, half,
32/// or all. The block's measured envelope is therefore the same for every
33/// alignment, so selection and hit boxes need no change, and single-line text
34/// (no slack) is unaffected. Tab-column cells move with their line.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum TextAlign {
38    /// Every line starts at the block's left edge — the layout all text had
39    /// before alignment existed.
40    #[default]
41    Left,
42    /// Each line is centred on the block.
43    Center,
44    /// Every line ends at the block's right edge.
45    Right,
46}
47
48impl TextAlign {
49    /// `true` for the default, `Left`. Used to keep it out of serialized JSON.
50    #[cfg(feature = "serde")]
51    fn is_left(&self) -> bool {
52        *self == TextAlign::Left
53    }
54
55    /// Horizontal offset of a line `line_width` wide inside a block
56    /// `block_width` wide. `Left` is exactly `0.0` whatever the widths, so
57    /// left-aligned layout never depends on the slack arithmetic.
58    pub fn line_offset(self, block_width: f32, line_width: f32) -> f32 {
59        match self {
60            TextAlign::Left => 0.0,
61            TextAlign::Center => (block_width - line_width) * 0.5,
62            TextAlign::Right => block_width - line_width,
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69pub struct RichSegment {
70    pub text: char,
71    pub bold: bool,
72    pub italic: bool,
73    pub underline: bool,
74    pub superscript: bool,
75    pub subscript: bool,
76    pub greek: bool,
77    /// Per-segment ink color. `None` inherits `RichText.color`.
78    #[cfg_attr(
79        feature = "serde",
80        serde(default, skip_serializing_if = "Option::is_none")
81    )]
82    pub color: Option<Color>,
83    /// Per-segment base font size. `None` inherits `RichText.font_size`;
84    /// the sub/superscript ratio applies on top of the effective size.
85    #[cfg_attr(
86        feature = "serde",
87        serde(default, skip_serializing_if = "Option::is_none")
88    )]
89    pub font_size: Option<f32>,
90    /// Fixed advance in em (× effective font size). The glyph ink is drawn
91    /// horizontally centered inside the field; the advance itself ignores
92    /// glyph metrics entirely. This is what gives legend marks an exact,
93    /// font-size-relative width no glyph combination could guarantee.
94    #[cfg_attr(
95        feature = "serde",
96        serde(default, skip_serializing_if = "Option::is_none")
97    )]
98    pub field_em: Option<f32>,
99    /// Render as a horizontal rule (drawn bar) spanning the full advance
100    /// instead of a glyph — the line part of legend marks. `text` stays as
101    /// a fallback character for consumers that ignore the flag.
102    #[cfg_attr(
103        feature = "serde",
104        serde(default, skip_serializing_if = "std::ops::Not::not")
105    )]
106    pub rule: bool,
107    /// Optional dash pattern for a rule segment, in em units. Values are
108    /// multiplied by the effective segment font size at draw time.
109    #[cfg_attr(
110        feature = "serde",
111        serde(default, skip_serializing_if = "Option::is_none")
112    )]
113    pub rule_dash: Option<Vec<f32>>,
114}
115
116impl RichSegment {
117    /// One plain character: all style flags off, no per-segment overrides —
118    /// color and font size inherit from the parent `RichText`.
119    pub fn plain(text: char) -> Self {
120        Self {
121            text,
122            bold: false,
123            italic: false,
124            underline: false,
125            superscript: false,
126            subscript: false,
127            greek: false,
128            color: None,
129            font_size: None,
130            field_em: None,
131            rule: false,
132            rule_dash: None,
133        }
134    }
135
136    /// A drawn horizontal rule filling a fixed `width_em × font_size`
137    /// advance — the line stroke of legend marks. `'—'` remains as the
138    /// fallback text form.
139    pub fn rule(width_em: f32, color: Option<Color>) -> Self {
140        Self {
141            field_em: Some(width_em),
142            rule: true,
143            color,
144            ..Self::plain('—')
145        }
146    }
147
148    /// A drawn horizontal rule with an optional dash pattern. Dash entries are
149    /// expressed in em units so the pattern follows the legend font size.
150    pub fn dashed_rule(width_em: f32, color: Option<Color>, dash_em: Vec<f32>) -> Self {
151        Self {
152            rule_dash: (!dash_em.is_empty()).then_some(dash_em),
153            ..Self::rule(width_em, color)
154        }
155    }
156
157    /// A glyph centered inside a fixed `width_em × font_size` advance.
158    pub fn fielded(text: char, width_em: f32, color: Option<Color>) -> Self {
159        Self {
160            field_em: Some(width_em),
161            color,
162            ..Self::plain(text)
163        }
164    }
165}
166
167impl RichText {
168    pub fn plain(text: &str, color: Color, font_size: f32, font: impl Into<String>) -> Self {
169        Self {
170            segments: rich_segments_from_text(text),
171            color,
172            font_size,
173            font: font.into(),
174            align: TextAlign::Left,
175        }
176    }
177}
178
179/// Measured pixel extents of a laid-out `RichText`.
180///
181/// Mirrors the renderer's skia `TextMetrics` shape so geometry formulas can be
182/// shared verbatim between drawing (renderer) and bounds policies (model).
183#[derive(Debug, Clone, Copy, PartialEq)]
184pub struct TextExtents {
185    pub width: f32,
186    pub ascent: f32,
187    pub descent: f32,
188}
189
190impl TextExtents {
191    pub fn height(&self) -> f32 {
192        self.ascent + self.descent
193    }
194}
195
196/// Text measurement contract.
197///
198/// The model crate computes element bounds (selection boxes, hit areas) from
199/// text extents but cannot measure glyphs itself — the renderer implements
200/// this with its skia text stack and injects it; tests use fixed stubs.
201pub trait MeasureText {
202    fn measure_rich(&self, rt: &RichText) -> TextExtents;
203}
204
205/// One plain `RichSegment` per char, mapping Unicode super/subscript chars to
206/// styled segments. Public because the renderer's `Chart` text builders
207/// (`with_title`, …) construct plain titles through this.
208pub fn rich_segments_from_text(text: &str) -> Vec<RichSegment> {
209    text.chars().map(rich_segment_from_char).collect()
210}
211
212fn rich_segment_from_char(c: char) -> RichSegment {
213    let (text, superscript, subscript) = match c {
214        '⁰' => ('0', true, false),
215        '¹' => ('1', true, false),
216        '²' => ('2', true, false),
217        '³' => ('3', true, false),
218        '⁴' => ('4', true, false),
219        '⁵' => ('5', true, false),
220        '⁶' => ('6', true, false),
221        '⁷' => ('7', true, false),
222        '⁸' => ('8', true, false),
223        '⁹' => ('9', true, false),
224        '⁺' => ('+', true, false),
225        '⁻' => ('-', true, false),
226        '⁽' => ('(', true, false),
227        '⁾' => (')', true, false),
228        'ⁿ' => ('n', true, false),
229        'ᵗ' => ('t', true, false),
230        '₀' => ('0', false, true),
231        '₁' => ('1', false, true),
232        '₂' => ('2', false, true),
233        '₃' => ('3', false, true),
234        '₄' => ('4', false, true),
235        '₅' => ('5', false, true),
236        '₆' => ('6', false, true),
237        '₇' => ('7', false, true),
238        '₈' => ('8', false, true),
239        '₉' => ('9', false, true),
240        '₊' => ('+', false, true),
241        '₋' => ('-', false, true),
242        '₍' => ('(', false, true),
243        '₎' => (')', false, true),
244        other => (other, false, false),
245    };
246    RichSegment {
247        superscript,
248        subscript,
249        ..RichSegment::plain(text)
250    }
251}
252
253/// Map Latin a-z/A-Z to the matching Greek letter following the Adobe Symbol
254/// encoding. Other input (digits, whitespace, already-Greek, etc.) passes through unchanged.
255pub fn greek_char(c: char) -> char {
256    match c {
257        'a' => 'α',
258        'b' => 'β',
259        'c' => 'χ',
260        'd' => 'δ',
261        'e' => 'ε',
262        'f' => 'φ',
263        'g' => 'γ',
264        'h' => 'η',
265        'i' => 'ι',
266        'j' => 'ϕ',
267        'k' => 'κ',
268        'l' => 'λ',
269        'm' => 'μ',
270        'n' => 'ν',
271        'o' => 'ο',
272        'p' => 'π',
273        'q' => 'θ',
274        'r' => 'ρ',
275        's' => 'σ',
276        't' => 'τ',
277        'u' => 'υ',
278        'v' => 'ϖ',
279        'w' => 'ω',
280        'x' => 'ξ',
281        'y' => 'ψ',
282        'z' => 'ζ',
283        'A' => 'Α',
284        'B' => 'Β',
285        'C' => 'Χ',
286        'D' => 'Δ',
287        'E' => 'Ε',
288        'F' => 'Φ',
289        'G' => 'Γ',
290        'H' => 'Η',
291        'I' => 'Ι',
292        'J' => 'ϑ',
293        'K' => 'Κ',
294        'L' => 'Λ',
295        'M' => 'Μ',
296        'N' => 'Ν',
297        'O' => 'Ο',
298        'P' => 'Π',
299        'Q' => 'Θ',
300        'R' => 'Ρ',
301        'S' => 'Σ',
302        'T' => 'Τ',
303        'U' => 'Υ',
304        'V' => 'ς',
305        'W' => 'Ω',
306        'X' => 'Ξ',
307        'Y' => 'Ψ',
308        'Z' => 'Ζ',
309        other => other,
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn lowercase_full_table() {
319        let pairs: &[(char, char)] = &[
320            ('a', 'α'),
321            ('b', 'β'),
322            ('c', 'χ'),
323            ('d', 'δ'),
324            ('e', 'ε'),
325            ('f', 'φ'),
326            ('g', 'γ'),
327            ('h', 'η'),
328            ('i', 'ι'),
329            ('j', 'ϕ'),
330            ('k', 'κ'),
331            ('l', 'λ'),
332            ('m', 'μ'),
333            ('n', 'ν'),
334            ('o', 'ο'),
335            ('p', 'π'),
336            ('q', 'θ'),
337            ('r', 'ρ'),
338            ('s', 'σ'),
339            ('t', 'τ'),
340            ('u', 'υ'),
341            ('v', 'ϖ'),
342            ('w', 'ω'),
343            ('x', 'ξ'),
344            ('y', 'ψ'),
345            ('z', 'ζ'),
346        ];
347        for &(l, g) in pairs {
348            assert_eq!(greek_char(l), g, "lower '{}' -> '{}'", l, g);
349        }
350    }
351
352    #[test]
353    fn uppercase_full_table() {
354        let pairs: &[(char, char)] = &[
355            ('A', 'Α'),
356            ('B', 'Β'),
357            ('C', 'Χ'),
358            ('D', 'Δ'),
359            ('E', 'Ε'),
360            ('F', 'Φ'),
361            ('G', 'Γ'),
362            ('H', 'Η'),
363            ('I', 'Ι'),
364            ('J', 'ϑ'),
365            ('K', 'Κ'),
366            ('L', 'Λ'),
367            ('M', 'Μ'),
368            ('N', 'Ν'),
369            ('O', 'Ο'),
370            ('P', 'Π'),
371            ('Q', 'Θ'),
372            ('R', 'Ρ'),
373            ('S', 'Σ'),
374            ('T', 'Τ'),
375            ('U', 'Υ'),
376            ('V', 'ς'),
377            ('W', 'Ω'),
378            ('X', 'Ξ'),
379            ('Y', 'Ψ'),
380            ('Z', 'Ζ'),
381        ];
382        for &(l, g) in pairs {
383            assert_eq!(greek_char(l), g, "upper '{}' -> '{}'", l, g);
384        }
385    }
386
387    #[test]
388    fn non_latin_passthrough() {
389        for c in ['0', '9', ' ', '+', '=', '.', '-', '_', 'α', 'Ω', '漢'] {
390            assert_eq!(greek_char(c), c);
391        }
392    }
393
394    // Per-segment overrides are additive schema: documents serialized before
395    // they existed must still parse (→ None), and None must not serialize.
396    #[cfg(feature = "serde")]
397    #[test]
398    fn segment_overrides_stay_backward_compatible_and_compact() {
399        let old = r#"{"text":"a","bold":false,"italic":false,"underline":false,"superscript":false,"subscript":false,"greek":false}"#;
400        let seg: RichSegment = serde_json::from_str(old).expect("pre-override document parses");
401        assert_eq!(seg, RichSegment::plain('a'));
402
403        let json = serde_json::to_string(&seg).unwrap();
404        assert!(
405            !json.contains("color"),
406            "None color must be skipped: {json}"
407        );
408        assert!(
409            !json.contains("font_size"),
410            "None font_size must be skipped: {json}"
411        );
412    }
413
414    // `align` is additive schema too: a document written before it existed
415    // parses as `Left`, `Left` does not serialize (so existing JSON stays
416    // byte-identical), and the other alignments round-trip by name.
417    #[cfg(feature = "serde")]
418    #[test]
419    fn align_defaults_to_left_and_round_trips() {
420        let old = r#"{"segments":[],"color":{"r":0.0,"g":0.0,"b":0.0,"a":1.0},"font_size":12.0,"font":""}"#;
421        let rt: RichText = serde_json::from_str(old).expect("pre-align document parses");
422        assert_eq!(rt.align, TextAlign::Left);
423        assert_eq!(rt, RichText::plain("", Color::BLACK, 12.0, ""));
424        assert_eq!(
425            serde_json::to_string(&rt).unwrap(),
426            old,
427            "a left-aligned document must serialize exactly as before"
428        );
429
430        for align in [TextAlign::Center, TextAlign::Right] {
431            let rt = RichText {
432                align,
433                ..RichText::plain("a\nbc", Color::BLACK, 12.0, "")
434            };
435            let json = serde_json::to_string(&rt).unwrap();
436            assert!(json.contains(&format!(r#""align":"{align:?}""#)), "{json}");
437            let back: RichText = serde_json::from_str(&json).unwrap();
438            assert_eq!(back, rt);
439        }
440    }
441
442    #[test]
443    fn align_spends_none_half_or_all_of_the_slack() {
444        assert_eq!(TextAlign::default(), TextAlign::Left);
445        assert_eq!(TextAlign::Left.line_offset(100.0, 40.0), 0.0);
446        assert_eq!(TextAlign::Center.line_offset(100.0, 40.0), 30.0);
447        assert_eq!(TextAlign::Right.line_offset(100.0, 40.0), 60.0);
448        // The widest line has no slack and never moves.
449        for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
450            assert_eq!(align.line_offset(100.0, 100.0), 0.0);
451        }
452        // Left never depends on the widths, even unusable ones.
453        assert_eq!(TextAlign::Left.line_offset(f32::NAN, 1.0), 0.0);
454    }
455}