Files
endorphin-tessera-gui/src/text.rs
T
2026-10-22 12:24:23 +08:00

384 lines
12 KiB
Rust

//! Shared font discovery, shaping, measurement and antialiased rasterization.
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
use cosmic_text::{Align, Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap};
use crate::display_list::Color;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum TextFamily {
Interface,
Code,
}
#[derive(Clone, Copy, Debug)]
pub struct TextStyle {
pub font_size: f32,
pub line_height: f32,
pub family: TextFamily,
}
impl TextStyle {
pub fn ui(size: u32) -> Self {
Self {
font_size: 14.0 * size as f32,
line_height: 20.0 * size as f32,
family: TextFamily::Interface,
}
}
pub fn code() -> Self {
Self {
font_size: 13.0,
line_height: 18.0,
family: TextFamily::Code,
}
}
pub fn scaled(self, scale: f32) -> Self {
Self {
font_size: self.font_size * scale,
line_height: self.line_height * scale,
..self
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct TextSize {
pub width: f32,
pub height: f32,
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct TextKey {
value: String,
font_size: u32,
line_height: u32,
family: TextFamily,
wrap_width: Option<u32>,
}
struct PreparedText {
buffer: Buffer,
size: TextSize,
}
struct TextEngine {
fonts: FontSystem,
code_family: Option<String>,
swash: SwashCache,
buffers: HashMap<TextKey, PreparedText>,
source_bytes: usize,
}
impl TextEngine {
fn new() -> Self {
let mut fonts = FontSystem::new();
// Select installed, readable families rather than assuming an OS font
// name exists. COSMIC Text handles per-glyph fallback (including CJK).
let choose = |choices: &[&str]| {
choices.iter().find_map(|choice| {
fonts
.db()
.faces()
.any(|face| face.families.iter().any(|(name, _)| name == choice))
.then(|| (*choice).to_owned())
})
};
let sans = choose(&[
".AppleSystemUIFont",
"SF Pro Text",
"Helvetica Neue",
"Segoe UI",
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
]);
let mono = choose(&["Menlo", "Consolas", "DejaVu Sans Mono", "Liberation Mono"]);
if let Some(name) = sans {
fonts.db_mut().set_sans_serif_family(name);
}
if let Some(name) = &mono {
fonts.db_mut().set_monospace_family(name);
}
Self {
fonts,
code_family: mono,
swash: SwashCache::new(),
buffers: HashMap::new(),
source_bytes: 0,
}
}
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
self.prepare_width(value, style, None)
}
fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option<f32>) -> TextKey {
let key = TextKey {
value: value.to_owned(),
font_size: style.font_size.to_bits(),
line_height: style.line_height.to_bits(),
family: style.family,
wrap_width: wrap_width.map(f32::to_bits),
};
if self.buffers.contains_key(&key) {
return key;
}
// Cache recent shaped strings, without retaining every submitted source.
if self.buffers.len() >= 256 || self.source_bytes + value.len() > 524_288 {
self.buffers.clear();
self.source_bytes = 0;
}
let family = match style.family {
TextFamily::Interface => Family::SansSerif,
// Keep Latin source on the selected code face, and allow ordinary
// script fallback for CJK instead of searching all monospace faces.
TextFamily::Code => self
.code_family
.as_deref()
.map_or(Family::Monospace, Family::Name),
};
let mut buffer = Buffer::new(
&mut self.fonts,
Metrics::new(style.font_size, style.line_height),
);
buffer.set_wrap(if wrap_width.is_some() {
Wrap::WordOrGlyph
} else {
Wrap::None
});
buffer.set_size(wrap_width, None);
buffer.set_text(
value,
&Attrs::new().family(family),
Shaping::Advanced,
Some(Align::Left),
);
buffer.shape_until_scroll(&mut self.fonts, false);
let mut size = TextSize {
width: 0.0,
height: style.line_height,
};
for run in buffer.layout_runs() {
size.width = size.width.max(run.line_w);
size.height = size.height.max(run.line_top + run.line_height);
}
self.source_bytes += value.len();
self.buffers
.insert(key.clone(), PreparedText { buffer, size });
key
}
}
fn engine() -> &'static Mutex<TextEngine> {
static ENGINE: OnceLock<Mutex<TextEngine>> = OnceLock::new();
ENGINE.get_or_init(|| Mutex::new(TextEngine::new()))
}
pub fn measure(value: &str, style: TextStyle) -> TextSize {
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare(value, style);
engine.buffers[&key].size
}
pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
if width <= 0.0 {
return TextSize {
width: 0.0,
height: style.line_height,
};
}
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare_width(value, style, Some(width));
engine.buffers[&key].size
}
/// Draw at the actual device font size; no scaling of an already rasterized bitmap.
pub fn draw(value: &str, style: TextStyle, color: Color, pixel: impl FnMut(i32, i32, Color)) {
draw_width(value, style, color, None, pixel)
}
pub fn draw_wrapped(
value: &str,
style: TextStyle,
color: Color,
width: f32,
pixel: impl FnMut(i32, i32, Color),
) {
if width > 0.0 {
draw_width(value, style, color, Some(width), pixel)
}
}
fn draw_width(
value: &str,
style: TextStyle,
color: Color,
width: Option<f32>,
mut pixel: impl FnMut(i32, i32, Color),
) {
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare_width(value, style, width);
let TextEngine {
fonts,
swash,
buffers,
..
} = &mut *engine;
let opacity = u16::from(color.alpha);
buffers.get_mut(&key).expect("prepared text").buffer.draw(
fonts,
swash,
cosmic_text::Color::rgb(color.red, color.green, color.blue),
|x, y, width, height, color| {
for dy in 0..height as i32 {
for dx in 0..width as i32 {
pixel(
x + dx,
y + dy,
Color::rgba(
color.r(),
color.g(),
color.b(),
(u16::from(color.a()) * opacity / 255) as u8,
),
);
}
}
},
);
// The cache is an optimization, never an archive of all submitted glyphs.
if swash.image_cache.len() > 4096
|| swash
.image_cache
.values()
.flatten()
.map(|image| image.data.len())
.sum::<usize>()
> 33_554_432
{
*swash = SwashCache::new();
}
}
/// Fit a single displayed source line without slicing a UTF-8/grapheme cluster.
pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
use unicode_segmentation::UnicodeSegmentation;
// A source line may include a very large comment. Only shape a bounded
// preview for the sidebar; the stored source and its AST spans stay intact.
let truncated = value.len() > 8192;
let value = if truncated {
let end = value
.grapheme_indices(true)
.map(|(index, _)| index)
.take_while(|index| *index <= 8192)
.last()
.unwrap_or(0);
&value[..end]
} else {
value
};
if !truncated && measure(value, style).width <= width {
return value.to_owned();
}
let ellipsis = "…";
if width <= 0.0 || measure(ellipsis, style).width > width {
return String::new();
}
let ends: Vec<_> = value
.grapheme_indices(true)
.map(|(start, part)| start + part.len())
.collect();
let mut low = 0;
let mut high = ends.len();
while low < high {
let count = (low + high).div_ceil(2);
let end = ends[count - 1];
let candidate = format!("{}{ellipsis}", &value[..end]);
if measure(&candidate, style).width <= width {
low = count;
} else {
high = count - 1;
}
}
let end = low.checked_sub(1).map_or(0, |index| ends[index]);
format!("{}{ellipsis}", &value[..end])
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_segmentation::UnicodeSegmentation;
#[test]
fn width_sensitive_shaping_wraps_words_and_clusters_without_changing_intrinsic_metrics() {
let style = TextStyle::ui(1);
let text = "中文段落 with words and long_unbroken_identifier é 👩‍💻 中文段落";
let intrinsic = measure(text, style);
let narrow = measure_wrapped(text, style, 70.0);
let wide = measure_wrapped(text, style, 240.0);
assert!(narrow.height > wide.height);
assert!(wide.height >= style.line_height);
assert!(narrow.width <= 70.1);
assert_eq!(measure(text, style).width, intrinsic.width);
assert_eq!(measure_wrapped(text, style, 0.0).width, 0.0);
let mut engine = engine().lock().unwrap();
let narrow_key = engine.prepare_width(text, style, Some(70.0));
let wide_key = engine.prepare_width(text, style, Some(240.0));
assert!(narrow_key != wide_key);
assert!(engine.buffers[&narrow_key].size.height > engine.buffers[&wide_key].size.height);
}
#[test]
fn proportional_metrics_monospace_and_multiline_height_come_from_the_fonts() {
let ui = TextStyle::ui(1);
assert!(measure("WWWW", ui).width > measure("iiii", ui).width * 2.0);
let mono = TextStyle::code();
assert!((measure("WWWW", mono).width - measure("iiii", mono).width).abs() < 0.1);
assert_eq!(measure("one\ntwo", ui).height, ui.line_height * 2.0);
let width = measure("Hello Endor", ui).width;
assert!((measure("Hello Endor", ui.scaled(1.25)).width - width * 1.25).abs() < 0.5);
}
#[test]
fn system_fallback_shapes_chinese_without_missing_glyphs() {
let mut engine = engine().lock().unwrap();
for style in [TextStyle::ui(1), TextStyle::code()] {
let key = engine.prepare("中文字体", style);
let glyphs: Vec<_> = engine.buffers[&key]
.buffer
.layout_runs()
.flat_map(|run| run.glyphs)
.collect();
assert_eq!(glyphs.len(), 4);
assert!(glyphs.iter().all(|glyph| glyph.glyph_id != 0));
assert!(engine.buffers[&key].size.width <= style.font_size * 8.0);
}
}
#[test]
fn ellipsis_fits_actual_width_and_preserves_graphemes() {
let style = TextStyle::code();
let short_source = "let s = ui.text(\"中文字体\")";
assert_eq!(fit(short_source, 365.0, style), short_source);
let value = "e\u{301}e\u{301}中文字体e\u{301}e\u{301}";
let width = measure("e\u{301}e\u{301}中…", style).width + 0.01;
let fitted = fit(value, width, style);
let prefix = fitted.strip_suffix('…').unwrap();
assert!(value.starts_with(prefix));
assert!(
value
.grapheme_indices(true)
.any(|(index, _)| index == prefix.len())
);
assert!(measure(&fitted, style).width <= width);
assert_eq!(fit(value, 0.0, style), "");
}
}