1use std::collections::HashMap;
11use yog_sync::{LazyLock, Mutex};
12
13use yog_gfx::{GfxContext, TextWidthFn};
14
15pub const CHAR_W: f32 = 6.0;
18pub const LINE_H: f32 = 9.0;
20pub const GLYPH_H: f32 = 8.0;
23pub const LINE_GAP: f32 = 1.0;
25pub use yog_ids::yog::DEFAULT_FONT;
27
28static GLYPH_WIDTHS: LazyLock<Vec<f32>> = LazyLock::new(|| {
30 yog_data::built_in(
31 "yog-ui's glyph widths",
32 include_str!("../data/glyph_widths.ydata"),
33 )
34});
35const FIRST_GLYPH: char = ' ';
37
38pub fn char_width(c: char) -> f32 {
42 (c as usize)
43 .checked_sub(FIRST_GLYPH as usize)
44 .and_then(|i| GLYPH_WIDTHS.get(i))
45 .copied()
46 .unwrap_or(CHAR_W)
47}
48
49pub fn is_default_font(font: &str) -> bool {
51 font.is_empty() || font == DEFAULT_FONT
52}
53
54fn host_font(font: &str) -> &str {
56 if font.is_empty() { DEFAULT_FONT } else { font }
57}
58
59const ADVANCE_CACHE_CAP: usize = 8192;
61
62struct HostMetrics {
63 width: Option<TextWidthFn>,
64 cache: Option<HashMap<String, HashMap<char, f32>>>,
67 cached: usize,
69}
70
71impl HostMetrics {
72 fn advance(&mut self, c: char, font: &str) -> Option<f32> {
75 let width = self.width?;
76 let cache = self.cache.get_or_insert_with(HashMap::new);
77 if let Some(w) = cache.get(font).and_then(|m| m.get(&c)) {
78 return Some(*w);
79 }
80 let mut buf = [0u8; 4];
81 let s: &str = c.encode_utf8(&mut buf);
82 let w = unsafe {
83 width(
84 yog_abi::YogStr::from_str(s),
85 yog_abi::YogStr::from_str(font),
86 )
87 };
88 if self.cached > ADVANCE_CACHE_CAP {
89 cache.clear();
90 self.cached = 0;
91 }
92 cache.entry(font.to_owned()).or_default().insert(c, w);
93 self.cached += 1;
94 Some(w)
95 }
96}
97
98static HOST: Mutex<HostMetrics> = Mutex::new(HostMetrics {
99 width: None,
100 cache: None,
101 cached: 0,
102});
103
104pub fn use_host_metrics(ctx: &GfxContext) {
108 HOST.lock().width = Some(ctx.text_width_fn());
109}
110
111pub fn char_width_in(c: char, font: &str) -> f32 {
114 let font = host_font(font);
115 HOST.lock()
116 .advance(c, font)
117 .unwrap_or_else(|| char_width(c))
118}
119
120pub fn str_width(s: &str, font_scale: f32) -> f32 {
122 crate::rich::width(s, char_width) * font_scale
123}
124
125pub fn str_width_in(s: &str, font_scale: f32, font: &str) -> f32 {
127 let font = host_font(font);
128 let mut h = HOST.lock();
129 crate::rich::width(s, |c| h.advance(c, font).unwrap_or_else(|| char_width(c))) * font_scale
130}
131
132pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
135 wrap_text_in(text, max_w, font_scale, "")
136}
137
138pub fn wrap_text_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> Vec<String> {
140 if font_scale <= 0.0 || max_w <= 0.0 {
141 return vec![text.to_owned()];
142 }
143 let mut lines = Lines::default();
144 wrap(text, max_w, font_scale, font, &mut lines);
145 lines.0
146}
147
148trait WrapSink {
151 fn push_str(&mut self, s: &str);
152 fn push_char(&mut self, c: char);
153 fn end_line(&mut self);
155}
156
157#[derive(Default)]
159struct Lines(Vec<String>, String);
160
161impl WrapSink for Lines {
162 fn push_str(&mut self, s: &str) {
163 self.1.push_str(s);
164 }
165 fn push_char(&mut self, c: char) {
166 self.1.push(c);
167 }
168 fn end_line(&mut self) {
169 self.0.push(std::mem::take(&mut self.1));
170 }
171}
172
173#[derive(Default)]
175struct LineCount(usize);
176
177impl WrapSink for LineCount {
178 fn push_str(&mut self, _: &str) {}
179 fn push_char(&mut self, _: char) {}
180 fn end_line(&mut self) {
181 self.0 += 1;
182 }
183}
184
185fn wrap(text: &str, max_w: f32, font_scale: f32, font: &str, out: &mut impl WrapSink) {
188 for para in text.split('\n') {
189 wrap_paragraph(para, max_w, font_scale, font, out);
190 }
191}
192
193fn wrap_paragraph(text: &str, max_w: f32, font_scale: f32, font: &str, out: &mut impl WrapSink) {
196 let space_w = char_width_in(' ', font) * font_scale;
197 let mut lines = 0usize;
198 let mut cur_w = 0.0f32;
199
200 for word in crate::rich::words(text) {
201 if word.is_empty() {
202 continue;
203 }
204 let word_w = str_width_in(word, font_scale, font);
205 if cur_w > 0.0 && cur_w + space_w + word_w <= max_w {
206 out.push_char(' ');
207 out.push_str(word);
208 cur_w += space_w + word_w;
209 continue;
210 }
211 if cur_w > 0.0 {
212 out.end_line();
213 lines += 1;
214 }
215 if word_w <= max_w {
216 out.push_str(word);
217 cur_w = word_w;
218 continue;
219 }
220 let (mut w, mut line_empty) = (0.0f32, true);
222 let mut code = false;
223 for (raw, ch) in crate::rich::glyphs(word) {
224 let shown = ch.filter(|&c| !code && c != '§');
226 code = ch == Some('§');
227 let Some(ch) = shown else {
228 raw.chars().for_each(|c| out.push_char(c));
229 continue;
230 };
231 let cw = char_width_in(ch, font) * font_scale;
232 if w + cw > max_w && !line_empty {
233 out.end_line();
234 lines += 1;
235 w = 0.0;
236 }
237 raw.chars().for_each(|c| out.push_char(c));
238 line_empty = false;
239 w += cw;
240 }
241 cur_w = w;
242 }
243 if cur_w > 0.0 || lines == 0 {
244 out.end_line();
245 }
246}
247
248pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
250 line_count_in(text, max_w, font_scale, "")
251}
252
253fn line_count_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> usize {
255 if font_scale <= 0.0 || max_w <= 0.0 {
256 return 1;
257 }
258 let mut count = LineCount::default();
259 wrap(text, max_w, font_scale, font, &mut count);
260 count.0
261}
262
263pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
265 text_height_in(text, max_w, font_scale, "")
266}
267
268pub fn text_height_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> f32 {
270 let n = line_count_in(text, max_w, font_scale, font);
271 n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
272}
273
274pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
277 let all_lines: Vec<String> = text
278 .split('\n')
279 .flat_map(|para| {
280 if para.is_empty() {
281 vec![String::new()]
282 } else {
283 wrap_text(para, max_w, font_scale)
284 }
285 })
286 .collect();
287
288 let line_h = LINE_H * font_scale + LINE_GAP;
289 let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
290 let per_page = per_page.max(1);
291
292 all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn wraps_words_and_hard_breaks_long_ones() {
301 assert_eq!(wrap_text("aaa bbb ccc", 40.0, 1.0), ["aaa bbb", "ccc"]);
302 assert_eq!(wrap_text("", 40.0, 1.0), [""]);
303 assert_eq!(wrap_text("aaa\nbbb", 400.0, 1.0), ["aaa", "bbb"]);
304 assert_eq!(wrap_text("aaa\n\nbbb", 400.0, 1.0), ["aaa", "", "bbb"]);
305 assert_eq!(
306 str_width("§5ab§r", 1.0),
307 str_width("ab", 1.0),
308 "codes take no room"
309 );
310 assert_eq!(
311 str_width("§lab", 1.0),
312 str_width("ab", 1.0) + 2.0,
313 "bold is wider"
314 );
315
316 assert_eq!(wrap_text("mmmmmmmmmm", 30.0, 1.0), ["mmmmm", "mmmmm"]);
317 assert_eq!(
318 wrap_text("a mmmmmmmmmm b", 30.0, 1.0),
319 ["a", "mmmmm", "mmmmm", "b"]
320 );
321 assert_eq!(
322 wrap_text("{b|mmmmmmmmmm}", 30.0, 1.0),
323 ["{b|mmmmm", "mmmmm}"],
324 "spans take no room"
325 );
326 assert_eq!(
327 wrap_text("aaa {b #ff0000|bb cc}", 40.0, 1.0),
328 ["aaa {b #ff0000|bb", "cc}"],
329 "a span's opening stays whole"
330 );
331 assert_eq!(
332 wrap_text("{{m}}{{m}}", 13.0, 1.0),
333 ["{{m", "}}{{", "m}}"],
334 "escaped braces are characters"
335 );
336 }
337
338 #[test]
339 fn the_glyph_widths_read_whole() {
340 assert_eq!(GLYPH_WIDTHS.len(), '~' as usize - FIRST_GLYPH as usize + 1);
341 assert_eq!(char_width('i'), 2.0);
342 }
343
344 #[test]
345 fn line_count_matches_the_wrapped_lines() {
346 let text = "The quick brown fox jumps over a supercalifragilistic dog";
347 for w in [0.0, 5.0, 20.0, 50.0, 116.0, 1000.0] {
348 for scale in [0.0, 0.5, 1.0, 2.0] {
349 let lines = if scale <= 0.0 || w <= 0.0 {
350 1
351 } else {
352 wrap_text(text, w, scale).len()
353 };
354 assert_eq!(line_count(text, w, scale), lines, "w={w} scale={scale}");
355 }
356 }
357 }
358}