Skip to main content

yog_ui/
text.rs

1//! Text wrapping utilities shared by layout (measuring) and render (drawing).
2//!
3//! Widths use Minecraft's default (ASCII) font advance table so that
4//! wrapping and centering match what the MC text renderer actually draws.
5//! Other fonts (`Style::font`, e.g. `"minecraft:uniform"` or a resource-pack
6//! font) are measured by the host's text renderer once [`use_host_metrics`]
7//! has been called (`UiRoot::render` does it); before that, and outside a
8//! game, they fall back to the default font's table.
9
10use std::collections::HashMap;
11use std::sync::Mutex;
12
13use yog_gfx::{GfxContext, TextWidthFn};
14
15/// Approximate pixel width per character at font_scale=1.0 (fallback for
16/// non-ASCII chars and legacy callers).
17pub const CHAR_W: f32 = 6.0;
18/// Line height at font_scale=1.0 (MC font is 9px; Patchouli uses 9-10px lines).
19pub const LINE_H: f32 = 9.0;
20/// Gap between wrapped lines.
21pub const LINE_GAP: f32 = 1.0;
22/// Minecraft's default font id — what an empty `Style::font` means.
23pub const DEFAULT_FONT: &str = "minecraft:default";
24
25/// Advance width in pixels (including 1px inter-glyph spacing) of one char
26/// in Minecraft's default ASCII font at GUI scale 1.
27pub fn char_width(c: char) -> f32 {
28    match c {
29        ' ' => 4.0,
30        '!' | ',' | '.' | ':' | ';' | '|' | '\'' => 2.0,
31        'i' => 2.0,
32        'l' => 3.0,
33        '`' => 3.0,
34        '(' | ')' | '*' | '<' | '>' | '{' | '}' => 5.0,
35        'f' | 'k' => 5.0,
36        '"' => 5.0,
37        't' | '[' | ']' | 'I' => 4.0,
38        '@' | '~' => 7.0,
39        c if c.is_ascii() => 6.0,
40        _ => 6.0, // non-ASCII: MC unicode glyphs vary; 6 is a fair average
41    }
42}
43
44/// Whether `font` means Minecraft's default font (empty or `minecraft:default`).
45pub fn is_default_font(font: &str) -> bool {
46    font.is_empty() || font == DEFAULT_FONT
47}
48
49/// Most `(font, char)` advances cached before the cache starts over.
50const ADVANCE_CACHE_CAP: usize = 8192;
51
52struct HostMetrics {
53    width: Option<TextWidthFn>,
54    /// font → char → advance; MC advances are additive, so per-char
55    /// widths sum to the string's width.
56    cache: Option<HashMap<String, HashMap<char, f32>>>,
57    /// Advances in `cache`, all fonts together.
58    cached: usize,
59}
60
61impl HostMetrics {
62    /// Advance of `c` in the non-default `font` as the host measures it
63    /// (cached); `None` without a host.
64    fn advance(&mut self, c: char, font: &str) -> Option<f32> {
65        let width = self.width?;
66        let cache = self.cache.get_or_insert_with(HashMap::new);
67        if let Some(w) = cache.get(font).and_then(|m| m.get(&c)) {
68            return Some(*w);
69        }
70        let mut buf = [0u8; 4];
71        let s: &str = c.encode_utf8(&mut buf);
72        let w = unsafe {
73            width(
74                yog_abi::YogStr::from_str(s),
75                yog_abi::YogStr::from_str(font),
76            )
77        };
78        if self.cached > ADVANCE_CACHE_CAP {
79            cache.clear();
80            self.cached = 0;
81        }
82        cache.entry(font.to_owned()).or_default().insert(c, w);
83        self.cached += 1;
84        Some(w)
85    }
86}
87
88static HOST: Mutex<HostMetrics> = Mutex::new(HostMetrics {
89    width: None,
90    cache: None,
91    cached: 0,
92});
93
94/// Lets non-default fonts be measured by the host's text renderer (see the
95/// module docs). Called by `UiRoot::render`; call it yourself before the
96/// first layout to get exact metrics from the very first frame.
97pub fn use_host_metrics(ctx: &GfxContext) {
98    if let Ok(mut h) = HOST.lock() {
99        h.width = Some(ctx.text_width_fn());
100    }
101}
102
103/// Advance width of `c` in `font` at scale 1 — the default font's table, or
104/// the host's measurement (cached) for any other font.
105pub fn char_width_in(c: char, font: &str) -> f32 {
106    if is_default_font(font) {
107        return char_width(c);
108    }
109    HOST.lock()
110        .ok()
111        .and_then(|mut h| h.advance(c, font))
112        .unwrap_or_else(|| char_width(c))
113}
114
115/// Pixel width of a string at the given font scale.
116pub fn str_width(s: &str, font_scale: f32) -> f32 {
117    crate::rich::width(s, char_width) * font_scale
118}
119
120/// [`str_width`] in `font` (see [`char_width_in`]).
121pub fn str_width_in(s: &str, font_scale: f32, font: &str) -> f32 {
122    if is_default_font(font) {
123        return str_width(s, font_scale);
124    }
125    let Ok(mut h) = HOST.lock() else {
126        return str_width(s, font_scale);
127    };
128    crate::rich::width(s, |c| h.advance(c, font).unwrap_or_else(|| char_width(c))) * font_scale
129}
130
131/// Break `text` into lines that fit within `max_w` pixels at `font_scale`.
132/// Width accounting is per-glyph (MC default font advances).
133pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
134    wrap_text_in(text, max_w, font_scale, "")
135}
136
137/// [`wrap_text`] in `font` (see [`char_width_in`]).
138pub fn wrap_text_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> Vec<String> {
139    if font_scale <= 0.0 || max_w <= 0.0 {
140        return vec![text.to_owned()];
141    }
142    let mut lines = Lines::default();
143    wrap(text, max_w, font_scale, font, &mut lines);
144    lines.0
145}
146
147/// Where [`wrap`] puts the text: the pieces of the current line and the
148/// line breaks.
149trait WrapSink {
150    fn push_str(&mut self, s: &str);
151    fn push_char(&mut self, c: char);
152    /// Ends the current line.
153    fn end_line(&mut self);
154}
155
156/// Collects the wrapped lines.
157#[derive(Default)]
158struct Lines(Vec<String>, String);
159
160impl WrapSink for Lines {
161    fn push_str(&mut self, s: &str) {
162        self.1.push_str(s);
163    }
164    fn push_char(&mut self, c: char) {
165        self.1.push(c);
166    }
167    fn end_line(&mut self) {
168        self.0.push(std::mem::take(&mut self.1));
169    }
170}
171
172/// Counts the wrapped lines without building them.
173#[derive(Default)]
174struct LineCount(usize);
175
176impl WrapSink for LineCount {
177    fn push_str(&mut self, _: &str) {}
178    fn push_char(&mut self, _: char) {}
179    fn end_line(&mut self) {
180        self.0 += 1;
181    }
182}
183
184/// The word-wrapping behind [`wrap_text_in`]: each `\n`-separated
185/// paragraph on lines of its own (see [`wrap_paragraph`]).
186fn wrap(text: &str, max_w: f32, font_scale: f32, font: &str, out: &mut impl WrapSink) {
187    for para in text.split('\n') {
188        wrap_paragraph(para, max_w, font_scale, font, out);
189    }
190}
191
192/// One paragraph: words joined by single spaces, a word wider than a line
193/// hard-broken at glyph boundaries. Always ends at least one line.
194fn wrap_paragraph(text: &str, max_w: f32, font_scale: f32, font: &str, out: &mut impl WrapSink) {
195    let space_w = char_width_in(' ', font) * font_scale;
196    let mut lines = 0usize;
197    let mut cur_w = 0.0f32;
198
199    for word in crate::rich::words(text) {
200        if word.is_empty() {
201            continue;
202        }
203        let word_w = str_width_in(word, font_scale, font);
204        if cur_w > 0.0 && cur_w + space_w + word_w <= max_w {
205            out.push_char(' ');
206            out.push_str(word);
207            cur_w += space_w + word_w;
208            continue;
209        }
210        if cur_w > 0.0 {
211            out.end_line();
212            lines += 1;
213        }
214        if word_w <= max_w {
215            out.push_str(word);
216            cur_w = word_w;
217            continue;
218        }
219        // Word is wider than one line — hard-break at glyph boundaries.
220        let (mut w, mut line_empty) = (0.0f32, true);
221        let mut code = false;
222        for (raw, ch) in crate::rich::glyphs(word) {
223            // Markup takes no room: a `§` code, a span's `{attrs|` and `}`.
224            let shown = ch.filter(|&c| !code && c != '§');
225            code = ch == Some('§');
226            let Some(ch) = shown else {
227                raw.chars().for_each(|c| out.push_char(c));
228                continue;
229            };
230            let cw = char_width_in(ch, font) * font_scale;
231            if w + cw > max_w && !line_empty {
232                out.end_line();
233                lines += 1;
234                w = 0.0;
235            }
236            raw.chars().for_each(|c| out.push_char(c));
237            line_empty = false;
238            w += cw;
239        }
240        cur_w = w;
241    }
242    if cur_w > 0.0 || lines == 0 {
243        out.end_line();
244    }
245}
246
247/// Number of lines that `text` wraps to.
248pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
249    line_count_in(text, max_w, font_scale, "")
250}
251
252/// [`line_count`] in `font`, without building the lines.
253fn line_count_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> usize {
254    if font_scale <= 0.0 || max_w <= 0.0 {
255        return 1;
256    }
257    let mut count = LineCount::default();
258    wrap(text, max_w, font_scale, font, &mut count);
259    count.0
260}
261
262/// Total pixel height of wrapped `text`.
263pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
264    text_height_in(text, max_w, font_scale, "")
265}
266
267/// [`text_height`] in `font` (see [`char_width_in`]).
268pub fn text_height_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> f32 {
269    let n = line_count_in(text, max_w, font_scale, font);
270    n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
271}
272
273/// Split `text` into page-sized chunks that fit within `max_h` pixels.
274/// Each page is a `Vec<String>` of ready-to-render lines.
275pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
276    let all_lines: Vec<String> = text
277        .split('\n')
278        .flat_map(|para| {
279            if para.is_empty() {
280                vec![String::new()]
281            } else {
282                wrap_text(para, max_w, font_scale)
283            }
284        })
285        .collect();
286
287    let line_h = LINE_H * font_scale + LINE_GAP;
288    let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
289    let per_page = per_page.max(1);
290
291    all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn wraps_words_and_hard_breaks_long_ones() {
300        assert_eq!(wrap_text("aaa  bbb ccc", 40.0, 1.0), ["aaa bbb", "ccc"]);
301        assert_eq!(wrap_text("", 40.0, 1.0), [""]);
302        assert_eq!(wrap_text("aaa\nbbb", 400.0, 1.0), ["aaa", "bbb"]);
303        assert_eq!(wrap_text("aaa\n\nbbb", 400.0, 1.0), ["aaa", "", "bbb"]);
304        assert_eq!(str_width("§5ab§r", 1.0), str_width("ab", 1.0), "codes take no room");
305        assert_eq!(str_width("§lab", 1.0), str_width("ab", 1.0) + 2.0, "bold is wider");
306
307        assert_eq!(wrap_text("mmmmmmmmmm", 30.0, 1.0), ["mmmmm", "mmmmm"]);
308        assert_eq!(
309            wrap_text("a mmmmmmmmmm b", 30.0, 1.0),
310            ["a", "mmmmm", "mmmmm", "b"]
311        );
312        assert_eq!(wrap_text("{b|mmmmmmmmmm}", 30.0, 1.0), ["{b|mmmmm", "mmmmm}"], "spans take no room");
313        assert_eq!(wrap_text("aaa {b #ff0000|bb cc}", 40.0, 1.0), ["aaa {b #ff0000|bb", "cc}"], "a span's opening stays whole");
314        assert_eq!(wrap_text("{{m}}{{m}}", 15.0, 1.0), ["{{m", "}}{{", "m}}"], "escaped braces are characters");
315    }
316
317    #[test]
318    fn line_count_matches_the_wrapped_lines() {
319        let text = "The quick brown fox jumps over a supercalifragilistic dog";
320        for w in [0.0, 5.0, 20.0, 50.0, 116.0, 1000.0] {
321            for scale in [0.0, 0.5, 1.0, 2.0] {
322                let lines = if scale <= 0.0 || w <= 0.0 {
323                    1
324                } else {
325                    wrap_text(text, w, scale).len()
326                };
327                assert_eq!(line_count(text, w, scale), lines, "w={w} scale={scale}");
328            }
329        }
330    }
331}