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
6/// Approximate pixel width per character at font_scale=1.0 (fallback for
7/// non-ASCII chars and legacy callers).
8pub const CHAR_W: f32 = 6.0;
9/// Line height at font_scale=1.0 (MC font is 9px; Patchouli uses 9-10px lines).
10pub const LINE_H: f32 = 9.0;
11/// Gap between wrapped lines.
12pub const LINE_GAP: f32 = 1.0;
13
14/// Advance width in pixels (including 1px inter-glyph spacing) of one char
15/// in Minecraft's default ASCII font at GUI scale 1.
16pub fn char_width(c: char) -> f32 {
17    match c {
18        ' ' => 4.0,
19        '!' | ',' | '.' | ':' | ';' | '|' | '\'' => 2.0,
20        'i' => 2.0,
21        'l' => 3.0,
22        '`' => 3.0,
23        '(' | ')' | '*' | '<' | '>' | '{' | '}' => 5.0,
24        'f' | 'k' => 5.0,
25        '"' => 5.0,
26        't' | '[' | ']' | 'I' => 4.0,
27        '@' | '~' => 7.0,
28        c if c.is_ascii() => 6.0,
29        _ => 6.0, // non-ASCII: MC unicode glyphs vary; 6 is a fair average
30    }
31}
32
33/// Pixel width of a string at the given font scale.
34pub fn str_width(s: &str, font_scale: f32) -> f32 {
35    s.chars().map(char_width).sum::<f32>() * font_scale
36}
37
38/// Break `text` into lines that fit within `max_w` pixels at `font_scale`.
39/// Width accounting is per-glyph (MC default font advances).
40pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
41    if font_scale <= 0.0 || max_w <= 0.0 {
42        return vec![text.to_owned()];
43    }
44    let space_w = char_width(' ') * font_scale;
45    let mut lines: Vec<String> = Vec::new();
46    let mut cur = String::new();
47    let mut cur_w = 0.0f32;
48
49    for word in text.split(' ') {
50        if word.is_empty() { continue; }
51        let word_w = str_width(word, font_scale);
52
53        if cur_w == 0.0 {
54            append_word(&mut lines, &mut cur, &mut cur_w, word, word_w, max_w, font_scale);
55        } else if cur_w + space_w + word_w <= max_w {
56            cur.push(' ');
57            cur.push_str(word);
58            cur_w += space_w + word_w;
59        } else {
60            lines.push(std::mem::take(&mut cur));
61            cur_w = 0.0;
62            append_word(&mut lines, &mut cur, &mut cur_w, word, word_w, max_w, font_scale);
63        }
64    }
65    if cur_w > 0.0 || lines.is_empty() {
66        lines.push(cur);
67    }
68    lines
69}
70
71/// Append a word, hard-breaking if it's wider than one line.
72fn append_word(
73    lines: &mut Vec<String>, cur: &mut String, cur_w: &mut f32,
74    word: &str, word_w: f32, max_w: f32, font_scale: f32,
75) {
76    if word_w <= max_w {
77        cur.push_str(word);
78        *cur_w = word_w;
79        return;
80    }
81    // Word is wider than one line — hard-break at glyph boundaries.
82    let mut line = String::new();
83    let mut w = 0.0f32;
84    for ch in word.chars() {
85        let cw = char_width(ch) * font_scale;
86        if w + cw > max_w && !line.is_empty() {
87            lines.push(std::mem::take(&mut line));
88            w = 0.0;
89        }
90        line.push(ch);
91        w += cw;
92    }
93    *cur = line;
94    *cur_w = w;
95}
96
97/// Number of lines that `text` wraps to.
98pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
99    wrap_text(text, max_w, font_scale).len().max(1)
100}
101
102/// Total pixel height of wrapped `text`.
103pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
104    let n = line_count(text, max_w, font_scale);
105    n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
106}
107
108/// Split `text` into page-sized chunks that fit within `max_h` pixels.
109/// Each page is a `Vec<String>` of ready-to-render lines.
110pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
111    let all_lines: Vec<String> = text.split('\n').flat_map(|para| {
112        if para.is_empty() { vec![String::new()] } else { wrap_text(para, max_w, font_scale) }
113    }).collect();
114
115    let line_h = LINE_H * font_scale + LINE_GAP;
116    let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
117    let per_page = per_page.max(1);
118
119    all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
120}