Skip to main content

yog_ui/
text.rs

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