Compare commits
25
Commits
94d8145079
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce2854d72 | ||
|
|
e1d8760a43 | ||
|
|
f78fd7ee41 | ||
|
|
6559956c3c | ||
|
|
77efdf03e9 | ||
|
|
314e474341 | ||
|
|
0afdcf4c83 | ||
|
|
21cbb5450e | ||
|
|
3abf610fbf | ||
|
|
66c2d49ffb | ||
|
|
3ad0b59d8c | ||
|
|
782a21f864 | ||
|
|
01c1b31636 | ||
|
|
53cebf14f3 | ||
|
|
50caed0110 | ||
|
|
1367b03b97 | ||
|
|
78631065e2 | ||
|
|
6bd4a3ee76 | ||
|
|
a99de502e2 | ||
|
|
8e317682a1 | ||
|
|
c607fc9a1c | ||
|
|
34548fe4f6 | ||
|
|
1f5b735541 | ||
|
|
86319aa309 | ||
|
|
13c0252cef |
Generated
+2
-2
@@ -605,8 +605,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wl-pick"
|
name = "wl-tab"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cosmic-text",
|
"cosmic-text",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
|
|||||||
+7
-8
@@ -1,14 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wl-pick"
|
name = "wl-tab"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A live grid of window and display previews, for picking one"
|
description = "An simple alt-tab window switcher for sway"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/mil-ad/wl-pick"
|
repository = "https://git.krzak.org/N0VA/wl-tab"
|
||||||
readme = "README.md"
|
readme = "README.adoc"
|
||||||
keywords = ["wayland", "sway", "wlroots", "screencast", "switcher"]
|
keywords = ["wayland", "sway", "wlroots", "alt-tab", "switcher"]
|
||||||
categories = ["command-line-utilities", "os::unix-apis"]
|
categories = ["command-line-utilities", "os::unix-apis"]
|
||||||
# The floor is cosmic-text, which needs 1.89; let-chains here need 1.88.
|
|
||||||
rust-version = "1.89"
|
rust-version = "1.89"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -16,7 +15,7 @@ wayland-client = "0.31"
|
|||||||
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
|
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
|
||||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||||
memmap2 = "0.9"
|
memmap2 = "0.9"
|
||||||
rustix = { version = "1", features = ["fs", "mm", "shm"] }
|
rustix = { version = "1", features = ["event", "fs", "mm", "shm"] }
|
||||||
swayipc = "4"
|
swayipc = "4"
|
||||||
cosmic-text = "0.19"
|
cosmic-text = "0.19"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Milad Alizadeh
|
Copyright (c) 2026 N0\A
|
||||||
|
Copyright (c) 2026-2026 Milad Alizadeh
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
= wl-tab
|
||||||
|
A simple alt-tab switcher for sway
|
||||||
|
|
||||||
|
Originally forked from https://github.com/mil-ad/wl-pick[wl-pick]
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# wl-pick
|
|
||||||
|
|
||||||
A window switcher for wlroots compositors: a grid overlay of **live** window
|
|
||||||
previews that looks like a rofi theme, and tells you which one you picked.
|
|
||||||
|
|
||||||
It replaces a `wlthumbs | rofi` pipeline, and doubles as a screencast source
|
|
||||||
picker for the desktop portal. The difference is that no thumbnails
|
|
||||||
exist: each window is captured straight into a `wl_shm` buffer that is handed to
|
|
||||||
its own `wl_subsurface`, and `wp_viewporter` tells the compositor which rectangle
|
|
||||||
to scale it into. There is no image encoding, no scaler, and no full-resolution
|
|
||||||
bitmap in this process — which is also why it appears in about 60 ms and holds
|
|
||||||
~18 MB of RSS however many windows are open.
|
|
||||||
|
|
||||||
```
|
|
||||||
sway-tree 0.6ms window list + con_ids over sway IPC
|
|
||||||
toplevels 0.2ms ext-foreign-toplevel-list handles
|
|
||||||
constraints 1.5ms every capture session's buffer size, in one roundtrip
|
|
||||||
capture 52.5ms 8 windows, all frames in flight at once
|
|
||||||
labels 0.0ms shaped on a worker thread while the captures ran
|
|
||||||
mapped 4.9ms layer surface + subsurfaces on screen
|
|
||||||
```
|
|
||||||
|
|
||||||
The capture phase is the compositor reading full-resolution window pixels out of
|
|
||||||
the GPU. It is bandwidth-bound (~1.1 GB/s here) and unaffected by how large the
|
|
||||||
thumbnails are — which also makes it a free window to do other work in. Loading
|
|
||||||
a font and rasterising its first glyphs costs ~20ms, so labels are shaped on a
|
|
||||||
worker thread started before the captures and joined after them, and cost
|
|
||||||
nothing in wall clock.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Working: a labelled grid of live previews with keyboard navigation. Type-to-filter
|
|
||||||
is the one thing the rofi version had that this doesn't — see the roadmap.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
|
||||||
[--no-outputs] [--hide-labels] [--font FAMILY] [--font-size PX]
|
|
||||||
[--timeout SECS] [--verbose]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `--format tsv|json|portal` how to report the pick (default `tsv`)
|
|
||||||
- `--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
|
|
||||||
- `--font FAMILY` label font family (default: the system monospace font)
|
|
||||||
- `--font-size PX` label size in logical px
|
|
||||||
- `--config PATH` config file (default `~/.config/wl-pick/config`)
|
|
||||||
- `--timeout SECS` exits after a deadline, in case the keyboard grab ever traps
|
|
||||||
you
|
|
||||||
- `--verbose` phase timings, the tile list, and capture stats
|
|
||||||
|
|
||||||
wl-pick is a chooser: the pick goes to stdout, nothing does if you cancel, and
|
|
||||||
the exit status is 0 for a pick and 1 for a cancel. It never acts on the choice —
|
|
||||||
it has no idea what you want to do with it. Focusing on sway looks like this:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# ~/.local/bin/winmenu, bound to $mod+Tab
|
|
||||||
IFS=$'\t' read -r type id toplevel app title < <(wl-pick) || exit 0
|
|
||||||
case $type in
|
|
||||||
window) swaymsg "[con_id=$id] focus" ;;
|
|
||||||
output) swaymsg "focus output $id" ;;
|
|
||||||
esac
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows only, as a one-liner:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
swaymsg "[con_id=$(wl-pick --no-outputs | cut -f2)] focus"
|
|
||||||
```
|
|
||||||
|
|
||||||
Three formats, because the identifiers different consumers need differ:
|
|
||||||
|
|
||||||
| `--format` | output |
|
|
||||||
|---|---|
|
|
||||||
| `tsv` (default) | `TYPE⇥ID⇥TOPLEVEL_ID⇥APP⇥TITLE` — `ID` is the sway `con_id`, or the output name for a display; `TOPLEVEL_ID` is the ext-foreign-toplevel-list-v1 identifier that `grim -T` and the portal capture by |
|
|
||||||
| `json` | the same record with every key always present, for `jq` |
|
|
||||||
| `portal` | `Monitor: NAME` or `Window: TOPLEVEL_ID` |
|
|
||||||
|
|
||||||
`portal` is exactly what xdg-desktop-portal-wlr's `simple` chooser reads, so
|
|
||||||
wl-pick can be the picker for `getDisplayMedia` and friends — with live previews
|
|
||||||
of both windows and displays:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[screencast]
|
|
||||||
chooser_type=simple
|
|
||||||
chooser_cmd=wl-pick --format portal
|
|
||||||
```
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| `Escape` / `q` | cancel |
|
|
||||||
| click | pick that tile |
|
|
||||||
| scroll | next / previous tile |
|
|
||||||
|
|
||||||
Hovering deliberately does not move the selection — the keyboard keeps it, and a
|
|
||||||
click acts on whatever is under the cursor. Clicking the margin, a gap, or an
|
|
||||||
empty cell of a ragged last row does nothing. Tiles are subsurfaces, so a click
|
|
||||||
on a thumbnail identifies its tile by surface; only clicks on the chrome around
|
|
||||||
them need hit-testing.
|
|
||||||
|
|
||||||
Navigation reads raw evdev keycodes, so it is layout-independent — but it also
|
|
||||||
means virtual-keyboard clients such as `wtype` (which invent their own keymap)
|
|
||||||
cannot drive it. That goes away with xkb support, which filtering needs anyway.
|
|
||||||
|
|
||||||
## Live previews
|
|
||||||
|
|
||||||
Capture sessions stay open, so a tile can be refreshed. Three things keep that
|
|
||||||
from being expensive:
|
|
||||||
|
|
||||||
- **It is damage-driven.** After a session's first frame the compositor only
|
|
||||||
produces another once the window content changes, so a request left
|
|
||||||
outstanding on an idle window costs nothing. Measured over 4s with one
|
|
||||||
animating window out of ten: `52,52,1,1,1,1,1,1,13,1` frames — the static
|
|
||||||
windows delivered exactly their first frame and nothing more.
|
|
||||||
- **Frame callbacks are the clock.** Re-captures are driven by the overlay's own
|
|
||||||
`wl_surface.frame` callbacks, so they stop when it isn't being presented, and
|
|
||||||
`--fps` throttles per tile on top of that (12 fps measured as 12.1).
|
|
||||||
- **Two buffers per window, alternating.** A capture must not write into a buffer
|
|
||||||
the compositor is reading, so each window gets two and `wl_buffer.release`
|
|
||||||
decides which is free. Note that release is the entire contract: with `wl_shm`
|
|
||||||
the compositor copies the pixels out at commit and hands the buffer straight
|
|
||||||
back, so the slot on screen is usually free too — waiting for it to stop being
|
|
||||||
displayed instead deadlocks after two frames.
|
|
||||||
|
|
||||||
The cost is memory and bandwidth: two full-resolution buffers per window (138 MB
|
|
||||||
of shm for eight windows and a display here, against 83 MB with `--live none`)
|
|
||||||
and a readback per refreshed frame. `--live current` refreshes only the selected tile,
|
|
||||||
which is much cheaper and still reads as alive.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
`~/.config/wl-pick/config`, or `--config PATH`. Flat `key = value` lines with
|
|
||||||
`#` comments, everything optional, and a flag always beats the file. No TOML
|
|
||||||
dependency, because there is nothing to nest.
|
|
||||||
|
|
||||||
```ini
|
|
||||||
background = #282828 # the grid's backdrop
|
|
||||||
foreground = #ebdbb2 # label text
|
|
||||||
selection = #d79921 # the highlighted tile
|
|
||||||
selection-text = #282828 # its label
|
|
||||||
border = #d79921
|
|
||||||
border-width = 2px
|
|
||||||
|
|
||||||
tile-width = 18ppt # largest a thumbnail may be
|
|
||||||
tile-height = 20ppt # defaults to the display's aspect
|
|
||||||
max-columns = 4
|
|
||||||
|
|
||||||
font = monospace
|
|
||||||
font-size = 13.3
|
|
||||||
labels = yes
|
|
||||||
outputs = yes
|
|
||||||
live = all
|
|
||||||
fps = 12
|
|
||||||
format = tsv
|
|
||||||
```
|
|
||||||
|
|
||||||
Sizes take sway's units: `600px` is absolute, `70ppt` a percentage — and the
|
|
||||||
percentage resolves against **the display the grid actually appears on**, every
|
|
||||||
time it runs. On a mixed setup one file gives 18% of a 1280-wide laptop panel and
|
|
||||||
18% of a 3840-wide monitor, instead of a pixel count that suits one and looks
|
|
||||||
wrong on the other. The overlay is mapped explicitly on that display, at that
|
|
||||||
display's scale, so mixed-DPI renders crisply either way.
|
|
||||||
|
|
||||||
`tile-width` and `tile-height` 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Look
|
|
||||||
|
|
||||||
The defaults come from the rofi theme this replaces: gruvbox dark, a yellow
|
|
||||||
selection filling the element padding, `ceil(sqrt(n))` columns capped at 4,
|
|
||||||
`title · app` centred underneath. Padding, gaps and margins are still fixed, in
|
|
||||||
`src/theme.rs`.
|
|
||||||
|
|
||||||
The label font defaults to the system monospace font — whatever `fc-match
|
|
||||||
monospace` answers, which is what the rest of the desktop uses. (cosmic-text's
|
|
||||||
own generic resolves through a built-in preference that is usually not
|
|
||||||
installed, and then lands on an arbitrary face, so it is asked directly
|
|
||||||
instead; if fontconfig isn't available, a short list of common distribution
|
|
||||||
defaults is tried.) `--font` names a family instead, and `--verbose` reports
|
|
||||||
which family the labels were actually shaped with.
|
|
||||||
|
|
||||||
Naming a family scans your own font directories first because they are small;
|
|
||||||
the full system scan (~37ms) happens only if it isn't found there. An unknown
|
|
||||||
family falls back to whatever cosmic-text picks rather than failing. Long titles
|
|
||||||
are ellipsised to the cell.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
A wlroots compositor advertising `ext-image-copy-capture-v1`,
|
|
||||||
`ext-image-capture-source-v1` (with the foreign-toplevel source manager),
|
|
||||||
`ext-foreign-toplevel-list-v1`, `wlr-layer-shell-unstable-v1` and
|
|
||||||
`wp_viewporter` — sway 1.11+, and in principle Hyprland, labwc and jay, though
|
|
||||||
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).
|
|
||||||
|
|
||||||
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
|
|
||||||
unaffected. It matters more once previews are live.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- type-to-filter with fzf-quality fuzzy matching (and the xkb keyboard input it
|
|
||||||
needs, which would also let virtual-keyboard clients drive the overlay)
|
|
||||||
- dmabuf capture, so the pixels never leave the GPU at all — and live previews
|
|
||||||
stop costing a readback per frame
|
|
||||||
|
|
||||||
## Source layout
|
|
||||||
|
|
||||||
```
|
|
||||||
main.rs orchestration: list, capture, map, report the pick
|
|
||||||
cli.rs flags, defaults, and the help text that documents them
|
|
||||||
sway.rs the window list and display names, over sway's IPC socket
|
|
||||||
target.rs what a tile stands for, and the three output formats
|
|
||||||
app.rs the Wayland client state every event dispatches into
|
|
||||||
capture.rs capture sessions, their buffers, and the live clock
|
|
||||||
overlay.rs the layer surface, the drawing, and the keyboard
|
|
||||||
theme.rs colours, grid geometry, aspect fitting
|
|
||||||
text.rs label shaping on a worker thread
|
|
||||||
shm.rs memfd allocation and the ARGB painter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo build --release
|
|
||||||
cargo test # grid geometry and hit-testing, ellipsising, output
|
|
||||||
# formats, glyph output
|
|
||||||
```
|
|
||||||
+76
-24
@@ -37,12 +37,16 @@ use wayland_protocols::wp::cursor_shape::v1::client::{
|
|||||||
wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
||||||
wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
|
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::{
|
use wayland_protocols::wp::viewporter::client::{
|
||||||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
||||||
};
|
};
|
||||||
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
||||||
|
|
||||||
use crate::capture::{Live, Tile};
|
use crate::capture::Tile;
|
||||||
use crate::overlay;
|
use crate::overlay;
|
||||||
use crate::shm;
|
use crate::shm;
|
||||||
use crate::target::Target;
|
use crate::target::Target;
|
||||||
@@ -50,18 +54,13 @@ use crate::text;
|
|||||||
use crate::theme::{Layout, Theme};
|
use crate::theme::{Layout, Theme};
|
||||||
|
|
||||||
/// What the caller decided before any of this started: the look, and how much
|
/// What the caller decided before any of this started: the look, and how much
|
||||||
/// live capture to do. Passed as one value because `theme`, `live`, `fps` and
|
/// live capture to do. `live` is always on for the alt-tab switcher mode.
|
||||||
/// `scale` all arrive together and two of them are bare integers.
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub live: Live,
|
|
||||||
pub fps: u32,
|
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,
|
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 output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +83,11 @@ pub struct App {
|
|||||||
|
|
||||||
pub(crate) theme: Theme,
|
pub(crate) theme: Theme,
|
||||||
pub(crate) layout: Layout,
|
pub(crate) layout: Layout,
|
||||||
pub(crate) live: Live,
|
|
||||||
pub(crate) fps: u32,
|
pub(crate) fps: u32,
|
||||||
pub(crate) scale: i32,
|
pub(crate) scale: i32,
|
||||||
pub(crate) sel: usize,
|
pub(crate) sel: usize,
|
||||||
/// First row of the grid on screen. The rest scroll.
|
/// First row of the grid on screen. The rest scroll.
|
||||||
pub(crate) scroll: i32,
|
pub(crate) scroll: i32,
|
||||||
/// Set when the viewport moved and the subsurfaces need re-placing.
|
|
||||||
pub(crate) needs_tiles: bool,
|
|
||||||
pub(crate) shift: bool,
|
pub(crate) shift: bool,
|
||||||
|
|
||||||
/// Where the pointer is, and which tile it pressed. Hovering deliberately
|
/// Where the pointer is, and which tile it pressed. Hovering deliberately
|
||||||
@@ -114,13 +110,28 @@ pub struct App {
|
|||||||
pub(crate) output: String,
|
pub(crate) output: String,
|
||||||
|
|
||||||
pub(crate) ending: Ending,
|
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) picked: Option<Target>,
|
||||||
pub(crate) stats: Stats,
|
pub(crate) stats: Stats,
|
||||||
|
|
||||||
|
pub(crate) seat: Option<WlSeat>,
|
||||||
|
pub(crate) inhibit_mgr: Option<ZwpKeyboardShortcutsInhibitManagerV1>,
|
||||||
|
pub(crate) inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>,
|
||||||
|
pub(crate) latched_modifiers: std::collections::BTreeSet<u32>,
|
||||||
|
|
||||||
|
/// The navigation key currently held down, and when the next repeat fires.
|
||||||
|
pub(crate) repeat_key: Option<u32>,
|
||||||
|
pub(crate) repeat_next: Option<std::time::Instant>,
|
||||||
|
/// Milliseconds before the first repeat fires. From wl_keyboard::RepeatInfo.
|
||||||
|
pub(crate) repeat_delay_ms: u32,
|
||||||
|
/// Milliseconds between subsequent repeats. From wl_keyboard::RepeatInfo.
|
||||||
|
pub(crate) repeat_rate_ms: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
|
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
|
||||||
/// wrong — a starved buffer pool or a clock that never ticks both look like
|
/// wrong - a starved buffer pool or a clock that never ticks both look like
|
||||||
/// "nothing updates" — so the numbers that distinguish those stay available.
|
/// "nothing updates" - so the numbers that distinguish those stay available.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Stats {
|
pub struct Stats {
|
||||||
/// Frame callbacks received, i.e. how often the live clock fired.
|
/// Frame callbacks received, i.e. how often the live clock fired.
|
||||||
@@ -139,6 +150,8 @@ pub enum Ending {
|
|||||||
Picked,
|
Picked,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
Closed,
|
Closed,
|
||||||
|
/// The keyboard went to another surface, so we can no longer be operated.
|
||||||
|
Unfocused,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ending {
|
impl Ending {
|
||||||
@@ -148,6 +161,7 @@ impl Ending {
|
|||||||
Ending::Picked => "picked",
|
Ending::Picked => "picked",
|
||||||
Ending::Cancelled => "cancelled",
|
Ending::Cancelled => "cancelled",
|
||||||
Ending::Closed => "the compositor closed the overlay",
|
Ending::Closed => "the compositor closed the overlay",
|
||||||
|
Ending::Unfocused => "another surface holds the keyboard",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,16 +172,16 @@ impl App {
|
|||||||
qh: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
targets: Vec<Target>,
|
targets: Vec<Target>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
layout: Layout,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> Result<Self, Box<dyn Error>> {
|
||||||
let Settings {
|
let Settings {
|
||||||
theme,
|
theme,
|
||||||
live,
|
|
||||||
fps,
|
fps,
|
||||||
scale,
|
scale,
|
||||||
display,
|
|
||||||
output,
|
output,
|
||||||
|
..
|
||||||
} = settings;
|
} = settings;
|
||||||
let layout = Layout::new(&theme, targets.len() as i32, display);
|
let sel = if targets.len() > 1 { 1 } else { 0 };
|
||||||
// Bind everything up front so a compositor missing a protocol fails
|
// Bind everything up front so a compositor missing a protocol fails
|
||||||
// here, with a name, rather than halfway through a capture.
|
// here, with a name, rather than halfway through a capture.
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
@@ -185,12 +199,10 @@ impl App {
|
|||||||
tiles: targets.into_iter().map(Tile::new).collect(),
|
tiles: targets.into_iter().map(Tile::new).collect(),
|
||||||
theme,
|
theme,
|
||||||
layout,
|
layout,
|
||||||
live,
|
|
||||||
fps,
|
fps,
|
||||||
scale,
|
scale,
|
||||||
sel: 0,
|
sel,
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
needs_tiles: false,
|
|
||||||
shift: false,
|
shift: false,
|
||||||
hover: None,
|
hover: None,
|
||||||
pressed: None,
|
pressed: None,
|
||||||
@@ -203,8 +215,17 @@ impl App {
|
|||||||
configured: false,
|
configured: false,
|
||||||
output,
|
output,
|
||||||
ending: Ending::Running,
|
ending: Ending::Running,
|
||||||
|
focused: false,
|
||||||
picked: None,
|
picked: None,
|
||||||
stats: Stats::default(),
|
stats: Stats::default(),
|
||||||
|
seat: None,
|
||||||
|
inhibit_mgr: None,
|
||||||
|
inhibitor: None,
|
||||||
|
latched_modifiers: std::collections::BTreeSet::new(),
|
||||||
|
repeat_key: None,
|
||||||
|
repeat_next: None,
|
||||||
|
repeat_delay_ms: 600,
|
||||||
|
repeat_rate_ms: 25,
|
||||||
};
|
};
|
||||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
||||||
// One wl_output per display, bound at v4 so it tells us its name.
|
// One wl_output per display, bound at v4 so it tells us its name.
|
||||||
@@ -217,7 +238,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)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +260,7 @@ impl App {
|
|||||||
eprintln!(" [{i}] {}{mark}", t.target.tsv());
|
eprintln!(" [{i}] {}{mark}", t.target.tsv());
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"wl-pick: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
|
"wl-tab: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
|
||||||
at scale {}, {} MB of capture buffers, labels in {:?}",
|
at scale {}, {} MB of capture buffers, labels in {:?}",
|
||||||
self.tiles.len(),
|
self.tiles.len(),
|
||||||
self.tiles.iter().filter(|t| t.ready).count(),
|
self.tiles.iter().filter(|t| t.ready).count(),
|
||||||
@@ -251,7 +274,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
if self.layout.scrollable() {
|
if self.layout.scrollable() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"wl-pick: {} of {} rows fit; the rest scroll",
|
"wl-tab: {} of {} rows fit; the rest scroll",
|
||||||
self.layout.visible_rows, self.layout.rows
|
self.layout.visible_rows, self.layout.rows
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -262,7 +285,7 @@ impl App {
|
|||||||
let frames: u32 = self.tiles.iter().map(|t| t.frames).sum();
|
let frames: u32 = self.tiles.iter().map(|t| t.frames).sum();
|
||||||
let secs = open_for.as_secs_f64();
|
let secs = open_for.as_secs_f64();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"wl-pick: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \
|
"wl-tab: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \
|
||||||
{} starved; per tile: {}",
|
{} starved; per tile: {}",
|
||||||
self.ending.as_str(),
|
self.ending.as_str(),
|
||||||
frames as f64 / secs,
|
frames as f64 / secs,
|
||||||
@@ -279,6 +302,25 @@ impl App {
|
|||||||
pub fn captures_settled(&self) -> bool {
|
pub fn captures_settled(&self) -> bool {
|
||||||
self.tiles.iter().all(|t| t.settled)
|
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-tab: no frame for {} of {} tiles ({}); \
|
||||||
|
another capture client may hold these sources",
|
||||||
|
stuck.len(),
|
||||||
|
self.tiles.len(),
|
||||||
|
stuck.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- enumeration ----------------------------------------------------------
|
// --- enumeration ----------------------------------------------------------
|
||||||
@@ -369,3 +411,13 @@ delegate_noop!(App: ignore WlSurface);
|
|||||||
delegate_noop!(App: WpCursorShapeManagerV1);
|
delegate_noop!(App: WpCursorShapeManagerV1);
|
||||||
delegate_noop!(App: WpCursorShapeDeviceV1);
|
delegate_noop!(App: WpCursorShapeDeviceV1);
|
||||||
delegate_noop!(App: ignore WlBuffer);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-44
@@ -7,12 +7,14 @@
|
|||||||
//!
|
//!
|
||||||
//! The pixels are never mapped into this process. A capture buffer goes straight
|
//! The pixels are never mapped into this process. A capture buffer goes straight
|
||||||
//! to a subsurface for display, so the compositor writes those pages and samples
|
//! to a subsurface for display, so the compositor writes those pages and samples
|
||||||
//! them again itself.
|
//! them again itself. Live previews are always on.
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::AsFd;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::Target;
|
||||||
|
|
||||||
use wayland_client::protocol::{
|
use wayland_client::protocol::{
|
||||||
wl_buffer::{self, WlBuffer},
|
wl_buffer::{self, WlBuffer},
|
||||||
wl_callback, wl_output, wl_shm,
|
wl_callback, wl_output, wl_shm,
|
||||||
@@ -30,32 +32,10 @@ use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport;
|
|||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::shm;
|
use crate::shm;
|
||||||
use crate::target::{Kind, Target};
|
use crate::target::Kind;
|
||||||
|
|
||||||
/// Which tiles keep updating after the first frame.
|
/// One capture buffer. `busy` means the compositor still holds it - either it is
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
/// on screen or a capture is writing into it - so we must not scribble over it.
|
||||||
pub enum Live {
|
|
||||||
/// Every tile.
|
|
||||||
All,
|
|
||||||
/// Only the selected tile: much cheaper, and still reads as alive.
|
|
||||||
Current,
|
|
||||||
/// Nothing: one snapshot each, a picker rather than an expose.
|
|
||||||
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 {
|
pub struct Slot {
|
||||||
pub(crate) buffer: WlBuffer,
|
pub(crate) buffer: WlBuffer,
|
||||||
pub(crate) busy: bool,
|
pub(crate) busy: bool,
|
||||||
@@ -202,9 +182,8 @@ impl App {
|
|||||||
tile.settled = true;
|
tile.settled = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Only a tile that will be re-captured needs a second buffer, and a
|
// Always 2 buffers for live preview; display gets full-screen buffer.
|
||||||
// display's is the size of the whole screen.
|
let slots = if tile.target.kind == Kind::Output {
|
||||||
let slots = if self.live == Live::None || tile.target.kind == Kind::Output {
|
|
||||||
1
|
1
|
||||||
} else {
|
} else {
|
||||||
2
|
2
|
||||||
@@ -222,7 +201,7 @@ impl App {
|
|||||||
self.stats.pool_bytes = total;
|
self.stats.pool_bytes = total;
|
||||||
// Note: no mmap. The compositor writes these pages and samples them
|
// Note: no mmap. The compositor writes these pages and samples them
|
||||||
// again for display; mapping them here would only cost us the faults.
|
// again for display; mapping them here would only cost us the faults.
|
||||||
let file = shm::memfd("wl-pick-capture", total)?;
|
let file = shm::memfd("wl-tab-capture", total)?;
|
||||||
let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ());
|
let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ());
|
||||||
for (i, slot_offsets) in offsets.iter().enumerate() {
|
for (i, slot_offsets) in offsets.iter().enumerate() {
|
||||||
let (w, h, format) = {
|
let (w, h, format) = {
|
||||||
@@ -301,9 +280,6 @@ impl App {
|
|||||||
/// Ask for the next frame callback. A commit is needed for the compositor to
|
/// Ask for the next frame callback. A commit is needed for the compositor to
|
||||||
/// schedule one, and an empty commit is enough.
|
/// schedule one, and an empty commit is enough.
|
||||||
pub fn arm_frame_callback(&mut self, qh: &QueueHandle<Self>) {
|
pub fn arm_frame_callback(&mut self, qh: &QueueHandle<Self>) {
|
||||||
if self.live == Live::None {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(surface) = self.surface.clone() {
|
if let Some(surface) = self.surface.clone() {
|
||||||
surface.frame(qh, ());
|
surface.frame(qh, ());
|
||||||
surface.commit();
|
surface.commit();
|
||||||
@@ -314,22 +290,16 @@ impl App {
|
|||||||
/// the overlay is not being presented.
|
/// the overlay is not being presented.
|
||||||
pub fn tick(&mut self, qh: &QueueHandle<Self>) {
|
pub fn tick(&mut self, qh: &QueueHandle<Self>) {
|
||||||
self.stats.ticks += 1;
|
self.stats.ticks += 1;
|
||||||
if self.live == Live::None {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let interval = Duration::from_secs_f64(1.0 / self.fps.max(1) as f64);
|
let interval = Duration::from_secs_f64(1.0 / self.fps.max(1) as f64);
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for i in 0..self.tiles.len() {
|
for i in 0..self.tiles.len() {
|
||||||
if self.live == Live::Current && i != self.sel {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A display tile shows this overlay, which shows the display tile:
|
// A display tile shows this overlay, which shows the display tile:
|
||||||
// refreshing it never settles and costs a whole screen per frame.
|
// refreshing it never settles and costs a whole screen per frame.
|
||||||
if self.tiles[i].target.kind == Kind::Output {
|
if self.tiles[i].target.kind == Kind::Output {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Nor is there any point refreshing a tile that is scrolled out of
|
// Nor is there any point refreshing a tile that is scrolled out of
|
||||||
// sight — that is a readback for pixels nobody sees.
|
// sight - that is a readback for pixels nobody sees.
|
||||||
if self.layout.tile(i as i32, self.scroll).is_none() {
|
if self.layout.tile(i as i32, self.scroll).is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -398,13 +368,11 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
|||||||
app.frame_ready(i);
|
app.frame_ready(i);
|
||||||
}
|
}
|
||||||
ext_image_copy_capture_frame_v1::Event::Failed { reason } => {
|
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
|
// Live mode retries on the next tick; only a failure with no
|
||||||
// frame yet leaves the tile without a thumbnail.
|
// frame yet leaves the tile without a thumbnail.
|
||||||
if tile.frames == 0 {
|
if tile.frames == 0 {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"wl-pick: capture failed for {:?} ({reason:?})",
|
"wl-tab: capture failed for {:?} ({reason:?})",
|
||||||
tile.target.title
|
tile.target.title
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,7 +394,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
|||||||
/// Release is the whole contract: with wl_shm the compositor copies the pixels
|
/// Release is the whole contract: with wl_shm the compositor copies the pixels
|
||||||
/// out at commit and hands the buffer straight back, so the slot currently on
|
/// out at commit and hands the buffer straight back, so the slot currently on
|
||||||
/// screen is usually free too. (Waiting for it to stop being the displayed slot
|
/// screen is usually free too. (Waiting for it to stop being the displayed slot
|
||||||
/// instead would deadlock — that release never comes twice.)
|
/// instead would deadlock - that release never comes twice.)
|
||||||
impl Dispatch<WlBuffer, (usize, usize)> for App {
|
impl Dispatch<WlBuffer, (usize, usize)> for App {
|
||||||
fn event(
|
fn event(
|
||||||
app: &mut Self,
|
app: &mut Self,
|
||||||
|
|||||||
+139
-61
@@ -4,36 +4,30 @@ use std::path::PathBuf;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::app::Settings;
|
use crate::app::Settings;
|
||||||
use crate::capture::Live;
|
use crate::config::{Config, Length};
|
||||||
use crate::config::Config;
|
use crate::sway::{Display, Order};
|
||||||
use crate::sway::Display;
|
|
||||||
use crate::target::Format;
|
use crate::target::Format;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
|
|
||||||
const HELP: &str = "\
|
const HELP: &str = "\
|
||||||
wl-pick — a live grid of window and display previews, for picking one
|
wl-tab - a simple alt-tab switcher for sway
|
||||||
|
|
||||||
usage: wl-pick [options]
|
usage: wl-tab [options]
|
||||||
|
|
||||||
--config PATH config file [~/.config/wl-pick/config]
|
--config PATH config file [~/.config/wl-tab/config]
|
||||||
--format tsv|json|portal how to report the pick [tsv]
|
--format tsv|json|portal how to report the pick [tsv]
|
||||||
--live all|current|none which tiles keep updating live [all]
|
|
||||||
(displays are always a single snapshot)
|
|
||||||
--fps N cap on live updates per tile per second [12]
|
--fps N cap on live updates per tile per second [12]
|
||||||
--no-outputs windows only; displays are included by default
|
--labels, --no-labels a label under each thumbnail [yes]
|
||||||
--hide-labels draw an icon-only grid
|
--order mru|tree window ordering: mru or layout tree [mru]
|
||||||
--font FAMILY label font family [the system monospace font]
|
--focus, --no-focus focus the picked target directly [no]
|
||||||
|
--font NAME label font family, optionally with a style, as
|
||||||
|
in 'FiraCode Mono' [system monospace]
|
||||||
--font-size PX label size in logical px [13.3]
|
--font-size PX label size in logical px [13.3]
|
||||||
--timeout SECS exit anyway after SECS, in case the keyboard
|
--timeout SECS exit anyway after SECS, in case the keyboard
|
||||||
grab ever traps you [off]
|
grab ever traps you [off]
|
||||||
-v, --verbose phase timings, tile list and capture stats
|
-v, --verbose phase timings, tile list and capture stats
|
||||||
-h, --help this
|
-h, --help this
|
||||||
|
|
||||||
keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump;
|
|
||||||
Enter picks; Escape or q cancels
|
|
||||||
mouse: click a tile to pick it, scroll to move. Hovering does not move the
|
|
||||||
selection, and a click outside a tile does nothing.
|
|
||||||
|
|
||||||
The pick goes to stdout and nothing does if you cancel, so exit status is
|
The pick goes to stdout and nothing does if you cancel, so exit status is
|
||||||
0 for a pick and 1 for a cancel. Acting on it is the caller's job.
|
0 for a pick and 1 for a cancel. Acting on it is the caller's job.
|
||||||
|
|
||||||
@@ -44,24 +38,27 @@ config:
|
|||||||
percentage of the display the grid appears on, so one file suits monitors
|
percentage of the display the grid appears on, so one file suits monitors
|
||||||
of different sizes.
|
of different sizes.
|
||||||
|
|
||||||
background = #282828 # the grid's backdrop
|
background = #222222 # the grid's backdrop
|
||||||
foreground = #ebdbb2 # label text
|
foreground = #888888 # label text
|
||||||
selection = #d79921 # the highlighted tile
|
selection = #285577 # the highlighted tile
|
||||||
selection-text = #282828 # its label
|
selection-text = #ffffff # its label
|
||||||
border = #d79921
|
border = #4c7899
|
||||||
border-width = 2px
|
border-width = 2px
|
||||||
|
|
||||||
tile-width = 18ppt # how big a thumbnail is
|
max-width = 90ppt # the box the grid may fill
|
||||||
tile-height = 20ppt # defaults to the display's aspect
|
max-height = 90ppt
|
||||||
max-columns = 4 # rows beyond the screen scroll
|
|
||||||
|
|
||||||
font = monospace # also --font
|
font = monospace # a family, optionally with a style
|
||||||
font-size = 13.3
|
font-size = 13.3
|
||||||
labels = yes
|
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
|
live = all
|
||||||
fps = 12
|
fps = 12
|
||||||
format = tsv
|
format = tsv
|
||||||
|
timeout = 0 # seconds; 0 means none
|
||||||
|
|
||||||
formats:
|
formats:
|
||||||
|
|
||||||
@@ -82,16 +79,7 @@ formats:
|
|||||||
|
|
||||||
[screencast]
|
[screencast]
|
||||||
chooser_type=simple
|
chooser_type=simple
|
||||||
chooser_cmd=wl-pick --format portal
|
chooser_cmd=wl-tab --format portal";
|
||||||
|
|
||||||
focusing on sway:
|
|
||||||
|
|
||||||
IFS=$'\\t' read -r type id toplevel app title < <(wl-pick) &&
|
|
||||||
case $type in
|
|
||||||
window) swaymsg \"[con_id=$id] focus\" ;;
|
|
||||||
output) swaymsg \"focus output $id\" ;;
|
|
||||||
esac
|
|
||||||
";
|
|
||||||
|
|
||||||
/// What the command line asked for. Every setting is optional so the config file
|
/// 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.
|
/// can fill the gaps: a flag beats the file, the file beats the default.
|
||||||
@@ -104,9 +92,10 @@ pub struct Args {
|
|||||||
pub(crate) labels: Option<bool>,
|
pub(crate) labels: Option<bool>,
|
||||||
pub(crate) font: Option<String>,
|
pub(crate) font: Option<String>,
|
||||||
pub(crate) font_size: Option<f32>,
|
pub(crate) font_size: Option<f32>,
|
||||||
pub(crate) live: Option<Live>,
|
|
||||||
pub(crate) fps: Option<u32>,
|
pub(crate) fps: Option<u32>,
|
||||||
pub(crate) timeout: Option<Duration>,
|
pub(crate) timeout: Option<Duration>,
|
||||||
|
pub(crate) order: Option<Order>,
|
||||||
|
pub(crate) focus: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every setting resolved, with sizes turned into pixels for the display the
|
/// Every setting resolved, with sizes turned into pixels for the display the
|
||||||
@@ -116,6 +105,10 @@ pub struct Options {
|
|||||||
pub format: Format,
|
pub format: Format,
|
||||||
pub outputs: bool,
|
pub outputs: bool,
|
||||||
pub timeout: Option<Duration>,
|
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,
|
pub settings: Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +118,7 @@ pub fn arm_timeout(timeout: Option<Duration>) {
|
|||||||
if let Some(d) = timeout {
|
if let Some(d) = timeout {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(d);
|
std::thread::sleep(d);
|
||||||
eprintln!("wl-pick: timeout");
|
eprintln!("wl-tab: timeout");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -138,15 +131,16 @@ impl Args {
|
|||||||
pub fn resolve(&self, cfg: &Config, display: &Display) -> Options {
|
pub fn resolve(&self, cfg: &Config, display: &Display) -> Options {
|
||||||
let base = Theme::default();
|
let base = Theme::default();
|
||||||
let font_px = self.font_size.or(cfg.font_size).unwrap_or(base.font_px);
|
let font_px = self.font_size.or(cfg.font_size).unwrap_or(base.font_px);
|
||||||
let tile_w = cfg
|
// The box the grid may fill. Left alone it is most of the display, and
|
||||||
.tile_width
|
// being a percentage it travels between monitors.
|
||||||
.map_or(base.tile_w, |l| l.resolve(display.width));
|
let max_w = cfg
|
||||||
// A tile is shaped like the display unless told otherwise, since that is
|
.max_width
|
||||||
// roughly the shape of the windows on it.
|
.unwrap_or(Length::Ppt(90.0))
|
||||||
let tile_h = cfg.tile_height.map_or_else(
|
.resolve(display.width);
|
||||||
|| (tile_w as f32 * display.height as f32 / display.width.max(1) as f32) as i32,
|
let max_h = cfg
|
||||||
|l| l.resolve(display.height),
|
.max_height
|
||||||
);
|
.unwrap_or(Length::Ppt(90.0))
|
||||||
|
.resolve(display.height);
|
||||||
let theme = Theme {
|
let theme = Theme {
|
||||||
bg: cfg.background.unwrap_or(base.bg),
|
bg: cfg.background.unwrap_or(base.bg),
|
||||||
fg: cfg.foreground.unwrap_or(base.fg),
|
fg: cfg.foreground.unwrap_or(base.fg),
|
||||||
@@ -156,9 +150,10 @@ impl Args {
|
|||||||
border_px: cfg
|
border_px: cfg
|
||||||
.border_width
|
.border_width
|
||||||
.map_or(base.border_px, |l| l.resolve(display.width)),
|
.map_or(base.border_px, |l| l.resolve(display.width)),
|
||||||
tile_w: tile_w.max(1),
|
max_w: max_w.max(1),
|
||||||
tile_h: tile_h.max(1),
|
max_h: max_h.max(1),
|
||||||
max_cols: cfg.max_columns.unwrap_or(base.max_cols).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),
|
labels: self.labels.or(cfg.labels).unwrap_or(base.labels),
|
||||||
font: self
|
font: self
|
||||||
.font
|
.font
|
||||||
@@ -176,14 +171,15 @@ impl Args {
|
|||||||
Options {
|
Options {
|
||||||
verbose: self.verbose,
|
verbose: self.verbose,
|
||||||
format: self.format.or(cfg.format).unwrap_or(Format::Tsv),
|
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),
|
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 {
|
settings: Settings {
|
||||||
theme,
|
theme,
|
||||||
live: self.live.or(cfg.live).unwrap_or(Live::All),
|
|
||||||
fps: self.fps.or(cfg.fps).unwrap_or(12),
|
fps: self.fps.or(cfg.fps).unwrap_or(12),
|
||||||
scale: display.scale,
|
scale: display.scale,
|
||||||
display: (display.width, display.height),
|
|
||||||
output: display.name.clone(),
|
output: display.name.clone(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -191,8 +187,12 @@ impl Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_args() -> Result<Args, String> {
|
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 args = Args::default();
|
||||||
let mut it = std::env::args().skip(1);
|
let mut it = it;
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--format" => {
|
"--format" => {
|
||||||
@@ -201,20 +201,25 @@ pub fn parse_args() -> Result<Args, String> {
|
|||||||
}
|
}
|
||||||
"--outputs" => args.outputs = Some(true),
|
"--outputs" => args.outputs = Some(true),
|
||||||
"--no-outputs" => args.outputs = Some(false),
|
"--no-outputs" => args.outputs = Some(false),
|
||||||
|
"--order" => {
|
||||||
|
let v = it.next().ok_or("--order needs mru|tree")?;
|
||||||
|
args.order = Some(Order::parse(&v)?);
|
||||||
|
}
|
||||||
|
"--focus" => args.focus = Some(true),
|
||||||
|
"--no-focus" => args.focus = Some(false),
|
||||||
"--config" => {
|
"--config" => {
|
||||||
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?))
|
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?))
|
||||||
}
|
}
|
||||||
"-v" | "--verbose" => args.verbose = true,
|
"-v" | "--verbose" => args.verbose = true,
|
||||||
"--hide-labels" => args.labels = Some(false),
|
"--labels" => args.labels = Some(true),
|
||||||
"--live" => {
|
// --hide-labels was the only spelling before --labels existed, and
|
||||||
let v = it.next().ok_or("--live needs all|current|none")?;
|
// is still accepted for whatever it is wired into.
|
||||||
args.live = Some(Live::parse(&v)?);
|
"--no-labels" | "--hide-labels" => args.labels = Some(false),
|
||||||
}
|
|
||||||
"--fps" => {
|
"--fps" => {
|
||||||
let v = it.next().ok_or("--fps needs a number")?;
|
let v = it.next().ok_or("--fps needs a number")?;
|
||||||
args.fps = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?);
|
args.fps = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?);
|
||||||
}
|
}
|
||||||
"--font" => args.font = Some(it.next().ok_or("--font needs a family name")?),
|
"--font" => args.font = Some(it.next().ok_or("--font needs a font name")?),
|
||||||
"--font-size" => {
|
"--font-size" => {
|
||||||
let v = it.next().ok_or("--font-size needs px")?;
|
let v = it.next().ok_or("--font-size needs px")?;
|
||||||
args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?);
|
args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?);
|
||||||
@@ -222,7 +227,7 @@ pub fn parse_args() -> Result<Args, String> {
|
|||||||
"--timeout" => {
|
"--timeout" => {
|
||||||
let v = it.next().ok_or("--timeout needs seconds")?;
|
let v = it.next().ok_or("--timeout needs seconds")?;
|
||||||
let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?;
|
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" => {
|
"-h" | "--help" => {
|
||||||
print!("{HELP}");
|
print!("{HELP}");
|
||||||
@@ -233,3 +238,76 @@ pub fn parse_args() -> Result<Args, String> {
|
|||||||
}
|
}
|
||||||
Ok(args)
|
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");
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+37
-24
@@ -1,20 +1,20 @@
|
|||||||
//! The config file: `~/.config/wl-pick/config`.
|
//! The config file: `~/.config/wl-tab/config`.
|
||||||
//!
|
//!
|
||||||
//! Flat `key = value` lines with `#` comments — no sections, no nesting, so a
|
//! Flat `key = value` lines with `#` comments - no sections, no nesting, so a
|
||||||
//! TOML parser would be a dependency bought for nothing. Every setting is
|
//! TOML parser would be a dependency bought for nothing. Every setting is
|
||||||
//! optional; anything absent keeps its default, and a command-line flag beats
|
//! optional; anything absent keeps its default, and a command-line flag beats
|
||||||
//! the file.
|
//! the file.
|
||||||
//!
|
//!
|
||||||
//! Sizes take sway's syntax: `600px` is absolute, `70ppt` is 70 percent of the
|
//! 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
|
//! 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
|
//! pixel size that suits one screen is wrong on the next - percentages are
|
||||||
//! resolved against whichever display the overlay actually maps on, each time
|
//! resolved against whichever display the overlay actually maps on, each time
|
||||||
//! it runs.
|
//! it runs.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::capture::Live;
|
use crate::sway::Order;
|
||||||
use crate::target::Format;
|
use crate::target::Format;
|
||||||
use crate::theme::Argb;
|
use crate::theme::Argb;
|
||||||
|
|
||||||
@@ -83,19 +83,22 @@ pub struct Config {
|
|||||||
pub selection_text: Option<Argb>,
|
pub selection_text: Option<Argb>,
|
||||||
pub border: Option<Argb>,
|
pub border: Option<Argb>,
|
||||||
pub border_width: Option<Length>,
|
pub border_width: Option<Length>,
|
||||||
/// Largest a thumbnail may be. Height defaults to the display's aspect, so
|
/// The box the grid may not exceed. Thumbnails are this divided by the
|
||||||
/// a tile is shaped like the windows it shows.
|
/// column and row caps, so their size does not depend on how many windows
|
||||||
pub tile_width: Option<Length>,
|
/// happen to be open.
|
||||||
pub tile_height: Option<Length>,
|
pub max_width: Option<Length>,
|
||||||
|
pub max_height: Option<Length>,
|
||||||
pub max_columns: Option<i32>,
|
pub max_columns: Option<i32>,
|
||||||
|
pub max_rows: Option<i32>,
|
||||||
pub font: Option<String>,
|
pub font: Option<String>,
|
||||||
pub font_size: Option<f32>,
|
pub font_size: Option<f32>,
|
||||||
pub labels: Option<bool>,
|
pub labels: Option<bool>,
|
||||||
pub outputs: Option<bool>,
|
pub outputs: Option<bool>,
|
||||||
pub live: Option<Live>,
|
|
||||||
pub fps: Option<u32>,
|
pub fps: Option<u32>,
|
||||||
pub format: Option<Format>,
|
pub format: Option<Format>,
|
||||||
pub timeout: Option<Duration>,
|
pub timeout: Option<Duration>,
|
||||||
|
pub order: Option<Order>,
|
||||||
|
pub focus: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -138,19 +141,25 @@ impl Config {
|
|||||||
"selection-text" => self.selection_text = Some(colour(value)?),
|
"selection-text" => self.selection_text = Some(colour(value)?),
|
||||||
"border" => self.border = Some(colour(value)?),
|
"border" => self.border = Some(colour(value)?),
|
||||||
"border-width" => self.border_width = Some(Length::parse(value)?),
|
"border-width" => self.border_width = Some(Length::parse(value)?),
|
||||||
"tile-width" => self.tile_width = Some(Length::parse(value)?),
|
"max-width" => self.max_width = Some(Length::parse(value)?),
|
||||||
"tile-height" => self.tile_height = Some(Length::parse(value)?),
|
"max-height" => self.max_height = Some(Length::parse(value)?),
|
||||||
"max-columns" => {
|
"max-columns" => self.max_columns = Some(number(value)?),
|
||||||
self.max_columns = Some(number(value)?);
|
"max-rows" => self.max_rows = Some(number(value)?),
|
||||||
}
|
|
||||||
"font" => self.font = Some(value.to_string()),
|
"font" => self.font = Some(value.to_string()),
|
||||||
"font-size" => self.font_size = Some(number(value)?),
|
"font-size" => self.font_size = Some(number(value)?),
|
||||||
"labels" => self.labels = Some(boolean(value)?),
|
"labels" => self.labels = Some(boolean(value)?),
|
||||||
"outputs" => self.outputs = Some(boolean(value)?),
|
"outputs" => self.outputs = Some(boolean(value)?),
|
||||||
"live" => self.live = Some(Live::parse(value)?),
|
|
||||||
"fps" => self.fps = Some(number(value)?),
|
"fps" => self.fps = Some(number(value)?),
|
||||||
"format" => self.format = Some(Format::parse(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)?),
|
||||||
|
"focus" => self.focus = Some(boolean(value)?),
|
||||||
other => return Err(format!("unknown setting {other:?}")),
|
other => return Err(format!("unknown setting {other:?}")),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -177,13 +186,13 @@ fn number<T: std::str::FromStr>(s: &str) -> Result<T, String> {
|
|||||||
.map_err(|_| format!("{s:?} is not a number"))
|
.map_err(|_| format!("{s:?} is not a number"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `$XDG_CONFIG_HOME/wl-pick/config`, or `~/.config/wl-pick/config`.
|
/// `$XDG_CONFIG_HOME/wl-tab/config`, or `~/.config/wl-tab/config`.
|
||||||
fn default_path() -> PathBuf {
|
fn default_path() -> PathBuf {
|
||||||
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
dir.join("wl-pick").join("config")
|
dir.join("wl-tab").join("config")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -228,26 +237,30 @@ background = #282828
|
|||||||
selection = #d79921 # trailing comment
|
selection = #d79921 # trailing comment
|
||||||
border-width = 2px
|
border-width = 2px
|
||||||
|
|
||||||
tile-width = 18ppt
|
max-width = 70ppt
|
||||||
max-columns = 4
|
max-columns = 4
|
||||||
|
max-rows = 3
|
||||||
|
|
||||||
live = current
|
|
||||||
fps = 30
|
fps = 30
|
||||||
labels = no
|
labels = no
|
||||||
|
timeout = 0
|
||||||
|
order = mru
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
.expect("should parse");
|
.expect("should parse");
|
||||||
assert_eq!(cfg.background, Some(0xff282828));
|
assert_eq!(cfg.background, Some(0xff282828));
|
||||||
assert_eq!(cfg.selection, Some(0xffd79921));
|
assert_eq!(cfg.selection, Some(0xffd79921));
|
||||||
assert_eq!(cfg.border_width, Some(Length::Px(2)));
|
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_columns, Some(4));
|
||||||
|
assert_eq!(cfg.max_rows, Some(3));
|
||||||
assert_eq!(cfg.fps, Some(30));
|
assert_eq!(cfg.fps, Some(30));
|
||||||
assert_eq!(cfg.labels, Some(false));
|
assert_eq!(cfg.labels, Some(false));
|
||||||
assert!(cfg.live.is_some());
|
assert_eq!(cfg.timeout, None, "zero means no timeout");
|
||||||
|
assert_eq!(cfg.order, Some(Order::Mru));
|
||||||
// Untouched settings stay unset, so defaults survive.
|
// Untouched settings stay unset, so defaults survive.
|
||||||
assert_eq!(cfg.foreground, None);
|
assert_eq!(cfg.foreground, None);
|
||||||
assert_eq!(cfg.tile_height, None);
|
assert_eq!(cfg.max_height, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -275,7 +288,7 @@ labels = no
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_file_is_not_an_error() {
|
fn a_missing_file_is_not_an_error() {
|
||||||
let missing = Path::new("/nonexistent/wl-pick/config");
|
let missing = Path::new("/nonexistent/wl-tab/config");
|
||||||
assert!(
|
assert!(
|
||||||
Config::load(Some(missing)).is_err(),
|
Config::load(Some(missing)).is_err(),
|
||||||
"named file must exist"
|
"named file must exist"
|
||||||
|
|||||||
+171
-39
@@ -1,4 +1,4 @@
|
|||||||
//! wl-pick shows a live grid of every window and display as a layer-shell
|
//! wl-tab shows a live grid of every window and display as a layer-shell
|
||||||
//! overlay and reports which one you picked. That is all it does: acting on the
|
//! overlay and reports which one you picked. That is all it does: acting on the
|
||||||
//! choice belongs to whatever called it.
|
//! choice belongs to whatever called it.
|
||||||
//!
|
//!
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
//! is no thumbnail encoding, no scaler, and no full-resolution image in our
|
//! is no thumbnail encoding, no scaler, and no full-resolution image in our
|
||||||
//! address space.
|
//! address space.
|
||||||
//!
|
//!
|
||||||
//! - `cli` — flags and help
|
//! - `cli` - flags and help
|
||||||
//! - `sway` — the window list, over sway's IPC socket
|
//! - `sway` - the window list, over sway's IPC socket
|
||||||
//! - `target` — what a tile stands for, and how a pick is reported
|
//! - `target` - what a tile stands for, and how a pick is reported
|
||||||
//! - `app` — the Wayland client state everything dispatches into
|
//! - `app` - the Wayland client state everything dispatches into
|
||||||
//! - `capture` — capture sessions and their buffers
|
//! - `capture` - capture sessions and their buffers
|
||||||
//! - `overlay` — the layer surface, the drawing, the keyboard
|
//! - `overlay` - the layer surface, the drawing, the keyboard
|
||||||
//! - `theme`, `text`, `shm` — look, labels, and shared memory
|
//! - `theme`, `text`, `shm` - look, labels, and shared memory
|
||||||
|
|
||||||
// `slice::as_chunks` and friends, which clippy suggests in place of
|
// `slice::as_chunks` and friends, which clippy suggests in place of
|
||||||
// `chunks_exact`, are newer than the toolchain this crate says it supports.
|
// `chunks_exact`, are newer than the toolchain this crate says it supports.
|
||||||
@@ -36,45 +36,53 @@ mod theme;
|
|||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::process::ExitCode;
|
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::globals::registry_queue_init;
|
||||||
use wayland_client::{Connection, EventQueue};
|
use wayland_client::{Connection, EventQueue};
|
||||||
|
|
||||||
use app::App;
|
use app::{App, Ending};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use target::Target;
|
use target::Target;
|
||||||
use theme::Layout;
|
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 {
|
fn main() -> ExitCode {
|
||||||
match run() {
|
match run() {
|
||||||
Ok(code) => code,
|
Ok(code) => code,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("wl-pick: {e}");
|
eprintln!("wl-tab: {e}");
|
||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run() -> Result<ExitCode, Box<dyn Error>> {
|
fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||||
let args = cli::parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?;
|
let args = cli::parse_args().map_err(Box::<dyn Error>::from)?;
|
||||||
let config =
|
let config = Config::load(args.config.as_deref()).map_err(Box::<dyn Error>::from)?;
|
||||||
Config::load(args.config.as_deref()).map_err(|e| -> Box<dyn Error> { e.into() })?;
|
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut phases = Phases::new(args.verbose);
|
let mut phases = Phases::new(args.verbose);
|
||||||
// One IPC conversation: the window list, and the displays the grid sizes
|
// One IPC conversation: the window list, and the displays the grid sizes
|
||||||
// itself against. It is closed again before the overlay maps.
|
// itself against. It is closed again before the overlay maps.
|
||||||
let (targets, opts) = {
|
let (targets, opts) = {
|
||||||
let mut sway = swayipc::Connection::new().map_err(|e| {
|
let mut sway = sway::connect()?;
|
||||||
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
|
// The displays come first: the grid is sized against the one it will
|
||||||
// appear on, so every percentage in the config resolves per monitor.
|
// appear on, so every percentage in the config resolves per monitor.
|
||||||
let displays = sway::displays(&mut sway)?;
|
let displays = sway::displays(&mut sway)?;
|
||||||
let display = sway::focused(&displays).ok_or("sway reports no active display")?;
|
let display = sway::focused(&displays).ok_or("sway reports no active display")?;
|
||||||
let opts = args.resolve(&config, 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 {
|
if opts.outputs {
|
||||||
// Displays go last, after the windows, so window positions are
|
// Displays go last, after the windows, so window positions are
|
||||||
// stable as windows come and go.
|
// stable as windows come and go.
|
||||||
@@ -88,27 +96,29 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
phases.mark("sway-tree");
|
phases.mark("sway-tree");
|
||||||
|
|
||||||
cli::arm_timeout(opts.timeout);
|
cli::arm_timeout(opts.timeout);
|
||||||
let settings = opts.settings;
|
let (display, settings) = (opts.display, opts.settings);
|
||||||
let theme = &settings.theme;
|
let theme = &settings.theme;
|
||||||
let scale = settings.scale;
|
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
|
// Start shaping labels now: it costs ~55ms of font loading and glyph
|
||||||
// rasterising, and the captures below are ~55ms of waiting on the
|
// rasterising, and the captures below are ~55ms of waiting on the
|
||||||
// compositor, so the two overlap almost exactly.
|
// compositor, so the two overlap almost exactly.
|
||||||
let labels = theme.labels.then(|| {
|
let labels = layout.label(0, 0).map(|label| {
|
||||||
let layout = Layout::new(theme, targets.len() as i32, settings.display);
|
|
||||||
text::spawn(
|
text::spawn(
|
||||||
targets.iter().map(Target::label).collect(),
|
targets.iter().map(Target::label).collect(),
|
||||||
theme.font.clone(),
|
theme.font.clone(),
|
||||||
theme.font_px * scale as f32,
|
theme.font_px * scale as f32,
|
||||||
(theme.line_h * 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 conn = Connection::connect_to_env()?;
|
||||||
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
||||||
let qh = queue.handle();
|
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.
|
// Two roundtrips: one for the toplevel list, one for each handle's state.
|
||||||
queue.roundtrip(&mut app)?;
|
queue.roundtrip(&mut app)?;
|
||||||
@@ -120,7 +130,18 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
phases.mark("constraints");
|
phases.mark("constraints");
|
||||||
|
|
||||||
app.start_captures(&qh)?;
|
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");
|
phases.mark("capture");
|
||||||
|
|
||||||
if let Some(job) = labels {
|
if let Some(job) = labels {
|
||||||
@@ -132,20 +153,83 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.show(&qh)?;
|
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.paint();
|
||||||
app.sync_tiles(&qh);
|
app.sync_tiles(&qh);
|
||||||
app.arm_frame_callback(&qh);
|
app.arm_frame_callback(&qh);
|
||||||
conn.flush()?;
|
conn.flush()?;
|
||||||
phases.mark("mapped");
|
phases.mark("mapped");
|
||||||
|
|
||||||
// Scrolling re-places the subsurfaces; doing it here rather than inside the
|
// The keyboard grab is what makes the overlay usable, so losing it for
|
||||||
// key handler coalesces a held-down arrow into one update per dispatch.
|
// good ends the run: that is how a second wl-tab, started from the same
|
||||||
while !app.finished() {
|
// keybinding, replaces the first instead of leaving it stranded on screen.
|
||||||
queue.blocking_dispatch(&mut app)?;
|
// A leave only counts once it has failed to come back, because sway also
|
||||||
if std::mem::take(&mut app.needs_tiles) {
|
// cycles focus off and on in a single batch as the pointer crosses us.
|
||||||
app.sync_tiles(&qh);
|
loop {
|
||||||
conn.flush()?;
|
if app.finished() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// When a navigation key is held, we need to fire repeat events on a
|
||||||
|
// timer rather than blocking indefinitely. Use a timed poll so we
|
||||||
|
// wake up when the next repeat is due without burning the CPU.
|
||||||
|
if let Some(next) = app.repeat_next {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= next {
|
||||||
|
let qh = queue.handle();
|
||||||
|
app.fire_repeat(&qh);
|
||||||
|
conn.flush()?;
|
||||||
|
} else {
|
||||||
|
// Poll for events with a timeout set to when the next repeat fires.
|
||||||
|
let left = next - now;
|
||||||
|
queue.dispatch_pending(&mut app)?;
|
||||||
|
if !app.finished() {
|
||||||
|
conn.flush()?;
|
||||||
|
if let Some(guard) = conn.prepare_read() {
|
||||||
|
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) => {
|
||||||
|
// Timeout expired: do NOT call guard.read() because
|
||||||
|
// nothing is pending on the socket. Dropping guard cancels read.
|
||||||
|
}
|
||||||
|
Ok(_) | Err(rustix::io::Errno::INTR) => {
|
||||||
|
let _ = guard.read();
|
||||||
|
}
|
||||||
|
Err(e) => return Err(Box::new(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queue.blocking_dispatch(&mut app)?;
|
||||||
|
}
|
||||||
|
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 {
|
if opts.verbose {
|
||||||
@@ -155,28 +239,76 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
let Some(target) = app.picked() else {
|
let Some(target) = app.picked() else {
|
||||||
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
||||||
};
|
};
|
||||||
|
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"));
|
||||||
|
sway::record_focus(con_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target::Kind::Output => {
|
||||||
|
let _ = sway.run_command(format!("focus output {}", target.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
match target.render(opts.format) {
|
match target.render(opts.format) {
|
||||||
Some(line) => println!("{line}"),
|
Some(line) => println!("{line}"),
|
||||||
// Only the portal format can fail to name something: it identifies a
|
// Only the portal format can fail to name something: it identifies a
|
||||||
// window by its foreign-toplevel identifier, and this one has none.
|
// window by its foreign-toplevel identifier, and this one has none.
|
||||||
None => {
|
None => {
|
||||||
eprintln!("wl-pick: {:?} has no toplevel identifier", target.title);
|
eprintln!("wl-tab: {:?} has no toplevel identifier", target.title);
|
||||||
return Ok(ExitCode::FAILURE);
|
return Ok(ExitCode::FAILURE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ExitCode::SUCCESS)
|
Ok(ExitCode::SUCCESS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the event loop until `done`.
|
/// Run the event loop until `done`, or until `limit` has passed. Returns
|
||||||
fn pump(
|
/// 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>,
|
queue: &mut EventQueue<App>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
done: impl Fn(&App) -> bool,
|
done: impl Fn(&App) -> bool,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
limit: Duration,
|
||||||
while !done(app) {
|
) -> Result<bool, Box<dyn Error>> {
|
||||||
queue.blocking_dispatch(app)?;
|
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
|
/// Phase timings, printed with --verbose. Opening latency is the whole point of
|
||||||
|
|||||||
+190
-45
@@ -3,7 +3,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Scaling is the compositor's job. A tile attaches its capture buffer directly
|
//! Scaling is the compositor's job. A tile attaches its capture buffer directly
|
||||||
//! and wp_viewporter names the rectangle to fit it into, so nothing here touches
|
//! and wp_viewporter names the rectangle to fit it into, so nothing here touches
|
||||||
//! a pixel of window content — only the background, selection and labels.
|
//! a pixel of window content - only the background, selection and labels.
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::AsFd;
|
||||||
@@ -32,23 +32,47 @@ use crate::theme::{Rect, fit_centred};
|
|||||||
const KEY_ESC: u32 = 1;
|
const KEY_ESC: u32 = 1;
|
||||||
const KEY_TAB: u32 = 15;
|
const KEY_TAB: u32 = 15;
|
||||||
const KEY_Q: u32 = 16;
|
const KEY_Q: u32 = 16;
|
||||||
// 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_ENTER: u32 = 28;
|
||||||
|
const KEY_LEFTCTRL: u32 = 29;
|
||||||
const KEY_LEFTSHIFT: u32 = 42;
|
const KEY_LEFTSHIFT: u32 = 42;
|
||||||
const KEY_RIGHTSHIFT: u32 = 54;
|
const KEY_RIGHTSHIFT: u32 = 54;
|
||||||
|
const KEY_LEFTALT: u32 = 56;
|
||||||
const KEY_KPENTER: u32 = 96;
|
const KEY_KPENTER: u32 = 96;
|
||||||
|
const KEY_RIGHTCTRL: u32 = 97;
|
||||||
|
const KEY_RIGHTALT: u32 = 100;
|
||||||
const KEY_HOME: u32 = 102;
|
const KEY_HOME: u32 = 102;
|
||||||
const KEY_UP: u32 = 103;
|
const KEY_UP: u32 = 103;
|
||||||
|
const KEY_PGUP: u32 = 104;
|
||||||
const KEY_LEFT: u32 = 105;
|
const KEY_LEFT: u32 = 105;
|
||||||
const KEY_RIGHT: u32 = 106;
|
const KEY_RIGHT: u32 = 106;
|
||||||
const KEY_END: u32 = 107;
|
const KEY_END: u32 = 107;
|
||||||
const KEY_DOWN: u32 = 108;
|
const KEY_DOWN: u32 = 108;
|
||||||
const KEY_PGUP: u32 = 104;
|
|
||||||
const KEY_PGDN: u32 = 109;
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys that should fire repeatedly while held.
|
||||||
|
fn is_repeatable_key(code: u32) -> bool {
|
||||||
|
matches!(
|
||||||
|
code,
|
||||||
|
KEY_TAB
|
||||||
|
| KEY_RIGHT
|
||||||
|
| KEY_LEFT
|
||||||
|
| KEY_DOWN
|
||||||
|
| KEY_UP
|
||||||
|
| KEY_HOME
|
||||||
|
| KEY_END
|
||||||
|
| KEY_PGUP
|
||||||
|
| KEY_PGDN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// evdev button code, as wl_pointer reports it.
|
/// evdev button code, as wl_pointer reports it.
|
||||||
const BTN_LEFT: u32 = 0x110;
|
const BTN_LEFT: u32 = 0x110;
|
||||||
@@ -79,7 +103,7 @@ impl App {
|
|||||||
&surface,
|
&surface,
|
||||||
output,
|
output,
|
||||||
Layer::Overlay,
|
Layer::Overlay,
|
||||||
"wl-pick".to_string(),
|
"wl-tab".to_string(),
|
||||||
qh,
|
qh,
|
||||||
(),
|
(),
|
||||||
);
|
);
|
||||||
@@ -90,7 +114,7 @@ impl App {
|
|||||||
|
|
||||||
let (pw, ph) = (lw * self.scale, lh * self.scale);
|
let (pw, ph) = (lw * self.scale, lh * self.scale);
|
||||||
let len = shm::Chrome::slot_len(pw, ph) * shm::Chrome::SLOTS;
|
let len = shm::Chrome::slot_len(pw, ph) * shm::Chrome::SLOTS;
|
||||||
let file = shm::memfd("wl-pick-chrome", len)?;
|
let file = shm::memfd("wl-tab-chrome", len)?;
|
||||||
let pool = self.shm.create_pool(file.as_fd(), len as i32, qh, ());
|
let pool = self.shm.create_pool(file.as_fd(), len as i32, qh, ());
|
||||||
for slot in 0..shm::Chrome::SLOTS {
|
for slot in 0..shm::Chrome::SLOTS {
|
||||||
self.chrome_buffers.push(pool.create_buffer(
|
self.chrome_buffers.push(pool.create_buffer(
|
||||||
@@ -105,6 +129,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
pool.destroy();
|
pool.destroy();
|
||||||
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
|
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);
|
self.surface = Some(surface);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -112,7 +139,7 @@ impl App {
|
|||||||
/// Put every visible tile where the viewport says, and unmap the rest.
|
/// Put every visible tile where the viewport says, and unmap the rest.
|
||||||
///
|
///
|
||||||
/// Runs again after each scroll, so a tile scrolled off screen gets a null
|
/// Runs again after each scroll, so a tile scrolled off screen gets a null
|
||||||
/// buffer — the way to hide a subsurface — rather than being left behind.
|
/// buffer - the way to hide a subsurface - rather than being left behind.
|
||||||
/// Scaling stays the compositor's job: the capture buffer is attached as it
|
/// Scaling stays the compositor's job: the capture buffer is attached as it
|
||||||
/// is, and wp_viewporter names the rectangle to fit it into.
|
/// is, and wp_viewporter names the rectangle to fit it into.
|
||||||
pub fn sync_tiles(&mut self, qh: &QueueHandle<Self>) {
|
pub fn sync_tiles(&mut self, qh: &QueueHandle<Self>) {
|
||||||
@@ -137,8 +164,8 @@ impl App {
|
|||||||
let surface = self.compositor.create_surface(qh, ());
|
let surface = self.compositor.create_surface(qh, ());
|
||||||
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
||||||
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
||||||
// Tiles change independently of the chrome — a live frame
|
// Tiles change independently of the chrome - a live frame
|
||||||
// arrives whenever its window does — so they must not wait on a
|
// arrives whenever its window does - so they must not wait on a
|
||||||
// parent commit.
|
// parent commit.
|
||||||
subsurface.set_desync();
|
subsurface.set_desync();
|
||||||
// The capture protocol reports the transform the compositor
|
// The capture protocol reports the transform the compositor
|
||||||
@@ -212,7 +239,7 @@ impl App {
|
|||||||
let (cw, ch) = (chrome.w, chrome.h);
|
let (cw, ch) = (chrome.w, chrome.h);
|
||||||
let mut p = chrome.painter();
|
let mut p = chrome.painter();
|
||||||
p.fill(bg);
|
p.fill(bg);
|
||||||
// The selection fills the whole element box, padding included — the same
|
// The selection fills the whole element box, padding included - the same
|
||||||
// thing rofi's element background does. It can be scrolled out of sight.
|
// thing rofi's element background does. It can be scrolled out of sight.
|
||||||
if let Some(elem) = elem {
|
if let Some(elem) = elem {
|
||||||
p.rect(elem, sel_bg);
|
p.rect(elem, sel_bg);
|
||||||
@@ -236,36 +263,37 @@ impl App {
|
|||||||
surface.commit();
|
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;
|
let n = self.tiles.len() as i32;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return;
|
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 n = self.tiles.len() as i32;
|
||||||
let target = self.sel as i32 + rows * self.layout.cols;
|
let target = self.sel as i32 + rows * self.layout.cols;
|
||||||
if target >= 0 && target < n {
|
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
|
/// Move the selection, scrolling the least that keeps it on screen. Every
|
||||||
/// keyboard move goes through here, so the selection is never off-view.
|
/// move goes through here, so the selection is never off-view and the
|
||||||
fn select(&mut self, i: usize) {
|
/// subsurfaces always match the viewport.
|
||||||
|
fn select(&mut self, i: usize, qh: &QueueHandle<Self>) {
|
||||||
self.sel = i;
|
self.sel = i;
|
||||||
let scroll = self.layout.reveal(i, self.scroll);
|
let scroll = self.layout.reveal(i, self.scroll);
|
||||||
if scroll != self.scroll {
|
if scroll != self.scroll {
|
||||||
self.scroll = scroll;
|
self.scroll = scroll;
|
||||||
self.needs_tiles = true;
|
self.sync_tiles(qh);
|
||||||
}
|
}
|
||||||
self.paint();
|
self.paint();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tile under the pointer, if it is over one. A tile's own subsurface
|
/// The tile under the pointer, if it is over one. A tile's own subsurface
|
||||||
/// answers directly; over the parent surface — padding, labels, gaps — the
|
/// answers directly; over the parent surface - padding, labels, gaps - the
|
||||||
/// layout is asked instead.
|
/// layout is asked instead.
|
||||||
fn tile_at_pointer(&self) -> Option<usize> {
|
fn tile_at_pointer(&self) -> Option<usize> {
|
||||||
let hover = self.hover.as_ref()?;
|
let hover = self.hover.as_ref()?;
|
||||||
@@ -280,8 +308,8 @@ impl App {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Press and release on the same tile picks it. Anywhere else — the margin,
|
/// Press and release on the same tile picks it. Anywhere else - the margin,
|
||||||
/// a gap, an empty cell of the last row — does nothing at all.
|
/// a gap, an empty cell of the last row - does nothing at all.
|
||||||
fn click(&mut self, pressed: bool) {
|
fn click(&mut self, pressed: bool) {
|
||||||
if pressed {
|
if pressed {
|
||||||
self.pressed = self.tile_at_pointer();
|
self.pressed = self.tile_at_pointer();
|
||||||
@@ -294,7 +322,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key(&mut self, code: u32) {
|
fn key(&mut self, code: u32, qh: &QueueHandle<Self>) {
|
||||||
|
if is_trigger_modifier(code) {
|
||||||
|
self.latched_modifiers.insert(code);
|
||||||
|
}
|
||||||
|
// Arm client-side repeat for navigation keys.
|
||||||
|
if is_repeatable_key(code) {
|
||||||
|
let delay = std::time::Duration::from_millis(self.repeat_delay_ms as u64);
|
||||||
|
self.repeat_key = Some(code);
|
||||||
|
self.repeat_next = Some(std::time::Instant::now() + delay);
|
||||||
|
} else {
|
||||||
|
// Non-repeating key clears any held repeat.
|
||||||
|
self.repeat_key = None;
|
||||||
|
self.repeat_next = None;
|
||||||
|
}
|
||||||
match code {
|
match code {
|
||||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||||
KEY_ESC | KEY_Q => self.ending = Ending::Cancelled,
|
KEY_ESC | KEY_Q => self.ending = Ending::Cancelled,
|
||||||
@@ -302,18 +343,99 @@ impl App {
|
|||||||
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||||
self.ending = Ending::Picked;
|
self.ending = Ending::Picked;
|
||||||
}
|
}
|
||||||
KEY_TAB if self.shift => self.move_sel(-1),
|
KEY_TAB if self.shift => self.move_sel(-1, qh),
|
||||||
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1),
|
KEY_TAB | KEY_RIGHT => self.move_sel(1, qh),
|
||||||
KEY_LEFT | KEY_H => self.move_sel(-1),
|
KEY_LEFT => self.move_sel(-1, qh),
|
||||||
KEY_DOWN | KEY_J => self.move_row(1),
|
KEY_DOWN => self.move_sel(1, qh),
|
||||||
KEY_UP | KEY_K => self.move_row(-1),
|
KEY_UP => self.move_sel(-1, qh),
|
||||||
KEY_HOME => self.select(0),
|
KEY_HOME => self.select(0, qh),
|
||||||
KEY_END => self.select(self.tiles.len().saturating_sub(1)),
|
KEY_END => self.select(self.tiles.len().saturating_sub(1), qh),
|
||||||
KEY_PGUP => self.move_row(-self.layout.visible_rows),
|
KEY_PGUP => self.move_row(-self.layout.visible_rows, qh),
|
||||||
KEY_PGDN => self.move_row(self.layout.visible_rows),
|
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;
|
||||||
|
}
|
||||||
|
// Clear repeat if this is the key that was held.
|
||||||
|
if self.repeat_key == Some(code) {
|
||||||
|
self.repeat_key = None;
|
||||||
|
self.repeat_next = None;
|
||||||
|
}
|
||||||
|
if 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the main loop when the key-repeat timer fires. Fires the
|
||||||
|
/// currently held navigation action, then arms the next repeat tick.
|
||||||
|
pub fn fire_repeat(&mut self, qh: &QueueHandle<Self>) {
|
||||||
|
let Some(code) = self.repeat_key else { return };
|
||||||
|
let rate = std::time::Duration::from_millis(self.repeat_rate_ms as u64);
|
||||||
|
self.repeat_next = Some(std::time::Instant::now() + rate);
|
||||||
|
// Re-run the navigation action without re-arming the delay.
|
||||||
|
match code {
|
||||||
|
KEY_TAB if self.shift => self.move_sel(-1, qh),
|
||||||
|
KEY_TAB | KEY_RIGHT => self.move_sel(1, qh),
|
||||||
|
KEY_LEFT => self.move_sel(-1, qh),
|
||||||
|
KEY_DOWN => self.move_sel(1, qh),
|
||||||
|
KEY_UP => self.move_sel(-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 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();
|
||||||
|
|
||||||
|
for &m in &held_modifiers {
|
||||||
|
self.latched_modifiers.insert(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no modifier is held on enter, the modifier (and/or Tab) was
|
||||||
|
// released before focus was acquired: commit selection immediately!
|
||||||
|
if 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;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A modifier is held. If Tab is also held upon enter, arm key-repeat
|
||||||
|
// immediately so holding Tab cycles through windows.
|
||||||
|
if held_keys.iter().any(|&k| k == KEY_TAB) {
|
||||||
|
let delay = std::time::Duration::from_millis(self.repeat_delay_ms as u64);
|
||||||
|
self.repeat_key = Some(KEY_TAB);
|
||||||
|
self.repeat_next = Some(std::time::Instant::now() + delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- event plumbing -------------------------------------------------------
|
// --- event plumbing -------------------------------------------------------
|
||||||
@@ -373,23 +495,44 @@ impl Dispatch<WlKeyboard, ()> for App {
|
|||||||
event: wl_keyboard::Event,
|
event: wl_keyboard::Event,
|
||||||
_: &(),
|
_: &(),
|
||||||
_: &Connection,
|
_: &Connection,
|
||||||
_: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
if let wl_keyboard::Event::Key { key, state, .. } = event {
|
match event {
|
||||||
match state {
|
wl_keyboard::Event::Key { key, state, .. } => match state {
|
||||||
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key),
|
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key, qh),
|
||||||
WEnum::Value(wl_keyboard::KeyState::Released)
|
WEnum::Value(wl_keyboard::KeyState::Released) => app.key_up(key),
|
||||||
if key == KEY_LEFTSHIFT || key == KEY_RIGHTSHIFT =>
|
|
||||||
{
|
|
||||||
app.shift = false
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
|
},
|
||||||
|
// Store compositor key-repeat settings for our client-side timer.
|
||||||
|
wl_keyboard::Event::RepeatInfo { rate, delay } => {
|
||||||
|
// rate == 0 means repeat is disabled.
|
||||||
|
if rate > 0 {
|
||||||
|
app.repeat_rate_ms = (1000 / rate as u32).max(1);
|
||||||
|
app.repeat_delay_ms = delay as u32;
|
||||||
|
} else {
|
||||||
|
app.repeat_key = None;
|
||||||
|
app.repeat_next = None;
|
||||||
|
app.repeat_delay_ms = 0;
|
||||||
|
app.repeat_rate_ms = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 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;
|
||||||
|
// Clear any held repeat - we no longer have the keyboard.
|
||||||
|
app.repeat_key = None;
|
||||||
|
app.repeat_next = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hovering does not move the selection — that belongs to the keyboard — so the
|
/// Hovering does not move the selection - that belongs to the keyboard - so the
|
||||||
/// pointer only tracks where it is and what it clicked. Scrolling is a
|
/// pointer only tracks where it is and what it clicked. Scrolling is a
|
||||||
/// deliberate gesture, so that does move the selection.
|
/// deliberate gesture, so that does move the selection.
|
||||||
impl Dispatch<WlPointer, ()> for App {
|
impl Dispatch<WlPointer, ()> for App {
|
||||||
@@ -399,7 +542,7 @@ impl Dispatch<WlPointer, ()> for App {
|
|||||||
event: wl_pointer::Event,
|
event: wl_pointer::Event,
|
||||||
_: &(),
|
_: &(),
|
||||||
_: &Connection,
|
_: &Connection,
|
||||||
_: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
wl_pointer::Event::Enter {
|
wl_pointer::Event::Enter {
|
||||||
@@ -437,7 +580,9 @@ impl Dispatch<WlPointer, ()> for App {
|
|||||||
state: WEnum::Value(state),
|
state: WEnum::Value(state),
|
||||||
..
|
..
|
||||||
} => app.click(state == wl_pointer::ButtonState::Pressed),
|
} => 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)
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Capture buffers deliberately never get mapped into this process. The
|
//! Capture buffers deliberately never get mapped into this process. The
|
||||||
//! compositor writes the window pixels and then samples them again for display,
|
//! compositor writes the window pixels and then samples them again for display,
|
||||||
//! so we only need the fd — mapping them would fault ~7 MB per window into our
|
//! so we only need the fd - mapping them would fault ~7 MB per window into our
|
||||||
//! address space for nothing.
|
//! address space for nothing.
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
|||||||
+219
-12
@@ -2,46 +2,229 @@
|
|||||||
//! Wayland side only supplies pixels. The join between the two is
|
//! Wayland side only supplies pixels. The join between the two is
|
||||||
//! `foreign_toplevel_identifier`, which sway reports per view.
|
//! `foreign_toplevel_identifier`, which sway reports per view.
|
||||||
//!
|
//!
|
||||||
//! Acting on the choice is deliberately not here: wl-pick reports what was picked
|
//! Acting on the choice is deliberately not here: wl-tab reports what was picked
|
||||||
//! and the caller decides what that means.
|
//! and the caller decides what that means.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use swayipc::{Connection, Node, NodeType};
|
use swayipc::{Connection, Node, NodeType};
|
||||||
|
|
||||||
use crate::target::Target;
|
use crate::target::Target;
|
||||||
|
|
||||||
/// Every view in the tree, in tree order (the same traversal the jq filter did,
|
/// Open the IPC connection, recovering when the environment lies about where
|
||||||
/// so the grid keeps the ordering the muscle memory expects).
|
/// the socket is.
|
||||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Target>, swayipc::Error> {
|
///
|
||||||
|
/// 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-tab 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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_path() -> PathBuf {
|
||||||
|
std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("wl-tab-history")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_focus(con_id: i64) {
|
||||||
|
let path = history_path();
|
||||||
|
let mut ids: Vec<i64> = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|s| {
|
||||||
|
s.lines()
|
||||||
|
.filter_map(|l| l.trim().parse::<i64>().ok())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ids.retain(|&id| id != con_id);
|
||||||
|
ids.insert(0, con_id);
|
||||||
|
ids.truncate(50);
|
||||||
|
let content = ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let _ = std::fs::write(&path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_focus_history() -> Vec<i64> {
|
||||||
|
std::fs::read_to_string(history_path())
|
||||||
|
.ok()
|
||||||
|
.map(|s| {
|
||||||
|
s.lines()
|
||||||
|
.filter_map(|l| l.trim().parse::<i64>().ok())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
let mut out = Vec::new();
|
||||||
collect(&conn.get_tree()?, &mut out);
|
let tree = conn.get_tree()?;
|
||||||
|
collect_tree(&tree, &mut out);
|
||||||
|
|
||||||
|
if out.is_empty() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record currently focused window into history
|
||||||
|
if let Some(pos) = out.iter().position(|t| t.focused) {
|
||||||
|
if let Some(con_id) = out[pos].con_id {
|
||||||
|
record_focus(con_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if order == Order::Mru {
|
||||||
|
let history = read_focus_history();
|
||||||
|
out.sort_by_key(|t| {
|
||||||
|
if t.focused {
|
||||||
|
return (0, 0, 0);
|
||||||
|
}
|
||||||
|
if let Some(con_id) = t.con_id {
|
||||||
|
if let Some(idx) = history.iter().position(|&id| id == con_id) {
|
||||||
|
return (1, idx, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.visible { (2, 0, 0) } else { (3, 0, 0) }
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(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 is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||||
let class = node
|
let class = node
|
||||||
.window_properties
|
.window_properties
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.class.clone());
|
.and_then(|p| p.class.clone());
|
||||||
if is_con && (node.app_id.is_some() || class.is_some()) {
|
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
|
Some(Target::window(
|
||||||
// the list: it gets a tile with no thumbnail.
|
|
||||||
out.push(Target::window(
|
|
||||||
node.id,
|
node.id,
|
||||||
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||||
node.app_id.clone().or(class).unwrap_or_default(),
|
node.app_id.clone().or(class).unwrap_or_default(),
|
||||||
node.name.clone().unwrap_or_default(),
|
node.name.clone().unwrap_or_default(),
|
||||||
));
|
node.focused,
|
||||||
|
node.visible.unwrap_or(true),
|
||||||
|
))
|
||||||
|
} 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()) {
|
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||||
collect(child, out);
|
collect_tree(child, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One active display: what the overlay needs to size itself against.
|
/// One active display: what the overlay needs to size itself against.
|
||||||
///
|
///
|
||||||
/// The overlay maps on the focused display, so percentages and the buffer scale
|
/// 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
|
/// 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
|
/// numbers differ per monitor, and taking the largest of everything would be
|
||||||
/// wrong on all but one.
|
/// wrong on all but one.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -78,3 +261,27 @@ pub fn focused(displays: &[Display]) -> Option<&Display> {
|
|||||||
.find(|d| d.focused)
|
.find(|d| d.focused)
|
||||||
.or_else(|| displays.first())
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+30
-8
@@ -1,7 +1,7 @@
|
|||||||
//! What a tile stands for: a window, or a whole display.
|
//! What a tile stands for: a window, or a whole display.
|
||||||
//!
|
//!
|
||||||
//! Both are capture sources as far as the protocol is concerned — one from a
|
//! Both are capture sources as far as the protocol is concerned - one from a
|
||||||
//! foreign-toplevel handle, one from a `wl_output` — so the grid treats them
|
//! foreign-toplevel handle, one from a `wl_output` - so the grid treats them
|
||||||
//! alike and only differs in how it labels them and what picking one does.
|
//! alike and only differs in how it labels them and what picking one does.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -55,10 +55,21 @@ pub struct Target {
|
|||||||
pub ft_id: String,
|
pub ft_id: String,
|
||||||
pub app: String,
|
pub app: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// Whether this window was the focused container when sway was queried.
|
||||||
|
pub focused: bool,
|
||||||
|
/// Whether this window is currently visible on screen.
|
||||||
|
pub visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Target {
|
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,
|
||||||
|
visible: bool,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind: Kind::Window,
|
kind: Kind::Window,
|
||||||
id: con_id.to_string(),
|
id: con_id.to_string(),
|
||||||
@@ -66,6 +77,8 @@ impl Target {
|
|||||||
ft_id,
|
ft_id,
|
||||||
app,
|
app,
|
||||||
title,
|
title,
|
||||||
|
focused,
|
||||||
|
visible,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +92,8 @@ impl Target {
|
|||||||
ft_id: String::new(),
|
ft_id: String::new(),
|
||||||
app: "display".to_string(),
|
app: "display".to_string(),
|
||||||
title: name,
|
title: name,
|
||||||
|
focused: false,
|
||||||
|
visible: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +110,8 @@ impl Target {
|
|||||||
/// `IFS=$'\t' read -r type id toplevel app title`.
|
/// `IFS=$'\t' read -r type id toplevel app title`.
|
||||||
///
|
///
|
||||||
/// Both identifiers are there because both get used: sway scripting acts on
|
/// Both identifiers are there because both get used: sway scripting acts on
|
||||||
/// the con_id (`[con_id=N] focus`), while tools that capture a window —
|
/// the con_id (`[con_id=N] focus`), while tools that capture a window -
|
||||||
/// grim -T, the desktop portal — want the foreign-toplevel identifier.
|
/// grim -T, the desktop portal - want the foreign-toplevel identifier.
|
||||||
pub fn tsv(&self) -> String {
|
pub fn tsv(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}\t{}\t{}\t{}\t{}",
|
"{}\t{}\t{}\t{}\t{}",
|
||||||
@@ -147,7 +162,7 @@ impl Target {
|
|||||||
|
|
||||||
/// What xdg-desktop-portal-wlr's `simple` chooser accepts: `Monitor: NAME`
|
/// What xdg-desktop-portal-wlr's `simple` chooser accepts: `Monitor: NAME`
|
||||||
/// or `Window: <foreign-toplevel identifier>`. A window the compositor never
|
/// or `Window: <foreign-toplevel identifier>`. A window the compositor never
|
||||||
/// gave an identifier for cannot be named this way, hence the Option — and
|
/// gave an identifier for cannot be named this way, hence the Option - and
|
||||||
/// an empty stdout is exactly how that chooser says "declined".
|
/// an empty stdout is exactly how that chooser says "declined".
|
||||||
pub fn portal(&self) -> Option<String> {
|
pub fn portal(&self) -> Option<String> {
|
||||||
match self.kind {
|
match self.kind {
|
||||||
@@ -194,7 +209,14 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn win() -> Target {
|
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,
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -228,7 +250,7 @@ mod tests {
|
|||||||
Some("Monitor: DP-1")
|
Some("Monitor: DP-1")
|
||||||
);
|
);
|
||||||
// No identifier means the portal cannot be told about this window.
|
// 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, false);
|
||||||
assert_eq!(anon.portal(), None);
|
assert_eq!(anon.portal(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+245
-31
@@ -1,18 +1,19 @@
|
|||||||
//! Labels.
|
//! Labels.
|
||||||
//!
|
//!
|
||||||
//! Building a font system and rasterising the first glyphs costs ~55ms, which is
|
//! Building a font system and rasterising the first glyphs costs ~55ms, about
|
||||||
//! almost exactly the window the compositor spends copying window pixels back
|
//! what the compositor spends copying window pixels back for a handful of
|
||||||
//! for us. So all of it happens on a worker thread started before the captures
|
//! windows. So all of it happens on a worker thread started before the captures
|
||||||
//! and joined after them: by the time anything is drawn, every label is shaped
|
//! and joined after them: by the time anything is drawn, every label is shaped
|
||||||
//! and its glyphs are already in the cache, and painting one costs ~0.1ms.
|
//! and its glyphs are already in the cache, and painting one costs ~0.1ms.
|
||||||
//!
|
//!
|
||||||
//! Sizes here are physical pixels — the caller scales logical units first,
|
//! Sizes here are physical pixels - the caller scales logical units first,
|
||||||
//! because the chrome buffer it paints into is physical too.
|
//! because the chrome buffer it paints into is physical too.
|
||||||
|
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use cosmic_text::{
|
use cosmic_text::{
|
||||||
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap, fontdb,
|
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight,
|
||||||
|
Wrap, fontdb,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::shm::Painter;
|
use crate::shm::Painter;
|
||||||
@@ -54,7 +55,7 @@ const MONO_CANDIDATES: &[&str] = &[
|
|||||||
|
|
||||||
/// Load the smallest font database that can render `family`.
|
/// Load the smallest font database that can render `family`.
|
||||||
///
|
///
|
||||||
/// `FontSystem::new()` scans every system font, which costs ~37ms — most of the
|
/// `FontSystem::new()` scans every system font, which costs ~37ms - most of the
|
||||||
/// startup budget. A user's own font directories are tiny by comparison, so try
|
/// startup budget. A user's own font directories are tiny by comparison, so try
|
||||||
/// those first and only pay for the full scan when the family really isn't there
|
/// those first and only pay for the full scan when the family really isn't there
|
||||||
/// (which is also what makes an unknown family fall back gracefully). The
|
/// (which is also what makes an unknown family fall back gracefully). The
|
||||||
@@ -66,7 +67,7 @@ fn font_db(family: &str) -> FontSystem {
|
|||||||
db.load_fonts_dir(format!("{home}/.fonts"));
|
db.load_fonts_dir(format!("{home}/.fonts"));
|
||||||
db.load_fonts_dir(format!("{home}/.local/share/fonts"));
|
db.load_fonts_dir(format!("{home}/.local/share/fonts"));
|
||||||
}
|
}
|
||||||
if has_family(&db, family) {
|
if interpret(&db, family).is_some() {
|
||||||
// The locale only orders CJK fallbacks; labels are ids and titles.
|
// The locale only orders CJK fallbacks; labels are ids and titles.
|
||||||
return FontSystem::new_with_locale_and_db("en-US".to_string(), db);
|
return FontSystem::new_with_locale_and_db("en-US".to_string(), db);
|
||||||
}
|
}
|
||||||
@@ -79,31 +80,178 @@ fn is_generic(family: &str) -> bool {
|
|||||||
family.eq_ignore_ascii_case(SYSTEM_MONO)
|
family.eq_ignore_ascii_case(SYSTEM_MONO)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_family(db: &fontdb::Database, family: &str) -> bool {
|
fn family_named(db: &fontdb::Database, want: &str) -> Option<String> {
|
||||||
db.faces()
|
db.faces()
|
||||||
.any(|f| f.families.iter().any(|(name, _)| name == family))
|
.flat_map(|face| face.families.iter())
|
||||||
|
.find(|(name, _)| name.eq_ignore_ascii_case(want))
|
||||||
|
.map(|(name, _)| name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turn the generic default into a real family name.
|
/// A resolved family and the face style requested within it.
|
||||||
///
|
///
|
||||||
/// cosmic-text's own generic resolves through fontdb's built-in preference
|
/// cosmic-text's own generic resolves through fontdb's built-in preference
|
||||||
/// ("FreeMono"), which is usually absent and then lands on an arbitrary face — so
|
/// ("FreeMono"), which is usually absent and then lands on an arbitrary face - so
|
||||||
/// ask fontconfig instead, since that is what the rest of the desktop uses. A
|
/// ask fontconfig instead, since that is what the rest of the desktop uses.
|
||||||
/// named family passes through untouched; if it turns out to be missing,
|
#[derive(Debug, PartialEq)]
|
||||||
/// cosmic-text falls back on its own.
|
struct Choice {
|
||||||
fn resolve_family(db: &fontdb::Database, family: &str) -> String {
|
family: String,
|
||||||
if !is_generic(family) {
|
weight: Weight,
|
||||||
return family.to_string();
|
stretch: Stretch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Choice {
|
||||||
|
fn plain(family: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
family: family.to_string(),
|
||||||
|
weight: Weight::NORMAL,
|
||||||
|
stretch: Stretch::Normal,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn attrs(&self) -> Attrs<'_> {
|
||||||
|
Attrs::new()
|
||||||
|
.family(Family::Name(&self.family))
|
||||||
|
.weight(self.weight)
|
||||||
|
.stretch(self.stretch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> String {
|
||||||
|
let mut parts = vec![self.family.as_str()];
|
||||||
|
if self.weight != Weight::NORMAL {
|
||||||
|
parts.extend(spelling(WEIGHTS, self.weight));
|
||||||
|
}
|
||||||
|
if self.stretch != Stretch::Normal {
|
||||||
|
parts.extend(spelling(WIDTHS, self.stretch));
|
||||||
|
}
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interpret(db: &fontdb::Database, request: &str) -> Option<Choice> {
|
||||||
|
split_request(request, |name| family_named(db, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_request(request: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<Choice> {
|
||||||
|
let words: Vec<&str> = request.split_whitespace().collect();
|
||||||
|
for split in (1..=words.len()).rev() {
|
||||||
|
let Some(family) = lookup(&words[..split].join(" ")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut choice = Choice::plain(&family);
|
||||||
|
if read_style(&words[split..], &mut choice) {
|
||||||
|
return Some(choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_style(words: &[&str], choice: &mut Choice) -> bool {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < words.len() {
|
||||||
|
let pair = words.get(i..i + 2).map(|two| two.concat());
|
||||||
|
if pair
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|pair| apply_style(pair, choice))
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
} else if apply_style(words[i], choice) {
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_style(word: &str, choice: &mut Choice) -> bool {
|
||||||
|
if let Some(weight) = lookup(WEIGHTS, word) {
|
||||||
|
choice.weight = weight;
|
||||||
|
} else if let Some(stretch) = lookup(WIDTHS, word) {
|
||||||
|
choice.stretch = stretch;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalise(word: &str) -> String {
|
||||||
|
word.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric())
|
||||||
|
.map(|c| c.to_ascii_lowercase())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEIGHTS: &[(&str, Weight)] = &[
|
||||||
|
("Thin", Weight::THIN),
|
||||||
|
("Hairline", Weight::THIN),
|
||||||
|
("ExtraLight", Weight::EXTRA_LIGHT),
|
||||||
|
("UltraLight", Weight::EXTRA_LIGHT),
|
||||||
|
("Light", Weight::LIGHT),
|
||||||
|
("Regular", Weight::NORMAL),
|
||||||
|
("Normal", Weight::NORMAL),
|
||||||
|
("Book", Weight::NORMAL),
|
||||||
|
("Medium", Weight::MEDIUM),
|
||||||
|
("SemiBold", Weight::SEMIBOLD),
|
||||||
|
("DemiBold", Weight::SEMIBOLD),
|
||||||
|
("Bold", Weight::BOLD),
|
||||||
|
("ExtraBold", Weight::EXTRA_BOLD),
|
||||||
|
("UltraBold", Weight::EXTRA_BOLD),
|
||||||
|
("Black", Weight::BLACK),
|
||||||
|
("Heavy", Weight::BLACK),
|
||||||
|
];
|
||||||
|
|
||||||
|
const WIDTHS: &[(&str, Stretch)] = &[
|
||||||
|
("UltraCondensed", Stretch::UltraCondensed),
|
||||||
|
("ExtraCondensed", Stretch::ExtraCondensed),
|
||||||
|
("Condensed", Stretch::Condensed),
|
||||||
|
("SemiCondensed", Stretch::SemiCondensed),
|
||||||
|
("Normal", Stretch::Normal),
|
||||||
|
("SemiExpanded", Stretch::SemiExpanded),
|
||||||
|
("Expanded", Stretch::Expanded),
|
||||||
|
("ExtraExpanded", Stretch::ExtraExpanded),
|
||||||
|
("UltraExpanded", Stretch::UltraExpanded),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn lookup<T: Copy>(table: &[(&str, T)], word: &str) -> Option<T> {
|
||||||
|
let word = normalise(word);
|
||||||
|
table
|
||||||
|
.iter()
|
||||||
|
.find(|(spelling, _)| normalise(spelling) == word)
|
||||||
|
.map(|(_, value)| *value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spelling<T: PartialEq>(table: &[(&'static str, T)], value: T) -> Option<&'static str> {
|
||||||
|
table
|
||||||
|
.iter()
|
||||||
|
.find(|(_, known)| *known == value)
|
||||||
|
.map(|(word, _)| *word)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose(db: &fontdb::Database, request: &str) -> Choice {
|
||||||
|
if !is_generic(request) {
|
||||||
|
if let Some(choice) = interpret(db, request) {
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
let fallback = system_mono(db);
|
||||||
|
eprintln!(
|
||||||
|
"wl-tab: no font matching {request:?}, using {:?}; `fc-match -f '%{{family}}\\n' {request:?}` names the family",
|
||||||
|
fallback.family
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
system_mono(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_mono(db: &fontdb::Database) -> Choice {
|
||||||
fc_match_mono()
|
fc_match_mono()
|
||||||
.filter(|name| has_family(db, name))
|
.filter(|name| family_named(db, name).is_some())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
MONO_CANDIDATES
|
MONO_CANDIDATES
|
||||||
.iter()
|
.iter()
|
||||||
.find(|name| has_family(db, name))
|
.find(|name| family_named(db, name).is_some())
|
||||||
.map(|name| name.to_string())
|
.map(|name| name.to_string())
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| family.to_string())
|
.map_or_else(|| Choice::plain(SYSTEM_MONO), |name| Choice::plain(&name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What fontconfig says "monospace" means here. A system without the fontconfig
|
/// What fontconfig says "monospace" means here. A system without the fontconfig
|
||||||
@@ -122,8 +270,8 @@ fn fc_match_mono() -> Option<String> {
|
|||||||
fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels {
|
fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels {
|
||||||
let mut fs = font_db(&family);
|
let mut fs = font_db(&family);
|
||||||
let mut cache = SwashCache::new();
|
let mut cache = SwashCache::new();
|
||||||
let family = resolve_family(fs.db(), &family);
|
let choice = choose(fs.db(), &family);
|
||||||
let attrs = Attrs::new().family(Family::Name(&family));
|
let attrs = choice.attrs();
|
||||||
let metrics = Metrics::new(font_px, line_h);
|
let metrics = Metrics::new(font_px, line_h);
|
||||||
|
|
||||||
let mut lines = Vec::with_capacity(texts.len());
|
let mut lines = Vec::with_capacity(texts.len());
|
||||||
@@ -141,11 +289,11 @@ fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f
|
|||||||
fs,
|
fs,
|
||||||
cache,
|
cache,
|
||||||
lines,
|
lines,
|
||||||
family,
|
family: choice.name(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorten `text` until it fits in `box_w`, ending with an ellipsis — window
|
/// Shorten `text` until it fits in `box_w`, ending with an ellipsis - window
|
||||||
/// titles are arbitrarily long, and rofi ellipsised them too.
|
/// titles are arbitrarily long, and rofi ellipsised them too.
|
||||||
fn ellipsize(
|
fn ellipsize(
|
||||||
fs: &mut FontSystem,
|
fs: &mut FontSystem,
|
||||||
@@ -242,14 +390,80 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_generic_default_resolves_to_a_real_monospace_family() {
|
fn the_generic_default_resolves_to_a_real_monospace_family() {
|
||||||
let fs = font_db(SYSTEM_MONO);
|
let fs = font_db(SYSTEM_MONO);
|
||||||
let resolved = resolve_family(fs.db(), SYSTEM_MONO);
|
let choice = choose(fs.db(), SYSTEM_MONO);
|
||||||
assert_ne!(resolved, SYSTEM_MONO, "should have named a real family");
|
assert_ne!(
|
||||||
assert!(
|
choice.family, SYSTEM_MONO,
|
||||||
has_family(fs.db(), &resolved),
|
"should have named a real family"
|
||||||
"{resolved:?} is not in the database"
|
|
||||||
);
|
);
|
||||||
// A named family passes through, present or not.
|
assert!(
|
||||||
assert_eq!(resolve_family(fs.db(), "Some Font"), "Some Font");
|
family_named(fs.db(), &choice.family).is_some(),
|
||||||
|
"{:?} is not in the database",
|
||||||
|
choice.family
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_font_falls_back_to_the_default() {
|
||||||
|
let fs = font_db(SYSTEM_MONO);
|
||||||
|
let choice = choose(fs.db(), "No Such Family At All");
|
||||||
|
assert_eq!(choice, choose(fs.db(), SYSTEM_MONO));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db(families: &[&'static str]) -> impl Fn(&str) -> Option<String> {
|
||||||
|
let families = families.to_vec();
|
||||||
|
move |want| {
|
||||||
|
families
|
||||||
|
.iter()
|
||||||
|
.find(|name| name.eq_ignore_ascii_case(want))
|
||||||
|
.map(|name| name.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_font_name_splits_into_family_and_style() {
|
||||||
|
let choice =
|
||||||
|
split_request("Berkeley Mono Medium SemiCondensed", db(&["Berkeley Mono"])).unwrap();
|
||||||
|
assert_eq!(choice.family, "Berkeley Mono");
|
||||||
|
assert_eq!(choice.weight, Weight::MEDIUM);
|
||||||
|
assert_eq!(choice.stretch, Stretch::SemiCondensed);
|
||||||
|
assert_eq!(choice.name(), "Berkeley Mono Medium SemiCondensed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_family_that_ends_in_a_style_word_wins() {
|
||||||
|
let choice =
|
||||||
|
split_request("Fira Code Light", db(&["Fira Code", "Fira Code Light"])).unwrap();
|
||||||
|
assert_eq!(choice.family, "Fira Code Light");
|
||||||
|
assert_eq!(choice.weight, Weight::NORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_words_accept_joined_spaced_and_mixed_case_forms() {
|
||||||
|
let cases = [
|
||||||
|
("Iosevka demibold", Weight::SEMIBOLD, Stretch::Normal),
|
||||||
|
("Iosevka Extra Light", Weight::EXTRA_LIGHT, Stretch::Normal),
|
||||||
|
(
|
||||||
|
"Iosevka ULTRACONDENSED",
|
||||||
|
Weight::NORMAL,
|
||||||
|
Stretch::UltraCondensed,
|
||||||
|
),
|
||||||
|
("Iosevka Bold Condensed", Weight::BOLD, Stretch::Condensed),
|
||||||
|
];
|
||||||
|
for (request, weight, stretch) in cases {
|
||||||
|
let choice = split_request(request, db(&["Iosevka"])).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(choice.weight, choice.stretch),
|
||||||
|
(weight, stretch),
|
||||||
|
"{request:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_trailing_words_do_not_match() {
|
||||||
|
let known = db(&["Berkeley Mono"]);
|
||||||
|
assert_eq!(split_request("Berkeley Mono Nonsense", &known), None);
|
||||||
|
assert_eq!(split_request("Comic Sans", &known), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+136
-224
@@ -1,6 +1,15 @@
|
|||||||
//! Look and layout, ported from the rofi setup this replaces (mytheme.rasi +
|
//! Look and layout.
|
||||||
//! the -theme-str rofigrid builds): gruvbox dark, a yellow selection that fills
|
//!
|
||||||
//! the element padding, and a window that hugs the grid.
|
//! The colours and spacing come from sway's default client colours:
|
||||||
|
//! unfocused background `#222222` and text `#888888` for the grid
|
||||||
|
//! backdrop and labels, focused `#285577`/`#ffffff` for the selection,
|
||||||
|
//! and focused border `#4c7899`.
|
||||||
|
//!
|
||||||
|
//! 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).
|
/// 0xAARRGGBB, premultiplied (everything here is opaque).
|
||||||
pub type Argb = u32;
|
pub type Argb = u32;
|
||||||
@@ -14,17 +23,21 @@ pub struct Theme {
|
|||||||
pub border: Argb,
|
pub border: Argb,
|
||||||
/// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px).
|
/// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px).
|
||||||
pub border_px: i32,
|
pub border_px: i32,
|
||||||
/// Thumbnail cell, logical px. 16:9 so wide windows fill it instead of
|
/// The box the grid may not exceed, in logical px. Thumbnails are sized to
|
||||||
/// letterboxing in a square box.
|
/// divide it by the column and row caps below, so a thumbnail is the same
|
||||||
pub tile_w: i32,
|
/// size whether one window is open or thirty - only the window around them
|
||||||
pub tile_h: i32,
|
/// 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`).
|
/// Padding inside one element, i.e. around its thumbnail (rasi `element`).
|
||||||
pub pad: i32,
|
pub pad: i32,
|
||||||
/// Space between elements (rasi `listview { spacing }`).
|
/// Space between elements (rasi `listview { spacing }`).
|
||||||
pub gap: i32,
|
pub gap: i32,
|
||||||
/// Margin between the grid and the window edge.
|
/// Margin between the grid and the window edge.
|
||||||
pub margin: i32,
|
pub margin: i32,
|
||||||
|
/// How many tiles the grid may show at once. Rows beyond `max_rows` scroll.
|
||||||
pub max_cols: i32,
|
pub max_cols: i32,
|
||||||
|
pub max_rows: i32,
|
||||||
/// Gap between a thumbnail and its label (rasi `element { spacing }`).
|
/// Gap between a thumbnail and its label (rasi `element { spacing }`).
|
||||||
pub spacing: i32,
|
pub spacing: i32,
|
||||||
/// Label font family, resolved against the system's fonts. The default is
|
/// Label font family, resolved against the system's fonts. The default is
|
||||||
@@ -41,18 +54,23 @@ pub struct Theme {
|
|||||||
impl Default for Theme {
|
impl Default for Theme {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
bg: 0xff282828, // gruvbox-dark-bg0
|
bg: 0xff222222, // sway unfocused background
|
||||||
fg: 0xffebdbb2, // gruvbox-dark-fg1
|
fg: 0xff888888, // sway unfocused text
|
||||||
sel_bg: 0xffd79921, // gruvbox-dark-yellow-dark
|
sel_bg: 0xff285577, // sway focused background
|
||||||
sel_fg: 0xff282828,
|
sel_fg: 0xffffffff, // sway focused text
|
||||||
border: 0xffd79921,
|
border: 0xff4c7899, // sway focused border
|
||||||
border_px: 2,
|
border_px: 2,
|
||||||
tile_w: 220,
|
// No cap of their own: Layout clamps to the display, and the
|
||||||
tile_h: 220 * 9 / 16,
|
// command line resolves the configured percentage over the top.
|
||||||
|
max_w: i32::MAX,
|
||||||
|
max_h: i32::MAX,
|
||||||
pad: 12,
|
pad: 12,
|
||||||
gap: 15,
|
gap: 15,
|
||||||
margin: 12,
|
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_cols: 4,
|
||||||
|
max_rows: 4,
|
||||||
spacing: 10,
|
spacing: 10,
|
||||||
font: crate::text::SYSTEM_MONO.to_string(),
|
font: crate::text::SYSTEM_MONO.to_string(),
|
||||||
font_px: 13.3,
|
font_px: 13.3,
|
||||||
@@ -66,13 +84,13 @@ impl Default for Theme {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Layout {
|
pub struct Layout {
|
||||||
pub cols: i32,
|
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 rows: i32,
|
||||||
pub visible_rows: i32,
|
pub visible_rows: i32,
|
||||||
/// How many tiles there are, which the last row may not fill.
|
|
||||||
n: i32,
|
|
||||||
pub width: i32,
|
pub width: i32,
|
||||||
pub height: i32,
|
pub height: i32,
|
||||||
|
/// How many tiles there are, which the last row may not fill.
|
||||||
|
n: i32,
|
||||||
elem_w: i32,
|
elem_w: i32,
|
||||||
elem_h: i32,
|
elem_h: i32,
|
||||||
margin: i32,
|
margin: i32,
|
||||||
@@ -84,47 +102,63 @@ pub struct Layout {
|
|||||||
labels: bool,
|
labels: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of the display the grid may occupy.
|
|
||||||
const FILL: i32 = 90;
|
|
||||||
|
|
||||||
impl Layout {
|
impl Layout {
|
||||||
/// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't
|
/// Lay out `n` tiles for a display of the given logical size.
|
||||||
/// ragged (6 windows -> 3x2, not 4x2 with two holes). Same rule rofigrid uses.
|
|
||||||
///
|
///
|
||||||
/// Tiles are the size the theme asks for — a configured size that quietly
|
/// A thumbnail is the configured box divided by the column and row caps, so
|
||||||
/// shrank would be a setting ignored — so when the grid needs more rows than
|
/// it does not change with how many windows are open: one window gets a
|
||||||
/// the display can show, the extra rows scroll. Only a tile too large for
|
/// normal thumbnail in a small overlay, thirty get the same thumbnail and
|
||||||
/// even one row or column is shrunk, since then something has to give.
|
/// 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.
|
||||||
|
/// Lay out `n` tiles in a single row for a display of the given logical size.
|
||||||
|
///
|
||||||
|
/// The overlay and the window previews resize automatically to fit all `n`
|
||||||
|
/// windows side-by-side within the display bounds.
|
||||||
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
|
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
|
||||||
let (dw, dh) = (display.0.max(1), display.1.max(1));
|
let n = n.max(0);
|
||||||
let room_w = (dw * FILL / 100 - 2 * t.margin).max(1);
|
let cols = n.max(1);
|
||||||
let room_h = (dh * FILL / 100 - 2 * t.margin).max(1);
|
let box_w = t.max_w.clamp(1, display.0.max(1));
|
||||||
let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
|
let box_h = t.max_h.clamp(1, display.1.max(1));
|
||||||
let (tile_w, tile_h) = shrink_to_one(t, label_row, room_w, room_h);
|
let aspect = display.0 as f64 / (display.1.max(1) as f64);
|
||||||
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.
|
let margin = t.margin.min(box_w / 10).max(2);
|
||||||
let mut cols = (n as f64).sqrt() as i32;
|
let avail_for_items = (box_w - 2 * margin).max(cols);
|
||||||
if cols * cols < n {
|
let pitch = avail_for_items / cols;
|
||||||
cols += 1;
|
|
||||||
}
|
// Gap and padding adapt when many items crowd the available width:
|
||||||
let fit_cols = ((room_w + t.gap) / (elem_w + t.gap)).max(1);
|
let gap = (pitch / 6).min(t.gap).max(0);
|
||||||
cols = cols.clamp(1, t.max_cols.min(fit_cols)).max(1);
|
let max_elem_w = (pitch - gap).max(1);
|
||||||
let rows = (n + cols - 1) / cols;
|
let pad = (max_elem_w / 8).min(t.pad).max(1);
|
||||||
let visible_rows = ((room_h + t.gap) / (elem_h + t.gap)).clamp(1, rows.max(1));
|
|
||||||
|
let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
|
||||||
|
let furniture_h = 2 * margin + 2 * pad + label_row;
|
||||||
|
let max_thumb_h = (box_h - furniture_h)
|
||||||
|
.min((display.1 as f64 * 0.35).round() as i32)
|
||||||
|
.max(1);
|
||||||
|
let ideal_tile_w = (max_thumb_h as f64 * aspect).round() as i32;
|
||||||
|
|
||||||
|
let max_fit_tile_w = (max_elem_w - 2 * pad).max(1);
|
||||||
|
let tile_w = ideal_tile_w.min(max_fit_tile_w).max(1);
|
||||||
|
let tile_h = ((tile_w as f64 / aspect).round() as i32).max(1);
|
||||||
|
|
||||||
|
let elem_w = tile_w + 2 * pad;
|
||||||
|
let elem_h = tile_h + label_row + 2 * pad;
|
||||||
|
let rows = 1;
|
||||||
|
let visible_rows = 1;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
visible_rows,
|
visible_rows,
|
||||||
n,
|
n,
|
||||||
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
|
width: (cols * elem_w + (cols - 1) * gap + 2 * margin).min(box_w),
|
||||||
height: visible_rows * elem_h + (visible_rows - 1) * t.gap + 2 * t.margin,
|
height: (elem_h + 2 * margin).min(box_h),
|
||||||
elem_w,
|
elem_w,
|
||||||
elem_h,
|
elem_h,
|
||||||
margin: t.margin,
|
margin,
|
||||||
gap: t.gap,
|
gap,
|
||||||
pad: t.pad,
|
pad,
|
||||||
tile_h,
|
tile_h,
|
||||||
spacing: t.spacing,
|
spacing: t.spacing,
|
||||||
line_h: t.line_h,
|
line_h: t.line_h,
|
||||||
@@ -238,21 +272,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)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
pub x: i32,
|
pub x: i32,
|
||||||
@@ -298,122 +317,96 @@ pub fn fit_centred(w: i32, h: i32, box_: Rect) -> Rect {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A display large enough that nothing is clamped, so the geometry tests
|
/// A display large enough that the caps, not the screen, decide everything.
|
||||||
/// keep testing geometry.
|
|
||||||
const ROOMY: (i32, i32) = (10_000, 10_000);
|
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]
|
#[test]
|
||||||
fn grid_matches_rofigrid() {
|
fn previews_and_overlay_resize_automatically() {
|
||||||
let t = Theme::default();
|
let t = theme(1000, 900, 4, 3);
|
||||||
// (n, cols, rows) from rofigrid: cols = min(ceil(sqrt(n)), 4)
|
let one = Layout::new(&t, 1, ROOMY);
|
||||||
for (n, cols, rows) in [
|
let two = Layout::new(&t, 2, ROOMY);
|
||||||
(1, 1, 1),
|
let eight = Layout::new(&t, 8, ROOMY);
|
||||||
(2, 2, 1),
|
|
||||||
(4, 2, 2),
|
assert_eq!((one.rows, one.visible_rows), (1, 1));
|
||||||
(6, 3, 2),
|
assert_eq!((two.rows, two.visible_rows), (1, 1));
|
||||||
(12, 4, 3),
|
assert_eq!((eight.rows, eight.visible_rows), (1, 1));
|
||||||
(17, 4, 5),
|
|
||||||
] {
|
assert_eq!(one.cols, 1);
|
||||||
|
assert_eq!(two.cols, 2);
|
||||||
|
assert_eq!(eight.cols, 8);
|
||||||
|
|
||||||
|
// Previews shrink automatically as more windows are added
|
||||||
|
assert!(
|
||||||
|
two.tile(0, 0).expect("visible").w >= eight.tile(0, 0).expect("visible").w,
|
||||||
|
"previews should scale down to fit"
|
||||||
|
);
|
||||||
|
// Overlay width adjusts with the count
|
||||||
|
assert!(one.width <= two.width);
|
||||||
|
assert!(eight.width <= 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_row_holds_all_windows() {
|
||||||
|
let t = theme(1000, 900, 4, 3);
|
||||||
|
for n in 1..=10 {
|
||||||
let l = Layout::new(&t, n, ROOMY);
|
let l = Layout::new(&t, n, ROOMY);
|
||||||
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}");
|
assert_eq!((l.cols, l.rows, l.visible_rows), (n, 1, 1), "n = {n}");
|
||||||
// rofigrid: win_w = cols*(ICON+24) + (cols-1)*15 + 24
|
assert!(!l.scrollable());
|
||||||
assert_eq!(
|
|
||||||
l.width,
|
|
||||||
cols * (t.tile_w + 24) + (cols - 1) * 15 + 24,
|
|
||||||
"width n = {n}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn elements_stay_inside_the_window() {
|
fn elements_stay_inside_the_window() {
|
||||||
let t = Theme::default();
|
let t = theme(1000, 900, 4, 3);
|
||||||
for n in 1..=20 {
|
for n in 1..=12 {
|
||||||
let l = Layout::new(&t, n, ROOMY);
|
let l = Layout::new(&t, n, ROOMY);
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let e = l.elem(i, 0).expect("visible");
|
let e = l.elem(i, 0).expect("visible");
|
||||||
assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}");
|
assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}");
|
||||||
assert!(e.y >= 0 && e.y + e.h <= l.height, "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]
|
#[test]
|
||||||
fn labels_add_a_row_under_each_thumbnail() {
|
fn labels_take_their_room_from_the_thumbnail() {
|
||||||
let mut t = Theme::default();
|
let mut t = theme(1000, 900, 4, 3);
|
||||||
let with = Layout::new(&t, 4, ROOMY);
|
let with = Layout::new(&t, 4, ROOMY);
|
||||||
t.labels = false;
|
t.labels = false;
|
||||||
let without = Layout::new(&t, 4, ROOMY);
|
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 t = Theme::default();
|
|
||||||
let l = Layout::new(&t, 4, ROOMY);
|
|
||||||
for i in 0..4 {
|
|
||||||
let (tile, label, elem) = (
|
|
||||||
l.tile(i, 0).expect("visible"),
|
|
||||||
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!(
|
assert!(
|
||||||
(want - got).abs() < 0.05,
|
without.tile(0, 0).expect("visible").h >= with.tile(0, 0).expect("visible").h,
|
||||||
"aspect {got} drifted from {want}"
|
"thumbnails should grow into the freed space"
|
||||||
);
|
);
|
||||||
|
assert!(with.label(0, 0).is_some() && without.label(0, 0).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_tiny_display_never_gets_an_oversized_surface() {
|
fn the_box_never_exceeds_the_display() {
|
||||||
let t = Theme::default();
|
// A config asking for more than the screen has, on a small screen.
|
||||||
// Thirty windows on a 640x480 screen: only a row or two can be shown,
|
let t = theme(4000, 3000, 4, 3);
|
||||||
// and the rest scroll.
|
|
||||||
let l = Layout::new(&t, 30, (640, 480));
|
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.width <= 640 && l.height <= 480, "{l:?}");
|
||||||
|
assert!(l.tile(0, 0).expect("visible").w >= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_testing_is_the_inverse_of_the_layout() {
|
fn hit_testing_is_the_inverse_of_the_layout() {
|
||||||
let t = Theme::default();
|
let t = Theme::default();
|
||||||
// 7 tiles over 3 columns: the last row holds one, so two cells are empty.
|
let l = Layout::new(&t, 5, ROOMY);
|
||||||
let l = Layout::new(&t, 7, ROOMY);
|
for i in 0..5 {
|
||||||
for i in 0..7 {
|
|
||||||
let e = l.elem(i, 0).expect("visible");
|
let e = l.elem(i, 0).expect("visible");
|
||||||
for (x, y, what) in [
|
for (x, y, what) in [
|
||||||
(e.x, e.y, "top left"),
|
(e.x, e.y, "top left"),
|
||||||
@@ -423,8 +416,6 @@ mod tests {
|
|||||||
assert_eq!(l.hit(x, y, 0), Some(i as usize), "{what} of element {i}");
|
assert_eq!(l.hit(x, y, 0), Some(i as usize), "{what} of element {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The window margin, the gap between elements, and the empty cells of
|
|
||||||
// the last row all belong to no tile.
|
|
||||||
assert_eq!(l.hit(0, 0, 0), None, "margin");
|
assert_eq!(l.hit(0, 0, 0), None, "margin");
|
||||||
let first = l.elem(0, 0).expect("visible");
|
let first = l.elem(0, 0).expect("visible");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -432,88 +423,9 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
"gap between columns"
|
"gap between columns"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
l.hit(first.x, first.y + first.h + 1, 0),
|
|
||||||
None,
|
|
||||||
"gap between rows"
|
|
||||||
);
|
|
||||||
// Row 2, column 2 is past the seventh tile: take its column from the top
|
|
||||||
// row and its row from the first column.
|
|
||||||
let col2 = l.elem(2, 0).expect("visible");
|
|
||||||
let row2 = l.elem(6, 0).expect("visible");
|
|
||||||
assert_eq!(l.hit(col2.x + 4, row2.y + 4, 0), None, "empty cell");
|
|
||||||
assert_eq!(l.hit(-5, -5, 0), None, "outside");
|
assert_eq!(l.hit(-5, -5, 0), None, "outside");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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"
|
|
||||||
);
|
|
||||||
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());
|
|
||||||
assert!(
|
|
||||||
l.elem(per_screen as i32, 0).is_none(),
|
|
||||||
"first row below the fold"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
l.elem(per_screen as i32, 1).is_some(),
|
|
||||||
"and visible once scrolled"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn revealing_moves_the_viewport_as_little_as_possible() {
|
|
||||||
let t = Theme::default();
|
|
||||||
let l = Layout::new(&t, 30, (1280, 1440));
|
|
||||||
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");
|
|
||||||
// One row further down scrolls by exactly one row.
|
|
||||||
assert_eq!(l.reveal(last_visible + 1, 0), 1);
|
|
||||||
// Jumping to the end goes as far as it can, and no further.
|
|
||||||
assert_eq!(l.reveal(29, 0), l.max_scroll());
|
|
||||||
// Coming back up scrolls the other way.
|
|
||||||
assert_eq!(l.reveal(0, l.max_scroll()), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hit_testing_follows_the_scroll() {
|
|
||||||
let t = Theme::default();
|
|
||||||
let l = Layout::new(&t, 30, (1280, 1440));
|
|
||||||
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));
|
|
||||||
// The same pixel is a different tile once the grid has scrolled.
|
|
||||||
assert_eq!(l.hit(probe.0, probe.1, 1), Some(l.cols as usize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_scrollbar_appears_only_when_there_is_more_to_see() {
|
|
||||||
let t = Theme::default();
|
|
||||||
assert!(Layout::new(&t, 4, ROOMY).scrollbar(0, 4).is_none());
|
|
||||||
let l = Layout::new(&t, 30, (1280, 1440));
|
|
||||||
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");
|
|
||||||
let (_, bottom) = l.scrollbar(l.max_scroll(), 4).expect("scrollable");
|
|
||||||
assert_eq!(
|
|
||||||
bottom.y + bottom.h,
|
|
||||||
track.y + track.h,
|
|
||||||
"and ends at the bottom"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_preserves_aspect_and_centres() {
|
fn fit_preserves_aspect_and_centres() {
|
||||||
let box_ = Rect {
|
let box_ = Rect {
|
||||||
|
|||||||
Reference in New Issue
Block a user