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    s.chars().map(char_width).sum::<f32>() * 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    s.chars()
129        .map(|c| h.advance(c, font).unwrap_or_else(|| char_width(c)))
130        .sum::<f32>()
131        * font_scale
132}
133
134/// Break `text` into lines that fit within `max_w` pixels at `font_scale`.
135/// Width accounting is per-glyph (MC default font advances).
136pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
137    wrap_text_in(text, max_w, font_scale, "")
138}
139
140/// [`wrap_text`] in `font` (see [`char_width_in`]).
141pub fn wrap_text_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> Vec<String> {
142    if font_scale <= 0.0 || max_w <= 0.0 {
143        return vec![text.to_owned()];
144    }
145    let mut lines = Lines::default();
146    wrap(text, max_w, font_scale, font, &mut lines);
147    lines.0
148}
149
150/// Where [`wrap`] puts the text: the pieces of the current line and the
151/// line breaks.
152trait WrapSink {
153    fn push_str(&mut self, s: &str);
154    fn push_char(&mut self, c: char);
155    /// Ends the current line.
156    fn end_line(&mut self);
157}
158
159/// Collects the wrapped lines.
160#[derive(Default)]
161struct Lines(Vec<String>, String);
162
163impl WrapSink for Lines {
164    fn push_str(&mut self, s: &str) {
165        self.1.push_str(s);
166    }
167    fn push_char(&mut self, c: char) {
168        self.1.push(c);
169    }
170    fn end_line(&mut self) {
171        self.0.push(std::mem::take(&mut self.1));
172    }
173}
174
175/// Counts the wrapped lines without building them.
176#[derive(Default)]
177struct LineCount(usize);
178
179impl WrapSink for LineCount {
180    fn push_str(&mut self, _: &str) {}
181    fn push_char(&mut self, _: char) {}
182    fn end_line(&mut self) {
183        self.0 += 1;
184    }
185}
186
187/// The word-wrapping behind [`wrap_text_in`]: words joined by single
188/// spaces, a word wider than a line hard-broken at glyph boundaries. Always
189/// ends at least one line.
190fn wrap(text: &str, max_w: f32, font_scale: f32, font: &str, out: &mut impl WrapSink) {
191    let space_w = char_width_in(' ', font) * font_scale;
192    let mut lines = 0usize;
193    let mut cur_w = 0.0f32;
194
195    for word in text.split(' ') {
196        if word.is_empty() {
197            continue;
198        }
199        let word_w = str_width_in(word, font_scale, font);
200        if cur_w > 0.0 && cur_w + space_w + word_w <= max_w {
201            out.push_char(' ');
202            out.push_str(word);
203            cur_w += space_w + word_w;
204            continue;
205        }
206        if cur_w > 0.0 {
207            out.end_line();
208            lines += 1;
209        }
210        if word_w <= max_w {
211            out.push_str(word);
212            cur_w = word_w;
213            continue;
214        }
215        // Word is wider than one line — hard-break at glyph boundaries.
216        let (mut w, mut line_empty) = (0.0f32, true);
217        for ch in word.chars() {
218            let cw = char_width_in(ch, font) * font_scale;
219            if w + cw > max_w && !line_empty {
220                out.end_line();
221                lines += 1;
222                w = 0.0;
223            }
224            out.push_char(ch);
225            line_empty = false;
226            w += cw;
227        }
228        cur_w = w;
229    }
230    if cur_w > 0.0 || lines == 0 {
231        out.end_line();
232    }
233}
234
235/// Number of lines that `text` wraps to.
236pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
237    line_count_in(text, max_w, font_scale, "")
238}
239
240/// [`line_count`] in `font`, without building the lines.
241fn line_count_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> usize {
242    if font_scale <= 0.0 || max_w <= 0.0 {
243        return 1;
244    }
245    let mut count = LineCount::default();
246    wrap(text, max_w, font_scale, font, &mut count);
247    count.0
248}
249
250/// Total pixel height of wrapped `text`.
251pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
252    text_height_in(text, max_w, font_scale, "")
253}
254
255/// [`text_height`] in `font` (see [`char_width_in`]).
256pub fn text_height_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> f32 {
257    let n = line_count_in(text, max_w, font_scale, font);
258    n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
259}
260
261/// Split `text` into page-sized chunks that fit within `max_h` pixels.
262/// Each page is a `Vec<String>` of ready-to-render lines.
263pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
264    let all_lines: Vec<String> = text
265        .split('\n')
266        .flat_map(|para| {
267            if para.is_empty() {
268                vec![String::new()]
269            } else {
270                wrap_text(para, max_w, font_scale)
271            }
272        })
273        .collect();
274
275    let line_h = LINE_H * font_scale + LINE_GAP;
276    let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
277    let per_page = per_page.max(1);
278
279    all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn wraps_words_and_hard_breaks_long_ones() {
288        assert_eq!(wrap_text("aaa  bbb ccc", 40.0, 1.0), ["aaa bbb", "ccc"]);
289        assert_eq!(wrap_text("", 40.0, 1.0), [""]);
290        assert_eq!(wrap_text("mmmmmmmmmm", 30.0, 1.0), ["mmmmm", "mmmmm"]);
291        assert_eq!(
292            wrap_text("a mmmmmmmmmm b", 30.0, 1.0),
293            ["a", "mmmmm", "mmmmm", "b"]
294        );
295    }
296
297    #[test]
298    fn line_count_matches_the_wrapped_lines() {
299        let text = "The quick brown fox jumps over a supercalifragilistic dog";
300        for w in [0.0, 5.0, 20.0, 50.0, 116.0, 1000.0] {
301            for scale in [0.0, 0.5, 1.0, 2.0] {
302                let lines = if scale <= 0.0 || w <= 0.0 {
303                    1
304                } else {
305                    wrap_text(text, w, scale).len()
306                };
307                assert_eq!(line_count(text, w, scale), lines, "w={w} scale={scale}");
308            }
309        }
310    }
311}