use std::thread::{self, JoinHandle};
use cosmic_text::{
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight,
Wrap, fontdb,
};
use crate::shm::Painter;
use crate::theme::{Argb, Rect};
pub struct Labels {
fs: FontSystem,
cache: SwashCache,
lines: Vec<Buffer>,
family: String,
}
pub fn spawn(
texts: Vec<String>,
family: String,
font_px: f32,
line_h: f32,
box_w: f32,
) -> JoinHandle<Labels> {
thread::spawn(move || build(texts, family, font_px, line_h, box_w))
}
pub const SYSTEM_MONO: &str = "monospace";
const MONO_CANDIDATES: &[&str] = &[
"Noto Sans Mono",
"DejaVu Sans Mono",
"Liberation Mono",
"Adwaita Mono",
"Source Code Pro",
"Hack",
"Fira Mono",
"Courier New",
];
fn font_db(family: &str) -> (FontSystem, bool) {
let mut db = fontdb::Database::new();
let mut full = is_generic(family);
if !full {
if let Ok(home) = std::env::var("HOME") {
db.load_fonts_dir(format!("{home}/.fonts"));
db.load_fonts_dir(format!("{home}/.local/share/fonts"));
}
full = interpret(&db, family).is_none();
}
if full {
db.load_system_fonts();
}
let fs = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
(fs, full)
}
fn is_generic(family: &str) -> bool {
family.eq_ignore_ascii_case(SYSTEM_MONO)
}
fn family_named(db: &fontdb::Database, want: &str) -> Option<String> {
db.faces()
.flat_map(|face| face.families.iter())
.find(|(name, _)| name.eq_ignore_ascii_case(want))
.map(|(name, _)| name.clone())
}
#[derive(Debug, PartialEq)]
struct Choice {
family: String,
weight: Weight,
stretch: Stretch,
}
impl Choice {
fn plain(family: &str) -> Self {
Self {
family: family.to_string(),
weight: Weight::NORMAL,
stretch: Stretch::Normal,
}
}
fn attrs(&self) -> Attrs<'_> {
Attrs::new()
.family(Family::Name(&self.family))
.weight(self.weight)
.stretch(self.stretch)
}
fn name(&self) -> String {
let mut parts = vec![self.family.as_str()];
if self.weight != Weight::NORMAL {
parts.extend(spelling(WEIGHTS, self.weight));
}
if self.stretch != Stretch::Normal {
parts.extend(spelling(WIDTHS, self.stretch));
}
parts.join(" ")
}
}
fn interpret(db: &fontdb::Database, request: &str) -> Option<Choice> {
split_request(request, |name| family_named(db, name))
}
fn split_request(request: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<Choice> {
let words: Vec<&str> = request.split_whitespace().collect();
for split in (1..=words.len()).rev() {
let Some(family) = lookup(&words[..split].join(" ")) else {
continue;
};
let mut choice = Choice::plain(&family);
if read_style(&words[split..], &mut choice) {
return Some(choice);
}
}
None
}
fn read_style(words: &[&str], choice: &mut Choice) -> bool {
let mut i = 0;
while i < words.len() {
let pair = words.get(i..i + 2).map(|two| two.concat());
if pair
.as_deref()
.is_some_and(|pair| apply_style(pair, choice))
{
i += 2;
} else if apply_style(words[i], choice) {
i += 1;
} else {
return false;
}
}
true
}
fn apply_style(word: &str, choice: &mut Choice) -> bool {
if let Some(weight) = lookup(WEIGHTS, word) {
choice.weight = weight;
} else if let Some(stretch) = lookup(WIDTHS, word) {
choice.stretch = stretch;
} else {
return false;
}
true
}
fn normalise(word: &str) -> String {
word.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
const WEIGHTS: &[(&str, Weight)] = &[
("Thin", Weight::THIN),
("Hairline", Weight::THIN),
("ExtraLight", Weight::EXTRA_LIGHT),
("UltraLight", Weight::EXTRA_LIGHT),
("Light", Weight::LIGHT),
("Regular", Weight::NORMAL),
("Normal", Weight::NORMAL),
("Book", Weight::NORMAL),
("Medium", Weight::MEDIUM),
("SemiBold", Weight::SEMIBOLD),
("DemiBold", Weight::SEMIBOLD),
("Bold", Weight::BOLD),
("ExtraBold", Weight::EXTRA_BOLD),
("UltraBold", Weight::EXTRA_BOLD),
("Black", Weight::BLACK),
("Heavy", Weight::BLACK),
];
const WIDTHS: &[(&str, Stretch)] = &[
("UltraCondensed", Stretch::UltraCondensed),
("ExtraCondensed", Stretch::ExtraCondensed),
("Condensed", Stretch::Condensed),
("SemiCondensed", Stretch::SemiCondensed),
("Normal", Stretch::Normal),
("SemiExpanded", Stretch::SemiExpanded),
("Expanded", Stretch::Expanded),
("ExtraExpanded", Stretch::ExtraExpanded),
("UltraExpanded", Stretch::UltraExpanded),
];
fn lookup<T: Copy>(table: &[(&str, T)], word: &str) -> Option<T> {
let word = normalise(word);
table
.iter()
.find(|(spelling, _)| normalise(spelling) == word)
.map(|(_, value)| *value)
}
fn spelling<T: PartialEq>(table: &[(&'static str, T)], value: T) -> Option<&'static str> {
table
.iter()
.find(|(_, known)| *known == value)
.map(|(word, _)| *word)
}
fn choose(db: &fontdb::Database, request: &str) -> Choice {
if !is_generic(request) {
if let Some(choice) = interpret(db, request) {
return choice;
}
let fallback = system_mono(db);
eprintln!(
"wl-pick: no font matching {request:?}, using {:?}; \
`fc-match -f '%{{family}}\\n' {request:?}` names the family",
fallback.family
);
return fallback;
}
system_mono(db)
}
fn system_mono(db: &fontdb::Database) -> Choice {
fc_match_mono()
.filter(|name| family_named(db, name).is_some())
.or_else(|| {
MONO_CANDIDATES
.iter()
.find(|name| family_named(db, name).is_some())
.map(|name| name.to_string())
})
.map_or_else(|| Choice::plain(SYSTEM_MONO), |name| Choice::plain(&name))
}
fn fc_match_mono() -> Option<String> {
let out = std::process::Command::new("fc-match")
.args(["-f", "%{family}", SYSTEM_MONO])
.output()
.ok()?;
let text = String::from_utf8(out.stdout).ok()?;
let first = text.split(',').next()?.trim().to_string();
(!first.is_empty()).then_some(first)
}
fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels {
let metrics = Metrics::new(font_px, line_h);
let (mut fs, full) = font_db(&family);
let choice = choose(fs.db(), &family);
let attrs = choice.attrs();
let mut cache = SwashCache::new();
let mut lines: Vec<Buffer> = texts
.iter()
.map(|text| shape(&mut fs, &mut cache, &attrs, metrics, text, box_w))
.collect();
if !full && lines.iter_mut().any(has_missing_glyph) {
fs.db_mut().load_system_fonts();
for (line, text) in lines.iter_mut().zip(&texts) {
if has_missing_glyph(line) {
*line = shape(&mut fs, &mut cache, &attrs, metrics, text, box_w);
}
}
}
Labels {
fs,
cache,
lines,
family: choice.name(),
}
}
fn shape(
fs: &mut FontSystem,
cache: &mut SwashCache,
attrs: &Attrs,
metrics: Metrics,
text: &str,
box_w: f32,
) -> Buffer {
let fitted = ellipsize(fs, attrs, metrics, text, box_w);
let mut buf = Buffer::new(fs, metrics);
buf.set_wrap(Wrap::None);
buf.set_size(Some(box_w), Some(metrics.line_height));
buf.set_text(&fitted, attrs, Shaping::Advanced, Some(Align::Center));
buf.draw(fs, cache, Color::rgb(0, 0, 0), |_, _, _, _, _| {});
buf
}
fn has_missing_glyph(line: &mut Buffer) -> bool {
line.layout_runs()
.any(|run| run.glyphs.iter().any(|glyph| glyph.glyph_id == 0))
}
fn ellipsize(
fs: &mut FontSystem,
attrs: &Attrs,
metrics: Metrics,
text: &str,
box_w: f32,
) -> String {
let measure = |fs: &mut FontSystem, s: &str| {
let mut b = Buffer::new(fs, metrics);
b.set_wrap(Wrap::None);
b.set_size(None, Some(metrics.line_height));
b.set_text(s, attrs, Shaping::Advanced, None);
b.shape_until_scroll(fs, false);
b.layout_runs().map(|r| r.line_w).fold(0.0, f32::max)
};
let full = measure(fs, text);
if full <= box_w {
return text.to_string();
}
let chars: Vec<char> = text.chars().collect();
let mut keep = ((chars.len() as f32) * box_w / full).floor() as usize;
for _ in 0..12 {
keep = keep.min(chars.len().saturating_sub(1));
let mut s: String = chars[..keep].iter().collect();
s.push('…');
if keep == 0 || measure(fs, &s) <= box_w {
return s;
}
keep = (keep * 9 / 10).min(keep.saturating_sub(1));
}
let mut s: String = chars[..keep.min(chars.len())].iter().collect();
s.push('…');
s
}
impl Labels {
pub fn family(&self) -> &str {
&self.family
}
pub fn draw(&mut self, p: &mut Painter, i: usize, at: Rect, color: Argb) {
let Some(buf) = self.lines.get_mut(i) else {
return;
};
let rgb = Color::rgb((color >> 16) as u8, (color >> 8) as u8, color as u8);
buf.draw(&mut self.fs, &mut self.cache, rgb, |x, y, w, h, c| {
p.blend(
Rect {
x: at.x + x,
y: at.y + y,
w: w as i32,
h: h as i32,
},
(c.r(), c.g(), c.b(), c.a()),
at,
);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn draws_visible_glyphs() {
let (w, h) = (400, 34);
let mut labels = build(
vec!["Hello".to_string()],
"monospace".to_string(),
26.0,
h as f32,
w as f32,
);
let mut px = vec![0u8; (w * h * 4) as usize];
let mut p = Painter::new(&mut px, w, h);
let at = Rect { x: 0, y: 0, w, h };
labels.draw(&mut p, 0, at, 0x00ffffff);
let touched = px
.chunks_exact(4)
.filter(|c| c[0] != 0 || c[1] != 0 || c[2] != 0)
.count();
assert!(touched > 20, "only {touched} pixels were painted");
}
#[test]
fn the_generic_default_resolves_to_a_real_monospace_family() {
let (fs, _) = font_db(SYSTEM_MONO);
let choice = choose(fs.db(), SYSTEM_MONO);
assert_ne!(
choice.family, SYSTEM_MONO,
"should have named a real family"
);
assert!(
family_named(fs.db(), &choice.family).is_some(),
"{:?} is not in the database",
choice.family
);
}
#[test]
fn a_family_that_is_not_installed_falls_back_to_the_default() {
let (fs, _) = font_db(SYSTEM_MONO);
let asked = choose(fs.db(), "No Such Family At All");
assert_eq!(asked, choose(fs.db(), SYSTEM_MONO), "should be the default");
assert_ne!(asked.family, "No Such Family At All");
}
fn db(families: &[&'static str]) -> impl Fn(&str) -> Option<String> {
let families: Vec<&str> = families.to_vec();
move |want| {
families
.iter()
.find(|name| name.eq_ignore_ascii_case(want))
.map(|name| name.to_string())
}
}
#[test]
fn a_full_font_name_splits_into_family_and_style() {
let choice = split_request("Berkeley Mono Medium SemiCondensed", db(&["Berkeley Mono"]))
.expect("should resolve");
assert_eq!(choice.family, "Berkeley Mono");
assert_eq!(choice.weight, Weight::MEDIUM);
assert_eq!(choice.stretch, Stretch::SemiCondensed);
assert_eq!(choice.name(), "Berkeley Mono Medium SemiCondensed");
}
#[test]
fn a_plain_family_keeps_its_defaults() {
let choice = split_request("Berkeley Mono", db(&["Berkeley Mono"])).expect("resolves");
assert_eq!(choice, Choice::plain("Berkeley Mono"));
assert_eq!(choice.name(), "Berkeley Mono");
}
#[test]
fn a_family_may_end_in_a_style_word() {
let choice = split_request("Fira Code Light", db(&["Fira Code", "Fira Code Light"]))
.expect("resolves");
assert_eq!(choice.family, "Fira Code Light");
assert_eq!(choice.weight, Weight::NORMAL);
}
#[test]
fn style_words_are_spelled_many_ways() {
let cases = [
("Iosevka demibold", Weight::SEMIBOLD, Stretch::Normal),
("Iosevka Extra Light", Weight::EXTRA_LIGHT, Stretch::Normal),
(
"Iosevka ULTRACONDENSED",
Weight::NORMAL,
Stretch::UltraCondensed,
),
("Iosevka Bold Condensed", Weight::BOLD, Stretch::Condensed),
];
for (request, weight, stretch) in cases {
let choice = split_request(request, db(&["Iosevka"]))
.unwrap_or_else(|| panic!("{request:?} should resolve"));
assert_eq!(choice.family, "Iosevka", "{request:?}");
assert_eq!(choice.weight, weight, "{request:?}");
assert_eq!(choice.stretch, stretch, "{request:?}");
}
}
#[test]
fn a_name_that_means_nothing_resolves_to_nothing() {
let known = db(&["Berkeley Mono"]);
assert_eq!(split_request("Berkeley Monospace Bold", &known), None);
assert_eq!(split_request("Berkeley Mono Nonsense", &known), None);
assert_eq!(split_request("Comic Sans", &known), None);
assert_eq!(split_request("", &known), None);
}
#[test]
fn case_does_not_matter_but_the_font_keeps_its_own_spelling() {
let choice = split_request("berkeley mono bold", db(&["Berkeley Mono"])).expect("resolves");
assert_eq!(choice.family, "Berkeley Mono", "the database's spelling");
assert_eq!(choice.weight, Weight::BOLD);
}
#[test]
fn a_missing_glyph_is_noticed() {
let mut full = fontdb::Database::new();
full.load_system_fonts();
let Some(face) = full.faces().find(|f| f.monospaced) else {
return; };
let fontdb::Source::File(path) = &face.source else {
return;
};
let mut one = fontdb::Database::new();
one.load_font_file(path)
.expect("the face came from this file");
let mut fs = FontSystem::new_with_locale_and_db("en-US".to_string(), one);
let mut cache = SwashCache::new();
let attrs = Attrs::new();
let metrics = Metrics::new(26.0, 34.0);
let mut boxes = shape(&mut fs, &mut cache, &attrs, metrics, "日本 🦀", 400.0);
assert!(has_missing_glyph(&mut boxes));
let mut plain = shape(&mut fs, &mut cache, &attrs, metrics, "Hello", 400.0);
assert!(!has_missing_glyph(&mut plain));
}
#[test]
fn ellipsizes_long_titles() {
let mut fs = FontSystem::new();
let attrs = Attrs::new();
let metrics = Metrics::new(26.0, 34.0);
let long = "a very long window title that certainly does not fit in one narrow cell";
let out = ellipsize(&mut fs, &attrs, metrics, long, 200.0);
assert!(out.ends_with('…'), "got {out:?}");
assert!(out.chars().count() < long.chars().count());
assert_eq!(ellipsize(&mut fs, &attrs, metrics, "zsh", 200.0), "zsh");
}
}