321 lines
10 KiB
Rust
321 lines
10 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,
|
|
}
|
|
|
|
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 {
|
|
let key = TextKey {
|
|
value: value.to_owned(),
|
|
font_size: style.font_size.to_bits(),
|
|
line_height: style.line_height.to_bits(),
|
|
family: style.family,
|
|
};
|
|
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(Wrap::None);
|
|
buffer.set_size(None, 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
|
|
}
|
|
|
|
/// Draw at the actual device font size; no scaling of an already rasterized bitmap.
|
|
pub fn draw(value: &str, style: TextStyle, color: Color, mut pixel: impl FnMut(i32, i32, Color)) {
|
|
let mut engine = engine().lock().expect("text engine lock");
|
|
let key = engine.prepare(value, style);
|
|
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 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), "");
|
|
}
|
|
}
|