use std::collections::HashMap;
use std::sync::Mutex;
use yog_gfx::{GfxContext, TextWidthFn};
pub const CHAR_W: f32 = 6.0;
pub const LINE_H: f32 = 9.0;
pub const LINE_GAP: f32 = 1.0;
pub const DEFAULT_FONT: &str = "minecraft:default";
pub fn char_width(c: char) -> f32 {
match c {
' ' => 4.0,
'!' | ',' | '.' | ':' | ';' | '|' | '\'' => 2.0,
'i' => 2.0,
'l' => 3.0,
'`' => 3.0,
'(' | ')' | '*' | '<' | '>' | '{' | '}' => 5.0,
'f' | 'k' => 5.0,
'"' => 5.0,
't' | '[' | ']' | 'I' => 4.0,
'@' | '~' => 7.0,
c if c.is_ascii() => 6.0,
_ => 6.0, }
}
pub fn is_default_font(font: &str) -> bool {
font.is_empty() || font == DEFAULT_FONT
}
const ADVANCE_CACHE_CAP: usize = 8192;
struct HostMetrics {
width: Option<TextWidthFn>,
cache: Option<HashMap<String, HashMap<char, f32>>>,
cached: usize,
}
impl HostMetrics {
fn advance(&mut self, c: char, font: &str) -> Option<f32> {
let width = self.width?;
let cache = self.cache.get_or_insert_with(HashMap::new);
if let Some(w) = cache.get(font).and_then(|m| m.get(&c)) {
return Some(*w);
}
let mut buf = [0u8; 4];
let s: &str = c.encode_utf8(&mut buf);
let w = unsafe {
width(
yog_abi::YogStr::from_str(s),
yog_abi::YogStr::from_str(font),
)
};
if self.cached > ADVANCE_CACHE_CAP {
cache.clear();
self.cached = 0;
}
cache.entry(font.to_owned()).or_default().insert(c, w);
self.cached += 1;
Some(w)
}
}
static HOST: Mutex<HostMetrics> = Mutex::new(HostMetrics {
width: None,
cache: None,
cached: 0,
});
pub fn use_host_metrics(ctx: &GfxContext) {
if let Ok(mut h) = HOST.lock() {
h.width = Some(ctx.text_width_fn());
}
}
pub fn char_width_in(c: char, font: &str) -> f32 {
if is_default_font(font) {
return char_width(c);
}
HOST.lock()
.ok()
.and_then(|mut h| h.advance(c, font))
.unwrap_or_else(|| char_width(c))
}
pub fn str_width(s: &str, font_scale: f32) -> f32 {
s.chars().map(char_width).sum::<f32>() * font_scale
}
pub fn str_width_in(s: &str, font_scale: f32, font: &str) -> f32 {
if is_default_font(font) {
return str_width(s, font_scale);
}
let Ok(mut h) = HOST.lock() else {
return str_width(s, font_scale);
};
s.chars()
.map(|c| h.advance(c, font).unwrap_or_else(|| char_width(c)))
.sum::<f32>()
* font_scale
}
pub fn wrap_text(text: &str, max_w: f32, font_scale: f32) -> Vec<String> {
wrap_text_in(text, max_w, font_scale, "")
}
pub fn wrap_text_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> Vec<String> {
if font_scale <= 0.0 || max_w <= 0.0 {
return vec![text.to_owned()];
}
let mut lines = Lines::default();
wrap(text, max_w, font_scale, font, &mut lines);
lines.0
}
trait WrapSink {
fn push_str(&mut self, s: &str);
fn push_char(&mut self, c: char);
fn end_line(&mut self);
}
#[derive(Default)]
struct Lines(Vec<String>, String);
impl WrapSink for Lines {
fn push_str(&mut self, s: &str) {
self.1.push_str(s);
}
fn push_char(&mut self, c: char) {
self.1.push(c);
}
fn end_line(&mut self) {
self.0.push(std::mem::take(&mut self.1));
}
}
#[derive(Default)]
struct LineCount(usize);
impl WrapSink for LineCount {
fn push_str(&mut self, _: &str) {}
fn push_char(&mut self, _: char) {}
fn end_line(&mut self) {
self.0 += 1;
}
}
fn wrap(text: &str, max_w: f32, font_scale: f32, font: &str, out: &mut impl WrapSink) {
let space_w = char_width_in(' ', font) * font_scale;
let mut lines = 0usize;
let mut cur_w = 0.0f32;
for word in text.split(' ') {
if word.is_empty() {
continue;
}
let word_w = str_width_in(word, font_scale, font);
if cur_w > 0.0 && cur_w + space_w + word_w <= max_w {
out.push_char(' ');
out.push_str(word);
cur_w += space_w + word_w;
continue;
}
if cur_w > 0.0 {
out.end_line();
lines += 1;
}
if word_w <= max_w {
out.push_str(word);
cur_w = word_w;
continue;
}
let (mut w, mut line_empty) = (0.0f32, true);
for ch in word.chars() {
let cw = char_width_in(ch, font) * font_scale;
if w + cw > max_w && !line_empty {
out.end_line();
lines += 1;
w = 0.0;
}
out.push_char(ch);
line_empty = false;
w += cw;
}
cur_w = w;
}
if cur_w > 0.0 || lines == 0 {
out.end_line();
}
}
pub fn line_count(text: &str, max_w: f32, font_scale: f32) -> usize {
line_count_in(text, max_w, font_scale, "")
}
fn line_count_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> usize {
if font_scale <= 0.0 || max_w <= 0.0 {
return 1;
}
let mut count = LineCount::default();
wrap(text, max_w, font_scale, font, &mut count);
count.0
}
pub fn text_height(text: &str, max_w: f32, font_scale: f32) -> f32 {
text_height_in(text, max_w, font_scale, "")
}
pub fn text_height_in(text: &str, max_w: f32, font_scale: f32, font: &str) -> f32 {
let n = line_count_in(text, max_w, font_scale, font);
n as f32 * LINE_H * font_scale + (n.saturating_sub(1)) as f32 * LINE_GAP
}
pub fn paginate_text(text: &str, max_w: f32, max_h: f32, font_scale: f32) -> Vec<Vec<String>> {
let all_lines: Vec<String> = text
.split('\n')
.flat_map(|para| {
if para.is_empty() {
vec![String::new()]
} else {
wrap_text(para, max_w, font_scale)
}
})
.collect();
let line_h = LINE_H * font_scale + LINE_GAP;
let per_page = ((max_h + LINE_GAP) / line_h).floor() as usize;
let per_page = per_page.max(1);
all_lines.chunks(per_page).map(|c| c.to_vec()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_words_and_hard_breaks_long_ones() {
assert_eq!(wrap_text("aaa bbb ccc", 40.0, 1.0), ["aaa bbb", "ccc"]);
assert_eq!(wrap_text("", 40.0, 1.0), [""]);
assert_eq!(wrap_text("mmmmmmmmmm", 30.0, 1.0), ["mmmmm", "mmmmm"]);
assert_eq!(
wrap_text("a mmmmmmmmmm b", 30.0, 1.0),
["a", "mmmmm", "mmmmm", "b"]
);
}
#[test]
fn line_count_matches_the_wrapped_lines() {
let text = "The quick brown fox jumps over a supercalifragilistic dog";
for w in [0.0, 5.0, 20.0, 50.0, 116.0, 1000.0] {
for scale in [0.0, 0.5, 1.0, 2.0] {
let lines = if scale <= 0.0 || w <= 0.0 {
1
} else {
wrap_text(text, w, scale).len()
};
assert_eq!(line_count(text, w, scale), lines, "w={w} scale={scale}");
}
}
}
}