Compare commits

..
3 Commits
Author SHA1 Message Date
N0VA 0ce2854d72 Port fixes from wl-pick 2026-09-10 09:36:55 +02:00
N0VA e1d8760a43 Update README.adoc 2026-09-10 09:12:31 +02:00
N0VA f78fd7ee41 Update Cargo.toml 2026-09-10 08:55:25 +02:00
5 changed files with 250 additions and 33 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2024"
description = "An simple alt-tab window switcher for sway" description = "An simple alt-tab window switcher for sway"
license = "MIT" license = "MIT"
repository = "https://git.krzak.org/N0VA/wl-tab" repository = "https://git.krzak.org/N0VA/wl-tab"
readme = "README.md" readme = "README.adoc"
keywords = ["wayland", "sway", "wlroots", "alt-tab", "switcher"] keywords = ["wayland", "sway", "wlroots", "alt-tab", "switcher"]
categories = ["command-line-utilities", "os::unix-apis"] categories = ["command-line-utilities", "os::unix-apis"]
rust-version = "1.89" rust-version = "1.89"
+3 -1
View File
@@ -1,2 +1,4 @@
= wl-tab = wl-tab
== A simple alt-tab switcher for sway A simple alt-tab switcher for sway
Originally forked from https://github.com/mil-ad/wl-pick[wl-pick]
+1 -1
View File
@@ -161,7 +161,7 @@ impl Ending {
Ending::Picked => "picked", Ending::Picked => "picked",
Ending::Cancelled => "cancelled", Ending::Cancelled => "cancelled",
Ending::Closed => "the compositor closed the overlay", Ending::Closed => "the compositor closed the overlay",
Ending::Unfocused => "lost the keyboard to another surface", Ending::Unfocused => "another surface holds the keyboard",
} }
} }
} }
+4 -3
View File
@@ -20,7 +20,8 @@ usage: wl-tab [options]
--labels, --no-labels a label under each thumbnail [yes] --labels, --no-labels a label under each thumbnail [yes]
--order mru|tree window ordering: mru or layout tree [mru] --order mru|tree window ordering: mru or layout tree [mru]
--focus, --no-focus focus the picked target directly [no] --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] --font-size PX label size in logical px [13.3]
--timeout SECS exit anyway after SECS, in case the keyboard --timeout SECS exit anyway after SECS, in case the keyboard
grab ever traps you [off] grab ever traps you [off]
@@ -47,7 +48,7 @@ config:
max-width = 90ppt # the box the grid may fill max-width = 90ppt # the box the grid may fill
max-height = 90ppt max-height = 90ppt
font = monospace # also --font font = monospace # a family, optionally with a style
font-size = 13.3 font-size = 13.3
labels = yes labels = yes
outputs = no # include whole displays as tiles outputs = no # include whole displays as tiles
@@ -218,7 +219,7 @@ fn parse(it: impl Iterator<Item = String>) -> Result<Args, String> {
let v = it.next().ok_or("--fps needs a number")?; let v = it.next().ok_or("--fps needs a number")?;
args.fps = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?); 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" => { "--font-size" => {
let v = it.next().ok_or("--font-size needs px")?; let v = it.next().ok_or("--font-size needs px")?;
args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?); args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?);
+241 -27
View File
@@ -1,8 +1,8 @@
//! Labels. //! Labels.
//! //!
//! Building a font system and rasterising the first glyphs costs ~55ms, which is //! Building a font system and rasterising the first glyphs costs ~55ms, about
//! almost exactly the window the compositor spends copying window pixels back //! what the compositor spends copying window pixels back for a handful of
//! for us. So all of it happens on a worker thread started before the captures //! 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 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. //! and its glyphs are already in the cache, and painting one costs ~0.1ms.
//! //!
@@ -12,7 +12,8 @@
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use cosmic_text::{ 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; 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}/.fonts"));
db.load_fonts_dir(format!("{home}/.local/share/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. // The locale only orders CJK fallbacks; labels are ids and titles.
return FontSystem::new_with_locale_and_db("en-US".to_string(), db); 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) 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<String> {
db.faces() 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 /// 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 /// ("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 /// ask fontconfig instead, since that is what the rest of the desktop uses.
/// named family passes through untouched; if it turns out to be missing, #[derive(Debug, PartialEq)]
/// cosmic-text falls back on its own. struct Choice {
fn resolve_family(db: &fontdb::Database, family: &str) -> String { family: String,
if !is_generic(family) { weight: Weight,
return family.to_string(); 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-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() fc_match_mono()
.filter(|name| has_family(db, name)) .filter(|name| family_named(db, name).is_some())
.or_else(|| { .or_else(|| {
MONO_CANDIDATES MONO_CANDIDATES
.iter() .iter()
.find(|name| has_family(db, name)) .find(|name| family_named(db, name).is_some())
.map(|name| name.to_string()) .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 /// What fontconfig says "monospace" means here. A system without the fontconfig
@@ -122,8 +270,8 @@ fn fc_match_mono() -> Option<String> {
fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels { 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 fs = font_db(&family);
let mut cache = SwashCache::new(); let mut cache = SwashCache::new();
let family = resolve_family(fs.db(), &family); let choice = choose(fs.db(), &family);
let attrs = Attrs::new().family(Family::Name(&family)); let attrs = choice.attrs();
let metrics = Metrics::new(font_px, line_h); let metrics = Metrics::new(font_px, line_h);
let mut lines = Vec::with_capacity(texts.len()); let mut lines = Vec::with_capacity(texts.len());
@@ -141,7 +289,7 @@ fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f
fs, fs,
cache, cache,
lines, lines,
family, family: choice.name(),
} }
} }
@@ -242,14 +390,80 @@ mod tests {
#[test] #[test]
fn the_generic_default_resolves_to_a_real_monospace_family() { fn the_generic_default_resolves_to_a_real_monospace_family() {
let fs = font_db(SYSTEM_MONO); let fs = font_db(SYSTEM_MONO);
let resolved = resolve_family(fs.db(), SYSTEM_MONO); let choice = choose(fs.db(), SYSTEM_MONO);
assert_ne!(resolved, SYSTEM_MONO, "should have named a real family"); assert_ne!(
assert!( choice.family, SYSTEM_MONO,
has_family(fs.db(), &resolved), "should have named a real family"
"{resolved:?} is not in the database"
); );
// A named family passes through, present or not. assert!(
assert_eq!(resolve_family(fs.db(), "Some Font"), "Some Font"); 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<String> {
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] #[test]