Compare commits
10
Commits
94d8145079
...
1367b03b97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1367b03b97 | ||
|
|
78631065e2 | ||
|
|
6bd4a3ee76 | ||
|
|
a99de502e2 | ||
|
|
8e317682a1 | ||
|
|
c607fc9a1c | ||
|
|
34548fe4f6 | ||
|
|
1f5b735541 | ||
|
|
86319aa309 | ||
|
|
13c0252cef |
Generated
+1
-1
@@ -606,7 +606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wl-pick"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"cosmic-text",
|
||||
"memmap2",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wl-pick"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
description = "A live grid of window and display previews, for picking one"
|
||||
license = "MIT"
|
||||
@@ -16,7 +16,7 @@ wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
memmap2 = "0.9"
|
||||
rustix = { version = "1", features = ["fs", "mm", "shm"] }
|
||||
rustix = { version = "1", features = ["event", "fs", "mm", "shm"] }
|
||||
swayipc = "4"
|
||||
cosmic-text = "0.19"
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ is the one thing the rofi version had that this doesn't — see the roadmap.
|
||||
|
||||
```
|
||||
wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
||||
[--no-outputs] [--hide-labels] [--font FAMILY] [--font-size PX]
|
||||
[--outputs|--no-outputs] [--labels|--no-labels]
|
||||
[--order mru|tree] [--alt-tab|--no-alt-tab]
|
||||
[--font FAMILY] [--font-size PX]
|
||||
[--timeout SECS] [--verbose]
|
||||
```
|
||||
|
||||
@@ -44,8 +46,14 @@ wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
||||
- `--live all|current|none` which tiles keep updating (default `all`; displays
|
||||
are always a single snapshot)
|
||||
- `--fps N` cap on live updates per tile per second (default 12)
|
||||
- `--no-outputs` windows only; displays are included as tiles by default
|
||||
- `--hide-labels` draws an icon-only grid
|
||||
- `--outputs` / `--no-outputs` whether whole displays are tiles too (default
|
||||
off). Both directions exist so either can override the config file
|
||||
- `--labels` / `--no-labels` whether a label is drawn under each thumbnail
|
||||
(default on); `--hide-labels` is the old spelling and still works
|
||||
- `--order mru|tree` window ordering: Most-Recently-Used focus order or tree
|
||||
layout order (default `mru`)
|
||||
- `--alt-tab` / `--no-alt-tab` switcher mode: automatically pick on modifier
|
||||
release (default `auto`, enabled whenever a modifier was held on enter)
|
||||
- `--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`)
|
||||
@@ -91,12 +99,22 @@ chooser_type=simple
|
||||
chooser_cmd=wl-pick --format portal
|
||||
```
|
||||
|
||||
**Starting a second wl-pick replaces the first.** The new overlay takes the
|
||||
keyboard grab, and the one that loses it exits without printing anything — so
|
||||
hitting the keybinding twice leaves you with one overlay, not a stranded
|
||||
process. The catch is that sway answers a capture request for a toplevel
|
||||
another client is already capturing with silence — no frame, no failure — so
|
||||
the replacement's thumbnails are mostly blank until the first instance has
|
||||
gone. Every wait before the overlay is interactive is capped at two seconds
|
||||
for that reason: a tile that never arrives is drawn as a bare label, and the
|
||||
grid still works.
|
||||
|
||||
| key | |
|
||||
|---|---|
|
||||
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
|
||||
| `↓` `↑` / `j` `k` | move a row |
|
||||
| `Home` `End` / `PgUp` `PgDn` | first / last, or a screen at a time |
|
||||
| `Enter` | pick the selection |
|
||||
| `Enter` / release modifier | pick the selection (release Alt/Super in Alt+Tab mode) |
|
||||
| `Escape` / `q` | cancel |
|
||||
| click | pick that tile |
|
||||
| scroll | next / previous tile |
|
||||
@@ -150,38 +168,44 @@ 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
|
||||
max-width = 90ppt # the box the grid may fill
|
||||
max-height = 90ppt
|
||||
max-columns = 4 # thumbnails are that box divided by these
|
||||
max-rows = 4
|
||||
|
||||
font = monospace
|
||||
font-size = 13.3
|
||||
labels = yes
|
||||
outputs = yes
|
||||
outputs = no # include whole displays as tiles (default no)
|
||||
order = mru # mru (default) or tree
|
||||
alt-tab = auto # auto (default), yes or no
|
||||
live = all
|
||||
fps = 12
|
||||
format = tsv
|
||||
timeout = 0 # seconds; 0 means none
|
||||
```
|
||||
|
||||
Sizes take sway's units: `600px` is absolute, `70ppt` a percentage — and the
|
||||
Sizes take sway's units: `600px` is absolute, `90ppt` 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.
|
||||
time it runs. On a mixed setup one file gives 90% of a 1280-wide laptop panel and
|
||||
90% of a 3840-wide monitor, rather than a pixel count that suits one and looks
|
||||
wrong on the other. The overlay maps explicitly on that display, at its scale, so
|
||||
mixed-DPI renders crisply either way.
|
||||
|
||||
`tile-width` and `tile-height` set how big a thumbnail actually is. 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.
|
||||
All four sizing settings are **caps**:
|
||||
|
||||
When there are more rows than the display can show, **the grid scrolls**: the
|
||||
tile size you asked for is honoured and a scrollbar appears in the right margin.
|
||||
Any move keeps the selection in view, `PgUp`/`PgDn` jump a screen, and tiles
|
||||
scrolled out of sight are unmapped — so live capture skips them too, which is
|
||||
what stops a long list costing bandwidth for pixels nobody sees. Only a tile too
|
||||
large for even one row or column is shrunk, since then nothing could be shown at
|
||||
all.
|
||||
- `max-width` and `max-height` bound the overlay.
|
||||
- `max-columns` and `max-rows` bound the grid inside it.
|
||||
|
||||
A thumbnail is simply that box divided by those caps, which means **its size
|
||||
never depends on how many windows are open**: one window gets the same
|
||||
thumbnail as thirty, in a smaller overlay, because the overlay hugs whatever is
|
||||
actually there. Rows past `max-rows` scroll, with a scrollbar in the right
|
||||
margin, `PgUp`/`PgDn`, and the selection always kept in view. Tiles scrolled out
|
||||
of sight are unmapped, so live capture skips them too.
|
||||
|
||||
Turning labels off gives that row back to the thumbnails rather than shrinking
|
||||
the window, since the box is what you asked for either way.
|
||||
|
||||
## Look
|
||||
|
||||
@@ -213,6 +237,12 @@ only sway is tested. sway is also the source of truth for the window list, over
|
||||
its IPC socket, which is the one thing that would need replacing to run
|
||||
elsewhere (`ext-foreign-toplevel-list-v1` already reports app id and title).
|
||||
|
||||
The socket is found from `SWAYSOCK`/`I3SOCK` when those point at something that
|
||||
exists, and otherwise by looking for the running sway's socket in
|
||||
`$XDG_RUNTIME_DIR`. Inheriting a stale path is easy — any process that outlives
|
||||
the sway that started it hands one to every shell it spawns — and a picker on a
|
||||
keybinding should not be the thing that notices.
|
||||
|
||||
Known upstream issue: holding per-toplevel capture sessions open makes windows
|
||||
blurry on **fractionally scaled** outputs
|
||||
([sway#9113](https://github.com/swaywm/sway/issues/9113)). Integer scales are
|
||||
|
||||
+71
-12
@@ -37,12 +37,17 @@ use wayland_protocols::wp::cursor_shape::v1::client::{
|
||||
wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
||||
wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
|
||||
};
|
||||
use wayland_protocols::wp::keyboard_shortcuts_inhibit::zv1::client::{
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1,
|
||||
zwp_keyboard_shortcuts_inhibitor_v1::ZwpKeyboardShortcutsInhibitorV1,
|
||||
};
|
||||
use wayland_protocols::wp::viewporter::client::{
|
||||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
||||
};
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
||||
|
||||
use crate::capture::{Live, Tile};
|
||||
use crate::config::AltTabMode;
|
||||
use crate::overlay;
|
||||
use crate::shm;
|
||||
use crate::target::Target;
|
||||
@@ -56,13 +61,11 @@ pub struct Settings {
|
||||
pub theme: Theme,
|
||||
pub live: Live,
|
||||
pub fps: u32,
|
||||
/// Integer scale of the display the overlay renders on.
|
||||
/// Integer scale of the display the overlay renders on, and its name, so
|
||||
/// the overlay maps there rather than wherever the compositor would put it.
|
||||
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 alt_tab: AltTabMode,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
@@ -90,8 +93,6 @@ pub struct App {
|
||||
pub(crate) sel: usize,
|
||||
/// First row of the grid on screen. The rest scroll.
|
||||
pub(crate) scroll: i32,
|
||||
/// Set when the viewport moved and the subsurfaces need re-placing.
|
||||
pub(crate) needs_tiles: bool,
|
||||
pub(crate) shift: bool,
|
||||
|
||||
/// Where the pointer is, and which tile it pressed. Hovering deliberately
|
||||
@@ -114,8 +115,18 @@ pub struct App {
|
||||
pub(crate) output: String,
|
||||
|
||||
pub(crate) ending: Ending,
|
||||
/// Whether we hold the keyboard. Without it the overlay cannot be operated.
|
||||
pub(crate) focused: bool,
|
||||
pub(crate) picked: Option<Target>,
|
||||
pub(crate) stats: Stats,
|
||||
|
||||
pub(crate) seat: Option<WlSeat>,
|
||||
pub(crate) inhibit_mgr: Option<ZwpKeyboardShortcutsInhibitManagerV1>,
|
||||
pub(crate) inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>,
|
||||
pub(crate) alt_tab: AltTabMode,
|
||||
pub(crate) is_alt_tab: bool,
|
||||
pub(crate) latched_modifiers: std::collections::BTreeSet<u32>,
|
||||
pub(crate) initial_stepped: bool,
|
||||
}
|
||||
|
||||
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
|
||||
@@ -139,6 +150,8 @@ pub enum Ending {
|
||||
Picked,
|
||||
Cancelled,
|
||||
Closed,
|
||||
/// The keyboard went to another surface, so we can no longer be operated.
|
||||
Unfocused,
|
||||
}
|
||||
|
||||
impl Ending {
|
||||
@@ -148,6 +161,7 @@ impl Ending {
|
||||
Ending::Picked => "picked",
|
||||
Ending::Cancelled => "cancelled",
|
||||
Ending::Closed => "the compositor closed the overlay",
|
||||
Ending::Unfocused => "lost the keyboard to another surface",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,16 +172,23 @@ impl App {
|
||||
qh: &QueueHandle<Self>,
|
||||
targets: Vec<Target>,
|
||||
settings: Settings,
|
||||
layout: Layout,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let Settings {
|
||||
theme,
|
||||
live,
|
||||
fps,
|
||||
scale,
|
||||
display,
|
||||
output,
|
||||
alt_tab,
|
||||
} = settings;
|
||||
let layout = Layout::new(&theme, targets.len() as i32, display);
|
||||
let focused_idx = targets.iter().position(|t| t.focused).unwrap_or(0);
|
||||
let sel = if alt_tab == AltTabMode::Yes && targets.len() > 1 {
|
||||
(focused_idx + 1) % targets.len()
|
||||
} else {
|
||||
focused_idx
|
||||
};
|
||||
let is_alt_tab = alt_tab == AltTabMode::Yes;
|
||||
// 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 {
|
||||
@@ -188,9 +209,8 @@ impl App {
|
||||
live,
|
||||
fps,
|
||||
scale,
|
||||
sel: 0,
|
||||
sel,
|
||||
scroll: 0,
|
||||
needs_tiles: false,
|
||||
shift: false,
|
||||
hover: None,
|
||||
pressed: None,
|
||||
@@ -203,8 +223,16 @@ impl App {
|
||||
configured: false,
|
||||
output,
|
||||
ending: Ending::Running,
|
||||
focused: false,
|
||||
picked: None,
|
||||
stats: Stats::default(),
|
||||
seat: None,
|
||||
inhibit_mgr: None,
|
||||
inhibitor: None,
|
||||
alt_tab,
|
||||
is_alt_tab,
|
||||
latched_modifiers: std::collections::BTreeSet::new(),
|
||||
initial_stepped: alt_tab == AltTabMode::Yes,
|
||||
};
|
||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
||||
// One wl_output per display, bound at v4 so it tells us its name.
|
||||
@@ -217,7 +245,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||
let seat: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||
app.seat = Some(seat);
|
||||
app.inhibit_mgr = globals.bind(qh, 1..=1, ()).ok();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
@@ -279,6 +309,25 @@ impl App {
|
||||
pub fn captures_settled(&self) -> bool {
|
||||
self.tiles.iter().all(|t| t.settled)
|
||||
}
|
||||
|
||||
/// Say which tiles the compositor went quiet on, and name the likeliest
|
||||
/// reason: sway answers a capture request on a toplevel that another client
|
||||
/// is already capturing with silence rather than with `failed`.
|
||||
pub fn report_unsettled(&self) {
|
||||
let stuck: Vec<&str> = self
|
||||
.tiles
|
||||
.iter()
|
||||
.filter(|t| !t.settled)
|
||||
.map(|t| t.target.title.as_str())
|
||||
.collect();
|
||||
eprintln!(
|
||||
"wl-pick: no frame for {} of {} tiles ({}); \
|
||||
another capture client may hold these sources",
|
||||
stuck.len(),
|
||||
self.tiles.len(),
|
||||
stuck.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- enumeration ----------------------------------------------------------
|
||||
@@ -369,3 +418,13 @@ delegate_noop!(App: ignore WlSurface);
|
||||
delegate_noop!(App: WpCursorShapeManagerV1);
|
||||
delegate_noop!(App: WpCursorShapeDeviceV1);
|
||||
delegate_noop!(App: ignore WlBuffer);
|
||||
delegate_noop!(App: ZwpKeyboardShortcutsInhibitManagerV1);
|
||||
delegate_noop!(App: ignore ZwpKeyboardShortcutsInhibitorV1);
|
||||
|
||||
impl Drop for App {
|
||||
fn drop(&mut self) {
|
||||
if let Some(inhibitor) = self.inhibitor.take() {
|
||||
inhibitor.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,8 +398,6 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
||||
app.frame_ready(i);
|
||||
}
|
||||
ext_image_copy_capture_frame_v1::Event::Failed { reason } => {
|
||||
// Live mode just retries on the next tick; only a failure with no
|
||||
// frame yet leaves the tile without a thumbnail.
|
||||
// Live mode retries on the next tick; only a failure with no
|
||||
// frame yet leaves the tile without a thumbnail.
|
||||
if tile.frames == 0 {
|
||||
|
||||
+130
-25
@@ -5,8 +5,8 @@ use std::time::Duration;
|
||||
|
||||
use crate::app::Settings;
|
||||
use crate::capture::Live;
|
||||
use crate::config::Config;
|
||||
use crate::sway::Display;
|
||||
use crate::config::{AltTabMode, Config, Length};
|
||||
use crate::sway::{Display, Order};
|
||||
use crate::target::Format;
|
||||
use crate::theme::Theme;
|
||||
|
||||
@@ -20,8 +20,11 @@ usage: wl-pick [options]
|
||||
--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]
|
||||
--no-outputs windows only; displays are included by default
|
||||
--hide-labels draw an icon-only grid
|
||||
--outputs, --no-outputs include whole displays as tiles [no]
|
||||
--labels, --no-labels a label under each thumbnail [yes]
|
||||
--order mru|tree window ordering: mru or layout tree [mru]
|
||||
--alt-tab, --no-alt-tab alt-tab switcher mode (commit on release) [auto]
|
||||
--focus, --no-focus focus the picked target in sway directly [no]
|
||||
--font FAMILY label font family [the system monospace font]
|
||||
--font-size PX label size in logical px [13.3]
|
||||
--timeout SECS exit anyway after SECS, in case the keyboard
|
||||
@@ -30,7 +33,8 @@ usage: wl-pick [options]
|
||||
-h, --help this
|
||||
|
||||
keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump;
|
||||
Enter picks; Escape or q cancels
|
||||
Enter picks; Escape or q cancels; in Alt+Tab mode, releasing
|
||||
the modifier (Alt/Super) picks the selection.
|
||||
mouse: click a tile to pick it, scroll to move. Hovering does not move the
|
||||
selection, and a click outside a tile does nothing.
|
||||
|
||||
@@ -51,17 +55,23 @@ config:
|
||||
border = #d79921
|
||||
border-width = 2px
|
||||
|
||||
tile-width = 18ppt # how big a thumbnail is
|
||||
tile-height = 20ppt # defaults to the display's aspect
|
||||
max-columns = 4 # rows beyond the screen scroll
|
||||
max-width = 90ppt # the box the grid may fill
|
||||
max-height = 90ppt
|
||||
max-columns = 4 # thumbnails are the box divided by these,
|
||||
max-rows = 4 # so their size never depends on how many
|
||||
# windows are open; further rows scroll
|
||||
|
||||
font = monospace # also --font
|
||||
font-size = 13.3
|
||||
labels = yes
|
||||
outputs = yes # include whole displays as tiles
|
||||
outputs = no # include whole displays as tiles
|
||||
order = mru # mru or tree
|
||||
alt-tab = auto # auto, yes or no
|
||||
focus = no # focus picked target in sway directly
|
||||
live = all
|
||||
fps = 12
|
||||
format = tsv
|
||||
timeout = 0 # seconds; 0 means none
|
||||
|
||||
formats:
|
||||
|
||||
@@ -86,6 +96,8 @@ formats:
|
||||
|
||||
focusing on sway:
|
||||
|
||||
wl-pick --focus or:
|
||||
|
||||
IFS=$'\\t' read -r type id toplevel app title < <(wl-pick) &&
|
||||
case $type in
|
||||
window) swaymsg \"[con_id=$id] focus\" ;;
|
||||
@@ -107,6 +119,9 @@ pub struct Args {
|
||||
pub(crate) live: Option<Live>,
|
||||
pub(crate) fps: Option<u32>,
|
||||
pub(crate) timeout: Option<Duration>,
|
||||
pub(crate) order: Option<Order>,
|
||||
pub(crate) alt_tab: Option<AltTabMode>,
|
||||
pub(crate) focus: Option<bool>,
|
||||
}
|
||||
|
||||
/// Every setting resolved, with sizes turned into pixels for the display the
|
||||
@@ -116,6 +131,10 @@ pub struct Options {
|
||||
pub format: Format,
|
||||
pub outputs: bool,
|
||||
pub timeout: Option<Duration>,
|
||||
pub order: Order,
|
||||
pub focus: bool,
|
||||
/// The logical size of the display the grid will be laid out for.
|
||||
pub display: (i32, i32),
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
@@ -138,15 +157,16 @@ 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 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),
|
||||
);
|
||||
// The box the grid may fill. Left alone it is most of the display, and
|
||||
// being a percentage it travels between monitors.
|
||||
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),
|
||||
@@ -156,9 +176,10 @@ impl Args {
|
||||
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_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
|
||||
@@ -176,23 +197,30 @@ impl Args {
|
||||
Options {
|
||||
verbose: self.verbose,
|
||||
format: self.format.or(cfg.format).unwrap_or(Format::Tsv),
|
||||
outputs: self.outputs.or(cfg.outputs).unwrap_or(true),
|
||||
outputs: self.outputs.or(cfg.outputs).unwrap_or(false),
|
||||
timeout: self.timeout.or(cfg.timeout),
|
||||
order: self.order.or(cfg.order).unwrap_or(Order::Mru),
|
||||
focus: self.focus.or(cfg.focus).unwrap_or(false),
|
||||
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,
|
||||
display: (display.width, display.height),
|
||||
output: display.name.clone(),
|
||||
alt_tab: self.alt_tab.or(cfg.alt_tab).unwrap_or(AltTabMode::Auto),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = std::env::args().skip(1);
|
||||
let mut it = it;
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--format" => {
|
||||
@@ -201,11 +229,22 @@ pub fn parse_args() -> Result<Args, String> {
|
||||
}
|
||||
"--outputs" => args.outputs = Some(true),
|
||||
"--no-outputs" => args.outputs = Some(false),
|
||||
"--order" => {
|
||||
let v = it.next().ok_or("--order needs mru|tree")?;
|
||||
args.order = Some(Order::parse(&v)?);
|
||||
}
|
||||
"--alt-tab" => args.alt_tab = Some(AltTabMode::Yes),
|
||||
"--no-alt-tab" => args.alt_tab = Some(AltTabMode::No),
|
||||
"--focus" => args.focus = Some(true),
|
||||
"--no-focus" => args.focus = Some(false),
|
||||
"--config" => {
|
||||
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?))
|
||||
}
|
||||
"-v" | "--verbose" => args.verbose = true,
|
||||
"--hide-labels" => args.labels = Some(false),
|
||||
"--labels" => args.labels = Some(true),
|
||||
// --hide-labels was the only spelling before --labels existed, and
|
||||
// is still accepted for whatever it is wired into.
|
||||
"--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)?);
|
||||
@@ -222,7 +261,7 @@ pub fn parse_args() -> Result<Args, String> {
|
||||
"--timeout" => {
|
||||
let v = it.next().ok_or("--timeout needs seconds")?;
|
||||
let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?;
|
||||
args.timeout = Some(Duration::from_secs_f64(secs));
|
||||
args.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs));
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print!("{HELP}");
|
||||
@@ -233,3 +272,69 @@ pub fn parse_args() -> Result<Args, String> {
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(flags: &[&str]) -> Args {
|
||||
parse(flags.iter().map(|s| s.to_string())).expect("should parse")
|
||||
}
|
||||
|
||||
/// A config file that sets both booleans the same way.
|
||||
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(&["--alt-tab"]).alt_tab, Some(AltTabMode::Yes));
|
||||
assert_eq!(args(&["--no-alt-tab"]).alt_tab, Some(AltTabMode::No));
|
||||
// Unset is what lets the file have its say.
|
||||
assert_eq!(args(&[]).outputs, None);
|
||||
assert_eq!(args(&[]).labels, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flag_beats_the_file_in_both_directions() {
|
||||
// Turning something back on is the case that used to be unsayable:
|
||||
// there was a --no-outputs but no --outputs, so a config saying no
|
||||
// could not be overridden from the command line at all.
|
||||
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);
|
||||
|
||||
// With no flag the file decides, and with no file either, the default (outputs: false).
|
||||
assert!(!args(&[]).resolve(&file(false), &display()).outputs);
|
||||
assert!(!args(&[]).resolve(&Config::default(), &display()).outputs);
|
||||
assert!(args(&["--outputs"]).resolve(&Config::default(), &display()).outputs);
|
||||
assert_eq!(args(&[]).resolve(&Config::default(), &display()).order, Order::Mru);
|
||||
assert_eq!(
|
||||
args(&["--order", "tree"]).resolve(&Config::default(), &display()).order,
|
||||
Order::Tree
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+65
-13
@@ -15,9 +15,29 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::capture::Live;
|
||||
use crate::sway::Order;
|
||||
use crate::target::Format;
|
||||
use crate::theme::Argb;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum AltTabMode {
|
||||
#[default]
|
||||
Auto,
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
impl AltTabMode {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.trim() {
|
||||
"auto" => Ok(AltTabMode::Auto),
|
||||
"yes" | "true" | "on" | "1" => Ok(AltTabMode::Yes),
|
||||
"no" | "false" | "off" | "0" => Ok(AltTabMode::No),
|
||||
other => Err(format!("{other:?} is not auto, yes or no")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A size, either absolute or relative to the display it will be shown on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Length {
|
||||
@@ -83,11 +103,13 @@ pub struct Config {
|
||||
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>,
|
||||
/// The box the grid may not exceed. Thumbnails are this divided by the
|
||||
/// column and row caps, so their size does not depend on how many windows
|
||||
/// happen to be open.
|
||||
pub max_width: Option<Length>,
|
||||
pub max_height: Option<Length>,
|
||||
pub max_columns: Option<i32>,
|
||||
pub max_rows: Option<i32>,
|
||||
pub font: Option<String>,
|
||||
pub font_size: Option<f32>,
|
||||
pub labels: Option<bool>,
|
||||
@@ -96,6 +118,9 @@ pub struct Config {
|
||||
pub fps: Option<u32>,
|
||||
pub format: Option<Format>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub order: Option<Order>,
|
||||
pub alt_tab: Option<AltTabMode>,
|
||||
pub focus: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -138,11 +163,10 @@ impl Config {
|
||||
"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)?);
|
||||
}
|
||||
"max-width" => self.max_width = Some(Length::parse(value)?),
|
||||
"max-height" => self.max_height = Some(Length::parse(value)?),
|
||||
"max-columns" => self.max_columns = Some(number(value)?),
|
||||
"max-rows" => self.max_rows = Some(number(value)?),
|
||||
"font" => self.font = Some(value.to_string()),
|
||||
"font-size" => self.font_size = Some(number(value)?),
|
||||
"labels" => self.labels = Some(boolean(value)?),
|
||||
@@ -150,7 +174,15 @@ impl Config {
|
||||
"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)?)),
|
||||
// Zero is how you say "no timeout"; an immediate deadline would
|
||||
// only ever be a mistake.
|
||||
"timeout" => {
|
||||
let secs: f64 = number(value)?;
|
||||
self.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs));
|
||||
}
|
||||
"order" => self.order = Some(Order::parse(value)?),
|
||||
"alt-tab" => self.alt_tab = Some(AltTabMode::parse(value)?),
|
||||
"focus" => self.focus = Some(boolean(value)?),
|
||||
other => return Err(format!("unknown setting {other:?}")),
|
||||
}
|
||||
Ok(())
|
||||
@@ -228,26 +260,34 @@ background = #282828
|
||||
selection = #d79921 # trailing comment
|
||||
border-width = 2px
|
||||
|
||||
tile-width = 18ppt
|
||||
max-width = 70ppt
|
||||
max-columns = 4
|
||||
max-rows = 3
|
||||
|
||||
live = current
|
||||
fps = 30
|
||||
labels = no
|
||||
timeout = 0
|
||||
order = mru
|
||||
alt-tab = yes
|
||||
",
|
||||
)
|
||||
.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_width, Some(Length::Ppt(70.0)));
|
||||
assert_eq!(cfg.max_columns, Some(4));
|
||||
assert_eq!(cfg.max_rows, Some(3));
|
||||
assert_eq!(cfg.fps, Some(30));
|
||||
assert_eq!(cfg.labels, Some(false));
|
||||
assert_eq!(cfg.timeout, None, "zero means no timeout");
|
||||
assert_eq!(cfg.order, Some(Order::Mru));
|
||||
assert_eq!(cfg.alt_tab, Some(AltTabMode::Yes));
|
||||
assert!(cfg.live.is_some());
|
||||
// Untouched settings stay unset, so defaults survive.
|
||||
assert_eq!(cfg.foreground, None);
|
||||
assert_eq!(cfg.tile_height, None);
|
||||
assert_eq!(cfg.max_height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -284,4 +324,16 @@ labels = no
|
||||
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/nonexistent") };
|
||||
assert!(Config::load(None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_tab_mode_parses() {
|
||||
assert_eq!(AltTabMode::parse("auto"), Ok(AltTabMode::Auto));
|
||||
assert_eq!(AltTabMode::parse("yes"), Ok(AltTabMode::Yes));
|
||||
assert_eq!(AltTabMode::parse("true"), Ok(AltTabMode::Yes));
|
||||
assert_eq!(AltTabMode::parse("1"), Ok(AltTabMode::Yes));
|
||||
assert_eq!(AltTabMode::parse("no"), Ok(AltTabMode::No));
|
||||
assert_eq!(AltTabMode::parse("false"), Ok(AltTabMode::No));
|
||||
assert_eq!(AltTabMode::parse("0"), Ok(AltTabMode::No));
|
||||
assert!(AltTabMode::parse("maybe").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+118
-28
@@ -36,16 +36,27 @@ mod theme;
|
||||
|
||||
use std::error::Error;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustix::event::{PollFd, PollFlags, Timespec};
|
||||
use wayland_client::globals::registry_queue_init;
|
||||
use wayland_client::{Connection, EventQueue};
|
||||
|
||||
use app::App;
|
||||
use app::{App, Ending};
|
||||
use config::Config;
|
||||
use target::Target;
|
||||
use theme::Layout;
|
||||
|
||||
/// How long the phases before the overlay is interactive may take. Capture
|
||||
/// measures ~90ms for fourteen windows, so this is a wide margin around
|
||||
/// anything healthy, and only a stall reaches it.
|
||||
const STARTUP_BUDGET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How long a keyboard leave is given to turn out to be a focus refresh rather
|
||||
/// than a real loss. sway's pair arrives microseconds apart; this is only long
|
||||
/// enough to be sure, and short enough that a real handover looks instant.
|
||||
const REFOCUS_GRACE: Duration = Duration::from_millis(150);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
@@ -57,24 +68,21 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let args = cli::parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?;
|
||||
let config =
|
||||
Config::load(args.config.as_deref()).map_err(|e| -> Box<dyn Error> { e.into() })?;
|
||||
let args = cli::parse_args().map_err(Box::<dyn Error>::from)?;
|
||||
let config = Config::load(args.config.as_deref()).map_err(Box::<dyn Error>::from)?;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut phases = Phases::new(args.verbose);
|
||||
// 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")
|
||||
})?;
|
||||
let mut sway = sway::connect()?;
|
||||
// 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)?;
|
||||
let mut targets = sway::windows(&mut sway, opts.order)?;
|
||||
if opts.outputs {
|
||||
// Displays go last, after the windows, so window positions are
|
||||
// stable as windows come and go.
|
||||
@@ -88,27 +96,29 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
phases.mark("sway-tree");
|
||||
|
||||
cli::arm_timeout(opts.timeout);
|
||||
let settings = opts.settings;
|
||||
let (display, settings) = (opts.display, opts.settings);
|
||||
let theme = &settings.theme;
|
||||
let scale = settings.scale;
|
||||
// The grid is measured once here: the label shaping below and the overlay
|
||||
// itself must agree about how wide a label may be.
|
||||
let layout = Layout::new(theme, targets.len() as i32, display);
|
||||
// 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, settings.display);
|
||||
let labels = layout.label(0, 0).map(|label| {
|
||||
text::spawn(
|
||||
targets.iter().map(Target::label).collect(),
|
||||
theme.font.clone(),
|
||||
theme.font_px * scale as f32,
|
||||
(theme.line_h * scale) as f32,
|
||||
(layout.label(0, 0).map(|r| r.w).unwrap_or(theme.tile_w) * scale) as f32,
|
||||
(label.w * scale) as f32,
|
||||
)
|
||||
});
|
||||
|
||||
let conn = Connection::connect_to_env()?;
|
||||
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
||||
let qh = queue.handle();
|
||||
let mut app = App::new(&globals, &qh, targets, settings)?;
|
||||
let mut app = App::new(&globals, &qh, targets, settings, layout)?;
|
||||
|
||||
// Two roundtrips: one for the toplevel list, one for each handle's state.
|
||||
queue.roundtrip(&mut app)?;
|
||||
@@ -120,7 +130,18 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
phases.mark("constraints");
|
||||
|
||||
app.start_captures(&qh)?;
|
||||
pump(&mut queue, &mut app, |a| a.captures_settled())?;
|
||||
// Tiles that never delivered are shown as labels without a thumbnail,
|
||||
// exactly as an outright capture failure is. Better a grid you can use
|
||||
// than a process you have to hunt down.
|
||||
if !pump_for(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut app,
|
||||
|a| a.captures_settled(),
|
||||
STARTUP_BUDGET,
|
||||
)? {
|
||||
app.report_unsettled();
|
||||
}
|
||||
phases.mark("capture");
|
||||
|
||||
if let Some(job) = labels {
|
||||
@@ -132,20 +153,42 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
}
|
||||
|
||||
app.show(&qh)?;
|
||||
pump(&mut queue, &mut app, |a| a.configured)?;
|
||||
if !pump_for(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut app,
|
||||
|a| a.configured,
|
||||
STARTUP_BUDGET,
|
||||
)? {
|
||||
return Err("the compositor never configured the overlay".into());
|
||||
}
|
||||
app.paint();
|
||||
app.sync_tiles(&qh);
|
||||
app.arm_frame_callback(&qh);
|
||||
conn.flush()?;
|
||||
phases.mark("mapped");
|
||||
|
||||
// Scrolling re-places the subsurfaces; doing it here rather than inside the
|
||||
// key handler coalesces a held-down arrow into one update per dispatch.
|
||||
while !app.finished() {
|
||||
// The keyboard grab is what makes the overlay usable, so losing it for
|
||||
// good ends the run: that is how a second wl-pick, started from the same
|
||||
// keybinding, replaces the first instead of leaving it stranded on screen.
|
||||
// A leave only counts once it has failed to come back, because sway also
|
||||
// cycles focus off and on in a single batch as the pointer crosses us.
|
||||
loop {
|
||||
queue.blocking_dispatch(&mut app)?;
|
||||
if std::mem::take(&mut app.needs_tiles) {
|
||||
app.sync_tiles(&qh);
|
||||
conn.flush()?;
|
||||
if !app.finished()
|
||||
&& !app.focused
|
||||
&& !pump_for(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut app,
|
||||
|a| a.focused || a.finished(),
|
||||
REFOCUS_GRACE,
|
||||
)?
|
||||
{
|
||||
app.ending = Ending::Unfocused;
|
||||
}
|
||||
if app.finished() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if opts.verbose {
|
||||
@@ -155,6 +198,20 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let Some(target) = app.picked() else {
|
||||
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
||||
};
|
||||
if opts.focus {
|
||||
if let Ok(mut sway) = sway::connect() {
|
||||
match target.kind {
|
||||
target::Kind::Window => {
|
||||
if let Some(con_id) = target.con_id {
|
||||
let _ = sway.run_command(format!("[con_id={con_id}] focus"));
|
||||
}
|
||||
}
|
||||
target::Kind::Output => {
|
||||
let _ = sway.run_command(format!("focus output {}", target.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match target.render(opts.format) {
|
||||
Some(line) => println!("{line}"),
|
||||
// Only the portal format can fail to name something: it identifies a
|
||||
@@ -167,16 +224,49 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// Run the event loop until `done`.
|
||||
fn pump(
|
||||
/// Run the event loop until `done`, or until `limit` has passed. Returns
|
||||
/// whether `done` came true in time.
|
||||
///
|
||||
/// Every wait before the overlay is interactive is bounded, because a
|
||||
/// compositor is entitled to simply never answer. sway does exactly that for a
|
||||
/// capture request on a toplevel another client is already capturing: no frame,
|
||||
/// no `failed`, no `stopped`, just silence — and an unbounded wait on that is a
|
||||
/// picker with no window that has to be killed from another terminal.
|
||||
fn pump_for(
|
||||
conn: &Connection,
|
||||
queue: &mut EventQueue<App>,
|
||||
app: &mut App,
|
||||
done: impl Fn(&App) -> bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
while !done(app) {
|
||||
queue.blocking_dispatch(app)?;
|
||||
limit: Duration,
|
||||
) -> Result<bool, Box<dyn Error>> {
|
||||
let deadline = Instant::now() + limit;
|
||||
loop {
|
||||
queue.dispatch_pending(app)?;
|
||||
if done(app) {
|
||||
return Ok(true);
|
||||
}
|
||||
conn.flush()?;
|
||||
// No guard means events arrived while we were asking; go read them.
|
||||
let Some(guard) = conn.prepare_read() else {
|
||||
continue;
|
||||
};
|
||||
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let fd = guard.connection_fd();
|
||||
let mut fds = [PollFd::new(&fd, PollFlags::IN)];
|
||||
let timeout = Timespec {
|
||||
tv_sec: left.as_secs() as _,
|
||||
tv_nsec: left.subsec_nanos() as _,
|
||||
};
|
||||
match rustix::event::poll(&mut fds, Some(&timeout)) {
|
||||
Ok(0) => return Ok(false),
|
||||
// An interrupted poll has simply not waited its full time yet.
|
||||
Ok(_) | Err(rustix::io::Errno::INTR) => {}
|
||||
Err(e) => return Err(Box::new(e)),
|
||||
}
|
||||
guard.read()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase timings, printed with --verbose. Opening latency is the whole point of
|
||||
|
||||
+118
-31
@@ -24,6 +24,7 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
|
||||
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
|
||||
|
||||
use crate::app::{App, Ending};
|
||||
use crate::config::AltTabMode;
|
||||
use crate::shm;
|
||||
use crate::theme::{Rect, fit_centred};
|
||||
|
||||
@@ -32,23 +33,41 @@ use crate::theme::{Rect, fit_centred};
|
||||
const KEY_ESC: u32 = 1;
|
||||
const KEY_TAB: u32 = 15;
|
||||
const KEY_Q: u32 = 16;
|
||||
const KEY_ENTER: u32 = 28;
|
||||
const KEY_LEFTCTRL: u32 = 29;
|
||||
// hjkl, by physical position: the same keys as vim on a qwerty layout.
|
||||
const KEY_H: u32 = 35;
|
||||
const KEY_J: u32 = 36;
|
||||
const KEY_K: u32 = 37;
|
||||
const KEY_L: u32 = 38;
|
||||
const KEY_ENTER: u32 = 28;
|
||||
const KEY_LEFTSHIFT: u32 = 42;
|
||||
const KEY_RIGHTSHIFT: u32 = 54;
|
||||
const KEY_LEFTALT: u32 = 56;
|
||||
const KEY_KPENTER: u32 = 96;
|
||||
const KEY_RIGHTCTRL: u32 = 97;
|
||||
const KEY_RIGHTALT: u32 = 100;
|
||||
const KEY_HOME: u32 = 102;
|
||||
const KEY_UP: u32 = 103;
|
||||
const KEY_PGUP: u32 = 104;
|
||||
const KEY_LEFT: u32 = 105;
|
||||
const KEY_RIGHT: u32 = 106;
|
||||
const KEY_END: u32 = 107;
|
||||
const KEY_DOWN: u32 = 108;
|
||||
const KEY_PGUP: u32 = 104;
|
||||
const KEY_PGDN: u32 = 109;
|
||||
const KEY_LEFTMETA: u32 = 125;
|
||||
const KEY_RIGHTMETA: u32 = 126;
|
||||
|
||||
fn is_trigger_modifier(code: u32) -> bool {
|
||||
matches!(
|
||||
code,
|
||||
KEY_LEFTALT
|
||||
| KEY_RIGHTALT
|
||||
| KEY_LEFTMETA
|
||||
| KEY_RIGHTMETA
|
||||
| KEY_LEFTCTRL
|
||||
| KEY_RIGHTCTRL
|
||||
)
|
||||
}
|
||||
|
||||
/// evdev button code, as wl_pointer reports it.
|
||||
const BTN_LEFT: u32 = 0x110;
|
||||
@@ -105,6 +124,9 @@ impl App {
|
||||
}
|
||||
pool.destroy();
|
||||
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
|
||||
if let (Some(mgr), Some(seat)) = (&self.inhibit_mgr, &self.seat) {
|
||||
self.inhibitor = Some(mgr.inhibit_shortcuts(&surface, seat, qh, ()));
|
||||
}
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
@@ -236,30 +258,31 @@ impl App {
|
||||
surface.commit();
|
||||
}
|
||||
|
||||
fn move_sel(&mut self, delta: i32) {
|
||||
fn move_sel(&mut self, delta: i32, qh: &QueueHandle<Self>) {
|
||||
let n = self.tiles.len() as i32;
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.select((self.sel as i32 + delta).rem_euclid(n) as usize);
|
||||
self.select((self.sel as i32 + delta).rem_euclid(n) as usize, qh);
|
||||
}
|
||||
|
||||
fn move_row(&mut self, rows: i32) {
|
||||
fn move_row(&mut self, rows: i32, qh: &QueueHandle<Self>) {
|
||||
let n = self.tiles.len() as i32;
|
||||
let target = self.sel as i32 + rows * self.layout.cols;
|
||||
if target >= 0 && target < n {
|
||||
self.select(target as usize);
|
||||
self.select(target as usize, qh);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the selection, scrolling the least that keeps it on screen. Every
|
||||
/// keyboard move goes through here, so the selection is never off-view.
|
||||
fn select(&mut self, i: usize) {
|
||||
/// move goes through here, so the selection is never off-view and the
|
||||
/// subsurfaces always match the viewport.
|
||||
fn select(&mut self, i: usize, qh: &QueueHandle<Self>) {
|
||||
self.sel = i;
|
||||
let scroll = self.layout.reveal(i, self.scroll);
|
||||
if scroll != self.scroll {
|
||||
self.scroll = scroll;
|
||||
self.needs_tiles = true;
|
||||
self.sync_tiles(qh);
|
||||
}
|
||||
self.paint();
|
||||
}
|
||||
@@ -294,7 +317,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, code: u32) {
|
||||
fn key(&mut self, code: u32, qh: &QueueHandle<Self>) {
|
||||
if self.is_alt_tab && is_trigger_modifier(code) {
|
||||
self.latched_modifiers.insert(code);
|
||||
}
|
||||
match code {
|
||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||
KEY_ESC | KEY_Q => self.ending = Ending::Cancelled,
|
||||
@@ -302,18 +328,74 @@ impl App {
|
||||
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||
self.ending = Ending::Picked;
|
||||
}
|
||||
KEY_TAB if self.shift => self.move_sel(-1),
|
||||
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1),
|
||||
KEY_LEFT | KEY_H => self.move_sel(-1),
|
||||
KEY_DOWN | KEY_J => self.move_row(1),
|
||||
KEY_UP | KEY_K => self.move_row(-1),
|
||||
KEY_HOME => self.select(0),
|
||||
KEY_END => self.select(self.tiles.len().saturating_sub(1)),
|
||||
KEY_PGUP => self.move_row(-self.layout.visible_rows),
|
||||
KEY_PGDN => self.move_row(self.layout.visible_rows),
|
||||
KEY_TAB if self.shift => self.move_sel(-1, qh),
|
||||
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1, qh),
|
||||
KEY_LEFT | KEY_H => self.move_sel(-1, qh),
|
||||
KEY_DOWN | KEY_J => self.move_row(1, qh),
|
||||
KEY_UP | KEY_K => self.move_row(-1, qh),
|
||||
KEY_HOME => self.select(0, qh),
|
||||
KEY_END => self.select(self.tiles.len().saturating_sub(1), qh),
|
||||
KEY_PGUP => self.move_row(-self.layout.visible_rows, qh),
|
||||
KEY_PGDN => self.move_row(self.layout.visible_rows, qh),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_up(&mut self, code: u32) {
|
||||
if code == KEY_LEFTSHIFT || code == KEY_RIGHTSHIFT {
|
||||
self.shift = false;
|
||||
}
|
||||
if self.is_alt_tab
|
||||
&& self.latched_modifiers.remove(&code)
|
||||
&& self.latched_modifiers.is_empty()
|
||||
{
|
||||
if self.ending == Ending::Running {
|
||||
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||
self.ending = Ending::Picked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard_enter(&mut self, keys: Vec<u8>, qh: &QueueHandle<Self>) {
|
||||
self.focused = true;
|
||||
let held_keys: Vec<u32> = keys
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap()))
|
||||
.collect();
|
||||
|
||||
if held_keys.iter().any(|&k| k == KEY_LEFTSHIFT || k == KEY_RIGHTSHIFT) {
|
||||
self.shift = true;
|
||||
}
|
||||
|
||||
let held_modifiers: Vec<u32> = held_keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&k| is_trigger_modifier(k))
|
||||
.collect();
|
||||
|
||||
if !held_modifiers.is_empty() && self.alt_tab != AltTabMode::No {
|
||||
self.is_alt_tab = true;
|
||||
for &m in &held_modifiers {
|
||||
self.latched_modifiers.insert(m);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_alt_tab && !self.initial_stepped {
|
||||
self.initial_stepped = true;
|
||||
if self.alt_tab == AltTabMode::Yes && self.latched_modifiers.is_empty() {
|
||||
// In explicit alt-tab mode, if no modifier was held on enter,
|
||||
// the modifier was released before focus was acquired: commit immediately!
|
||||
if self.ending == Ending::Running {
|
||||
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||
self.ending = Ending::Picked;
|
||||
}
|
||||
} else if self.alt_tab == AltTabMode::Auto {
|
||||
// In auto mode, step selection now that we know a modifier was held:
|
||||
let step = if self.shift { -1 } else { 1 };
|
||||
self.move_sel(step, qh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- event plumbing -------------------------------------------------------
|
||||
@@ -373,18 +455,21 @@ impl Dispatch<WlKeyboard, ()> for App {
|
||||
event: wl_keyboard::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_keyboard::Event::Key { key, state, .. } = event {
|
||||
match state {
|
||||
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key),
|
||||
WEnum::Value(wl_keyboard::KeyState::Released)
|
||||
if key == KEY_LEFTSHIFT || key == KEY_RIGHTSHIFT =>
|
||||
{
|
||||
app.shift = false
|
||||
}
|
||||
match event {
|
||||
wl_keyboard::Event::Key { key, state, .. } => match state {
|
||||
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key, qh),
|
||||
WEnum::Value(wl_keyboard::KeyState::Released) => app.key_up(key),
|
||||
_ => {}
|
||||
},
|
||||
// Focus is only tracked here. sway sends leave immediately
|
||||
// followed by enter on the same surface when the pointer crosses
|
||||
// it, so whether the grab is really gone is decided by the main
|
||||
// loop, once the event batch has been dispatched.
|
||||
wl_keyboard::Event::Enter { keys, .. } => app.keyboard_enter(keys, qh),
|
||||
wl_keyboard::Event::Leave { .. } => app.focused = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +484,7 @@ impl Dispatch<WlPointer, ()> for App {
|
||||
event: wl_pointer::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_pointer::Event::Enter {
|
||||
@@ -437,7 +522,9 @@ impl Dispatch<WlPointer, ()> for App {
|
||||
state: WEnum::Value(state),
|
||||
..
|
||||
} => app.click(state == wl_pointer::ButtonState::Pressed),
|
||||
wl_pointer::Event::Axis { value, .. } => app.move_sel(if value > 0.0 { 1 } else { -1 }),
|
||||
wl_pointer::Event::Axis { value, .. } => {
|
||||
app.move_sel(if value > 0.0 { 1 } else { -1 }, qh)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+179
-10
@@ -5,36 +5,181 @@
|
||||
//! Acting on the choice is deliberately not here: wl-pick reports what was picked
|
||||
//! and the caller decides what that means.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use swayipc::{Connection, Node, NodeType};
|
||||
|
||||
use crate::target::Target;
|
||||
|
||||
/// Every view in the tree, in tree order (the same traversal the jq filter did,
|
||||
/// so the grid keeps the ordering the muscle memory expects).
|
||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Target>, swayipc::Error> {
|
||||
/// Open the IPC connection, recovering when the environment lies about where
|
||||
/// the socket is.
|
||||
///
|
||||
/// swayipc takes the path from `I3SOCK` or `SWAYSOCK` and only falls back to
|
||||
/// asking sway directly when *neither is set* -- a variable that is set but
|
||||
/// stale is used as-is, and fails. That happens whenever something in a
|
||||
/// shell's ancestry outlived the sway that started it: one long-running daemon
|
||||
/// is enough, and every shell it spawns inherits a path to a socket that no
|
||||
/// longer exists. Since the running compositor is the one we want either way,
|
||||
/// go and find its socket instead of failing.
|
||||
pub fn connect() -> Result<Connection, String> {
|
||||
// The environment still wins when it points at something real. Going
|
||||
// through swayipc's own lookup instead would spawn `sway
|
||||
// --get-socketpath` whenever the variables are unset, which prints a
|
||||
// complaint of its own before we can say anything useful.
|
||||
if let Some(conn) = env_socket().and_then(|p| UnixStream::connect(p).ok()) {
|
||||
return Ok(Connection::from(conn));
|
||||
}
|
||||
let live = live_sockets();
|
||||
let [path] = live.as_slice() else {
|
||||
return Err(if live.is_empty() {
|
||||
"cannot reach sway; wl-pick reads the window list from its IPC \
|
||||
socket, and no running sway has one"
|
||||
.to_string()
|
||||
} else {
|
||||
// Several live compositors, so any choice would be a guess: a
|
||||
// nested sway is a real thing to be running.
|
||||
format!(
|
||||
"several sway sockets to choose from ({}); set SWAYSOCK to the one you mean",
|
||||
live.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
});
|
||||
};
|
||||
UnixStream::connect(path)
|
||||
.map(Connection::from)
|
||||
.map_err(|e| format!("cannot reach sway on {} ({e})", path.display()))
|
||||
}
|
||||
|
||||
/// The pid out of a `sway-ipc.<uid>.<pid>.sock` name, and nothing else.
|
||||
fn socket_pid(name: &str) -> Option<&str> {
|
||||
name.strip_prefix("sway-ipc.")?
|
||||
.strip_suffix(".sock")?
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.filter(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// The socket the environment names, if it is actually there. sway's own
|
||||
/// variable comes second because swayipc reads them in this order.
|
||||
fn env_socket() -> Option<PathBuf> {
|
||||
["I3SOCK", "SWAYSOCK"]
|
||||
.into_iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.map(PathBuf::from)
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
/// Sockets in the runtime directory whose sway is still running. They are named
|
||||
/// `sway-ipc.<uid>.<pid>.sock`, so the pid says which are worth trying -- and
|
||||
/// pids get reused, so it has to actually be a sway.
|
||||
fn live_sockets() -> Vec<PathBuf> {
|
||||
let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut found: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|path| {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
socket_pid(name)
|
||||
.and_then(|pid| std::fs::read_to_string(format!("/proc/{pid}/comm")).ok())
|
||||
.is_some_and(|comm| comm.trim() == "sway")
|
||||
})
|
||||
.collect();
|
||||
// Stable order, so the message about several of them does not shuffle.
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
/// How to order windows in the grid.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Order {
|
||||
/// Most-Recently-Used (MRU) focus order: current window first, then previous, etc.
|
||||
#[default]
|
||||
Mru,
|
||||
/// Traversal of sway's layout tree (workspace by workspace).
|
||||
Tree,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.trim() {
|
||||
"mru" => Ok(Order::Mru),
|
||||
"tree" => Ok(Order::Tree),
|
||||
other => Err(format!("{other:?} is not mru or tree")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Views in the tree, either in MRU focus order or tree layout order.
|
||||
pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayipc::Error> {
|
||||
let mut out = Vec::new();
|
||||
collect(&conn.get_tree()?, &mut out);
|
||||
let tree = conn.get_tree()?;
|
||||
match order {
|
||||
Order::Mru => {
|
||||
collect_mru(&tree, &mut out);
|
||||
// Ensure the currently focused window is at index 0
|
||||
if let Some(pos) = out.iter().position(|t| t.focused) {
|
||||
if pos > 0 {
|
||||
let focused = out.remove(pos);
|
||||
out.insert(0, focused);
|
||||
}
|
||||
}
|
||||
}
|
||||
Order::Tree => collect_tree(&tree, &mut out),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect(node: &Node, out: &mut Vec<Target>) {
|
||||
fn collect_target(node: &Node) -> Option<Target> {
|
||||
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||
let class = node
|
||||
.window_properties
|
||||
.as_ref()
|
||||
.and_then(|p| p.class.clone());
|
||||
if is_con && (node.app_id.is_some() || class.is_some()) {
|
||||
// A view with no identifier can't be captured, but it still belongs in
|
||||
// the list: it gets a tile with no thumbnail.
|
||||
out.push(Target::window(
|
||||
Some(Target::window(
|
||||
node.id,
|
||||
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||
node.app_id.clone().or(class).unwrap_or_default(),
|
||||
node.name.clone().unwrap_or_default(),
|
||||
));
|
||||
node.focused,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_tree(node: &Node, out: &mut Vec<Target>) {
|
||||
if let Some(target) = collect_target(node) {
|
||||
out.push(target);
|
||||
}
|
||||
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||
collect(child, out);
|
||||
collect_tree(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_mru(node: &Node, out: &mut Vec<Target>) {
|
||||
if let Some(target) = collect_target(node) {
|
||||
out.push(target);
|
||||
}
|
||||
let mut children: Vec<&Node> = node.nodes.iter().chain(node.floating_nodes.iter()).collect();
|
||||
children.sort_by_key(|child| {
|
||||
node.focus
|
||||
.iter()
|
||||
.position(|&id| id == child.id)
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
for child in children {
|
||||
collect_mru(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,3 +223,27 @@ pub fn focused(displays: &[Display]) -> Option<&Display> {
|
||||
.find(|d| d.focused)
|
||||
.or_else(|| displays.first())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::socket_pid;
|
||||
|
||||
#[test]
|
||||
fn a_socket_name_gives_up_its_pid() {
|
||||
assert_eq!(socket_pid("sway-ipc.1000.573773.sock"), Some("573773"));
|
||||
// Anything that is not a live sway's socket must not be tried: the
|
||||
// runtime directory is full of other people's sockets.
|
||||
assert_eq!(socket_pid("wayland-1"), None);
|
||||
assert_eq!(socket_pid("sway-ipc.1000.573773.sock.bak"), None);
|
||||
assert_eq!(socket_pid("i3-ipc.1000.5.sock"), None);
|
||||
assert_eq!(socket_pid("sway-ipc.1000..sock"), None);
|
||||
assert_eq!(socket_pid("sway-ipc.1000.notapid.sock"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_parses() {
|
||||
assert_eq!(super::Order::parse("mru"), Ok(super::Order::Mru));
|
||||
assert_eq!(super::Order::parse("tree"), Ok(super::Order::Tree));
|
||||
assert!(super::Order::parse("invalid").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -55,10 +55,12 @@ pub struct Target {
|
||||
pub ft_id: String,
|
||||
pub app: String,
|
||||
pub title: String,
|
||||
/// Whether this window was the focused container when sway was queried.
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn window(con_id: i64, ft_id: String, app: String, title: String) -> Self {
|
||||
pub fn window(con_id: i64, ft_id: String, app: String, title: String, focused: bool) -> Self {
|
||||
Self {
|
||||
kind: Kind::Window,
|
||||
id: con_id.to_string(),
|
||||
@@ -66,6 +68,7 @@ impl Target {
|
||||
ft_id,
|
||||
app,
|
||||
title,
|
||||
focused,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +82,7 @@ impl Target {
|
||||
ft_id: String::new(),
|
||||
app: "display".to_string(),
|
||||
title: name,
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn win() -> Target {
|
||||
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into())
|
||||
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into(), false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -228,7 +232,7 @@ mod tests {
|
||||
Some("Monitor: DP-1")
|
||||
);
|
||||
// No identifier means the portal cannot be told about this window.
|
||||
let anon = Target::window(7, String::new(), "x".into(), "y".into());
|
||||
let anon = Target::window(7, String::new(), "x".into(), "y".into(), false);
|
||||
assert_eq!(anon.portal(), None);
|
||||
}
|
||||
|
||||
|
||||
+152
-123
@@ -1,6 +1,14 @@
|
||||
//! Look and layout, ported from the rofi setup this replaces (mytheme.rasi +
|
||||
//! the -theme-str rofigrid builds): gruvbox dark, a yellow selection that fills
|
||||
//! the element padding, and a window that hugs the grid.
|
||||
//! Look and layout.
|
||||
//!
|
||||
//! The colours and spacing come from the rofi setup this replaces (mytheme.rasi
|
||||
//! plus the -theme-str rofigrid built): gruvbox dark, a yellow selection filling
|
||||
//! the element padding, `title · app` centred under each thumbnail.
|
||||
//!
|
||||
//! Sizing works from caps rather than from a thumbnail size. The config gives a
|
||||
//! box the grid may fill and a column and row limit; a thumbnail is that box
|
||||
//! divided by those limits. So a thumbnail is the same size whether one window
|
||||
//! is open or thirty — the overlay hugs whatever is there, and rows past the
|
||||
//! limit scroll.
|
||||
|
||||
/// 0xAARRGGBB, premultiplied (everything here is opaque).
|
||||
pub type Argb = u32;
|
||||
@@ -14,17 +22,21 @@ pub struct Theme {
|
||||
pub border: Argb,
|
||||
/// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px).
|
||||
pub border_px: i32,
|
||||
/// Thumbnail cell, logical px. 16:9 so wide windows fill it instead of
|
||||
/// letterboxing in a square box.
|
||||
pub tile_w: i32,
|
||||
pub tile_h: i32,
|
||||
/// The box the grid may not exceed, in logical px. Thumbnails are sized to
|
||||
/// divide it by the column and row caps below, so a thumbnail is the same
|
||||
/// size whether one window is open or thirty — only the window around them
|
||||
/// shrinks to hug what is there.
|
||||
pub max_w: i32,
|
||||
pub max_h: i32,
|
||||
/// Padding inside one element, i.e. around its thumbnail (rasi `element`).
|
||||
pub pad: i32,
|
||||
/// Space between elements (rasi `listview { spacing }`).
|
||||
pub gap: i32,
|
||||
/// Margin between the grid and the window edge.
|
||||
pub margin: i32,
|
||||
/// How many tiles the grid may show at once. Rows beyond `max_rows` scroll.
|
||||
pub max_cols: i32,
|
||||
pub max_rows: i32,
|
||||
/// Gap between a thumbnail and its label (rasi `element { spacing }`).
|
||||
pub spacing: i32,
|
||||
/// Label font family, resolved against the system's fonts. The default is
|
||||
@@ -47,12 +59,17 @@ impl Default for Theme {
|
||||
sel_fg: 0xff282828,
|
||||
border: 0xffd79921,
|
||||
border_px: 2,
|
||||
tile_w: 220,
|
||||
tile_h: 220 * 9 / 16,
|
||||
// No cap of their own: Layout clamps to the display, and the
|
||||
// command line resolves the configured percentage over the top.
|
||||
max_w: i32::MAX,
|
||||
max_h: i32::MAX,
|
||||
pad: 12,
|
||||
gap: 15,
|
||||
margin: 12,
|
||||
// Equal caps make a cell shaped like the display, since max_w and
|
||||
// max_h are the same fraction of it.
|
||||
max_cols: 4,
|
||||
max_rows: 4,
|
||||
spacing: 10,
|
||||
font: crate::text::SYSTEM_MONO.to_string(),
|
||||
font_px: 13.3,
|
||||
@@ -66,13 +83,13 @@ impl Default for Theme {
|
||||
#[derive(Debug)]
|
||||
pub struct Layout {
|
||||
pub cols: i32,
|
||||
/// Rows the whole grid needs, and how many of them fit on screen at once.
|
||||
/// Rows the whole grid needs, and how many of them are on screen at once.
|
||||
pub rows: i32,
|
||||
pub visible_rows: i32,
|
||||
/// How many tiles there are, which the last row may not fill.
|
||||
n: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
/// How many tiles there are, which the last row may not fill.
|
||||
n: i32,
|
||||
elem_w: i32,
|
||||
elem_h: i32,
|
||||
margin: i32,
|
||||
@@ -84,34 +101,41 @@ pub struct Layout {
|
||||
labels: bool,
|
||||
}
|
||||
|
||||
/// How much of the display the grid may occupy.
|
||||
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.
|
||||
/// Lay out `n` tiles for a display of the given logical size.
|
||||
///
|
||||
/// Tiles are the size the theme asks for — a configured size that quietly
|
||||
/// shrank would be a setting ignored — so when the grid needs more rows than
|
||||
/// the display can show, the extra rows scroll. Only a tile too large for
|
||||
/// even one row or column is shrunk, since then something has to give.
|
||||
/// A thumbnail is the configured box divided by the column and row caps, so
|
||||
/// it does not change with how many windows are open: one window gets a
|
||||
/// normal thumbnail in a small overlay, thirty get the same thumbnail and
|
||||
/// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of
|
||||
/// windows makes a tidy grid rather than one long row — the rule rofigrid
|
||||
/// used — and the overlay hugs whatever is there.
|
||||
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
|
||||
let (dw, dh) = (display.0.max(1), display.1.max(1));
|
||||
let room_w = (dw * FILL / 100 - 2 * t.margin).max(1);
|
||||
let room_h = (dh * FILL / 100 - 2 * t.margin).max(1);
|
||||
let n = n.max(0);
|
||||
let (cap_cols, cap_rows) = (t.max_cols.max(1), t.max_rows.max(1));
|
||||
// The box may never exceed the display, whatever the config says.
|
||||
let box_w = t.max_w.clamp(1, display.0.max(1));
|
||||
let box_h = t.max_h.clamp(1, display.1.max(1));
|
||||
let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
|
||||
let (tile_w, tile_h) = shrink_to_one(t, label_row, room_w, room_h);
|
||||
|
||||
// Divide the box by the caps: what is left after the furniture is one
|
||||
// thumbnail.
|
||||
let per_col = 2 * t.pad + t.gap;
|
||||
let per_row = 2 * t.pad + label_row + t.gap;
|
||||
let tile_w = ((box_w - 2 * t.margin + t.gap) / cap_cols - per_col).max(1);
|
||||
let tile_h = ((box_h - 2 * t.margin + t.gap) / cap_rows - per_row).max(1);
|
||||
let (elem_w, elem_h) = (tile_w + 2 * t.pad, tile_h + label_row + 2 * t.pad);
|
||||
|
||||
// Columns: the balanced rule, capped by the config and by what fits.
|
||||
// Columns: the balanced rule, so a handful of windows makes a tidy grid
|
||||
// rather than one long row, capped by the config.
|
||||
let mut cols = (n as f64).sqrt() as i32;
|
||||
if cols * cols < n {
|
||||
cols += 1;
|
||||
}
|
||||
let fit_cols = ((room_w + t.gap) / (elem_w + t.gap)).max(1);
|
||||
cols = cols.clamp(1, t.max_cols.min(fit_cols)).max(1);
|
||||
cols = cols.clamp(1, cap_cols);
|
||||
// i32::div_ceil is still unstable; only the unsigned one is not.
|
||||
let rows = (n + cols - 1) / cols;
|
||||
let visible_rows = ((room_h + t.gap) / (elem_h + t.gap)).clamp(1, rows.max(1));
|
||||
let visible_rows = cap_rows.clamp(1, rows.max(1));
|
||||
|
||||
Self {
|
||||
cols,
|
||||
@@ -238,21 +262,6 @@ impl Layout {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile larger than one row or column of the display has to give way, since
|
||||
/// nothing can be shown otherwise. Both axes shrink together, keeping its shape.
|
||||
fn shrink_to_one(t: &Theme, label_row: i32, room_w: i32, room_h: i32) -> (i32, i32) {
|
||||
let (want_w, want_h) = (t.tile_w.max(1), t.tile_h.max(1));
|
||||
let cell_w = want_w + 2 * t.pad;
|
||||
let cell_h = want_h + label_row + 2 * t.pad;
|
||||
let scale = (room_w as f32 / cell_w as f32)
|
||||
.min(room_h as f32 / cell_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,
|
||||
@@ -298,60 +307,100 @@ 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.
|
||||
/// A display large enough that the caps, not the screen, decide everything.
|
||||
const ROOMY: (i32, i32) = (10_000, 10_000);
|
||||
|
||||
/// The grid maths must match rofigrid's, or the window stops hugging the grid.
|
||||
/// The caps and the box are what the config sets; a test theme states them
|
||||
/// outright rather than relying on placeholders.
|
||||
fn theme(max_w: i32, max_h: i32, cols: i32, rows: i32) -> Theme {
|
||||
Theme {
|
||||
max_w,
|
||||
max_h,
|
||||
max_cols: cols,
|
||||
max_rows: rows,
|
||||
..Theme::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_matches_rofigrid() {
|
||||
let t = Theme::default();
|
||||
// (n, cols, rows) from rofigrid: cols = min(ceil(sqrt(n)), 4)
|
||||
fn a_thumbnail_is_the_box_divided_by_the_caps() {
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
let l = Layout::new(&t, 12, ROOMY);
|
||||
let tile = l.tile(0, 0).expect("visible");
|
||||
// Four columns of (tile + padding) plus three gaps plus two margins fill
|
||||
// the box, give or take integer division.
|
||||
let used = 4 * (tile.w + 2 * t.pad) + 3 * t.gap + 2 * t.margin;
|
||||
assert!((1000 - used).abs() <= 4, "width {used} should fill 1000");
|
||||
let label_row = t.spacing + t.line_h;
|
||||
let used = 3 * (tile.h + label_row + 2 * t.pad) + 2 * t.gap + 2 * t.margin;
|
||||
assert!((900 - used).abs() <= 4, "height {used} should fill 900");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_window_gets_the_same_thumbnail_as_thirty() {
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
let one = Layout::new(&t, 1, ROOMY);
|
||||
let many = Layout::new(&t, 30, ROOMY);
|
||||
assert_eq!(
|
||||
one.tile(0, 0).expect("visible").w,
|
||||
many.tile(0, 0).expect("visible").w,
|
||||
"thumbnail size must not depend on how many windows are open"
|
||||
);
|
||||
// The overlay hugs what is there: one tile is a small window.
|
||||
assert_eq!((one.cols, one.rows), (1, 1));
|
||||
assert!(
|
||||
one.width < many.width && one.height < many.height,
|
||||
"{one:?}"
|
||||
);
|
||||
assert!(!one.scrollable() && many.scrollable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_stay_balanced_and_within_the_caps() {
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
// (n, cols, rows): ceil(sqrt(n)) columns, capped at four.
|
||||
for (n, cols, rows) in [
|
||||
(1, 1, 1),
|
||||
(2, 2, 1),
|
||||
(4, 2, 2),
|
||||
(6, 3, 2),
|
||||
(12, 4, 3),
|
||||
(17, 4, 5),
|
||||
(30, 4, 8),
|
||||
] {
|
||||
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!(
|
||||
l.width,
|
||||
cols * (t.tile_w + 24) + (cols - 1) * 15 + 24,
|
||||
"width n = {n}"
|
||||
);
|
||||
assert!(l.visible_rows <= t.max_rows, "n = {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_stay_inside_the_window() {
|
||||
let t = Theme::default();
|
||||
for n in 1..=20 {
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
for n in 1..=12 {
|
||||
let l = Layout::new(&t, n, ROOMY);
|
||||
for i in 0..n {
|
||||
let e = l.elem(i, 0).expect("visible");
|
||||
assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}");
|
||||
assert!(e.y >= 0 && e.y + e.h <= l.height, "n = {n}, i = {i}");
|
||||
let tile = l.tile(i, 0).expect("visible");
|
||||
assert!(tile.w == t.tile_w && tile.h == t.tile_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_add_a_row_under_each_thumbnail() {
|
||||
let mut t = Theme::default();
|
||||
let with = Layout::new(&t, 4, ROOMY);
|
||||
fn labels_take_their_room_from_the_thumbnail() {
|
||||
let mut t = theme(1000, 900, 4, 3);
|
||||
let with = Layout::new(&t, 12, ROOMY);
|
||||
t.labels = false;
|
||||
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, 0).is_none());
|
||||
let without = Layout::new(&t, 12, ROOMY);
|
||||
// The box is fixed, so dropping labels makes thumbnails taller rather
|
||||
// than the window shorter.
|
||||
assert!(
|
||||
without.tile(0, 0).expect("visible").h > with.tile(0, 0).expect("visible").h,
|
||||
"thumbnails should grow into the freed row"
|
||||
);
|
||||
assert!(with.label(0, 0).is_some() && without.label(0, 0).is_none());
|
||||
|
||||
let t = Theme::default();
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
let l = Layout::new(&t, 4, ROOMY);
|
||||
for i in 0..4 {
|
||||
let (tile, label, elem) = (
|
||||
@@ -359,53 +408,20 @@ mod tests {
|
||||
l.label(i, 0).unwrap(),
|
||||
l.elem(i, 0).expect("visible"),
|
||||
);
|
||||
assert_eq!(tile.h, t.tile_h);
|
||||
assert_eq!(label.y, tile.y + tile.h + t.spacing);
|
||||
assert_eq!(label.w, tile.w);
|
||||
// Everything, padding included, stays inside the element.
|
||||
assert!(label.y + label.h + t.pad <= elem.y + elem.h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_too_big_for_one_cell_is_the_only_thing_that_shrinks() {
|
||||
let mut t = Theme::default();
|
||||
let roomy = Layout::new(&t, 12, ROOMY);
|
||||
assert_eq!(
|
||||
roomy.tile(0, 0).expect("visible").w,
|
||||
t.tile_w,
|
||||
"left alone when there is room"
|
||||
);
|
||||
|
||||
// A tile wider and taller than the whole screen has to give way, since
|
||||
// otherwise there is nothing to show.
|
||||
t.tile_w = 2000;
|
||||
t.tile_h = 1500;
|
||||
let l = Layout::new(&t, 4, (800, 600));
|
||||
let tile = l.tile(0, 0).expect("visible");
|
||||
assert!(tile.w < t.tile_w && tile.h < t.tile_h, "should have shrunk");
|
||||
assert!(l.width <= 800 && l.height <= 600, "{l:?}");
|
||||
assert_eq!(l.cols, 1, "only one column can fit");
|
||||
// Shrinking keeps the tile's shape.
|
||||
let (want, got) = (
|
||||
t.tile_w as f32 / t.tile_h as f32,
|
||||
tile.w as f32 / tile.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: only a row or two can be shown,
|
||||
// and the rest scroll.
|
||||
fn the_box_never_exceeds_the_display() {
|
||||
// A config asking for more than the screen has, on a small screen.
|
||||
let t = theme(4000, 3000, 4, 3);
|
||||
let l = Layout::new(&t, 30, (640, 480));
|
||||
let tile = l.tile(0, 0).expect("visible");
|
||||
assert!(tile.w >= 1 && tile.h >= 1, "{l:?}");
|
||||
assert!(l.width <= 640 && l.height <= 480, "{l:?}");
|
||||
assert!(l.tile(0, 0).expect("visible").w >= 1);
|
||||
assert!(l.scrollable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -447,18 +463,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rows_beyond_the_display_scroll_instead_of_shrinking() {
|
||||
let t = Theme::default();
|
||||
// Thirty tiles cannot fit; the configured tile size must survive anyway.
|
||||
let l = Layout::new(&t, 30, (1280, 1440));
|
||||
assert_eq!(
|
||||
l.tile(0, 0).expect("visible").w,
|
||||
t.tile_w,
|
||||
"tiles kept their size"
|
||||
);
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
// Thirty tiles need more rows than the cap allows, so they scroll.
|
||||
let l = Layout::new(&t, 30, ROOMY);
|
||||
assert!(l.scrollable(), "{l:?} should scroll");
|
||||
assert!(l.visible_rows < l.rows);
|
||||
assert!(l.height <= 1440 && l.width <= 1280, "{l:?}");
|
||||
|
||||
// The viewport shows a window of rows, and nothing outside it.
|
||||
let per_screen = (l.visible_rows * l.cols) as usize;
|
||||
assert!(l.elem(0, 0).is_some());
|
||||
@@ -472,10 +481,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_rows_keeps_the_grid_compact() {
|
||||
let mut t = theme(1000, 900, 4, 3);
|
||||
let full = Layout::new(&t, 30, ROOMY);
|
||||
t.max_rows = 2;
|
||||
let capped = Layout::new(&t, 30, ROOMY);
|
||||
assert!(
|
||||
capped.visible_rows == 2 && full.visible_rows > 2,
|
||||
"{capped:?}"
|
||||
);
|
||||
assert!(capped.height < full.height, "a shorter overlay");
|
||||
assert!(capped.scrollable());
|
||||
// The cap cannot invent rows: four tiles make a 2x2 grid, and a cap of
|
||||
// five leaves it alone.
|
||||
t.max_rows = 5;
|
||||
let few = Layout::new(&t, 4, ROOMY);
|
||||
assert_eq!((few.cols, few.rows, few.visible_rows), (2, 2, 2), "{few:?}");
|
||||
assert!(!few.scrollable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revealing_moves_the_viewport_as_little_as_possible() {
|
||||
let t = Theme::default();
|
||||
let l = Layout::new(&t, 30, (1280, 1440));
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
let l = Layout::new(&t, 30, ROOMY);
|
||||
let last_visible = (l.visible_rows * l.cols - 1) as usize;
|
||||
assert_eq!(l.reveal(0, 0), 0, "already on screen");
|
||||
assert_eq!(l.reveal(last_visible, 0), 0, "still on screen");
|
||||
@@ -489,8 +518,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hit_testing_follows_the_scroll() {
|
||||
let t = Theme::default();
|
||||
let l = Layout::new(&t, 30, (1280, 1440));
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
let l = Layout::new(&t, 30, ROOMY);
|
||||
let first = l.elem(0, 0).expect("visible");
|
||||
let probe = (first.x + first.w / 2, first.y + first.h / 2);
|
||||
assert_eq!(l.hit(probe.0, probe.1, 0), Some(0));
|
||||
@@ -500,9 +529,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_scrollbar_appears_only_when_there_is_more_to_see() {
|
||||
let t = Theme::default();
|
||||
let t = theme(1000, 900, 4, 3);
|
||||
assert!(Layout::new(&t, 4, ROOMY).scrollbar(0, 4).is_none());
|
||||
let l = Layout::new(&t, 30, (1280, 1440));
|
||||
let l = Layout::new(&t, 30, ROOMY);
|
||||
let (track, top) = l.scrollbar(0, 4).expect("scrollable");
|
||||
assert_eq!(top.y, track.y, "thumb starts at the top");
|
||||
assert!(top.h < track.h, "thumb is shorter than its track");
|
||||
|
||||
Reference in New Issue
Block a user