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() {
51            continue;
52        }
53        let word_w = str_width(word, font_scale);
54
55        if cur_w == 0.0 {
56            append_word(
57                &mut lines, &mut cur, &mut cur_w, word, word_w, max_w, font_scale,
58            );
59        } else if cur_w + space_w + word_w <= max_w {
60            cur.push(' ');
61            cur.push_str(word);
62            cur_w += space_w + word_w;
63        } else {
64            lines.push(std::mem::take(&mut cur));
65            cur_w = 0.0;
66            append_word(
67                &mut lines, &mut cur, &mut cur_w, word, word_w, max_w, font_scale,
68            );
69        }
70    }
71    if cur_w > 0.0 || lines.is_empty() {
72        lines.push(cur);
73    }
74    lines
75}
76
77/// Append a word, hard-breaking if it's wider than one line.
78fn append_word(
79    lines: &mut Vec<String>,
80    cur: &mut String,
81    cur_w: &mut f32,
82    word: &str,
83    word_w: f32,
84    max_w: f32,
85    font_scale: f32,
86) {
87    if word_w <= max_w {
88        cur.push_str(word);
89        *cur_w = word_w;
90        return;
91    }
92    // Word is wider than one line — hard-break at glyph boundaries.
93    let mut line = String::new();
94    let mut w = 0.0f32;
95    for ch in word.chars() {
96        let cw = char_width(ch) * font_scale;
97        if w + cw > max_w && !line.is_empty() {
98            lines.push(std::mem::take(&mut line));
99            w = 0.0;
100        }
101        line.push(ch);
102        w += cw;
103    }
104    *cur = line;
105    *cur_w = w;
106}
107
108/// Number of lines that `text` wraps to.
109pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
110    wrap_text(text, max_w, font_scale).len().max(1)
111}
112
113/// Total pixel height of wrapped `text`.
114pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
115    let n = line_count(text, max_w, font_scale);
116    n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
117}
118
119/// Split `text` into page-sized chunks that fit within `max_h` pixels.
120/// Each page is a `Vec<String>` of ready-to-render lines.
121pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
122    let all_lines: Vec<String> = text
123        .split('\n')
124        .flat_map(|para| {
125            if para.is_empty() {
126                vec![String::new()]
127            } else {
128                wrap_text(para, max_w, font_scale)
129            }
130        })
131        .collect();
132
133    let line_h = LINE_H * font_scale + LINE_GAP;
134    let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
135    let per_page = per_page.max(1);
136
137    all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
138}