268 lines
9.2 KiB
Rust
268 lines
9.2 KiB
Rust
//! Labels.
|
|
//!
|
|
//! Building a font system and rasterising the first glyphs costs ~55ms, which is
|
|
//! almost exactly the window the compositor spends copying window pixels back
|
|
//! for us. So all of it happens on a worker thread started before the captures
|
|
//! and joined after them: by the time anything is drawn, every label is shaped
|
|
//! and its glyphs are already in the cache, and painting one costs ~0.1ms.
|
|
//!
|
|
//! Sizes here are physical pixels - the caller scales logical units first,
|
|
//! because the chrome buffer it paints into is physical too.
|
|
|
|
use std::thread::{self, JoinHandle};
|
|
|
|
use cosmic_text::{
|
|
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap, fontdb,
|
|
};
|
|
|
|
use crate::shm::Painter;
|
|
use crate::theme::{Argb, Rect};
|
|
|
|
pub struct Labels {
|
|
fs: FontSystem,
|
|
cache: SwashCache,
|
|
lines: Vec<Buffer>,
|
|
family: String,
|
|
}
|
|
|
|
/// Shape `texts` into one centred single line each, at most `box_w` wide.
|
|
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))
|
|
}
|
|
|
|
/// The default family: whatever this system calls its monospace font.
|
|
pub const SYSTEM_MONO: &str = "monospace";
|
|
|
|
/// Families to try when fontconfig cannot be asked, roughly in order of how
|
|
/// likely a distribution is to ship one as its default monospace.
|
|
const MONO_CANDIDATES: &[&str] = &[
|
|
"Noto Sans Mono",
|
|
"DejaVu Sans Mono",
|
|
"Liberation Mono",
|
|
"Adwaita Mono",
|
|
"Source Code Pro",
|
|
"Hack",
|
|
"Fira Mono",
|
|
"Courier New",
|
|
];
|
|
|
|
/// Load the smallest font database that can render `family`.
|
|
///
|
|
/// `FontSystem::new()` scans every system font, which costs ~37ms - most of the
|
|
/// startup budget. A user's own font directories are tiny by comparison, so try
|
|
/// those first and only pay for the full scan when the family really isn't there
|
|
/// (which is also what makes an unknown family fall back gracefully). The
|
|
/// generic default lives among the system fonts, so it skips that shortcut.
|
|
fn font_db(family: &str) -> FontSystem {
|
|
let mut db = fontdb::Database::new();
|
|
if !is_generic(family) {
|
|
if let Ok(home) = std::env::var("HOME") {
|
|
db.load_fonts_dir(format!("{home}/.fonts"));
|
|
db.load_fonts_dir(format!("{home}/.local/share/fonts"));
|
|
}
|
|
if has_family(&db, family) {
|
|
// The locale only orders CJK fallbacks; labels are ids and titles.
|
|
return FontSystem::new_with_locale_and_db("en-US".to_string(), db);
|
|
}
|
|
}
|
|
db.load_system_fonts();
|
|
FontSystem::new_with_locale_and_db("en-US".to_string(), db)
|
|
}
|
|
|
|
fn is_generic(family: &str) -> bool {
|
|
family.eq_ignore_ascii_case(SYSTEM_MONO)
|
|
}
|
|
|
|
fn has_family(db: &fontdb::Database, family: &str) -> bool {
|
|
db.faces()
|
|
.any(|f| f.families.iter().any(|(name, _)| name == family))
|
|
}
|
|
|
|
/// Turn the generic default into a real family name.
|
|
///
|
|
/// cosmic-text's own generic resolves through fontdb's built-in preference
|
|
/// ("FreeMono"), which is usually absent and then lands on an arbitrary face - so
|
|
/// ask fontconfig instead, since that is what the rest of the desktop uses. A
|
|
/// named family passes through untouched; if it turns out to be missing,
|
|
/// cosmic-text falls back on its own.
|
|
fn resolve_family(db: &fontdb::Database, family: &str) -> String {
|
|
if !is_generic(family) {
|
|
return family.to_string();
|
|
}
|
|
fc_match_mono()
|
|
.filter(|name| has_family(db, name))
|
|
.or_else(|| {
|
|
MONO_CANDIDATES
|
|
.iter()
|
|
.find(|name| has_family(db, name))
|
|
.map(|name| name.to_string())
|
|
})
|
|
.unwrap_or_else(|| family.to_string())
|
|
}
|
|
|
|
/// What fontconfig says "monospace" means here. A system without the fontconfig
|
|
/// tools is not an error: the candidate list covers the common defaults.
|
|
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()?;
|
|
// fc-match can answer with several comma-separated aliases for one face.
|
|
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 mut fs = font_db(&family);
|
|
let mut cache = SwashCache::new();
|
|
let family = resolve_family(fs.db(), &family);
|
|
let attrs = Attrs::new().family(Family::Name(&family));
|
|
let metrics = Metrics::new(font_px, line_h);
|
|
|
|
let mut lines = Vec::with_capacity(texts.len());
|
|
for text in &texts {
|
|
let fitted = ellipsize(&mut fs, &attrs, metrics, text, box_w);
|
|
let mut buf = Buffer::new(&mut fs, metrics);
|
|
buf.set_wrap(Wrap::None);
|
|
buf.set_size(Some(box_w), Some(line_h));
|
|
buf.set_text(&fitted, &attrs, Shaping::Advanced, Some(Align::Center));
|
|
// Warm the glyph cache here instead of on the first paint.
|
|
buf.draw(&mut fs, &mut cache, Color::rgb(0, 0, 0), |_, _, _, _, _| {});
|
|
lines.push(buf);
|
|
}
|
|
Labels {
|
|
fs,
|
|
cache,
|
|
lines,
|
|
family,
|
|
}
|
|
}
|
|
|
|
/// Shorten `text` until it fits in `box_w`, ending with an ellipsis - window
|
|
/// titles are arbitrarily long, and rofi ellipsised them too.
|
|
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();
|
|
// Proportional first guess, then shrink geometrically. Bounded, because a
|
|
// pathological title should not cost hundreds of reshapes.
|
|
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 {
|
|
/// The family the labels were actually shaped with, which for the generic
|
|
/// default depends on what this system has installed.
|
|
pub fn family(&self) -> &str {
|
|
&self.family
|
|
}
|
|
|
|
/// Draw label `i` inside `at` (physical px), clipped to it.
|
|
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::*;
|
|
|
|
/// The whole point of a label is pixels on the surface, so assert some.
|
|
#[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 resolved = resolve_family(fs.db(), SYSTEM_MONO);
|
|
assert_ne!(resolved, SYSTEM_MONO, "should have named a real family");
|
|
assert!(
|
|
has_family(fs.db(), &resolved),
|
|
"{resolved:?} is not in the database"
|
|
);
|
|
// A named family passes through, present or not.
|
|
assert_eq!(resolve_family(fs.db(), "Some Font"), "Some Font");
|
|
}
|
|
|
|
#[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());
|
|
// Short text is left alone.
|
|
assert_eq!(ellipsize(&mut fs, &attrs, metrics, "zsh", 200.0), "zsh");
|
|
}
|
|
}
|