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/// One step of reading rich text.
46enum Token<'a> {
47    Char(char),
48    /// A span opens: its attribute text.
49    Open(&'a str),
50    Close,
51}
52
53/// Reads `s` token by token.
54fn tokens(s: &str) -> Vec<Token<'_>> {
55    pieces(s).into_iter().map(|(t, _)| t).collect()
56}
57
58/// `s` read token by token, each with the text it was read from.
59fn pieces(s: &str) -> Vec<(Token<'_>, &str)> {
60    let mut out = Vec::new();
61    let mut depth = 0usize;
62    let mut i = 0;
63    let b = s.as_bytes();
64    while i < s.len() {
65        let c = s[i..].chars().next().unwrap_or_default();
66        match c {
67            '{' if b.get(i + 1) == Some(&b'{') => {
68                out.push((Token::Char('{'), &s[i..i + 2]));
69                i += 2;
70                continue;
71            }
72            '{' => {
73                if let Some(bar) = s[i + 1..].find(['|', '{', '}']).map(|p| p + i + 1)
74                    && b[bar] == b'|'
75                    && apply(&s[i + 1..bar], Style::default()).is_some()
76                {
77                    out.push((Token::Open(&s[i + 1..bar]), &s[i..=bar]));
78                    depth += 1;
79                    i = bar + 1;
80                    continue;
81                }
82            }
83            '}' if depth > 0 => {
84                out.push((Token::Close, &s[i..=i]));
85                depth -= 1;
86                i += 1;
87                continue;
88            }
89            '}' if b.get(i + 1) == Some(&b'}') => {
90                out.push((Token::Char('}'), &s[i..i + 2]));
91                i += 2;
92                continue;
93            }
94            _ => {}
95        }
96        out.push((Token::Char(c), &s[i..i + c.len_utf8()]));
97        i += c.len_utf8();
98    }
99    out
100}
101
102/// `s` split at its shown spaces (a span's `{b #fff|` stays whole).
103pub fn words(s: &str) -> Vec<&str> {
104    let mut out = Vec::new();
105    let mut start = 0;
106    let mut at = 0;
107    for (t, raw) in pieces(s) {
108        if matches!(t, Token::Char(' ')) {
109            out.push(&s[start..at]);
110            start = at + 1;
111        }
112        at += raw.len();
113    }
114    out.push(&s[start..]);
115    out
116}
117
118/// `s` split into its markup and its characters: each piece's text as
119/// written, and the character it shows (`None`: markup, no room taken).
120pub fn glyphs(s: &str) -> Vec<(&str, Option<char>)> {
121    pieces(s)
122        .into_iter()
123        .map(|(t, raw)| match t {
124            Token::Char(c) => (raw, Some(c)),
125            _ => (raw, None),
126        })
127        .collect()
128}
129
130/// `s` with every span's attribute words passed through `f` (`Some`: the
131/// words to write instead) — how a producer turns its own words (`accent`,
132/// `link=…`) into this format's.
133pub fn map_attrs(s: &str, f: impl Fn(&str) -> Option<String>) -> String {
134    let mut out = String::with_capacity(s.len());
135    let b = s.as_bytes();
136    let mut i = 0;
137    while i < s.len() {
138        if b[i] == b'{' && b.get(i + 1) == Some(&b'{') {
139            out.push_str("{{");
140            i += 2;
141            continue;
142        }
143        if b[i] == b'{'
144            && let Some(bar) = s[i + 1..].find(['|', '{', '}']).map(|p| p + i + 1)
145            && b[bar] == b'|'
146        {
147            let words: Vec<String> = s[i + 1..bar].split_whitespace().map(|w| f(w).unwrap_or_else(|| w.to_string())).collect();
148            out.push('{');
149            out.push_str(&words.join(" "));
150            out.push('|');
151            i = bar + 1;
152            continue;
153        }
154        let c = s[i..].chars().next().unwrap_or_default();
155        out.push(c);
156        i += c.len_utf8();
157    }
158    out
159}
160
161/// `s` as runs of one style each.
162pub fn runs(s: &str) -> Vec<(String, Style)> {
163    let mut out: Vec<(String, Style)> = Vec::new();
164    let mut stack = vec![Style::default()];
165    for t in tokens(s) {
166        match t {
167            Token::Char(c) => {
168                let st = *stack.last().unwrap_or(&Style::default());
169                match out.last_mut() {
170                    Some((text, last)) if *last == st => text.push(c),
171                    _ => out.push((c.to_string(), st)),
172                }
173            }
174            Token::Open(a) => {
175                let top = *stack.last().unwrap_or(&Style::default());
176                stack.push(apply(a, top).unwrap_or(top));
177            }
178            Token::Close => {
179                if stack.len() > 1 {
180                    stack.pop();
181                }
182            }
183        }
184    }
185    out
186}
187
188/// The text without its markup.
189pub fn plain(s: &str) -> String {
190    runs(s).into_iter().map(|(t, _)| t).collect()
191}
192
193/// Width with `advance` per glyph: markup takes no room, bold glyphs one
194/// more (Minecraft's own `§` codes too).
195pub fn width(s: &str, mut advance: impl FnMut(char) -> f32) -> f32 {
196    let mut w = 0.0;
197    for (text, st) in runs(s) {
198        let mut legacy_bold = false;
199        let mut chars = text.chars();
200        while let Some(c) = chars.next() {
201            if c == '§' {
202                match chars.next().map(|k| k.to_ascii_lowercase()) {
203                    Some('l') => legacy_bold = true,
204                    Some(k) if k == 'r' || k.is_ascii_hexdigit() => legacy_bold = false,
205                    _ => {}
206                }
207                continue;
208            }
209            w += advance(c) + if st.bold || legacy_bold { 1.0 } else { 0.0 };
210        }
211    }
212    w
213}
214
215/// The spans still open at the end of `s`, outermost first (their
216/// attribute text) — a wrapped line closes them and the next reopens them,
217/// since every line is drawn on its own.
218pub fn open_spans(s: &str) -> Vec<String> {
219    let mut stack = Vec::new();
220    for t in tokens(s) {
221        match t {
222            Token::Open(a) => stack.push(a.to_string()),
223            Token::Close => {
224                stack.pop();
225            }
226            Token::Char(_) => {}
227        }
228    }
229    stack
230}
231
232/// `{a|` for each of `spans`.
233pub fn reopen(spans: &[String]) -> String {
234    spans.iter().map(|a| format!("{{{a}|")).collect()
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn parses_spans() {
243        let r = runs("a {b|bold {#8d6acc|purple}} {{x}} {not a span}");
244        assert_eq!(r[0], ("a ".to_string(), Style::default()));
245        assert_eq!(r[1].0, "bold ");
246        assert!(r[1].1.bold);
247        assert_eq!(r[2], ("purple".to_string(), Style { bold: true, color: Some(0x8d6acc), ..Default::default() }));
248        assert_eq!(r[3].0, " {x} {not a span}");
249        assert_eq!(plain("{i|x}y"), "xy");
250    }
251
252    #[test]
253    fn producers_map_their_words() {
254        let s = map_attrs("a {accent|term} {{x}} {b link=m:e|y}", |w| match w {
255            "accent" => Some("#8d6acc".into()),
256            w if w.starts_with("link=") => Some("u #2f5fbf".into()),
257            _ => None,
258        });
259        assert_eq!(s, "a {#8d6acc|term} {{x}} {b u #2f5fbf|y}");
260    }
261
262    #[test]
263    fn spans_carry_over_lines() {
264        assert_eq!(open_spans("a {b|b {#ffffff|c} d"), ["b"]);
265        assert_eq!(reopen(&["b".into(), "#123456".into()]), "{b|{#123456|");
266        assert_eq!(width("{b|ab}", |_| 5.0), 12.0, "bold is one wider a glyph");
267        assert_eq!(width("§5ab", |_| 5.0), 10.0);
268    }
269}