Add a config file, and size the grid to the display

Colours, border and thumbnail size come from ~/.config/wl-pick/config
now, since ten more flags would have made a keybinding unreadable — the
split the rofi setup this replaces already used: look in a file,
behaviour on the command line. Flat `key = value` lines, so no TOML
dependency for something with nothing to nest, and a flag still beats the
file.

Sizes take sway's units. `600px` is absolute; `70ppt` is a percentage of
the display the grid appears on, resolved on every run rather than baked
in, so one config suits monitors of different sizes. That needed the
overlay to know which display it is on, so it now asks sway for the
focused one and maps there explicitly, at that display's scale, instead
of letting the compositor choose and taking the largest scale in use —
which was wrong on any mixed-DPI setup.

tile-width and tile-height are maxima. Given only a width, the height
follows the display's aspect: a 16:9 cell, inherited from a rofi theme
written for a landscape screen, wasted about half of every cell on a
portrait monitor. And if the grid would outgrow the display, tiles now
shrink together, keeping their shape, so thirty windows produce small
tiles rather than a surface larger than the screen. The surface is capped
at the display as a backstop, because on a small screen the padding and
label rows can exceed it no matter how small the tiles get.

Two bugs the tests caught while writing this:

- Stripping comments at the first '#' ate colour values, so
  `selection = #d79921 # note` parsed as empty. A comment is now a '#'
  followed by whitespace or end of line; a colour is '#' then a hex
  digit, so the two cannot collide.
- Twelve tiles at the old fixed size fit a 1280x800 screen, so the first
  version of the shrink test proved nothing. It now uses numbers that
  genuinely overflow.

