Compare commits

...
9 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
N0VA 6559956c3c Update cli.rs 2026-09-10 08:55:06 +02:00
N0VA 77efdf03e9 rename 2026-09-10 08:53:44 +02:00
N0VA 314e474341 colours 2026-09-10 08:50:07 +02:00
N0VA 0afdcf4c83 Update cli.rs 2026-09-10 07:37:21 +02:00
N0VA 21cbb5450e help text 2026-09-10 07:33:57 +02:00
N0VA 3abf610fbf metadata 2026-09-10 07:29:39 +02:00
14 changed files with 348 additions and 146 deletions
+2 -3
View File
@@ -2,13 +2,12 @@
name = "wl-tab"
version = "0.4.0"
edition = "2024"
description = "An alt-tab window switcher for sway: live window previews in a single row"
description = "An simple alt-tab window switcher for sway"
license = "MIT"
repository = "https://git.krzak.org/N0VA/wl-tab"
readme = "README.md"
readme = "README.adoc"
keywords = ["wayland", "sway", "wlroots", "alt-tab", "switcher"]
categories = ["command-line-utilities", "os::unix-apis"]
# The floor is cosmic-text, which needs 1.89; let-chains here need 1.88.
rust-version = "1.89"
[dependencies]
+2 -1
View File
@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2026 Milad Alizadeh
Copyright (c) 2026 N0\A
Copyright (c) 2026-2026 Milad Alizadeh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+4
View File
@@ -0,0 +1,4 @@
= wl-tab
A simple alt-tab switcher for sway
Originally forked from https://github.com/mil-ad/wl-pick[wl-pick]
+7 -7
View File
@@ -130,8 +130,8 @@ pub struct App {
}
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
/// wrong — a starved buffer pool or a clock that never ticks both look like
/// "nothing updates" — so the numbers that distinguish those stay available.
/// wrong - a starved buffer pool or a clock that never ticks both look like
/// "nothing updates" - so the numbers that distinguish those stay available.
#[derive(Default)]
pub struct Stats {
/// Frame callbacks received, i.e. how often the live clock fired.
@@ -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",
}
}
}
@@ -260,7 +260,7 @@ impl App {
eprintln!(" [{i}] {}{mark}", t.target.tsv());
}
eprintln!(
"wl-pick: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
"wl-tab: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
at scale {}, {} MB of capture buffers, labels in {:?}",
self.tiles.len(),
self.tiles.iter().filter(|t| t.ready).count(),
@@ -274,7 +274,7 @@ impl App {
);
if self.layout.scrollable() {
eprintln!(
"wl-pick: {} of {} rows fit; the rest scroll",
"wl-tab: {} of {} rows fit; the rest scroll",
self.layout.visible_rows, self.layout.rows
);
}
@@ -285,7 +285,7 @@ impl App {
let frames: u32 = self.tiles.iter().map(|t| t.frames).sum();
let secs = open_for.as_secs_f64();
eprintln!(
"wl-pick: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \
"wl-tab: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \
{} starved; per tile: {}",
self.ending.as_str(),
frames as f64 / secs,
@@ -314,7 +314,7 @@ impl App {
.map(|t| t.target.title.as_str())
.collect();
eprintln!(
"wl-pick: no frame for {} of {} tiles ({}); \
"wl-tab: no frame for {} of {} tiles ({}); \
another capture client may hold these sources",
stuck.len(),
self.tiles.len(),
+6 -6
View File
@@ -34,8 +34,8 @@ use crate::app::App;
use crate::shm;
use crate::target::Kind;
/// One capture buffer. `busy` means the compositor still holds it — either it is
/// on screen or a capture is writing into it — so we must not scribble over it.
/// One capture buffer. `busy` means the compositor still holds it - either it is
/// on screen or a capture is writing into it - so we must not scribble over it.
pub struct Slot {
pub(crate) buffer: WlBuffer,
pub(crate) busy: bool,
@@ -201,7 +201,7 @@ impl App {
self.stats.pool_bytes = total;
// Note: no mmap. The compositor writes these pages and samples them
// again for display; mapping them here would only cost us the faults.
let file = shm::memfd("wl-pick-capture", total)?;
let file = shm::memfd("wl-tab-capture", total)?;
let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ());
for (i, slot_offsets) in offsets.iter().enumerate() {
let (w, h, format) = {
@@ -299,7 +299,7 @@ impl App {
continue;
}
// Nor is there any point refreshing a tile that is scrolled out of
// sight — that is a readback for pixels nobody sees.
// sight - that is a readback for pixels nobody sees.
if self.layout.tile(i as i32, self.scroll).is_none() {
continue;
}
@@ -372,7 +372,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
// frame yet leaves the tile without a thumbnail.
if tile.frames == 0 {
eprintln!(
"wl-pick: capture failed for {:?} ({reason:?})",
"wl-tab: capture failed for {:?} ({reason:?})",
tile.target.title
);
}
@@ -394,7 +394,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
/// Release is the whole contract: with wl_shm the compositor copies the pixels
/// out at commit and hands the buffer straight back, so the slot currently on
/// screen is usually free too. (Waiting for it to stop being the displayed slot
/// instead would deadlock — that release never comes twice.)
/// instead would deadlock - that release never comes twice.)
impl Dispatch<WlBuffer, (usize, usize)> for App {
fn event(
app: &mut Self,
+27 -40
View File
@@ -10,32 +10,24 @@ use crate::target::Format;
use crate::theme::Theme;
const HELP: &str = "\
wl-pick — a live grid of window and display previews, for picking one
wl-tab - a simple alt-tab switcher for sway
usage: wl-pick [options]
usage: wl-tab [options]
--config PATH config file [~/.config/wl-pick/config]
--config PATH config file [~/.config/wl-tab/config]
--format tsv|json|portal how to report the pick [tsv]
--live all|current|none which tiles keep updating live [all]
(displays are always a single snapshot)
--fps N cap on live updates per tile per second [12]
--outputs, --no-outputs include whole displays as tiles [no]
--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 in sway directly [no]
--font FAMILY label font family [the system monospace font]
--focus, --no-focus focus the picked target directly [no]
--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]
-v, --verbose phase timings, tile list and capture stats
-h, --help this
keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump;
Enter picks; Escape or q cancels; in Alt+Tab mode, releasing
the modifier (Alt/Super) picks the selection.
mouse: click a tile to pick it, scroll to move. Hovering does not move the
selection, and a click outside a tile does nothing.
The pick goes to stdout and nothing does if you cancel, so exit status is
0 for a pick and 1 for a cancel. Acting on it is the caller's job.
@@ -46,20 +38,17 @@ config:
percentage of the display the grid appears on, so one file suits monitors
of different sizes.
background = #282828 # the grid's backdrop
foreground = #ebdbb2 # label text
selection = #d79921 # the highlighted tile
selection-text = #282828 # its label
border = #d79921
background = #222222 # the grid's backdrop
foreground = #888888 # label text
selection = #285577 # the highlighted tile
selection-text = #ffffff # its label
border = #4c7899
border-width = 2px
max-width = 90ppt # the box the grid may fill
max-height = 90ppt
max-columns = 4 # thumbnails are the box divided by these,
max-rows = 4 # so their size never depends on how many
# windows are open; further rows scroll
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
@@ -90,18 +79,7 @@ formats:
[screencast]
chooser_type=simple
chooser_cmd=wl-pick --format portal
focusing on sway:
wl-pick --focus or:
IFS=$'\\t' read -r type id toplevel app title < <(wl-pick) &&
case $type in
window) swaymsg \"[con_id=$id] focus\" ;;
output) swaymsg \"focus output $id\" ;;
esac
";
chooser_cmd=wl-tab --format portal";
/// What the command line asked for. Every setting is optional so the config file
/// can fill the gaps: a flag beats the file, the file beats the default.
@@ -140,7 +118,7 @@ pub fn arm_timeout(timeout: Option<Duration>) {
if let Some(d) = timeout {
std::thread::spawn(move || {
std::thread::sleep(d);
eprintln!("wl-pick: timeout");
eprintln!("wl-tab: timeout");
std::process::exit(2);
});
}
@@ -241,7 +219,7 @@ fn parse(it: impl Iterator<Item = String>) -> Result<Args, String> {
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}"))?);
@@ -316,10 +294,19 @@ mod tests {
// With no flag the file decides, and with no file either, the default (outputs: false).
assert!(!args(&[]).resolve(&file(false), &display()).outputs);
assert!(!args(&[]).resolve(&Config::default(), &display()).outputs);
assert!(args(&["--outputs"]).resolve(&Config::default(), &display()).outputs);
assert_eq!(args(&[]).resolve(&Config::default(), &display()).order, Order::Mru);
assert!(
args(&["--outputs"])
.resolve(&Config::default(), &display())
.outputs
);
assert_eq!(
args(&["--order", "tree"]).resolve(&Config::default(), &display()).order,
args(&[]).resolve(&Config::default(), &display()).order,
Order::Mru
);
assert_eq!(
args(&["--order", "tree"])
.resolve(&Config::default(), &display())
.order,
Order::Tree
);
}
+6 -6
View File
@@ -1,13 +1,13 @@
//! The config file: `~/.config/wl-pick/config`.
//! The config file: `~/.config/wl-tab/config`.
//!
//! Flat `key = value` lines with `#` comments — no sections, no nesting, so a
//! Flat `key = value` lines with `#` comments - no sections, no nesting, so a
//! TOML parser would be a dependency bought for nothing. Every setting is
//! optional; anything absent keeps its default, and a command-line flag beats
//! the file.
//!
//! Sizes take sway's syntax: `600px` is absolute, `70ppt` is 70 percent of the
//! display the grid appears on. That matters on a multi-monitor setup, where a
//! pixel size that suits one screen is wrong on the next — percentages are
//! pixel size that suits one screen is wrong on the next - percentages are
//! resolved against whichever display the overlay actually maps on, each time
//! it runs.
@@ -186,13 +186,13 @@ fn number<T: std::str::FromStr>(s: &str) -> Result<T, String> {
.map_err(|_| format!("{s:?} is not a number"))
}
/// `$XDG_CONFIG_HOME/wl-pick/config`, or `~/.config/wl-pick/config`.
/// `$XDG_CONFIG_HOME/wl-tab/config`, or `~/.config/wl-tab/config`.
fn default_path() -> PathBuf {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_default();
dir.join("wl-pick").join("config")
dir.join("wl-tab").join("config")
}
#[cfg(test)]
@@ -288,7 +288,7 @@ order = mru
#[test]
fn a_missing_file_is_not_an_error() {
let missing = Path::new("/nonexistent/wl-pick/config");
let missing = Path::new("/nonexistent/wl-tab/config");
assert!(
Config::load(Some(missing)).is_err(),
"named file must exist"
+12 -12
View File
@@ -1,4 +1,4 @@
//! wl-pick shows a live grid of every window and display as a layer-shell
//! wl-tab shows a live grid of every window and display as a layer-shell
//! overlay and reports which one you picked. That is all it does: acting on the
//! choice belongs to whatever called it.
//!
@@ -11,13 +11,13 @@
//! is no thumbnail encoding, no scaler, and no full-resolution image in our
//! address space.
//!
//! - `cli` — flags and help
//! - `sway` — the window list, over sway's IPC socket
//! - `target` — what a tile stands for, and how a pick is reported
//! - `app` — the Wayland client state everything dispatches into
//! - `capture` — capture sessions and their buffers
//! - `overlay` — the layer surface, the drawing, the keyboard
//! - `theme`, `text`, `shm` — look, labels, and shared memory
//! - `cli` - flags and help
//! - `sway` - the window list, over sway's IPC socket
//! - `target` - what a tile stands for, and how a pick is reported
//! - `app` - the Wayland client state everything dispatches into
//! - `capture` - capture sessions and their buffers
//! - `overlay` - the layer surface, the drawing, the keyboard
//! - `theme`, `text`, `shm` - look, labels, and shared memory
// `slice::as_chunks` and friends, which clippy suggests in place of
// `chunks_exact`, are newer than the toolchain this crate says it supports.
@@ -61,7 +61,7 @@ fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("wl-pick: {e}");
eprintln!("wl-tab: {e}");
ExitCode::FAILURE
}
}
@@ -169,7 +169,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
phases.mark("mapped");
// The keyboard grab is what makes the overlay usable, so losing it for
// good ends the run: that is how a second wl-pick, started from the same
// good ends the run: that is how a second wl-tab, started from the same
// keybinding, replaces the first instead of leaving it stranded on screen.
// A leave only counts once it has failed to come back, because sway also
// cycles focus off and on in a single batch as the pointer crosses us.
@@ -259,7 +259,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
// Only the portal format can fail to name something: it identifies a
// window by its foreign-toplevel identifier, and this one has none.
None => {
eprintln!("wl-pick: {:?} has no toplevel identifier", target.title);
eprintln!("wl-tab: {:?} has no toplevel identifier", target.title);
return Ok(ExitCode::FAILURE);
}
}
@@ -272,7 +272,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
/// Every wait before the overlay is interactive is bounded, because a
/// compositor is entitled to simply never answer. sway does exactly that for a
/// capture request on a toplevel another client is already capturing: no frame,
/// no `failed`, no `stopped`, just silence — and an unbounded wait on that is a
/// no `failed`, no `stopped`, just silence - and an unbounded wait on that is a
/// picker with no window that has to be killed from another terminal.
fn pump_for(
conn: &Connection,
+12 -12
View File
@@ -3,7 +3,7 @@
//!
//! Scaling is the compositor's job. A tile attaches its capture buffer directly
//! and wp_viewporter names the rectangle to fit it into, so nothing here touches
//! a pixel of window content — only the background, selection and labels.
//! a pixel of window content - only the background, selection and labels.
use std::error::Error;
use std::os::fd::AsFd;
@@ -103,7 +103,7 @@ impl App {
&surface,
output,
Layer::Overlay,
"wl-pick".to_string(),
"wl-tab".to_string(),
qh,
(),
);
@@ -114,7 +114,7 @@ impl App {
let (pw, ph) = (lw * self.scale, lh * self.scale);
let len = shm::Chrome::slot_len(pw, ph) * shm::Chrome::SLOTS;
let file = shm::memfd("wl-pick-chrome", len)?;
let file = shm::memfd("wl-tab-chrome", len)?;
let pool = self.shm.create_pool(file.as_fd(), len as i32, qh, ());
for slot in 0..shm::Chrome::SLOTS {
self.chrome_buffers.push(pool.create_buffer(
@@ -139,7 +139,7 @@ impl App {
/// Put every visible tile where the viewport says, and unmap the rest.
///
/// Runs again after each scroll, so a tile scrolled off screen gets a null
/// buffer — the way to hide a subsurface — rather than being left behind.
/// buffer - the way to hide a subsurface - rather than being left behind.
/// Scaling stays the compositor's job: the capture buffer is attached as it
/// is, and wp_viewporter names the rectangle to fit it into.
pub fn sync_tiles(&mut self, qh: &QueueHandle<Self>) {
@@ -164,8 +164,8 @@ impl App {
let surface = self.compositor.create_surface(qh, ());
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
let viewport = self.viewporter.get_viewport(&surface, qh, ());
// Tiles change independently of the chrome — a live frame
// arrives whenever its window does — so they must not wait on a
// Tiles change independently of the chrome - a live frame
// arrives whenever its window does - so they must not wait on a
// parent commit.
subsurface.set_desync();
// The capture protocol reports the transform the compositor
@@ -239,7 +239,7 @@ impl App {
let (cw, ch) = (chrome.w, chrome.h);
let mut p = chrome.painter();
p.fill(bg);
// The selection fills the whole element box, padding included — the same
// The selection fills the whole element box, padding included - the same
// thing rofi's element background does. It can be scrolled out of sight.
if let Some(elem) = elem {
p.rect(elem, sel_bg);
@@ -293,7 +293,7 @@ impl App {
}
/// The tile under the pointer, if it is over one. A tile's own subsurface
/// answers directly; over the parent surface — padding, labels, gaps — the
/// answers directly; over the parent surface - padding, labels, gaps - the
/// layout is asked instead.
fn tile_at_pointer(&self) -> Option<usize> {
let hover = self.hover.as_ref()?;
@@ -308,8 +308,8 @@ impl App {
})
}
/// Press and release on the same tile picks it. Anywhere else — the margin,
/// a gap, an empty cell of the last row — does nothing at all.
/// Press and release on the same tile picks it. Anywhere else - the margin,
/// a gap, an empty cell of the last row - does nothing at all.
fn click(&mut self, pressed: bool) {
if pressed {
self.pressed = self.tile_at_pointer();
@@ -523,7 +523,7 @@ impl Dispatch<WlKeyboard, ()> for App {
wl_keyboard::Event::Enter { keys, .. } => app.keyboard_enter(keys, qh),
wl_keyboard::Event::Leave { .. } => {
app.focused = false;
// Clear any held repeat — we no longer have the keyboard.
// Clear any held repeat - we no longer have the keyboard.
app.repeat_key = None;
app.repeat_next = None;
}
@@ -532,7 +532,7 @@ impl Dispatch<WlKeyboard, ()> for App {
}
}
/// Hovering does not move the selection — that belongs to the keyboard — so the
/// Hovering does not move the selection - that belongs to the keyboard - so the
/// pointer only tracks where it is and what it clicked. Scrolling is a
/// deliberate gesture, so that does move the selection.
impl Dispatch<WlPointer, ()> for App {
+1 -1
View File
@@ -3,7 +3,7 @@
//!
//! Capture buffers deliberately never get mapped into this process. The
//! compositor writes the window pixels and then samples them again for display,
//! so we only need the fd — mapping them would fault ~7 MB per window into our
//! so we only need the fd - mapping them would fault ~7 MB per window into our
//! address space for nothing.
use std::fs::File;
+5 -9
View File
@@ -2,7 +2,7 @@
//! Wayland side only supplies pixels. The join between the two is
//! `foreign_toplevel_identifier`, which sway reports per view.
//!
//! Acting on the choice is deliberately not here: wl-pick reports what was picked
//! Acting on the choice is deliberately not here: wl-tab reports what was picked
//! and the caller decides what that means.
use std::os::unix::net::UnixStream;
@@ -33,7 +33,7 @@ pub fn connect() -> Result<Connection, String> {
let live = live_sockets();
let [path] = live.as_slice() else {
return Err(if live.is_empty() {
"cannot reach sway; wl-pick reads the window list from its IPC \
"cannot reach sway; wl-tab reads the window list from its IPC \
socket, and no running sway has one"
.to_string()
} else {
@@ -123,7 +123,7 @@ fn history_path() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("wl-pick-history")
.join("wl-tab-history")
}
pub fn record_focus(con_id: i64) {
@@ -186,11 +186,7 @@ pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayi
return (1, idx, 0);
}
}
if t.visible {
(2, 0, 0)
} else {
(3, 0, 0)
}
if t.visible { (2, 0, 0) } else { (3, 0, 0) }
});
}
Ok(out)
@@ -228,7 +224,7 @@ fn collect_tree(node: &Node, out: &mut Vec<Target>) {
/// One active display: what the overlay needs to size itself against.
///
/// The overlay maps on the focused display, so percentages and the buffer scale
/// are resolved against *that* one — on a mixed-DPI, mixed-size setup the
/// are resolved against *that* one - on a mixed-DPI, mixed-size setup the
/// numbers differ per monitor, and taking the largest of everything would be
/// wrong on all but one.
#[derive(Clone, Debug)]
+5 -5
View File
@@ -1,7 +1,7 @@
//! What a tile stands for: a window, or a whole display.
//!
//! Both are capture sources as far as the protocol is concerned — one from a
//! foreign-toplevel handle, one from a `wl_output` — so the grid treats them
//! Both are capture sources as far as the protocol is concerned - one from a
//! foreign-toplevel handle, one from a `wl_output` - so the grid treats them
//! alike and only differs in how it labels them and what picking one does.
use std::fmt;
@@ -110,8 +110,8 @@ impl Target {
/// `IFS=$'\t' read -r type id toplevel app title`.
///
/// Both identifiers are there because both get used: sway scripting acts on
/// the con_id (`[con_id=N] focus`), while tools that capture a window —
/// grim -T, the desktop portal — want the foreign-toplevel identifier.
/// the con_id (`[con_id=N] focus`), while tools that capture a window -
/// grim -T, the desktop portal - want the foreign-toplevel identifier.
pub fn tsv(&self) -> String {
format!(
"{}\t{}\t{}\t{}\t{}",
@@ -162,7 +162,7 @@ impl Target {
/// What xdg-desktop-portal-wlr's `simple` chooser accepts: `Monitor: NAME`
/// or `Window: <foreign-toplevel identifier>`. A window the compositor never
/// gave an identifier for cannot be named this way, hence the Option — and
/// gave an identifier for cannot be named this way, hence the Option - and
/// an empty stdout is exactly how that chooser says "declined".
pub fn portal(&self) -> Option<String> {
match self.kind {
+245 -31
View File
@@ -1,18 +1,19 @@
//! 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.
//!
//! Sizes here are physical pixels — the caller scales logical units first,
//! 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,
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight,
Wrap, fontdb,
};
use crate::shm::Painter;
@@ -54,7 +55,7 @@ const MONO_CANDIDATES: &[&str] = &[
/// Load the smallest font database that can render `family`.
///
/// `FontSystem::new()` scans every system font, which costs ~37ms — most of the
/// `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
@@ -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<String> {
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();
/// ("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.
#[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-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<String> {
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 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,11 +289,11 @@ fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f
fs,
cache,
lines,
family,
family: choice.name(),
}
}
/// Shorten `text` until it fits in `box_w`, ending with an ellipsis — window
/// 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,
@@ -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<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]
+13 -12
View File
@@ -1,13 +1,14 @@
//! Look and layout.
//!
//! The colours and spacing come from the rofi setup this replaces (mytheme.rasi
//! plus the -theme-str rofigrid built): gruvbox dark, a yellow selection filling
//! the element padding, `title · app` centred under each thumbnail.
//! The colours and spacing come from sway's default client colours:
//! unfocused background `#222222` and text `#888888` for the grid
//! backdrop and labels, focused `#285577`/`#ffffff` for the selection,
//! and focused border `#4c7899`.
//!
//! Sizing works from caps rather than from a thumbnail size. The config gives a
//! box the grid may fill and a column and row limit; a thumbnail is that box
//! divided by those limits. So a thumbnail is the same size whether one window
//! is open or thirty — the overlay hugs whatever is there, and rows past the
//! is open or thirty - the overlay hugs whatever is there, and rows past the
//! limit scroll.
/// 0xAARRGGBB, premultiplied (everything here is opaque).
@@ -24,7 +25,7 @@ pub struct Theme {
pub border_px: i32,
/// The box the grid may not exceed, in logical px. Thumbnails are sized to
/// divide it by the column and row caps below, so a thumbnail is the same
/// size whether one window is open or thirty — only the window around them
/// size whether one window is open or thirty - only the window around them
/// shrinks to hug what is there.
pub max_w: i32,
pub max_h: i32,
@@ -53,11 +54,11 @@ pub struct Theme {
impl Default for Theme {
fn default() -> Self {
Self {
bg: 0xff282828, // gruvbox-dark-bg0
fg: 0xffebdbb2, // gruvbox-dark-fg1
sel_bg: 0xffd79921, // gruvbox-dark-yellow-dark
sel_fg: 0xff282828,
border: 0xffd79921,
bg: 0xff222222, // sway unfocused background
fg: 0xff888888, // sway unfocused text
sel_bg: 0xff285577, // sway focused background
sel_fg: 0xffffffff, // sway focused text
border: 0xff4c7899, // sway focused border
border_px: 2,
// No cap of their own: Layout clamps to the display, and the
// command line resolves the configured percentage over the top.
@@ -108,8 +109,8 @@ impl Layout {
/// it does not change with how many windows are open: one window gets a
/// normal thumbnail in a small overlay, thirty get the same thumbnail and
/// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of
/// windows makes a tidy grid rather than one long row — the rule rofigrid
/// used — and the overlay hugs whatever is there.
/// windows makes a tidy grid rather than one long row - the rule rofigrid
/// used - and the overlay hugs whatever is there.
/// Lay out `n` tiles in a single row for a display of the given logical size.
///
/// The overlay and the window previews resize automatically to fit all `n`