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