From 0ce2854d72652ad62780218f2cb5b53849e9b3fc Mon Sep 17 00:00:00 2001 From: "N0\\A" Date: Thu, 10 Sep 2026 09:36:55 +0200 Subject: [PATCH] Port fixes from wl-pick --- src/app.rs | 2 +- src/cli.rs | 7 +- src/text.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 246 insertions(+), 31 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2afb5a1..2a8abc2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -161,7 +161,7 @@ impl Ending { Ending::Picked => "picked", Ending::Cancelled => "cancelled", Ending::Closed => "the compositor closed the overlay", - Ending::Unfocused => "lost the keyboard to another surface", + Ending::Unfocused => "another surface holds the keyboard", } } } diff --git a/src/cli.rs b/src/cli.rs index 980467e..516f0b5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,7 +20,8 @@ usage: wl-tab [options] --labels, --no-labels a label under each thumbnail [yes] --order mru|tree window ordering: mru or layout tree [mru] --focus, --no-focus focus the picked target directly [no] - --font FAMILY label font family [the system monospace font] + --font NAME label font family, optionally with a style, as + in 'FiraCode Mono' [system monospace] --font-size PX label size in logical px [13.3] --timeout SECS exit anyway after SECS, in case the keyboard grab ever traps you [off] @@ -47,7 +48,7 @@ config: max-width = 90ppt # the box the grid may fill max-height = 90ppt - font = monospace # also --font + font = monospace # a family, optionally with a style font-size = 13.3 labels = yes outputs = no # include whole displays as tiles @@ -218,7 +219,7 @@ fn parse(it: impl Iterator) -> Result { let v = it.next().ok_or("--fps needs a number")?; args.fps = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?); } - "--font" => args.font = Some(it.next().ok_or("--font needs a family name")?), + "--font" => args.font = Some(it.next().ok_or("--font needs a font name")?), "--font-size" => { let v = it.next().ok_or("--font-size needs px")?; args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?); diff --git a/src/text.rs b/src/text.rs index a666581..8cca564 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,8 +1,8 @@ //! 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 +//! Building a font system and rasterising the first glyphs costs ~55ms, about +//! what the compositor spends copying window pixels back for a handful of +//! windows. 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. //! @@ -12,7 +12,8 @@ use std::thread::{self, JoinHandle}; use cosmic_text::{ - Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap, fontdb, + Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight, + Wrap, fontdb, }; use crate::shm::Painter; @@ -66,7 +67,7 @@ fn font_db(family: &str) -> FontSystem { db.load_fonts_dir(format!("{home}/.fonts")); db.load_fonts_dir(format!("{home}/.local/share/fonts")); } - if has_family(&db, family) { + if interpret(&db, family).is_some() { // The locale only orders CJK fallbacks; labels are ids and titles. return FontSystem::new_with_locale_and_db("en-US".to_string(), db); } @@ -79,31 +80,178 @@ fn is_generic(family: &str) -> bool { family.eq_ignore_ascii_case(SYSTEM_MONO) } -fn has_family(db: &fontdb::Database, family: &str) -> bool { +fn family_named(db: &fontdb::Database, want: &str) -> Option { db.faces() - .any(|f| f.families.iter().any(|(name, _)| name == family)) + .flat_map(|face| face.families.iter()) + .find(|(name, _)| name.eq_ignore_ascii_case(want)) + .map(|(name, _)| name.clone()) } -/// Turn the generic default into a real family name. +/// A resolved family and the face style requested within it. /// /// 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(); +/// ask fontconfig instead, since that is what the rest of the desktop uses. +#[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 { + split_request(request, |name| family_named(db, name)) +} + +fn split_request(request: &str, lookup: impl Fn(&str) -> Option) -> Option { + 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(table: &[(&str, T)], word: &str) -> Option { + let word = normalise(word); + table + .iter() + .find(|(spelling, _)| normalise(spelling) == word) + .map(|(_, value)| *value) +} + +fn spelling(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-tab: 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| has_family(db, name)) + .filter(|name| family_named(db, name).is_some()) .or_else(|| { MONO_CANDIDATES .iter() - .find(|name| has_family(db, name)) + .find(|name| family_named(db, name).is_some()) .map(|name| name.to_string()) }) - .unwrap_or_else(|| family.to_string()) + .map_or_else(|| Choice::plain(SYSTEM_MONO), |name| Choice::plain(&name)) } /// What fontconfig says "monospace" means here. A system without the fontconfig @@ -122,8 +270,8 @@ fn fc_match_mono() -> Option { fn build(texts: Vec, 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 choice = choose(fs.db(), &family); + let attrs = choice.attrs(); let metrics = Metrics::new(font_px, line_h); let mut lines = Vec::with_capacity(texts.len()); @@ -141,7 +289,7 @@ fn build(texts: Vec, family: String, font_px: f32, line_h: f32, box_w: f fs, cache, lines, - family, + family: choice.name(), } } @@ -242,14 +390,80 @@ mod tests { #[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" + let choice = choose(fs.db(), SYSTEM_MONO); + assert_ne!( + choice.family, SYSTEM_MONO, + "should have named a real family" ); - // A named family passes through, present or not. - assert_eq!(resolve_family(fs.db(), "Some Font"), "Some Font"); + assert!( + family_named(fs.db(), &choice.family).is_some(), + "{:?} is not in the database", + choice.family + ); + } + + #[test] + fn an_unknown_font_falls_back_to_the_default() { + let fs = font_db(SYSTEM_MONO); + let choice = choose(fs.db(), "No Such Family At All"); + assert_eq!(choice, choose(fs.db(), SYSTEM_MONO)); + } + + fn db(families: &[&'static str]) -> impl Fn(&str) -> Option { + let families = 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"])).unwrap(); + 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_family_that_ends_in_a_style_word_wins() { + let choice = + split_request("Fira Code Light", db(&["Fira Code", "Fira Code Light"])).unwrap(); + assert_eq!(choice.family, "Fira Code Light"); + assert_eq!(choice.weight, Weight::NORMAL); + } + + #[test] + fn style_words_accept_joined_spaced_and_mixed_case_forms() { + 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(); + assert_eq!( + (choice.weight, choice.stretch), + (weight, stretch), + "{request:?}" + ); + } + } + + #[test] + fn unreadable_trailing_words_do_not_match() { + let known = db(&["Berkeley Mono"]); + assert_eq!(split_request("Berkeley Mono Nonsense", &known), None); + assert_eq!(split_request("Comic Sans", &known), None); } #[test]