Also: failing to reach sway said only "No such file or directory", which
tells a first-time user nothing; it now names sway and what it wanted.
This commit is contained in:
Milad Alizadeh
2026-08-31 17:29:06 +01:00
parent c22e3cdd31
commit 487acb8b6a
10 changed files with 654 additions and 126 deletions
+46 -5
View File
@@ -48,6 +48,7 @@ wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
- `--hide-labels` draws an icon-only grid - `--hide-labels` draws an icon-only grid
- `--font FAMILY` label font family (default: the system monospace font) - `--font FAMILY` label font family (default: the system monospace font)
- `--font-size PX` label size in logical px - `--font-size PX` label size in logical px
- `--config PATH` config file (default `~/.config/wl-pick/config`)
- `--timeout SECS` exits after a deadline, in case the keyboard grab ever traps - `--timeout SECS` exits after a deadline, in case the keyboard grab ever traps
you you
- `--verbose` phase timings, the tile list, and capture stats - `--verbose` phase timings, the tile list, and capture stats
@@ -135,13 +136,53 @@ of shm for eight windows and a display here, against 83 MB with `--live none`)
and a readback per refreshed frame. `--live current` refreshes only the selected tile, and a readback per refreshed frame. `--live current` refreshes only the selected tile,
which is much cheaper and still reads as alive. which is much cheaper and still reads as alive.
## Config
`~/.config/wl-pick/config`, or `--config PATH`. Flat `key = value` lines with
`#` comments, everything optional, and a flag always beats the file. No TOML
dependency, because there is nothing to nest.
```ini
background = #282828 # the grid's backdrop
foreground = #ebdbb2 # label text
selection = #d79921 # the highlighted tile
selection-text = #282828 # its label
border = #d79921
border-width = 2px
tile-width = 18ppt # largest a thumbnail may be
tile-height = 20ppt # defaults to the display's aspect
max-columns = 4
font = monospace
font-size = 13.3
labels = yes
outputs = yes
live = all
fps = 12
format = tsv
```
Sizes take sway's units: `600px` is absolute, `70ppt` a percentage — and the
percentage resolves against **the display the grid actually appears on**, every
time it runs. On a mixed setup one file gives 18% of a 1280-wide laptop panel and
18% of a 3840-wide monitor, instead of a pixel count that suits one and looks
wrong on the other. The overlay is mapped explicitly on that display, at that
display's scale, so mixed-DPI renders crisply either way.
`tile-width` and `tile-height` are maxima for the thumbnail cell. Give only the
width and the height follows the display's aspect, which is roughly the shape of
the windows on it — a 16:9 cell wastes about half its area on a portrait monitor.
If the grid would outgrow the display, tiles shrink together and keep their
shape, so thirty windows give small tiles rather than a surface larger than the
screen.
## Look ## Look
Colours, font metrics and grid geometry come from the rofi theme this replaces The defaults come from the rofi theme this replaces: gruvbox dark, a yellow
(gruvbox dark, a yellow selection filling the element padding, `ceil(sqrt(n))` selection filling the element padding, `ceil(sqrt(n))` columns capped at 4,
columns capped at 4, 16:9 tiles, `title · app` centred underneath) and live in `title · app` centred underneath. Padding, gaps and margins are still fixed, in
`src/theme.rs`, which is the one place to change them. They are not `src/theme.rs`.
configurable at runtime beyond the font flags.
The label font defaults to the system monospace font — whatever `fc-match The label font defaults to the system monospace font — whatever `fc-match
monospace` answers, which is what the rest of the desktop uses. (cosmic-text's monospace` answers, which is what the rest of the desktop uses. (cosmic-text's
+13 -2
View File
@@ -56,8 +56,13 @@ pub struct Settings {
pub theme: Theme, pub theme: Theme,
pub live: Live, pub live: Live,
pub fps: u32, pub fps: u32,
/// Integer output scale the overlay renders at. /// Integer scale of the display the overlay renders on.
pub scale: i32, pub scale: i32,
/// That display's logical size, which the grid is fitted into.
pub display: (i32, i32),
/// And its name, so the overlay maps there rather than wherever the
/// compositor would have put it.
pub output: String,
} }
pub struct App { pub struct App {
@@ -101,6 +106,9 @@ pub struct App {
pub(crate) chrome_buffers: Vec<WlBuffer>, pub(crate) chrome_buffers: Vec<WlBuffer>,
pub(crate) configured: bool, pub(crate) configured: bool,
/// The display the overlay maps on, by name.
pub(crate) output: String,
pub(crate) ending: Ending, pub(crate) ending: Ending,
pub(crate) picked: Option<Target>, pub(crate) picked: Option<Target>,
pub(crate) stats: Stats, pub(crate) stats: Stats,
@@ -152,8 +160,10 @@ impl App {
live, live,
fps, fps,
scale, scale,
display,
output,
} = settings; } = settings;
let layout = Layout::new(&theme, targets.len() as i32); let layout = Layout::new(&theme, targets.len() as i32, display);
// Bind everything up front so a compositor missing a protocol fails // Bind everything up front so a compositor missing a protocol fails
// here, with a name, rather than halfway through a capture. // here, with a name, rather than halfway through a capture.
let mut app = Self { let mut app = Self {
@@ -185,6 +195,7 @@ impl App {
chrome: None, chrome: None,
chrome_buffers: Vec::new(), chrome_buffers: Vec::new(),
configured: false, configured: false,
output,
ending: Ending::Running, ending: Ending::Running,
picked: None, picked: None,
stats: Stats::default(), stats: Stats::default(),
+12 -1
View File
@@ -33,7 +33,7 @@ use crate::shm;
use crate::target::{Kind, Target}; use crate::target::{Kind, Target};
/// Which tiles keep updating after the first frame. /// Which tiles keep updating after the first frame.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Live { pub enum Live {
/// Every tile. /// Every tile.
All, All,
@@ -43,6 +43,17 @@ pub enum Live {
None, None,
} }
impl Live {
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim() {
"all" => Ok(Live::All),
"current" => Ok(Live::Current),
"none" => Ok(Live::None),
other => Err(format!("{other:?} is not all, current or none")),
}
}
}
/// One capture buffer. `busy` means the compositor still holds it — either it is /// 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. /// on screen or a capture is writing into it — so we must not scribble over it.
pub struct Slot { pub struct Slot {
+113 -55
View File
@@ -1,9 +1,12 @@
//! Command line: flags, defaults, and the help text that documents them. //! Command line: flags, defaults, and the help text that documents them.
use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use crate::app::Settings; use crate::app::Settings;
use crate::capture::Live; use crate::capture::Live;
use crate::config::Config;
use crate::sway::Display;
use crate::target::Format; use crate::target::Format;
use crate::theme::Theme; use crate::theme::Theme;
@@ -12,6 +15,7 @@ wl-pick — a live grid of window and display previews, for picking one
usage: wl-pick [options] usage: wl-pick [options]
--config PATH config file [~/.config/wl-pick/config]
--format tsv|json|portal how to report the pick [tsv] --format tsv|json|portal how to report the pick [tsv]
--live all|current|none which tiles keep updating live [all] --live all|current|none which tiles keep updating live [all]
(displays are always a single snapshot) (displays are always a single snapshot)
@@ -33,6 +37,32 @@ mouse: click a tile to pick it, scroll to move. Hovering does not move the
The pick goes to stdout and nothing does if you cancel, so exit status is 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. 0 for a pick and 1 for a cancel. Acting on it is the caller's job.
config:
Flat `key = value` lines, `#` comments, everything optional; a flag beats
the file. Sizes take sway's units — `600px` is absolute, `70ppt` is a
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
border-width = 2px
tile-width = 18ppt # largest a thumbnail may be
tile-height = 20ppt # defaults to the display's aspect
max-columns = 4
font = monospace # also --font
font-size = 13.3
labels = yes
outputs = yes # include whole displays as tiles
live = all
fps = 12
format = tsv
formats: formats:
tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE, e.g. tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE, e.g.
@@ -63,23 +93,36 @@ focusing on sway:
esac esac
"; ";
/// 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.
#[derive(Default)]
pub struct Args { pub struct Args {
pub(crate) format: Format, pub(crate) config: Option<PathBuf>,
pub(crate) outputs: bool,
pub(crate) verbose: bool, pub(crate) verbose: bool,
pub(crate) hide_labels: bool, pub(crate) format: Option<Format>,
pub(crate) outputs: Option<bool>,
pub(crate) labels: Option<bool>,
pub(crate) font: Option<String>, pub(crate) font: Option<String>,
pub(crate) font_size: Option<f32>, pub(crate) font_size: Option<f32>,
pub(crate) live: Live, pub(crate) live: Option<Live>,
pub(crate) fps: u32, pub(crate) fps: Option<u32>,
pub(crate) timeout: Option<Duration>, pub(crate) timeout: Option<Duration>,
} }
impl Args { /// Every setting resolved, with sizes turned into pixels for the display the
/// An exclusive keyboard grab makes a hung overlay unusable, so keep an /// overlay is about to appear on.
/// escape hatch that cannot itself deadlock: a thread that only exits. pub struct Options {
pub fn arm_timeout(&self) { pub verbose: bool,
if let Some(d) = self.timeout { pub format: Format,
pub outputs: bool,
pub timeout: Option<Duration>,
pub settings: Settings,
}
/// An exclusive keyboard grab makes a hung overlay unusable, so keep an escape
/// hatch that cannot itself deadlock: a thread that only exits.
pub fn arm_timeout(timeout: Option<Duration>) {
if let Some(d) = timeout {
std::thread::spawn(move || { std::thread::spawn(move || {
std::thread::sleep(d); std::thread::sleep(d);
eprintln!("wl-pick: timeout"); eprintln!("wl-pick: timeout");
@@ -88,73 +131,88 @@ impl Args {
} }
} }
/// Everything the overlay needs to know up front. `scale` comes from the impl Args {
/// compositor, not the command line, so it is passed in. /// Resolve against the file and the display. Percentages become pixels here,
pub fn settings(&self, scale: i32) -> Settings { /// against this display, which is what lets one config suit monitors of
Settings { /// different sizes.
theme: self.theme(), pub fn resolve(&self, cfg: &Config, display: &Display) -> Options {
live: self.live,
fps: self.fps,
scale,
}
}
/// The look, with any overrides applied. Line height follows the font size
/// unless the size came from the theme, where it is already tuned.
fn theme(&self) -> Theme {
let base = Theme::default(); let base = Theme::default();
let font_px = self.font_size.unwrap_or(base.font_px); let font_px = self.font_size.or(cfg.font_size).unwrap_or(base.font_px);
Theme { let tile_w = cfg
labels: !self.hide_labels, .tile_width
font: self.font.clone().unwrap_or_else(|| base.font.clone()), .map_or(base.tile_w, |l| l.resolve(display.width));
line_h: match self.font_size { // A tile is shaped like the display unless told otherwise, since that is
// roughly the shape of the windows on it.
let tile_h = cfg.tile_height.map_or_else(
|| (tile_w as f32 * display.height as f32 / display.width.max(1) as f32) as i32,
|l| l.resolve(display.height),
);
let theme = Theme {
bg: cfg.background.unwrap_or(base.bg),
fg: cfg.foreground.unwrap_or(base.fg),
sel_bg: cfg.selection.unwrap_or(base.sel_bg),
sel_fg: cfg.selection_text.unwrap_or(base.sel_fg),
border: cfg.border.unwrap_or(base.border),
border_px: cfg
.border_width
.map_or(base.border_px, |l| l.resolve(display.width)),
tile_w: tile_w.max(1),
tile_h: tile_h.max(1),
max_cols: cfg.max_columns.unwrap_or(base.max_cols).max(1),
labels: self.labels.or(cfg.labels).unwrap_or(base.labels),
font: self
.font
.clone()
.or_else(|| cfg.font.clone())
.unwrap_or_else(|| base.font.clone()),
// Line height follows an explicit size; the default is already tuned.
line_h: match self.font_size.or(cfg.font_size) {
Some(_) => (font_px * 1.3).ceil() as i32, Some(_) => (font_px * 1.3).ceil() as i32,
None => base.line_h, None => base.line_h,
}, },
font_px, font_px,
..base ..base
};
Options {
verbose: self.verbose,
format: self.format.or(cfg.format).unwrap_or(Format::Tsv),
outputs: self.outputs.or(cfg.outputs).unwrap_or(true),
timeout: self.timeout.or(cfg.timeout),
settings: Settings {
theme,
live: self.live.or(cfg.live).unwrap_or(Live::All),
fps: self.fps.or(cfg.fps).unwrap_or(12),
scale: display.scale,
display: (display.width, display.height),
output: display.name.clone(),
},
} }
} }
} }
pub fn parse_args() -> Result<Args, String> { pub fn parse_args() -> Result<Args, String> {
let mut args = Args { let mut args = Args::default();
format: Format::Tsv,
outputs: true,
verbose: false,
hide_labels: false,
font: None,
font_size: None,
live: Live::All,
fps: 12,
timeout: None,
};
let mut it = std::env::args().skip(1); let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
match arg.as_str() { match arg.as_str() {
"--format" => { "--format" => {
args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() { let v = it.next().ok_or("--format needs tsv|json|portal")?;
"tsv" => Format::Tsv, args.format = Some(Format::parse(&v)?);
"json" => Format::Json,
"portal" => Format::Portal,
other => return Err(format!("bad --format: {other}")),
} }
"--outputs" => args.outputs = Some(true),
"--no-outputs" => args.outputs = Some(false),
"--config" => {
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?))
} }
"--outputs" => args.outputs = true,
"--no-outputs" => args.outputs = false,
"-v" | "--verbose" => args.verbose = true, "-v" | "--verbose" => args.verbose = true,
"--hide-labels" => args.hide_labels = true, "--hide-labels" => args.labels = Some(false),
"--live" => { "--live" => {
args.live = match it.next().ok_or("--live needs all|current|none")?.as_str() { let v = it.next().ok_or("--live needs all|current|none")?;
"all" => Live::All, args.live = Some(Live::parse(&v)?);
"current" => Live::Current,
"none" => Live::None,
other => return Err(format!("bad --live: {other}")),
}
} }
"--fps" => { "--fps" => {
let v = it.next().ok_or("--fps needs a number")?; let v = it.next().ok_or("--fps needs a number")?;
args.fps = 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 family name")?),
"--font-size" => { "--font-size" => {
+287
View File
@@ -0,0 +1,287 @@
//! The config file: `~/.config/wl-pick/config`.
//!
//! 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
//! resolved against whichever display the overlay actually maps on, each time
//! it runs.
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::capture::Live;
use crate::target::Format;
use crate::theme::Argb;
/// A size, either absolute or relative to the display it will be shown on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Length {
Px(i32),
/// Percentage points, as sway spells it.
Ppt(f32),
}
impl Length {
/// `600px`, `70ppt`, `70%`, or a bare number meaning pixels.
pub fn parse(s: &str) -> Result<Self, String> {
let s = s.trim();
let (number, unit) = match s.find(|c: char| c.is_alphabetic() || c == '%') {
Some(i) => (&s[..i], s[i..].trim()),
None => (s, ""),
};
let n: f32 = number
.trim()
.parse()
.map_err(|_| format!("{s:?} is not a number followed by px or ppt"))?;
match unit {
"" | "px" => Ok(Length::Px(n.round() as i32)),
"ppt" | "%" => Ok(Length::Ppt(n)),
other => Err(format!("unknown unit {other:?}, expected px or ppt")),
}
}
/// Turn into pixels. `basis` is the display's size along the same axis.
pub fn resolve(self, basis: i32) -> i32 {
match self {
Length::Px(px) => px,
Length::Ppt(pct) => (basis as f32 * pct / 100.0).round() as i32,
}
}
}
/// `#rrggbb` or `#aarrggbb`, to the premultiplied-alpha-free 0xAARRGGBB the
/// painter uses. Opaque when no alpha is given.
pub fn colour(s: &str) -> Result<Argb, String> {
let hex = s.trim().strip_prefix('#').unwrap_or(s.trim());
let value = u32::from_str_radix(hex, 16).map_err(|_| format!("{s:?} is not a colour"))?;
match hex.len() {
6 => Ok(0xff00_0000 | value),
8 => Ok(value),
_ => Err(format!("{s:?} should be #rrggbb or #aarrggbb")),
}
}
fn boolean(s: &str) -> Result<bool, String> {
match s.trim() {
"true" | "yes" | "on" | "1" => Ok(true),
"false" | "no" | "off" | "0" => Ok(false),
other => Err(format!("{other:?} is not true or false")),
}
}
/// Everything the file can set. `None` means "keep the default".
#[derive(Debug, Default)]
pub struct Config {
pub background: Option<Argb>,
pub foreground: Option<Argb>,
pub selection: Option<Argb>,
pub selection_text: Option<Argb>,
pub border: Option<Argb>,
pub border_width: Option<Length>,
/// Largest a thumbnail may be. Height defaults to the display's aspect, so
/// a tile is shaped like the windows it shows.
pub tile_width: Option<Length>,
pub tile_height: Option<Length>,
pub max_columns: Option<i32>,
pub font: Option<String>,
pub font_size: Option<f32>,
pub labels: Option<bool>,
pub outputs: Option<bool>,
pub live: Option<Live>,
pub fps: Option<u32>,
pub format: Option<Format>,
pub timeout: Option<Duration>,
}
impl Config {
/// Read `path`, or the default location. A missing file is not an error; a
/// malformed one is, because silently ignoring a typo in a colour is worse
/// than refusing to start.
pub fn load(path: Option<&Path>) -> Result<Self, String> {
let (path, required) = match path {
Some(p) => (p.to_path_buf(), true),
None => (default_path(), false),
};
match std::fs::read_to_string(&path) {
Ok(text) => Self::parse(&text).map_err(|e| format!("{}: {e}", path.display())),
Err(_) if !required => Ok(Self::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
fn parse(text: &str) -> Result<Self, String> {
let mut cfg = Self::default();
for (n, line) in text.lines().enumerate() {
let line = strip_comment(line).trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
return Err(format!("line {}: expected key = value", n + 1));
};
cfg.set(key.trim(), value.trim())
.map_err(|e| format!("line {}: {e}", n + 1))?;
}
Ok(cfg)
}
fn set(&mut self, key: &str, value: &str) -> Result<(), String> {
match key {
"background" => self.background = Some(colour(value)?),
"foreground" => self.foreground = Some(colour(value)?),
"selection" => self.selection = Some(colour(value)?),
"selection-text" => self.selection_text = Some(colour(value)?),
"border" => self.border = Some(colour(value)?),
"border-width" => self.border_width = Some(Length::parse(value)?),
"tile-width" => self.tile_width = Some(Length::parse(value)?),
"tile-height" => self.tile_height = Some(Length::parse(value)?),
"max-columns" => {
self.max_columns = Some(number(value)?);
}
"font" => self.font = Some(value.to_string()),
"font-size" => self.font_size = Some(number(value)?),
"labels" => self.labels = Some(boolean(value)?),
"outputs" => self.outputs = Some(boolean(value)?),
"live" => self.live = Some(Live::parse(value)?),
"fps" => self.fps = Some(number(value)?),
"format" => self.format = Some(Format::parse(value)?),
"timeout" => self.timeout = Some(Duration::from_secs_f64(number(value)?)),
other => return Err(format!("unknown setting {other:?}")),
}
Ok(())
}
}
/// Cut a trailing comment. `#` starts one only when followed by whitespace or
/// the end of the line, so `#d79921` stays a colour.
fn strip_comment(line: &str) -> &str {
let bytes = line.as_bytes();
for (i, _) in line.char_indices().filter(|(_, c)| *c == '#') {
match bytes.get(i + 1) {
None => return &line[..i],
Some(c) if c.is_ascii_whitespace() => return &line[..i],
_ => {}
}
}
line
}
fn number<T: std::str::FromStr>(s: &str) -> Result<T, String> {
s.trim()
.parse()
.map_err(|_| format!("{s:?} is not a number"))
}
/// `$XDG_CONFIG_HOME/wl-pick/config`, or `~/.config/wl-pick/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")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lengths_take_sways_units() {
assert_eq!(Length::parse("600px"), Ok(Length::Px(600)));
assert_eq!(Length::parse("600"), Ok(Length::Px(600)));
assert_eq!(Length::parse(" 70 ppt "), Ok(Length::Ppt(70.0)));
assert_eq!(Length::parse("70ppt"), Ok(Length::Ppt(70.0)));
assert_eq!(Length::parse("12.5%"), Ok(Length::Ppt(12.5)));
assert!(Length::parse("wide").is_err());
assert!(Length::parse("70em").is_err());
}
#[test]
fn percentages_resolve_against_the_display() {
// The same config gives different pixels on different monitors, which is
// the whole point of allowing ppt.
assert_eq!(Length::Ppt(20.0).resolve(1280), 256);
assert_eq!(Length::Ppt(20.0).resolve(3840), 768);
assert_eq!(Length::Px(220).resolve(3840), 220);
}
#[test]
fn colours_take_both_lengths() {
assert_eq!(colour("#282828"), Ok(0xff282828));
assert_eq!(colour("#80ffffff"), Ok(0x80ffffff));
assert_eq!(colour("282828"), Ok(0xff282828));
assert!(colour("#zzz").is_err());
assert!(colour("#fff").is_err());
}
#[test]
fn a_whole_file_parses() {
let cfg = Config::parse(
"\
# looks
background = #282828
selection = #d79921 # trailing comment
border-width = 2px
tile-width = 18ppt
max-columns = 4
live = current
fps = 30
labels = no
",
)
.expect("should parse");
assert_eq!(cfg.background, Some(0xff282828));
assert_eq!(cfg.selection, Some(0xffd79921));
assert_eq!(cfg.border_width, Some(Length::Px(2)));
assert_eq!(cfg.tile_width, Some(Length::Ppt(18.0)));
assert_eq!(cfg.max_columns, Some(4));
assert_eq!(cfg.fps, Some(30));
assert_eq!(cfg.labels, Some(false));
assert!(cfg.live.is_some());
// Untouched settings stay unset, so defaults survive.
assert_eq!(cfg.foreground, None);
assert_eq!(cfg.tile_height, None);
}
#[test]
fn comments_do_not_eat_colours() {
assert_eq!(
strip_comment("selection = #d79921 # note"),
"selection = #d79921 "
);
assert_eq!(strip_comment("# whole line"), "");
assert_eq!(strip_comment("border = #fff000"), "border = #fff000");
assert_eq!(strip_comment("fps = 30 #"), "fps = 30 ");
let cfg = Config::parse("selection = #d79921 # trailing\n").expect("parses");
assert_eq!(cfg.selection, Some(0xffd79921));
}
#[test]
fn mistakes_say_which_line() {
let err = Config::parse("background = #282828\nselection = nope\n").unwrap_err();
assert!(err.starts_with("line 2:"), "{err}");
let err = Config::parse("border-width\n").unwrap_err();
assert!(err.starts_with("line 1:"), "{err}");
let err = Config::parse("colour = #fff000\n").unwrap_err();
assert!(err.contains("unknown setting"), "{err}");
}
#[test]
fn a_missing_file_is_not_an_error() {
let missing = Path::new("/nonexistent/wl-pick/config");
assert!(
Config::load(Some(missing)).is_err(),
"named file must exist"
);
// The default location is allowed to be absent.
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/nonexistent") };
assert!(Config::load(None).is_ok());
}
}
+30 -21
View File
@@ -26,6 +26,7 @@
mod app; mod app;
mod capture; mod capture;
mod cli; mod cli;
mod config;
mod overlay; mod overlay;
mod shm; mod shm;
mod sway; mod sway;
@@ -41,7 +42,7 @@ use wayland_client::globals::registry_queue_init;
use wayland_client::{Connection, EventQueue}; use wayland_client::{Connection, EventQueue};
use app::App; use app::App;
use cli::Args; use config::Config;
use target::Target; use target::Target;
use theme::Layout; use theme::Layout;
@@ -57,23 +58,44 @@ fn main() -> ExitCode {
fn run() -> Result<ExitCode, Box<dyn Error>> { fn run() -> Result<ExitCode, Box<dyn Error>> {
let args = cli::parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?; let args = cli::parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?;
args.arm_timeout(); let config =
Config::load(args.config.as_deref()).map_err(|e| -> Box<dyn Error> { e.into() })?;
let start = Instant::now(); let start = Instant::now();
let mut phases = Phases::new(args.verbose); let mut phases = Phases::new(args.verbose);
let (targets, scale) = list(&args)?; // One IPC conversation: the window list, and the displays the grid sizes
// itself against. It is closed again before the overlay maps.
let (targets, opts) = {
let mut sway = swayipc::Connection::new().map_err(|e| {
format!("cannot reach sway ({e}); wl-pick reads the window list from its IPC socket")
})?;
// The displays come first: the grid is sized against the one it will
// appear on, so every percentage in the config resolves per monitor.
let displays = sway::displays(&mut sway)?;
let display = sway::focused(&displays).ok_or("sway reports no active display")?;
let opts = args.resolve(&config, display);
let mut targets = sway::windows(&mut sway)?;
if opts.outputs {
// Displays go last, after the windows, so window positions are
// stable as windows come and go.
targets.extend(displays.iter().map(|d| Target::output(d.name.clone())));
}
(targets, opts)
};
if targets.is_empty() { if targets.is_empty() {
return Ok(ExitCode::SUCCESS); return Ok(ExitCode::SUCCESS);
} }
phases.mark("sway-tree"); phases.mark("sway-tree");
let settings = args.settings(scale); cli::arm_timeout(opts.timeout);
let settings = opts.settings;
let theme = &settings.theme; let theme = &settings.theme;
let scale = settings.scale;
// Start shaping labels now: it costs ~55ms of font loading and glyph // Start shaping labels now: it costs ~55ms of font loading and glyph
// rasterising, and the captures below are ~55ms of waiting on the // rasterising, and the captures below are ~55ms of waiting on the
// compositor, so the two overlap almost exactly. // compositor, so the two overlap almost exactly.
let labels = theme.labels.then(|| { let labels = theme.labels.then(|| {
let layout = Layout::new(theme, targets.len() as i32); let layout = Layout::new(theme, targets.len() as i32, settings.display);
text::spawn( text::spawn(
targets.iter().map(Target::label).collect(), targets.iter().map(Target::label).collect(),
theme.font.clone(), theme.font.clone(),
@@ -105,7 +127,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
app.labels = Some(job.join().map_err(|_| "label thread panicked")?); app.labels = Some(job.join().map_err(|_| "label thread panicked")?);
} }
phases.mark("labels"); phases.mark("labels");
if args.verbose { if opts.verbose {
app.describe(); app.describe();
} }
@@ -118,14 +140,14 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
phases.mark("mapped"); phases.mark("mapped");
pump(&mut queue, &mut app, |a| a.finished())?; pump(&mut queue, &mut app, |a| a.finished())?;
if args.verbose { if opts.verbose {
app.report(start.elapsed()); app.report(start.elapsed());
} }
let Some(target) = app.picked() else { let Some(target) = app.picked() else {
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
}; };
match target.render(args.format) { match target.render(opts.format) {
Some(line) => println!("{line}"), Some(line) => println!("{line}"),
// Only the portal format can fail to name something: it identifies a // Only the portal format can fail to name something: it identifies a
// window by its foreign-toplevel identifier, and this one has none. // window by its foreign-toplevel identifier, and this one has none.
@@ -137,19 +159,6 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)
} }
/// Everything the grid can show, windows first so their positions stay stable as
/// displays come and go. The IPC connection is only needed for this, and is
/// closed again before the overlay maps.
fn list(args: &Args) -> Result<(Vec<Target>, i32), Box<dyn Error>> {
let mut sway = swayipc::Connection::new()?;
let mut targets = sway::windows(&mut sway)?;
let displays = sway::displays(&mut sway)?;
if args.outputs {
targets.extend(displays.names.iter().cloned().map(Target::output));
}
Ok((targets, displays.scale))
}
/// Run the event loop until `done`. /// Run the event loop until `done`.
fn pump( fn pump(
queue: &mut EventQueue<App>, queue: &mut EventQueue<App>,
+8 -1
View File
@@ -66,9 +66,16 @@ impl App {
pub fn show(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> { pub fn show(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
let (lw, lh) = (self.layout.width, self.layout.height); let (lw, lh) = (self.layout.width, self.layout.height);
let surface = self.compositor.create_surface(qh, ()); let surface = self.compositor.create_surface(qh, ());
// Map on the display the layout was sized against, not wherever the
// compositor would otherwise put it.
let output = self
.outputs
.iter()
.find(|(_, name)| *name == self.output)
.map(|(output, _)| output);
let layer = self.layer_shell.get_layer_surface( let layer = self.layer_shell.get_layer_surface(
&surface, &surface,
None, // let the compositor place it on the active output output,
Layer::Overlay, Layer::Overlay,
"wl-pick".to_string(), "wl-pick".to_string(),
qh, qh,
+33 -16
View File
@@ -38,26 +38,43 @@ fn collect(node: &Node, out: &mut Vec<Target>) {
} }
} }
/// The active displays, and the scale the overlay should render at: the largest /// One active display: what the overlay needs to size itself against.
/// in use, rounded up, since a buffer can be downscaled but not invented. ///
pub struct Displays { /// The overlay maps on the focused display, so percentages and the buffer scale
pub(crate) names: Vec<String>, /// are resolved against *that* one — on a mixed-DPI, mixed-size setup the
pub(crate) scale: i32, /// numbers differ per monitor, and taking the largest of everything would be
/// wrong on all but one.
#[derive(Clone, Debug)]
pub struct Display {
pub name: String,
/// Logical size, which is what layer-shell and pointer events speak in.
pub width: i32,
pub height: i32,
/// Integer scale to render at: a buffer can be downscaled, not invented.
pub scale: i32,
pub focused: bool,
} }
pub fn displays(conn: &mut Connection) -> Result<Displays, swayipc::Error> { pub fn displays(conn: &mut Connection) -> Result<Vec<Display>, swayipc::Error> {
let active: Vec<_> = conn Ok(conn
.get_outputs()? .get_outputs()?
.into_iter() .into_iter()
.filter(|o| o.active) .filter(|o| o.active)
.collect(); .map(|o| Display {
Ok(Displays { name: o.name,
scale: active width: o.rect.width,
.iter() height: o.rect.height,
.map(|o| o.scale.unwrap_or(1.0).ceil() as i32) scale: (o.scale.unwrap_or(1.0).ceil() as i32).max(1),
.max() focused: o.focused,
.unwrap_or(1)
.max(1),
names: active.into_iter().map(|o| o.name).collect(),
}) })
.collect())
}
/// The display the overlay will appear on: the focused one, or any active one if
/// sway reports none focused.
pub fn focused(displays: &[Display]) -> Option<&Display> {
displays
.iter()
.find(|d| d.focused)
.or_else(|| displays.first())
} }
+13 -2
View File
@@ -7,7 +7,7 @@
use std::fmt; use std::fmt;
/// How a pick is written to stdout. /// How a pick is written to stdout.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format { pub enum Format {
/// Tab-separated columns, for `IFS=$'\t' read` or cut(1). /// Tab-separated columns, for `IFS=$'\t' read` or cut(1).
Tsv, Tsv,
@@ -17,7 +17,18 @@ pub enum Format {
Portal, Portal,
} }
#[derive(Clone, Copy, PartialEq, Eq)] impl Format {
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim() {
"tsv" => Ok(Format::Tsv),
"json" => Ok(Format::Json),
"portal" => Ok(Format::Portal),
other => Err(format!("{other:?} is not tsv, json or portal")),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
Window, Window,
Output, Output,
+90 -14
View File
@@ -7,11 +7,9 @@ pub type Argb = u32;
pub struct Theme { pub struct Theme {
pub bg: Argb, pub bg: Argb,
/// Label colours; used once tiles are labelled. /// Label colours.
#[allow(dead_code)]
pub fg: Argb, pub fg: Argb,
pub sel_bg: Argb, pub sel_bg: Argb,
#[allow(dead_code)]
pub sel_fg: Argb, pub sel_fg: Argb,
pub border: Argb, pub border: Argb,
/// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px). /// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px).
@@ -65,6 +63,7 @@ impl Default for Theme {
} }
/// Where every element and thumbnail goes, in logical px. /// Where every element and thumbnail goes, in logical px.
#[derive(Debug)]
pub struct Layout { pub struct Layout {
pub cols: i32, pub cols: i32,
pub rows: i32, pub rows: i32,
@@ -83,10 +82,18 @@ pub struct Layout {
labels: bool, labels: bool,
} }
/// How much of the display the grid may occupy before tiles start shrinking.
const FILL: i32 = 90;
impl Layout { impl Layout {
/// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't /// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't
/// ragged (6 windows -> 3x2, not 4x2 with two holes). Same rule rofigrid uses. /// ragged (6 windows -> 3x2, not 4x2 with two holes). Same rule rofigrid uses.
pub fn new(t: &Theme, n: i32) -> Self { ///
/// `display` is the logical size of the screen this will appear on. Tiles are
/// the size the theme asks for until the grid would outgrow the screen, at
/// which point they shrink together — keeping their aspect — so thirty
/// windows give small tiles rather than a surface larger than the monitor.
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
let mut cols = (n as f64).sqrt() as i32; let mut cols = (n as f64).sqrt() as i32;
if cols * cols < n { if cols * cols < n {
cols += 1; cols += 1;
@@ -95,19 +102,23 @@ impl Layout {
let rows = (n + cols - 1) / cols; let rows = (n + cols - 1) / cols;
// An element is the thumbnail, optionally a label under it, and padding. // An element is the thumbnail, optionally a label under it, and padding.
let label_row = if t.labels { t.spacing + t.line_h } else { 0 }; let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
let (elem_w, elem_h) = (t.tile_w + 2 * t.pad, t.tile_h + label_row + 2 * t.pad); let (tile_w, tile_h) = fit_grid(t, n, cols, rows, label_row, display);
let (elem_w, elem_h) = (tile_w + 2 * t.pad, tile_h + label_row + 2 * t.pad);
Self { Self {
cols, cols,
rows, rows,
n, n,
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin, // Padding, gaps and label rows can outgrow a small display all by
height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin, // themselves, so the surface is capped: better a clipped last row
// than asking for a window larger than the screen showing it.
width: (cols * elem_w + (cols - 1) * t.gap + 2 * t.margin).min(display.0.max(1)),
height: (rows * elem_h + (rows - 1) * t.gap + 2 * t.margin).min(display.1.max(1)),
elem_w, elem_w,
elem_h, elem_h,
margin: t.margin, margin: t.margin,
gap: t.gap, gap: t.gap,
pad: t.pad, pad: t.pad,
tile_h: t.tile_h, tile_h,
spacing: t.spacing, spacing: t.spacing,
line_h: t.line_h, line_h: t.line_h,
labels: t.labels, labels: t.labels,
@@ -173,6 +184,35 @@ impl Layout {
} }
} }
/// The largest tile that keeps a `cols`x`rows` grid inside `FILL`% of the
/// display, never larger than the theme asks for, and at least a pixel. Both
/// axes shrink by the same factor, so a tile keeps its shape.
fn fit_grid(
t: &Theme,
n: i32,
cols: i32,
rows: i32,
label_row: i32,
(dw, dh): (i32, i32),
) -> (i32, i32) {
let (want_w, want_h) = (t.tile_w.max(1), t.tile_h.max(1));
if n == 0 || dw <= 0 || dh <= 0 {
return (want_w, want_h);
}
// Everything in the surface that is not thumbnail.
let chrome_w = cols * 2 * t.pad + (cols - 1) * t.gap + 2 * t.margin;
let chrome_h = rows * (2 * t.pad + label_row) + (rows - 1) * t.gap + 2 * t.margin;
let room_w = (dw * FILL / 100 - chrome_w).max(cols);
let room_h = (dh * FILL / 100 - chrome_h).max(rows);
let scale = (room_w as f32 / (cols * want_w) as f32)
.min(room_h as f32 / (rows * want_h) as f32)
.min(1.0);
(
((want_w as f32 * scale) as i32).max(1),
((want_h as f32 * scale) as i32).max(1),
)
}
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rect { pub struct Rect {
pub x: i32, pub x: i32,
@@ -218,6 +258,10 @@ pub fn fit_centred(w: i32, h: i32, box_: Rect) -> Rect {
mod tests { mod tests {
use super::*; use super::*;
/// A display large enough that nothing is clamped, so the geometry tests
/// keep testing geometry.
const ROOMY: (i32, i32) = (10_000, 10_000);
/// The grid maths must match rofigrid's, or the window stops hugging the grid. /// The grid maths must match rofigrid's, or the window stops hugging the grid.
#[test] #[test]
fn grid_matches_rofigrid() { fn grid_matches_rofigrid() {
@@ -231,7 +275,7 @@ mod tests {
(12, 4, 3), (12, 4, 3),
(17, 4, 5), (17, 4, 5),
] { ] {
let l = Layout::new(&t, n); let l = Layout::new(&t, n, ROOMY);
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}"); assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}");
// rofigrid: win_w = cols*(ICON+24) + (cols-1)*15 + 24 // rofigrid: win_w = cols*(ICON+24) + (cols-1)*15 + 24
assert_eq!( assert_eq!(
@@ -246,7 +290,7 @@ mod tests {
fn elements_stay_inside_the_window() { fn elements_stay_inside_the_window() {
let t = Theme::default(); let t = Theme::default();
for n in 1..=20 { for n in 1..=20 {
let l = Layout::new(&t, n); let l = Layout::new(&t, n, ROOMY);
for i in 0..n { for i in 0..n {
let e = l.elem(i); let e = l.elem(i);
assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}"); assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}");
@@ -260,15 +304,15 @@ mod tests {
#[test] #[test]
fn labels_add_a_row_under_each_thumbnail() { fn labels_add_a_row_under_each_thumbnail() {
let mut t = Theme::default(); let mut t = Theme::default();
let with = Layout::new(&t, 4); let with = Layout::new(&t, 4, ROOMY);
t.labels = false; t.labels = false;
let without = Layout::new(&t, 4); let without = Layout::new(&t, 4, ROOMY);
let rows = 2; let rows = 2;
assert_eq!(with.height - without.height, rows * (t.spacing + t.line_h)); assert_eq!(with.height - without.height, rows * (t.spacing + t.line_h));
assert!(without.label(0).is_none()); assert!(without.label(0).is_none());
let t = Theme::default(); let t = Theme::default();
let l = Layout::new(&t, 4); let l = Layout::new(&t, 4, ROOMY);
for i in 0..4 { for i in 0..4 {
let (tile, label, elem) = (l.tile(i), l.label(i).unwrap(), l.elem(i)); let (tile, label, elem) = (l.tile(i), l.label(i).unwrap(), l.elem(i));
assert_eq!(tile.h, t.tile_h); assert_eq!(tile.h, t.tile_h);
@@ -279,11 +323,43 @@ mod tests {
} }
} }
#[test]
fn the_grid_shrinks_to_fit_a_small_display() {
let t = Theme::default();
let big = Layout::new(&t, 12, ROOMY);
assert_eq!(big.tile(0).w, t.tile_w, "no clamping when there is room");
// Twenty tiles at full size cannot fit a 1024x768 screen.
let small = Layout::new(&t, 20, (1024, 768));
assert!(
small.width <= 1024 && small.height <= 768,
"{small:?} overflows"
);
assert!(small.tile(0).w < t.tile_w, "tiles should have shrunk");
// Shrinking keeps the tile's shape.
let want = t.tile_w as f32 / t.tile_h as f32;
let got = small.tile(0).w as f32 / small.tile(0).h as f32;
assert!(
(want - got).abs() < 0.05,
"aspect {got} drifted from {want}"
);
}
#[test]
fn a_tiny_display_never_gets_an_oversized_surface() {
let t = Theme::default();
// Thirty windows on a 640x480 screen: the padding and label rows alone
// do not fit, so tiles bottom out and the surface is capped instead.
let l = Layout::new(&t, 30, (640, 480));
assert!(l.tile(0).w >= 1 && l.tile(0).h >= 1, "{l:?}");
assert!(l.width <= 640 && l.height <= 480, "{l:?}");
}
#[test] #[test]
fn hit_testing_is_the_inverse_of_the_layout() { fn hit_testing_is_the_inverse_of_the_layout() {
let t = Theme::default(); let t = Theme::default();
// 7 tiles over 3 columns: the last row holds one, so two cells are empty. // 7 tiles over 3 columns: the last row holds one, so two cells are empty.
let l = Layout::new(&t, 7); let l = Layout::new(&t, 7, ROOMY);
for i in 0..7 { for i in 0..7 {
let e = l.elem(i); let e = l.elem(i);
for (x, y, what) in [ for (x, y, what) in [