diff --git a/README.md b/README.md index 6baced6..e2bd5c7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N] - `--hide-labels` draws an icon-only grid - `--font FAMILY` label font family (default: the system monospace font) - `--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 you - `--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, 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 -Colours, font metrics and grid geometry come from the rofi theme this replaces -(gruvbox dark, a yellow selection filling the element padding, `ceil(sqrt(n))` -columns capped at 4, 16:9 tiles, `title · app` centred underneath) and live in -`src/theme.rs`, which is the one place to change them. They are not -configurable at runtime beyond the font flags. +The defaults come from the rofi theme this replaces: gruvbox dark, a yellow +selection filling the element padding, `ceil(sqrt(n))` columns capped at 4, +`title · app` centred underneath. Padding, gaps and margins are still fixed, in +`src/theme.rs`. 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 diff --git a/src/app.rs b/src/app.rs index 8723aa0..52b6399 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,8 +56,13 @@ pub struct Settings { pub theme: Theme, pub live: Live, pub fps: u32, - /// Integer output scale the overlay renders at. + /// Integer scale of the display the overlay renders on. 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 { @@ -101,6 +106,9 @@ pub struct App { pub(crate) chrome_buffers: Vec, pub(crate) configured: bool, + /// The display the overlay maps on, by name. + pub(crate) output: String, + pub(crate) ending: Ending, pub(crate) picked: Option, pub(crate) stats: Stats, @@ -152,8 +160,10 @@ impl App { live, fps, scale, + display, + output, } = 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 // here, with a name, rather than halfway through a capture. let mut app = Self { @@ -185,6 +195,7 @@ impl App { chrome: None, chrome_buffers: Vec::new(), configured: false, + output, ending: Ending::Running, picked: None, stats: Stats::default(), diff --git a/src/capture.rs b/src/capture.rs index 0432706..8c5b9dc 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -33,7 +33,7 @@ use crate::shm; use crate::target::{Kind, Target}; /// Which tiles keep updating after the first frame. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Live { /// Every tile. All, @@ -43,6 +43,17 @@ pub enum Live { None, } +impl Live { + pub fn parse(s: &str) -> Result { + 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 /// on screen or a capture is writing into it — so we must not scribble over it. pub struct Slot { diff --git a/src/cli.rs b/src/cli.rs index 9768a5c..5ddc38e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,12 @@ //! Command line: flags, defaults, and the help text that documents them. +use std::path::PathBuf; use std::time::Duration; use crate::app::Settings; use crate::capture::Live; +use crate::config::Config; +use crate::sway::Display; use crate::target::Format; 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] + --config PATH config file [~/.config/wl-pick/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) @@ -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 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: tsv TYPEIDTOPLEVEL_IDAPPTITLE, e.g. @@ -63,98 +93,126 @@ focusing on sway: 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(crate) format: Format, - pub(crate) outputs: bool, + pub(crate) config: Option, pub(crate) verbose: bool, - pub(crate) hide_labels: bool, + pub(crate) format: Option, + pub(crate) outputs: Option, + pub(crate) labels: Option, pub(crate) font: Option, pub(crate) font_size: Option, - pub(crate) live: Live, - pub(crate) fps: u32, + pub(crate) live: Option, + pub(crate) fps: Option, pub(crate) timeout: Option, } +/// Every setting resolved, with sizes turned into pixels for the display the +/// overlay is about to appear on. +pub struct Options { + pub verbose: bool, + pub format: Format, + pub outputs: bool, + pub timeout: Option, + 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) { + if let Some(d) = timeout { + std::thread::spawn(move || { + std::thread::sleep(d); + eprintln!("wl-pick: timeout"); + std::process::exit(2); + }); + } +} + impl Args { - /// 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(&self) { - if let Some(d) = self.timeout { - std::thread::spawn(move || { - std::thread::sleep(d); - eprintln!("wl-pick: timeout"); - std::process::exit(2); - }); - } - } - - /// Everything the overlay needs to know up front. `scale` comes from the - /// compositor, not the command line, so it is passed in. - pub fn settings(&self, scale: i32) -> Settings { - Settings { - theme: self.theme(), - 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 { + /// Resolve against the file and the display. Percentages become pixels here, + /// against this display, which is what lets one config suit monitors of + /// different sizes. + pub fn resolve(&self, cfg: &Config, display: &Display) -> Options { let base = Theme::default(); - let font_px = self.font_size.unwrap_or(base.font_px); - Theme { - labels: !self.hide_labels, - font: self.font.clone().unwrap_or_else(|| base.font.clone()), - line_h: match self.font_size { + let font_px = self.font_size.or(cfg.font_size).unwrap_or(base.font_px); + let tile_w = cfg + .tile_width + .map_or(base.tile_w, |l| l.resolve(display.width)); + // 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, None => base.line_h, }, font_px, ..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 { - let mut args = Args { - format: Format::Tsv, - outputs: true, - verbose: false, - hide_labels: false, - font: None, - font_size: None, - live: Live::All, - fps: 12, - timeout: None, - }; + let mut args = Args::default(); let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { match arg.as_str() { "--format" => { - args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() { - "tsv" => Format::Tsv, - "json" => Format::Json, - "portal" => Format::Portal, - other => return Err(format!("bad --format: {other}")), - } + let v = it.next().ok_or("--format needs tsv|json|portal")?; + args.format = Some(Format::parse(&v)?); + } + "--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, - "--hide-labels" => args.hide_labels = true, + "--hide-labels" => args.labels = Some(false), "--live" => { - args.live = match it.next().ok_or("--live needs all|current|none")?.as_str() { - "all" => Live::All, - "current" => Live::Current, - "none" => Live::None, - other => return Err(format!("bad --live: {other}")), - } + let v = it.next().ok_or("--live needs all|current|none")?; + args.live = Some(Live::parse(&v)?); } "--fps" => { 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-size" => { diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..05878b8 --- /dev/null +++ b/src/config.rs @@ -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 { + 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 { + 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 { + 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, + pub foreground: Option, + pub selection: Option, + pub selection_text: Option, + pub border: Option, + pub border_width: Option, + /// 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, + pub tile_height: Option, + pub max_columns: Option, + pub font: Option, + pub font_size: Option, + pub labels: Option, + pub outputs: Option, + pub live: Option, + pub fps: Option, + pub format: Option, + pub timeout: Option, +} + +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 { + 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 { + 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(s: &str) -> Result { + 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()); + } +} diff --git a/src/main.rs b/src/main.rs index b515df9..ebbe2e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod app; mod capture; mod cli; +mod config; mod overlay; mod shm; mod sway; @@ -41,7 +42,7 @@ use wayland_client::globals::registry_queue_init; use wayland_client::{Connection, EventQueue}; use app::App; -use cli::Args; +use config::Config; use target::Target; use theme::Layout; @@ -57,23 +58,44 @@ fn main() -> ExitCode { fn run() -> Result> { let args = cli::parse_args().map_err(|e| -> Box { e.into() })?; - args.arm_timeout(); + let config = + Config::load(args.config.as_deref()).map_err(|e| -> Box { e.into() })?; let start = Instant::now(); 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() { return Ok(ExitCode::SUCCESS); } phases.mark("sway-tree"); - let settings = args.settings(scale); + cli::arm_timeout(opts.timeout); + let settings = opts.settings; let theme = &settings.theme; + let scale = settings.scale; // Start shaping labels now: it costs ~55ms of font loading and glyph // rasterising, and the captures below are ~55ms of waiting on the // compositor, so the two overlap almost exactly. 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( targets.iter().map(Target::label).collect(), theme.font.clone(), @@ -105,7 +127,7 @@ fn run() -> Result> { app.labels = Some(job.join().map_err(|_| "label thread panicked")?); } phases.mark("labels"); - if args.verbose { + if opts.verbose { app.describe(); } @@ -118,14 +140,14 @@ fn run() -> Result> { phases.mark("mapped"); pump(&mut queue, &mut app, |a| a.finished())?; - if args.verbose { + if opts.verbose { app.report(start.elapsed()); } let Some(target) = app.picked() else { return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout }; - match target.render(args.format) { + match target.render(opts.format) { Some(line) => println!("{line}"), // Only the portal format can fail to name something: it identifies a // window by its foreign-toplevel identifier, and this one has none. @@ -137,19 +159,6 @@ fn run() -> Result> { 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, i32), Box> { - 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`. fn pump( queue: &mut EventQueue, diff --git a/src/overlay.rs b/src/overlay.rs index d6d086b..164b2bb 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -66,9 +66,16 @@ impl App { pub fn show(&mut self, qh: &QueueHandle) -> Result<(), Box> { let (lw, lh) = (self.layout.width, self.layout.height); 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( &surface, - None, // let the compositor place it on the active output + output, Layer::Overlay, "wl-pick".to_string(), qh, diff --git a/src/sway.rs b/src/sway.rs index c4ab964..05c7b8d 100644 --- a/src/sway.rs +++ b/src/sway.rs @@ -38,26 +38,43 @@ fn collect(node: &Node, out: &mut Vec) { } } -/// The active displays, and the scale the overlay should render at: the largest -/// in use, rounded up, since a buffer can be downscaled but not invented. -pub struct Displays { - pub(crate) names: Vec, - pub(crate) scale: i32, +/// 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 +/// 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 { - let active: Vec<_> = conn +pub fn displays(conn: &mut Connection) -> Result, swayipc::Error> { + Ok(conn .get_outputs()? .into_iter() .filter(|o| o.active) - .collect(); - Ok(Displays { - scale: active - .iter() - .map(|o| o.scale.unwrap_or(1.0).ceil() as i32) - .max() - .unwrap_or(1) - .max(1), - names: active.into_iter().map(|o| o.name).collect(), - }) + .map(|o| Display { + name: o.name, + width: o.rect.width, + height: o.rect.height, + scale: (o.scale.unwrap_or(1.0).ceil() as i32).max(1), + focused: o.focused, + }) + .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()) } diff --git a/src/target.rs b/src/target.rs index c49f290..750418d 100644 --- a/src/target.rs +++ b/src/target.rs @@ -7,7 +7,7 @@ use std::fmt; /// How a pick is written to stdout. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { /// Tab-separated columns, for `IFS=$'\t' read` or cut(1). Tsv, @@ -17,7 +17,18 @@ pub enum Format { Portal, } -#[derive(Clone, Copy, PartialEq, Eq)] +impl Format { + pub fn parse(s: &str) -> Result { + 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 { Window, Output, diff --git a/src/theme.rs b/src/theme.rs index a7632c9..c80b369 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -7,11 +7,9 @@ pub type Argb = u32; pub struct Theme { pub bg: Argb, - /// Label colours; used once tiles are labelled. - #[allow(dead_code)] + /// Label colours. pub fg: Argb, pub sel_bg: Argb, - #[allow(dead_code)] pub sel_fg: Argb, pub border: Argb, /// 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. +#[derive(Debug)] pub struct Layout { pub cols: i32, pub rows: i32, @@ -83,10 +82,18 @@ pub struct Layout { labels: bool, } +/// How much of the display the grid may occupy before tiles start shrinking. +const FILL: i32 = 90; + impl Layout { /// 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. - 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; if cols * cols < n { cols += 1; @@ -95,19 +102,23 @@ impl Layout { let rows = (n + cols - 1) / cols; // 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 (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 { cols, rows, n, - width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin, - height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin, + // Padding, gaps and label rows can outgrow a small display all by + // 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_h, margin: t.margin, gap: t.gap, pad: t.pad, - tile_h: t.tile_h, + tile_h, spacing: t.spacing, line_h: t.line_h, 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)] pub struct Rect { pub x: i32, @@ -218,6 +258,10 @@ pub fn fit_centred(w: i32, h: i32, box_: Rect) -> Rect { mod tests { 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. #[test] fn grid_matches_rofigrid() { @@ -231,7 +275,7 @@ mod tests { (12, 4, 3), (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}"); // rofigrid: win_w = cols*(ICON+24) + (cols-1)*15 + 24 assert_eq!( @@ -246,7 +290,7 @@ mod tests { fn elements_stay_inside_the_window() { let t = Theme::default(); for n in 1..=20 { - let l = Layout::new(&t, n); + let l = Layout::new(&t, n, ROOMY); for i in 0..n { let e = l.elem(i); assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}"); @@ -260,15 +304,15 @@ mod tests { #[test] fn labels_add_a_row_under_each_thumbnail() { let mut t = Theme::default(); - let with = Layout::new(&t, 4); + let with = Layout::new(&t, 4, ROOMY); t.labels = false; - let without = Layout::new(&t, 4); + let without = Layout::new(&t, 4, ROOMY); let rows = 2; assert_eq!(with.height - without.height, rows * (t.spacing + t.line_h)); assert!(without.label(0).is_none()); let t = Theme::default(); - let l = Layout::new(&t, 4); + let l = Layout::new(&t, 4, ROOMY); for i in 0..4 { let (tile, label, elem) = (l.tile(i), l.label(i).unwrap(), l.elem(i)); 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] fn hit_testing_is_the_inverse_of_the_layout() { let t = Theme::default(); // 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 { let e = l.elem(i); for (x, y, what) in [