Skip to main content

yog_ui/
text.rs

1//! Text wrapping utilities shared by layout (measuring) and render (drawing).
2
3/// Approximate pixel width per character at font_scale=1.0 in Minecraft's default font.
4pub const CHAR_W: f32 = 6.0;
5/// Line height at font_scale=1.0.
6pub const LINE_H: f32 = 10.0;
7/// Gap between wrapped lines.
8pub const LINE_GAP: f32 = 2.0;
9
10/// Break `text` into lines that fit within `max_w` pixels at `font_scale`.
11/// All comparisons are char-count based (Unicode-safe).
12pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
13    let char_w = CHAR_W * font_scale;
14    if char_w <= 0.0 || max_w <= 0.0 {
15        return vec![text.to_owned()];
16    }
17    let max_chars = ((max_w / char_w).floor() as usize).max(1);
18    let mut lines: Vec<String> = Vec::new();
19    let mut cur = String::new();
20    let mut cur_chars = 0usize;
21
22    for word in text.split(' ') {
23        if word.is_empty() { continue; }
24        let word_chars = word.chars().count();
25
26        if cur_chars == 0 {
27            append_word(&mut lines, &mut cur, &mut cur_chars, word, word_chars, max_chars);
28        } else if cur_chars + 1 + word_chars <= max_chars {
29            cur.push(' ');
30            cur.push_str(word);
31            cur_chars += 1 + word_chars;
32        } else {
33            lines.push(std::mem::take(&mut cur));
34            cur_chars = 0;
35            append_word(&mut lines, &mut cur, &mut cur_chars, word, word_chars, max_chars);
36        }
37    }
38    if cur_chars > 0 || lines.is_empty() {
39        lines.push(cur);
40    }
41    lines
42}
43
44/// Append a word, hard-breaking if it's longer than max_chars.
45fn append_word(
46    lines: &mut Vec<String>, cur: &mut String, cur_chars: &mut usize,
47    word: &str, word_chars: usize, max_chars: usize,
48) {
49    if word_chars <= max_chars {
50        cur.push_str(word);
51        *cur_chars = word_chars;
52        return;
53    }
54    // Word is longer than one line — hard-break at char boundaries.
55    let mut remaining = word;
56    let mut rem_chars = word_chars;
57    while rem_chars > max_chars {
58        let split = char_boundary(remaining, max_chars);
59        lines.push(remaining[..split].to_owned());
60        remaining = &remaining[split..];
61        rem_chars -= max_chars;
62    }
63    cur.push_str(remaining);
64    *cur_chars = rem_chars;
65}
66
67/// Byte index of the `n`-th char boundary in `s` (Unicode-safe).
68fn char_boundary(s: &str, n: usize) -> usize {
69    s.char_indices().nth(n).map(|(i, _)| i).unwrap_or(s.len())
70}
71
72/// Number of lines that `text` wraps to.
73pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
74    wrap_text(text, max_w, font_scale).len().max(1)
75}
76
77/// Total pixel height of wrapped `text`.
78pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
79    let n = line_count(text, max_w, font_scale);
80    n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
81}
82
83/// Split `text` into page-sized chunks that fit within `max_h` pixels.
84/// Each page is a `Vec<String>` of ready-to-render lines.
85pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
86    let all_lines: Vec<String> = text.split('\n').flat_map(|para| {
87        if para.is_empty() { vec![String::new()] } else { wrap_text(para, max_w, font_scale) }
88    }).collect();
89
90    let line_h = LINE_H * font_scale + LINE_GAP;
91    let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
92    let per_page = per_page.max(1);
93
94    all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
95}