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
49struct HostMetrics {
50    width: Option<TextWidthFn>,
51    /// `(font, char)` → advance; MC advances are additive, so per-char
52    /// widths sum to the string's width.
53    cache: Option<HashMap<(String, char), f32>>,
54}
55
56static HOST: Mutex<HostMetrics> = Mutex::new(HostMetrics {
57    width: None,
58    cache: None,
59});
60
61/// Lets non-default fonts be measured by the host's text renderer (see the
62/// module docs). Called by `UiRoot::render`; call it yourself before the
63/// first layout to get exact metrics from the very first frame.
64pub fn use_host_metrics(ctx: &GfxContext) {
65    if let Ok(mut h) = HOST.lock() {
66        h.width = Some(ctx.text_width_fn());
67    }
68}
69
70/// Advance width of `c` in `font` at scale 1 — the default font's table, or
71/// the host's measurement (cached) for any other font.
72pub fn char_width_in(c: char, font: &str) -> f32 {
73    if is_default_font(font) {
74        return char_width(c);
75    }
76    let Ok(mut h) = HOST.lock() else {
77        return char_width(c);
78    };
79    let Some(width) = h.width else {
80        return char_width(c);
81    };
82    let cache = h.cache.get_or_insert_with(HashMap::new);
83    if let Some(w) = cache.get(&(font.to_owned(), c)) {
84        return *w;
85    }
86    let mut buf = [0u8; 4];
87    let s: &str = c.encode_utf8(&mut buf);
88    let w = unsafe {
89        width(
90            yog_abi::YogStr::from_str(s),
91            yog_abi::YogStr::from_str(font),
92        )
93    };
94    let cache = h.cache.get_or_insert_with(HashMap::new);
95    if cache.len() > 8192 {
96        cache.clear();
97    }
98    cache.insert((font.to_owned(), c), w);
99    w
100}
101
102/// Pixel width of a string at the given font scale.
103pub fn str_width(s: &str, font_scale: f32) -> f32 {
104    s.chars().map(char_width).sum::<f32>() * font_scale
105}
106
107/// [`str_width`] in `font` (see [`char_width_in`]).
108pub fn str_width_in(s: &str, font_scale: f32, font: &str) -> f32 {
109    if is_default_font(font) {
110        return str_width(s, font_scale);
111    }
112    s.chars().map(|c| char_width_in(c, font)).sum::<f32>() * font_scale
113}
114
115/// Break `text` into lines that fit within `max_w` pixels at `font_scale`.
116/// Width accounting is per-glyph (MC default font advances).
117pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
118    wrap_text_in(text, max_w, font_scale, "")
119}
120
121/// [`wrap_text`] in `font` (see [`char_width_in`]).
122pub fn wrap_text_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> Vec<String> {
123    if font_scale <= 0.0 || max_w <= 0.0 {
124        return vec![text.to_owned()];
125    }
126    let space_w = char_width_in(' ', font) * font_scale;
127    let mut lines: Vec<String> = Vec::new();
128    let mut cur = String::new();
129    let mut cur_w = 0.0f32;
130
131    for word in text.split(' ') {
132        if word.is_empty() {
133            continue;
134        }
135        let word_w = str_width_in(word, font_scale, font);
136
137        if cur_w == 0.0 {
138            append_word(
139                &mut lines, &mut cur, &mut cur_w, word, word_w, max_w, font_scale, font,
140            );
141        } else if cur_w + space_w + word_w <= max_w {
142            cur.push(' ');
143            cur.push_str(word);
144            cur_w += space_w + word_w;
145        } else {
146            lines.push(std::mem::take(&mut cur));
147            cur_w = 0.0;
148            append_word(
149                &mut lines, &mut cur, &mut cur_w, word, word_w, max_w, font_scale, font,
150            );
151        }
152    }
153    if cur_w > 0.0 || lines.is_empty() {
154        lines.push(cur);
155    }
156    lines
157}
158
159/// Append a word, hard-breaking if it's wider than one line.
160#[allow(clippy::too_many_arguments)]
161fn append_word(
162    lines: &mut Vec<String>,
163    cur: &mut String,
164    cur_w: &mut f32,
165    word: &str,
166    word_w: f32,
167    max_w: f32,
168    font_scale: f32,
169    font: &str,
170) {
171    if word_w <= max_w {
172        cur.push_str(word);
173        *cur_w = word_w;
174        return;
175    }
176    // Word is wider than one line — hard-break at glyph boundaries.
177    let mut line = String::new();
178    let mut w = 0.0f32;
179    for ch in word.chars() {
180        let cw = char_width_in(ch, font) * font_scale;
181        if w + cw > max_w && !line.is_empty() {
182            lines.push(std::mem::take(&mut line));
183            w = 0.0;
184        }
185        line.push(ch);
186        w += cw;
187    }
188    *cur = line;
189    *cur_w = w;
190}
191
192/// Number of lines that `text` wraps to.
193pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
194    wrap_text(text, max_w, font_scale).len().max(1)
195}
196
197/// Total pixel height of wrapped `text`.
198pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
199    text_height_in(text, max_w, font_scale, "")
200}
201
202/// [`text_height`] in `font` (see [`char_width_in`]).
203pub fn text_height_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> f32 {
204    let n = wrap_text_in(text, max_w, font_scale, font).len().max(1);
205    n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
206}
207
208/// Split `text` into page-sized chunks that fit within `max_h` pixels.
209/// Each page is a `Vec<String>` of ready-to-render lines.
210pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
211    let all_lines: Vec<String> = text
212        .split('\n')
213        .flat_map(|para| {
214            if para.is_empty() {
215                vec![String::new()]
216            } else {
217                wrap_text(para, max_w, font_scale)
218            }
219        })
220        .collect();
221
222    let line_h = LINE_H * font_scale + LINE_GAP;
223    let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
224    let per_page = per_page.max(1);
225
226    all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
227}