//! 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, } struct PreparedText { buffer: Buffer, size: TextSize, } struct TextEngine { fonts: FontSystem, code_family: Option, swash: SwashCache, buffers: HashMap, 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) -> 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 { static ENGINE: OnceLock> = 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, 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::() > 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), ""); } }