Skip to main content

yog_ui/
rich.rs

1//! Yog text — one format for every text a mod writes (`.yres`, `.yui`,
2//! books), the same for every host, version and the designer:
3//!
4//! | Write | Means |
5//! |---|---|
6//! | `{b\|bold}` `{i\|…}` `{u\|…}` `{s\|…}` | bold, italic, underlined, struck through |
7//! | `{#8d6acc\|text}` | any color |
8//! | `{b #8d6acc\|text}` | several at once; spans nest |
9//! | `{name}`, `{0}` | an argument (`.yres` texts; filled in before drawing) |
10//! | `{{`, `}}` | a literal `{`, `}` (`}}` outside a span; in one, `}` closes it) |
11//!
12//! A producer may add its own attribute words and turn them into these
13//! before drawing (a book's `accent`, `link=entry` — see [`map_attrs`]).
14//! Minecraft's own `§` codes pass through as the game reads them.
15
16/// What a stretch of text looks like.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub struct Style {
19    pub bold: bool,
20    pub italic: bool,
21    pub underline: bool,
22    pub strike: bool,
23    /// `0xRRGGBB`; `None`: the text's own color.
24    pub color: Option<u32>,
25}
26
27/// The attributes of a span opening `{attrs|`, applied to `style`;
28/// `None` if they aren't attributes.
29fn apply(attrs: &str, mut style: Style) -> Option<Style> {
30    let mut any = false;
31    for a in attrs.split_whitespace() {
32        any = true;
33        match a {
34            "b" => style.bold = true,
35            "i" => style.italic = true,
36            "u" => style.underline = true,
37            "s" => style.strike = true,
38            hex if hex.len() == 7 && hex.starts_with('#') => {
39                style.color = Some(u32::from_str_radix(&hex[1..], 16).ok()?)
40            }
41            _ => return None,
42        }
43    }
44    any.then_some(style)
45}
46
47/// Whether `s` has anything but plain characters: span braces or a `§`
48/// code (most words don't, and skip the tokenizer).
49fn has_markup(s: &str) -> bool {
50    // `§` is U+00A7, encoded C2 A7.
51    s.bytes().any(|b| matches!(b, b'{' | b'}' | 0xC2))
52}
53
54/// One step of reading rich text.
55enum Token<'a> {
56    Char(char),
57    /// A span opens: its attribute text.
58    Open(&'a str),
59    Close,
60}
61
62/// Reads `s` token by token.
63fn tokens(s: &str) -> impl Iterator<Item = Token<'_>> {
64    pieces(s).map(|(t, _)| t)
65}
66
67/// `s` read token by token, each with the text it was read from — lazily,
68/// so measuring a word allocates nothing.
69struct Pieces<'a> {
70    s: &'a str,
71    i: usize,
72    depth: usize,
73}
74
75impl<'a> Iterator for Pieces<'a> {
76    type Item = (Token<'a>, &'a str);
77
78    fn next(&mut self) -> Option<Self::Item> {
79        let (s, i) = (self.s, self.i);
80        let b = s.as_bytes();
81        let c = s[i..].chars().next()?;
82        match c {
83            '{' if b.get(i + 1) == Some(&b'{') => {
84                self.i += 2;
85                return Some((Token::Char('{'), &s[i..i + 2]));
86            }
87            '{' => {
88                if let Some(bar) = s[i + 1..].find(['|', '{', '}']).map(|p| p + i + 1)
89                    && b[bar] == b'|'
90                    && apply(&s[i + 1..bar], Style::default()).is_some()
91                {
92                    self.depth += 1;
93                    self.i = bar + 1;
94                    return Some((Token::Open(&s[i + 1..bar]), &s[i..=bar]));
95                }
96            }
97            '}' if self.depth > 0 => {
98                self.depth -= 1;
99                self.i += 1;
100                return Some((Token::Close, &s[i..=i]));
101            }
102            '}' if b.get(i + 1) == Some(&b'}') => {
103                self.i += 2;
104                return Some((Token::Char('}'), &s[i..i + 2]));
105            }
106            _ => {}
107        }
108        self.i += c.len_utf8();
109        Some((Token::Char(c), &s[i..i + c.len_utf8()]))
110    }
111}
112
113fn pieces(s: &str) -> Pieces<'_> {
114    Pieces { s, i: 0, depth: 0 }
115}
116
117/// `s` split at its shown spaces (a span's `{b #fff|` stays whole).
118pub fn words(s: &str) -> Vec<&str> {
119    // Byte by byte, as `Pieces` reads it: the bytes that matter (space,
120    // braces, `|`) are ASCII, never part of another character's encoding.
121    let b = s.as_bytes();
122    let mut out = Vec::new();
123    let (mut start, mut i, mut depth) = (0, 0, 0usize);
124    while i < b.len() {
125        match b[i] {
126            b'{' if b.get(i + 1) == Some(&b'{') => i += 2,
127            b'{' => match s[i + 1..].find(['|', '{', '}']).map(|p| p + i + 1) {
128                Some(bar)
129                    if b[bar] == b'|' && apply(&s[i + 1..bar], Style::default()).is_some() =>
130                {
131                    depth += 1;
132                    i = bar + 1;
133                }
134                _ => i += 1,
135            },
136            b'}' if depth > 0 => {
137                depth -= 1;
138                i += 1;
139            }
140            b'}' if b.get(i + 1) == Some(&b'}') => i += 2,
141            b' ' => {
142                out.push(&s[start..i]);
143                start = i + 1;
144                i += 1;
145            }
146            _ => i += 1,
147        }
148    }
149    out.push(&s[start..]);
150    out
151}
152
153/// `s` split into its markup and its characters: each piece's text as
154/// written, and the character it shows (`None`: markup, no room taken).
155pub fn glyphs(s: &str) -> Vec<(&str, Option<char>)> {
156    pieces(s)
157        .map(|(t, raw)| match t {
158            Token::Char(c) => (raw, Some(c)),
159            _ => (raw, None),
160        })
161        .collect()
162}
163
164/// `s` with every span's attribute words passed through `f` (`Some`: the
165/// words to write instead) — how a producer turns its own words (`accent`,
166/// `link=…`) into this format's.
167pub fn map_attrs(s: &str, f: impl Fn(&str) -> Option<String>) -> String {
168    let mut out = String::with_capacity(s.len());
169    let b = s.as_bytes();
170    let mut i = 0;
171    while i < s.len() {
172        if b[i] == b'{' && b.get(i + 1) == Some(&b'{') {
173            out.push_str("{{");
174            i += 2;
175            continue;
176        }
177        if b[i] == b'{'
178            && let Some(bar) = s[i + 1..].find(['|', '{', '}']).map(|p| p + i + 1)
179            && b[bar] == b'|'
180        {
181            let words: Vec<String> = s[i + 1..bar]
182                .split_whitespace()
183                .map(|w| f(w).unwrap_or_else(|| w.to_string()))
184                .collect();
185            out.push('{');
186            out.push_str(&words.join(" "));
187            out.push('|');
188            i = bar + 1;
189            continue;
190        }
191        let c = s[i..].chars().next().unwrap_or_default();
192        out.push(c);
193        i += c.len_utf8();
194    }
195    out
196}
197
198/// `s` as runs of one style each.
199pub fn runs(s: &str) -> Vec<(String, Style)> {
200    let mut out: Vec<(String, Style)> = Vec::new();
201    let mut stack = vec![Style::default()];
202    for t in tokens(s) {
203        match t {
204            Token::Char(c) => {
205                let st = *stack.last().unwrap_or(&Style::default());
206                match out.last_mut() {
207                    Some((text, last)) if *last == st => text.push(c),
208                    _ => out.push((c.to_string(), st)),
209                }
210            }
211            Token::Open(a) => {
212                let top = *stack.last().unwrap_or(&Style::default());
213                stack.push(apply(a, top).unwrap_or(top));
214            }
215            Token::Close => {
216                if stack.len() > 1 {
217                    stack.pop();
218                }
219            }
220        }
221    }
222    out
223}
224
225/// The text without its markup.
226pub fn plain(s: &str) -> String {
227    runs(s).into_iter().map(|(t, _)| t).collect()
228}
229
230/// Width with `advance` per glyph: markup takes no room, bold glyphs one
231/// more (Minecraft's own `§` codes too).
232pub fn width(s: &str, mut advance: impl FnMut(char) -> f32) -> f32 {
233    if !has_markup(s) {
234        return s.chars().map(advance).sum();
235    }
236    // As `runs` reads it, without building the runs: a `§` code acts on
237    // the glyph after it within one stretch of the same style.
238    let mut w = 0.0;
239    let mut outer: Vec<Style> = Vec::new();
240    let mut style = Style::default();
241    let mut legacy_bold = false;
242    let mut code = false;
243    for t in tokens(s) {
244        match t {
245            Token::Char(c) => {
246                if code {
247                    code = false;
248                    match c.to_ascii_lowercase() {
249                        'l' => legacy_bold = true,
250                        k if k == 'r' || k.is_ascii_hexdigit() => legacy_bold = false,
251                        _ => {}
252                    }
253                    continue;
254                }
255                if c == '§' {
256                    code = true;
257                    continue;
258                }
259                w += advance(c) + if style.bold || legacy_bold { 1.0 } else { 0.0 };
260            }
261            Token::Open(a) => {
262                outer.push(style);
263                let next = apply(a, style).unwrap_or(style);
264                if next != style {
265                    (legacy_bold, code) = (false, false);
266                }
267                style = next;
268            }
269            Token::Close => {
270                if let Some(prev) = outer.pop() {
271                    if prev != style {
272                        (legacy_bold, code) = (false, false);
273                    }
274                    style = prev;
275                }
276            }
277        }
278    }
279    w
280}
281
282/// The spans still open at the end of `s`, outermost first (their
283/// attribute text) — a wrapped line closes them and the next reopens them,
284/// since every line is drawn on its own.
285pub fn open_spans(s: &str) -> Vec<String> {
286    let mut stack = Vec::new();
287    for t in tokens(s) {
288        match t {
289            Token::Open(a) => stack.push(a.to_string()),
290            Token::Close => {
291                stack.pop();
292            }
293            Token::Char(_) => {}
294        }
295    }
296    stack
297}
298
299/// `{a|` for each of `spans`.
300pub fn reopen(spans: &[String]) -> String {
301    spans.iter().map(|a| format!("{{{a}|")).collect()
302}
303
304#[cfg(test)]
305mod tests {
306    /// `width` as it was computed before it stopped allocating: through `runs`.
307    fn width_via_runs(s: &str, advance: impl Fn(char) -> f32) -> f32 {
308        let mut w = 0.0;
309        for (text, st) in super::runs(s) {
310            let mut legacy_bold = false;
311            let mut chars = text.chars();
312            while let Some(c) = chars.next() {
313                if c == '§' {
314                    match chars.next().map(|k| k.to_ascii_lowercase()) {
315                        Some('l') => legacy_bold = true,
316                        Some(k) if k == 'r' || k.is_ascii_hexdigit() => legacy_bold = false,
317                        _ => {}
318                    }
319                    continue;
320                }
321                w += advance(c) + if st.bold || legacy_bold { 1.0 } else { 0.0 };
322            }
323        }
324        w
325    }
326
327    /// `words` as it was: splitting at the space tokens of `pieces`.
328    fn words_via_pieces(s: &str) -> Vec<&str> {
329        let (mut out, mut start, mut at) = (Vec::new(), 0, 0);
330        for (t, raw) in super::pieces(s) {
331            if matches!(t, super::Token::Char(' ')) {
332                out.push(&s[start..at]);
333                start = at + 1;
334            }
335            at += raw.len();
336        }
337        out.push(&s[start..]);
338        out
339    }
340
341    #[test]
342    fn words_split_as_pieces_do() {
343        for s in [
344            "a b  c",
345            "{b #ff0000|bold words} x",
346            "{{a b}} c",
347            "{nope b|x} y",
348            "ünï ç {i|é ö}",
349            "{b|unclosed a b",
350            " lead trail ",
351        ] {
352            assert_eq!(super::words(s), words_via_pieces(s), "{s:?}");
353        }
354    }
355
356    #[test]
357    fn width_measures_as_runs_do() {
358        let adv = |c: char| if c == 'i' { 2.0 } else { 6.0 };
359        for s in [
360            "plain",
361            "{b|bold} x",
362            "{b|a {#ff0000|b} c}d",
363            "§lbold§r then",
364            "{{x}} }}",
365            "{nope|x}",
366            "{b|unclosed",
367            "a§",
368            "{b|§lx} y",
369            "ünï {i|ç}",
370        ] {
371            assert_eq!(super::width(s, adv), width_via_runs(s, adv), "{s:?}");
372        }
373    }
374
375    use super::*;
376
377    #[test]
378    fn parses_spans() {
379        let r = runs("a {b|bold {#8d6acc|purple}} {{x}} {not a span}");
380        assert_eq!(r[0], ("a ".to_string(), Style::default()));
381        assert_eq!(r[1].0, "bold ");
382        assert!(r[1].1.bold);
383        assert_eq!(
384            r[2],
385            (
386                "purple".to_string(),
387                Style {
388                    bold: true,
389                    color: Some(0x8d6acc),
390                    ..Default::default()
391                }
392            )
393        );
394        assert_eq!(r[3].0, " {x} {not a span}");
395        assert_eq!(plain("{i|x}y"), "xy");
396    }
397
398    #[test]
399    fn producers_map_their_words() {
400        let s = map_attrs("a {accent|term} {{x}} {b link=m:e|y}", |w| match w {
401            "accent" => Some("#8d6acc".into()),
402            w if w.starts_with("link=") => Some("u #2f5fbf".into()),
403            _ => None,
404        });
405        assert_eq!(s, "a {#8d6acc|term} {{x}} {b u #2f5fbf|y}");
406    }
407
408    #[test]
409    fn spans_carry_over_lines() {
410        assert_eq!(open_spans("a {b|b {#ffffff|c} d"), ["b"]);
411        assert_eq!(reopen(&["b".into(), "#123456".into()]), "{b|{#123456|");
412        assert_eq!(width("{b|ab}", |_| 5.0), 12.0, "bold is one wider a glyph");
413        assert_eq!(width("§5ab", |_| 5.0), 10.0);
414    }
415}