//! 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 } /// Rasterize only visible glyph pixels. Large camera zooms use a bounded glyph /// bitmap and sample it at the requested size instead of allocating an enormous /// font image. Layout and wrapping still use proportionally identical metrics. pub fn draw_clipped( value: &str, style: TextStyle, color: Color, width: Option, clip: crate::geometry::Rect, mut pixel: impl FnMut(i32, i32, Color), ) { if clip.width <= 0. || clip.height <= 0. || !style.font_size.is_finite() || style.font_size <= 0. { return; } let lod = (512. / style.font_size).min(1.); let mut engine = engine().lock().expect("text engine lock"); let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod)); let TextEngine { fonts, swash, buffers, .. } = &mut *engine; let buffer = &buffers[&key].buffer; let mut cache_bytes: usize = swash .image_cache .values() .flatten() .map(|image| image.data.len()) .sum(); for run in buffer.layout_runs() { for glyph in run.glyphs { let physical = glyph.physical((0., run.line_y), 1.); // Conservative font-sized guard includes bearings/overhangs. The // exact bitmap bounds below determine the actual visible pixels. let guard = glyph.font_size * 4. / lod; let gx = physical.x as f32 / lod; let gy = physical.y as f32 / lod; if gx + guard < clip.x || gx - guard > clip.x + clip.width || gy + guard < clip.y || gy - guard > clip.y + clip.height { continue; } if swash.image_cache.len() >= 4096 || cache_bytes >= 33_554_432 { *swash = SwashCache::new(); cache_bytes = 0; } let cached = swash.image_cache.contains_key(&physical.cache_key); let Some(image) = swash.get_image(fonts, physical.cache_key) else { continue; }; if !cached { cache_bytes += image.data.len(); } let left = (physical.x + image.placement.left) as f32 / lod; let top = (physical.y - image.placement.top) as f32 / lod; let right = left + image.placement.width as f32 / lod; let bottom = top + image.placement.height as f32 / lod; let base = glyph.color_opt.map_or(color, |value| { Color::rgba(value.r(), value.g(), value.b(), color.alpha) }); for y in top.max(clip.y).ceil() as i32..bottom.min(clip.y + clip.height).ceil() as i32 { for x in left.max(clip.x).ceil() as i32..right.min(clip.x + clip.width).ceil() as i32 { let u = (((x as f32 + 0.5 - left) * lod) as u32) .min(image.placement.width.saturating_sub(1)); let v = (((y as f32 + 0.5 - top) * lod) as u32) .min(image.placement.height.saturating_sub(1)); let index = (v * image.placement.width + u) as usize; let sample = match image.content { cosmic_text::SwashContent::Mask => Color::rgba( base.red, base.green, base.blue, (u16::from(image.data[index]) * u16::from(color.alpha) / 255) as u8, ), cosmic_text::SwashContent::Color => { let i = index * 4; Color::rgba( image.data[i], image.data[i + 1], image.data[i + 2], (u16::from(image.data[i + 3]) * u16::from(color.alpha) / 255) as u8, ) } cosmic_text::SwashContent::SubpixelMask => continue, }; pixel(x, y, sample); } } } } } /// 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 extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() { let mut count = 0; draw_clipped( "M", TextStyle::ui(1).scaled(10_000.), Color::rgba(1, 2, 3, 255), None, crate::geometry::Rect::new(50_000., 60_000., 32., 32.), |x, y, _| { assert!((50_000..50_032).contains(&x)); assert!((60_000..60_032).contains(&y)); count += 1; }, ); assert_eq!(count, 1024); let engine = engine().lock().unwrap(); assert!( engine .swash .image_cache .keys() .all(|key| f32::from_bits(key.font_size_bits) <= 512.) ); } #[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), ""); } }