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:
+13
-2
@@ -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<WlBuffer>,
|
||||
pub(crate) configured: bool,
|
||||
|
||||
/// The display the overlay maps on, by name.
|
||||
pub(crate) output: String,
|
||||
|
||||
pub(crate) ending: Ending,
|
||||
pub(crate) picked: Option<Target>,
|
||||
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(),
|
||||
|
||||
+12
-1
@@ -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<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
|
||||
/// on screen or a capture is writing into it — so we must not scribble over it.
|
||||
pub struct Slot {
|
||||
|
||||
+121
-63
@@ -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 TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE, 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<PathBuf>,
|
||||
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_size: Option<f32>,
|
||||
pub(crate) live: Live,
|
||||
pub(crate) fps: u32,
|
||||
pub(crate) live: Option<Live>,
|
||||
pub(crate) fps: Option<u32>,
|
||||
pub(crate) timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
/// 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<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::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<Args, String> {
|
||||
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" => {
|
||||
|
||||
+287
@@ -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
@@ -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<ExitCode, Box<dyn Error>> {
|
||||
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 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<ExitCode, Box<dyn Error>> {
|
||||
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<ExitCode, Box<dyn Error>> {
|
||||
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<ExitCode, Box<dyn Error>> {
|
||||
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`.
|
||||
fn pump(
|
||||
queue: &mut EventQueue<App>,
|
||||
|
||||
+8
-1
@@ -66,9 +66,16 @@ impl App {
|
||||
pub fn show(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
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,
|
||||
|
||||
+34
-17
@@ -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
|
||||
/// in use, rounded up, since a buffer can be downscaled but not invented.
|
||||
pub struct Displays {
|
||||
pub(crate) names: Vec<String>,
|
||||
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<Displays, swayipc::Error> {
|
||||
let active: Vec<_> = conn
|
||||
pub fn displays(conn: &mut Connection) -> Result<Vec<Display>, 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())
|
||||
}
|
||||
|
||||
+13
-2
@@ -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<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 {
|
||||
Window,
|
||||
Output,
|
||||
|
||||
+90
-14
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user