Files

443 lines
15 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
}
/// 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<f32>,
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), "");
}
}