use std::path::PathBuf;
use std::time::Duration;
use crate::app::Settings;
use crate::capture::Live;
use crate::config::{Config, Length};
use crate::sway::Display;
use crate::target::Format;
use crate::theme::Theme;
const HELP: &str = "\
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)
--fps N cap on live updates per tile per second [12]
--outputs, --no-outputs include whole displays as tiles [yes]
--labels, --no-labels a label under each thumbnail [yes]
--font NAME label font family, optionally with a style, as
in 'Iosevka Medium Condensed' [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
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.
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
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 # a family, optionally with a style
font-size = 13.3
labels = yes
outputs = yes # include whole displays as tiles
live = all
fps = 12
format = tsv
timeout = 0 # seconds; 0 means none
formats:
tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE, e.g.
window 1234 f0e1d2c3b4a59687 firefox Wikipedia
output HDMI-A-1 display HDMI-A-1
ID is the thing to act on: a sway con_id, or the display name.
TOPLEVEL_ID is the ext-foreign-toplevel-list-v1 identifier that
capture tools address a window by (grim -T, the desktop portal),
empty for a display.
json the same fields as one object, every key always present, for jq
portal \"Window: TOPLEVEL_ID\" or \"Monitor: NAME\", what
xdg-desktop-portal-wlr's simple chooser reads:
[screencast]
chooser_type=simple
chooser_cmd=wl-pick --format portal
focusing on sway:
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
";
#[derive(Default)]
pub struct Args {
pub(crate) config: Option<PathBuf>,
pub(crate) verbose: bool,
pub(crate) format: Option<Format>,
pub(crate) outputs: Option<bool>,
pub(crate) labels: Option<bool>,
pub(crate) font: Option<String>,
pub(crate) font_size: Option<f32>,
pub(crate) live: Option<Live>,
pub(crate) fps: Option<u32>,
pub(crate) timeout: Option<Duration>,
}
pub struct Options {
pub verbose: bool,
pub format: Format,
pub outputs: bool,
pub timeout: Option<Duration>,
pub display: (i32, i32),
pub settings: Settings,
}
pub fn arm_timeout(timeout: Option<Duration>) {
if let Some(d) = timeout {
std::thread::spawn(move || {
std::thread::sleep(d);
eprintln!("wl-pick: timeout");
std::process::exit(2);
});
}
}
impl Args {
pub fn resolve(&self, cfg: &Config, display: &Display) -> Options {
let base = Theme::default();
let font_px = self.font_size.or(cfg.font_size).unwrap_or(base.font_px);
let max_w = cfg
.max_width
.unwrap_or(Length::Ppt(90.0))
.resolve(display.width);
let max_h = cfg
.max_height
.unwrap_or(Length::Ppt(90.0))
.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)),
max_w: max_w.max(1),
max_h: max_h.max(1),
max_cols: cfg.max_columns.unwrap_or(base.max_cols).max(1),
max_rows: cfg.max_rows.unwrap_or(base.max_rows).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_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),
display: (display.width, display.height),
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,
output: display.name.clone(),
},
}
}
}
pub fn parse_args() -> Result<Args, String> {
parse(std::env::args().skip(1))
}
fn parse(it: impl Iterator<Item = String>) -> Result<Args, String> {
let mut args = Args::default();
let mut it = it;
while let Some(arg) = it.next() {
match arg.as_str() {
"--format" => {
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")?))
}
"-v" | "--verbose" => args.verbose = true,
"--labels" => args.labels = Some(true),
"--no-labels" | "--hide-labels" => args.labels = Some(false),
"--live" => {
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 = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?);
}
"--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}"))?);
}
"--timeout" => {
let v = it.next().ok_or("--timeout needs seconds")?;
let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?;
args.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs));
}
"-h" | "--help" => {
print!("{HELP}");
std::process::exit(0);
}
other => return Err(format!("unknown argument: {other}")),
}
}
Ok(args)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(flags: &[&str]) -> Args {
parse(flags.iter().map(|s| s.to_string())).expect("should parse")
}
fn file(on: bool) -> Config {
Config {
outputs: Some(on),
labels: Some(on),
..Config::default()
}
}
fn display() -> Display {
Display {
name: "DP-1".into(),
width: 2560,
height: 1440,
scale: 2,
focused: true,
}
}
#[test]
fn every_boolean_can_be_set_both_ways() {
assert_eq!(args(&["--outputs"]).outputs, Some(true));
assert_eq!(args(&["--no-outputs"]).outputs, Some(false));
assert_eq!(args(&["--labels"]).labels, Some(true));
assert_eq!(args(&["--no-labels"]).labels, Some(false));
assert_eq!(args(&["--hide-labels"]).labels, Some(false), "old spelling");
assert_eq!(args(&[]).outputs, None);
assert_eq!(args(&[]).labels, None);
}
#[test]
fn a_flag_beats_the_file_in_both_directions() {
let on = args(&["--outputs", "--labels"]).resolve(&file(false), &display());
assert!(on.outputs);
assert!(on.settings.theme.labels);
let off = args(&["--no-outputs", "--no-labels"]).resolve(&file(true), &display());
assert!(!off.outputs);
assert!(!off.settings.theme.labels);
assert!(!args(&[]).resolve(&file(false), &display()).outputs);
assert!(args(&[]).resolve(&Config::default(), &display()).outputs);
}
}