Add wlgrid: a window-thumbnail grid overlay for wlroots
A switcher to replace a wlthumbs + rofi pipeline, with the same look (gruvbox, ceil(sqrt(n)) columns capped at 4, 16:9 tiles, a yellow selection filling the element padding) but no thumbnails anywhere. Each window is captured straight into a wl_shm buffer that is handed to its own wl_subsurface, with wp_viewporter giving the compositor the rectangle to scale it into. So there is no PNG encode, no scaler, no full-resolution bitmap in this process, and the capture buffers are never even mapped here — the compositor writes those pages and samples them again for display. Opens in ~65ms for 8 windows (55ms of which is the compositor reading pixels back out of the GPU) and holds ~9MB of RSS. All capture sessions are opened before a single roundtrip and every frame goes in flight together, the same batching wlthumbs uses, because the readback is bandwidth-bound rather than latency-bound. sway remains the source of truth: the window list, the con_ids and the focusing all come from its IPC socket, joined to the Wayland side by foreign_toplevel_identifier. Navigation reads raw evdev keycodes so it is layout-independent, which does mean virtual-keyboard clients that invent their own keymap can't drive it; that resolves when filtering brings xkb. Labels, type-to-filter and live previews are next.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+337
@@ -0,0 +1,337 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "swayipc"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fd6ee13016b1ae2fbf741ddb2133d983a3fecdc34686eec6202e2a80612d82f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swayipc-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swayipc-types"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a29165dc67819a052bd67962b6c9de7c5d4461f0e8d224eb0f7cdd26cf05a650"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wlgrid"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"memmap2",
|
||||
"rustix",
|
||||
"swayipc",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "wlgrid"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Live window switcher for wlroots compositors: a thumbnail grid with the look of a rofi theme"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
memmap2 = "0.9"
|
||||
rustix = { version = "1", features = ["fs", "mm", "shm"] }
|
||||
swayipc = "4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,93 @@
|
||||
# wlgrid
|
||||
|
||||
A window switcher for wlroots compositors: a thumbnail grid overlay that looks
|
||||
like a rofi theme, and focuses the window you pick.
|
||||
|
||||
It replaces a `wlthumbs | rofi` pipeline. 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 holds ~9 MB of RSS and appears in
|
||||
about 60 ms.
|
||||
|
||||
```
|
||||
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 55.0ms 8 windows, all frames in flight at once
|
||||
mapped 4.2ms 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.
|
||||
|
||||
## Status
|
||||
|
||||
Working, and usable as a switcher today: a static grid with keyboard navigation.
|
||||
Labels, filtering and live previews are next — see the roadmap.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
wlgrid [--print] [--verbose] [--timeout SECS]
|
||||
```
|
||||
|
||||
- `--print` writes the selected sway `con_id` to stdout instead of focusing it
|
||||
- `--verbose` prints phase timings and how many windows were captured
|
||||
- `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an
|
||||
exclusive keyboard grab)
|
||||
|
||||
Bind it in sway:
|
||||
|
||||
```
|
||||
bindsym $mod+Tab exec wlgrid
|
||||
```
|
||||
|
||||
| key | |
|
||||
|---|---|
|
||||
| `→` `←` / `Tab` `Shift+Tab` | next / previous window |
|
||||
| `↑` `↓` | move a row |
|
||||
| `Home` `End` | first / last |
|
||||
| `Enter` | focus the selection |
|
||||
| `Escape` / `q` | cancel, leaving focus alone |
|
||||
|
||||
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.
|
||||
|
||||
## Look
|
||||
|
||||
Colours, font metrics and grid geometry come from the rofi theme this replaces
|
||||
(gruvbox dark, a yellow selection filling the element padding, `ceil(sqrt(n))`
|
||||
columns capped at 4, 16:9 tiles) and live in `src/theme.rs`. They will move to a
|
||||
config file so they can't drift from the `.rasi`.
|
||||
|
||||
## 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 and
|
||||
for focusing, over its IPC socket.
|
||||
|
||||
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
|
||||
|
||||
- **M2** labels: real text via cosmic-text, `--hide-labels` for an icon-only grid
|
||||
- **M3** type-to-filter with fzf-quality fuzzy matching (and xkb keyboard input)
|
||||
- **M4** live previews: keep the capture sessions open and re-capture on a rate
|
||||
limit, `--live all|current|none`
|
||||
- **M5** dmabuf capture, so the pixels never leave the GPU at all
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
cargo test # grid geometry
|
||||
```
|
||||
+811
@@ -0,0 +1,811 @@
|
||||
//! wlgrid shows a thumbnail grid of every open window as a layer-shell overlay
|
||||
//! and focuses the one you pick. It replaces a wlthumbs + rofi pipeline, so it
|
||||
//! keeps that pipeline's contract: sway owns the window list and the focusing,
|
||||
//! and the look comes straight from the rofi theme (see theme.rs).
|
||||
//!
|
||||
//! The pixels never pass through this process. Each window is captured into an
|
||||
//! shm buffer handed straight to a subsurface, with wp_viewporter telling the
|
||||
//! compositor which rectangle to scale it into — so there is no thumbnail
|
||||
//! encoding, no scaler, and no full-resolution image in our address space.
|
||||
|
||||
mod shm;
|
||||
mod sway;
|
||||
mod theme;
|
||||
|
||||
use std::error::Error;
|
||||
use std::os::fd::AsFd;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wayland_client::globals::{GlobalList, GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::{
|
||||
wl_buffer::WlBuffer,
|
||||
wl_compositor::WlCompositor,
|
||||
wl_keyboard::{self, WlKeyboard},
|
||||
wl_output,
|
||||
wl_registry::WlRegistry,
|
||||
wl_seat::{self, WlSeat},
|
||||
wl_shm::{self, WlShm},
|
||||
wl_shm_pool::WlShmPool,
|
||||
wl_subcompositor::WlSubcompositor,
|
||||
wl_subsurface::WlSubsurface,
|
||||
wl_surface::WlSurface,
|
||||
};
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum, delegate_noop, event_created_child,
|
||||
};
|
||||
use wayland_protocols::ext::foreign_toplevel_list::v1::client::{
|
||||
ext_foreign_toplevel_handle_v1::{self, ExtForeignToplevelHandleV1},
|
||||
ext_foreign_toplevel_list_v1::{self, ExtForeignToplevelListV1},
|
||||
};
|
||||
use wayland_protocols::ext::image_capture_source::v1::client::{
|
||||
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
|
||||
ext_image_capture_source_v1::ExtImageCaptureSourceV1,
|
||||
};
|
||||
use wayland_protocols::ext::image_copy_capture::v1::client::{
|
||||
ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1},
|
||||
ext_image_copy_capture_manager_v1::{self, ExtImageCopyCaptureManagerV1},
|
||||
ext_image_copy_capture_session_v1::{self, ExtImageCopyCaptureSessionV1},
|
||||
};
|
||||
use wayland_protocols::wp::viewporter::client::{
|
||||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
||||
};
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::{
|
||||
zwlr_layer_shell_v1::{Layer, ZwlrLayerShellV1},
|
||||
zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
|
||||
};
|
||||
|
||||
use theme::{Layout, Rect, Theme, fit_centred};
|
||||
|
||||
// evdev keycodes: physical positions, so navigation works on any keyboard layout
|
||||
// without an xkb keymap. Typing (and therefore xkb) arrives with filtering.
|
||||
const KEY_ESC: u32 = 1;
|
||||
const KEY_TAB: u32 = 15;
|
||||
const KEY_Q: u32 = 16;
|
||||
const KEY_ENTER: u32 = 28;
|
||||
const KEY_LEFTSHIFT: u32 = 42;
|
||||
const KEY_RIGHTSHIFT: u32 = 54;
|
||||
const KEY_KPENTER: u32 = 96;
|
||||
const KEY_HOME: u32 = 102;
|
||||
const KEY_UP: u32 = 103;
|
||||
const KEY_LEFT: u32 = 105;
|
||||
const KEY_RIGHT: u32 = 106;
|
||||
const KEY_END: u32 = 107;
|
||||
const KEY_DOWN: u32 = 108;
|
||||
|
||||
/// One window: its sway identity, its capture plumbing, and its subsurface.
|
||||
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
|
||||
struct Tile {
|
||||
win: sway::Win,
|
||||
handle: Option<ExtForeignToplevelHandleV1>,
|
||||
|
||||
session: Option<ExtImageCopyCaptureSessionV1>,
|
||||
frame: Option<ExtImageCopyCaptureFrameV1>,
|
||||
buffer: Option<WlBuffer>,
|
||||
formats: Vec<wl_shm::Format>,
|
||||
format: Option<wl_shm::Format>,
|
||||
/// Buffer size the session requires: the window's full resolution.
|
||||
size: (u32, u32),
|
||||
transform: wl_output::Transform,
|
||||
offset: usize,
|
||||
session_done: bool,
|
||||
ready: bool,
|
||||
failed: bool,
|
||||
settled: bool,
|
||||
|
||||
surface: Option<WlSurface>,
|
||||
subsurface: Option<WlSubsurface>,
|
||||
viewport: Option<WpViewport>,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
fn new(win: sway::Win) -> Self {
|
||||
Self {
|
||||
win,
|
||||
handle: None,
|
||||
session: None,
|
||||
frame: None,
|
||||
buffer: None,
|
||||
formats: Vec::new(),
|
||||
format: None,
|
||||
size: (0, 0),
|
||||
transform: wl_output::Transform::Normal,
|
||||
offset: 0,
|
||||
session_done: false,
|
||||
ready: false,
|
||||
failed: false,
|
||||
settled: false,
|
||||
surface: None,
|
||||
subsurface: None,
|
||||
viewport: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(&self) -> usize {
|
||||
self.size.0 as usize * 4 * self.size.1 as usize
|
||||
}
|
||||
|
||||
/// Whether the buffer's contents are turned on their side relative to the
|
||||
/// window, which flips the aspect ratio we have to fit.
|
||||
fn rotated(&self) -> bool {
|
||||
use wl_output::Transform;
|
||||
matches!(
|
||||
self.transform,
|
||||
Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
compositor: WlCompositor,
|
||||
subcompositor: WlSubcompositor,
|
||||
shm: WlShm,
|
||||
viewporter: WpViewporter,
|
||||
layer_shell: ZwlrLayerShellV1,
|
||||
copy_mgr: ExtImageCopyCaptureManagerV1,
|
||||
src_mgr: ExtForeignToplevelImageCaptureSourceManagerV1,
|
||||
|
||||
/// Toplevel handles as the compositor announces them, paired with the
|
||||
/// identifier that joins them to sway's tree.
|
||||
toplevels: Vec<(ExtForeignToplevelHandleV1, String)>,
|
||||
tiles: Vec<Tile>,
|
||||
|
||||
theme: Theme,
|
||||
layout: Layout,
|
||||
scale: i32,
|
||||
sel: usize,
|
||||
shift: bool,
|
||||
|
||||
surface: Option<WlSurface>,
|
||||
chrome: Option<shm::Chrome>,
|
||||
chrome_buffers: Vec<WlBuffer>,
|
||||
configured: bool,
|
||||
|
||||
quit: bool,
|
||||
activate: Option<i64>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(
|
||||
globals: &GlobalList,
|
||||
qh: &QueueHandle<Self>,
|
||||
wins: Vec<sway::Win>,
|
||||
theme: Theme,
|
||||
scale: i32,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let layout = Layout::new(&theme, wins.len() as i32);
|
||||
// Bind everything up front so a compositor missing a protocol fails
|
||||
// here, with a name, rather than halfway through a capture.
|
||||
let app = Self {
|
||||
compositor: globals.bind(qh, 1..=6, ())?,
|
||||
subcompositor: globals.bind(qh, 1..=1, ())?,
|
||||
shm: globals.bind(qh, 1..=1, ())?,
|
||||
viewporter: globals.bind(qh, 1..=1, ())?,
|
||||
layer_shell: globals.bind(qh, 1..=5, ())?,
|
||||
copy_mgr: globals.bind(qh, 1..=1, ())?,
|
||||
src_mgr: globals.bind(qh, 1..=1, ())?,
|
||||
toplevels: Vec::new(),
|
||||
tiles: wins.into_iter().map(Tile::new).collect(),
|
||||
theme,
|
||||
layout,
|
||||
scale,
|
||||
sel: 0,
|
||||
shift: false,
|
||||
surface: None,
|
||||
chrome: None,
|
||||
chrome_buffers: Vec::new(),
|
||||
configured: false,
|
||||
quit: false,
|
||||
activate: None,
|
||||
};
|
||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
||||
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Open one capture session per window whose toplevel we recognise. They are
|
||||
/// all opened before a single roundtrip, so every session's buffer
|
||||
/// constraints arrive together instead of costing a round trip each.
|
||||
fn open_sessions(&mut self, qh: &QueueHandle<Self>) {
|
||||
for (i, tile) in self.tiles.iter_mut().enumerate() {
|
||||
let Some(handle) = self
|
||||
.toplevels
|
||||
.iter()
|
||||
.find(|(_, id)| !id.is_empty() && *id == tile.win.ft_id)
|
||||
.map(|(h, _)| h.clone())
|
||||
else {
|
||||
// No identifier match: the tile stays label-only, and must not
|
||||
// be waited on.
|
||||
tile.settled = true;
|
||||
continue;
|
||||
};
|
||||
let source: ExtImageCaptureSourceV1 = self.src_mgr.create_source(&handle, qh, ());
|
||||
tile.handle = Some(handle);
|
||||
tile.session = Some(self.copy_mgr.create_session(
|
||||
&source,
|
||||
ext_image_copy_capture_manager_v1::Options::empty(),
|
||||
qh,
|
||||
i,
|
||||
));
|
||||
source.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate one pool for every capture buffer and put all the frames in
|
||||
/// flight at once: the compositor is bandwidth-bound reading pixels back, so
|
||||
/// serialising the captures only adds latency.
|
||||
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
const PAGE: usize = 4096;
|
||||
let mut total = 0usize;
|
||||
for tile in &mut self.tiles {
|
||||
if tile.session.is_none() {
|
||||
continue;
|
||||
}
|
||||
if !tile.session_done || tile.size.0 == 0 || tile.size.1 == 0 {
|
||||
tile.settled = true;
|
||||
continue;
|
||||
}
|
||||
// Any 32-bit format will do: we never read these pixels, we hand the
|
||||
// buffer straight back for display, so byte order stays the
|
||||
// compositor's business on both ends.
|
||||
tile.format = tile
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| matches!(f, wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888))
|
||||
.or_else(|| tile.formats.first().copied());
|
||||
if tile.format.is_none() {
|
||||
tile.settled = true;
|
||||
continue;
|
||||
}
|
||||
tile.offset = total;
|
||||
total += tile.bytes().div_ceil(PAGE) * PAGE;
|
||||
}
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Note: no mmap. The compositor writes these pages and samples them
|
||||
// again for display; mapping them here would only cost us the faults.
|
||||
let file = shm::memfd("wlgrid-capture", total)?;
|
||||
let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ());
|
||||
for i in 0..self.tiles.len() {
|
||||
let (w, h, format, offset) = {
|
||||
let t = &self.tiles[i];
|
||||
if t.settled || t.session.is_none() || t.format.is_none() {
|
||||
continue;
|
||||
}
|
||||
(
|
||||
t.size.0 as i32,
|
||||
t.size.1 as i32,
|
||||
t.format.unwrap(),
|
||||
t.offset as i32,
|
||||
)
|
||||
};
|
||||
let buffer = pool.create_buffer(offset, w, h, w * 4, format, qh, ());
|
||||
let session = self.tiles[i].session.clone().unwrap();
|
||||
let frame = session.create_frame(qh, i);
|
||||
frame.attach_buffer(&buffer);
|
||||
frame.damage_buffer(0, 0, w, h);
|
||||
frame.capture();
|
||||
let t = &mut self.tiles[i];
|
||||
t.buffer = Some(buffer);
|
||||
t.frame = Some(frame);
|
||||
}
|
||||
pool.destroy(); // the buffers keep the mapping alive
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn captures_settled(&self) -> bool {
|
||||
self.tiles.iter().all(|t| t.settled)
|
||||
}
|
||||
|
||||
/// Map the overlay: a layer surface sized to hug the grid, plus the shm the
|
||||
/// chrome is painted into.
|
||||
fn show(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
let (lw, lh) = (self.layout.width, self.layout.height);
|
||||
let surface = self.compositor.create_surface(qh, ());
|
||||
let layer = self.layer_shell.get_layer_surface(
|
||||
&surface,
|
||||
None, // let the compositor place it on the active output
|
||||
Layer::Overlay,
|
||||
"wlgrid".to_string(),
|
||||
qh,
|
||||
(),
|
||||
);
|
||||
layer.set_size(lw as u32, lh as u32);
|
||||
layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
|
||||
surface.set_buffer_scale(self.scale);
|
||||
surface.commit();
|
||||
|
||||
let (pw, ph) = (lw * self.scale, lh * self.scale);
|
||||
let len = shm::Chrome::slot_len(pw, ph) * shm::Chrome::SLOTS;
|
||||
let file = shm::memfd("wlgrid-chrome", len)?;
|
||||
let pool = self.shm.create_pool(file.as_fd(), len as i32, qh, ());
|
||||
for slot in 0..shm::Chrome::SLOTS {
|
||||
self.chrome_buffers.push(pool.create_buffer(
|
||||
(slot * shm::Chrome::slot_len(pw, ph)) as i32,
|
||||
pw,
|
||||
ph,
|
||||
shm::Chrome::stride(pw),
|
||||
wl_shm::Format::Argb8888,
|
||||
qh,
|
||||
(),
|
||||
));
|
||||
}
|
||||
pool.destroy();
|
||||
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attach each captured buffer to its own subsurface and let the compositor
|
||||
/// scale it into the tile rectangle.
|
||||
fn place_tiles(&mut self, qh: &QueueHandle<Self>) {
|
||||
let parent = self.surface.clone().expect("show() runs first");
|
||||
for i in 0..self.tiles.len() {
|
||||
if !self.tiles[i].ready {
|
||||
continue;
|
||||
}
|
||||
let (bw, bh) = self.tiles[i].size;
|
||||
let (fit_w, fit_h) = if self.tiles[i].rotated() {
|
||||
(bh as i32, bw as i32)
|
||||
} else {
|
||||
(bw as i32, bh as i32)
|
||||
};
|
||||
let dst = fit_centred(fit_w, fit_h, self.layout.tile(i as i32));
|
||||
let surface = self.compositor.create_surface(qh, ());
|
||||
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
||||
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
||||
subsurface.set_position(dst.x, dst.y);
|
||||
// Tiles change independently of the chrome (selection moves now,
|
||||
// live frames later), so they must not wait on a parent commit.
|
||||
subsurface.set_desync();
|
||||
// The capture protocol reports the transform the compositor already
|
||||
// applied to the buffer, which is exactly what this request means,
|
||||
// so it passes straight through and the compositor un-rotates it.
|
||||
surface.set_buffer_transform(self.tiles[i].transform);
|
||||
viewport.set_destination(dst.w, dst.h);
|
||||
surface.attach(self.tiles[i].buffer.as_ref(), 0, 0);
|
||||
surface.damage_buffer(0, 0, bw as i32, bh as i32);
|
||||
surface.commit();
|
||||
let t = &mut self.tiles[i];
|
||||
t.surface = Some(surface);
|
||||
t.subsurface = Some(subsurface);
|
||||
t.viewport = Some(viewport);
|
||||
}
|
||||
// Subsurface placement is *parent* state: it only takes effect when the
|
||||
// parent commits, desynced children included.
|
||||
parent.commit();
|
||||
}
|
||||
|
||||
/// Repaint background, selection highlight and border.
|
||||
fn paint(&mut self) {
|
||||
let (theme, scale, sel) = (&self.theme, self.scale, self.sel);
|
||||
let elem = self.layout.elem(sel as i32);
|
||||
let Some(chrome) = self.chrome.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let slot = chrome.next_slot();
|
||||
let (cw, ch) = (chrome.w, chrome.h);
|
||||
let mut p = chrome.painter();
|
||||
p.fill(theme.bg);
|
||||
// The selection fills the whole element box, padding included — the same
|
||||
// thing rofi's element background does.
|
||||
p.rect(
|
||||
Rect {
|
||||
x: elem.x * scale,
|
||||
y: elem.y * scale,
|
||||
w: elem.w * scale,
|
||||
h: elem.h * scale,
|
||||
},
|
||||
theme.sel_bg,
|
||||
);
|
||||
p.frame(theme.border_px * scale, theme.border);
|
||||
|
||||
let surface = self.surface.clone().expect("show() runs first");
|
||||
surface.attach(self.chrome_buffers.get(slot), 0, 0);
|
||||
surface.damage_buffer(0, 0, cw, ch);
|
||||
surface.commit();
|
||||
}
|
||||
|
||||
fn move_sel(&mut self, delta: i32) {
|
||||
let n = self.tiles.len() as i32;
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.sel = (self.sel as i32 + delta).rem_euclid(n) as usize;
|
||||
self.paint();
|
||||
}
|
||||
|
||||
fn move_row(&mut self, rows: i32) {
|
||||
let n = self.tiles.len() as i32;
|
||||
let target = self.sel as i32 + rows * self.layout.cols;
|
||||
if target >= 0 && target < n {
|
||||
self.sel = target as usize;
|
||||
self.paint();
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, code: u32) {
|
||||
match code {
|
||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||
KEY_ESC | KEY_Q => self.quit = true,
|
||||
KEY_ENTER | KEY_KPENTER => {
|
||||
self.activate = self.tiles.get(self.sel).map(|t| t.win.con_id);
|
||||
self.quit = true;
|
||||
}
|
||||
KEY_TAB if self.shift => self.move_sel(-1),
|
||||
KEY_TAB | KEY_RIGHT => self.move_sel(1),
|
||||
KEY_LEFT => self.move_sel(-1),
|
||||
KEY_DOWN => self.move_row(1),
|
||||
KEY_UP => self.move_row(-1),
|
||||
KEY_HOME => {
|
||||
self.sel = 0;
|
||||
self.paint();
|
||||
}
|
||||
KEY_END => {
|
||||
self.sel = self.tiles.len().saturating_sub(1);
|
||||
self.paint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase timings, printed with --verbose. Opening latency is the whole point of
|
||||
/// this tool, so it stays measurable.
|
||||
struct Phases {
|
||||
on: bool,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl Phases {
|
||||
fn new(on: bool) -> Self {
|
||||
Self {
|
||||
on,
|
||||
last: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark(&mut self, label: &str) {
|
||||
if self.on {
|
||||
let now = Instant::now();
|
||||
eprintln!(
|
||||
"{label:<12} {:6.1}ms",
|
||||
(now - self.last).as_secs_f64() * 1000.0
|
||||
);
|
||||
self.last = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump(
|
||||
queue: &mut EventQueue<App>,
|
||||
app: &mut App,
|
||||
done: impl Fn(&App) -> bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
while !done(app) {
|
||||
queue.blocking_dispatch(app)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
print: bool,
|
||||
verbose: bool,
|
||||
timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut args = Args {
|
||||
print: false,
|
||||
verbose: false,
|
||||
timeout: None,
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--print" => args.print = true,
|
||||
"-v" | "--verbose" => args.verbose = true,
|
||||
"--timeout" => {
|
||||
let v = it.next().ok_or("--timeout needs seconds")?;
|
||||
let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?;
|
||||
args.timeout = Some(Duration::from_secs_f64(secs));
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("usage: wlgrid [--print] [--verbose] [--timeout SECS]");
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("wlgrid: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let args = parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?;
|
||||
// An exclusive keyboard grab makes a hung overlay unusable, so keep an
|
||||
// escape hatch that cannot itself deadlock.
|
||||
if let Some(d) = args.timeout {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(d);
|
||||
eprintln!("wlgrid: timeout");
|
||||
std::process::exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
let mut phases = Phases::new(args.verbose);
|
||||
let mut sway_conn = swayipc::Connection::new()?;
|
||||
let wins = sway::windows(&mut sway_conn)?;
|
||||
if wins.is_empty() {
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let scale = sway_conn
|
||||
.get_outputs()?
|
||||
.iter()
|
||||
.filter(|o| o.active)
|
||||
.map(|o| o.scale.unwrap_or(1.0).ceil() as i32)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
phases.mark("sway-tree");
|
||||
|
||||
let conn = Connection::connect_to_env()?;
|
||||
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
||||
let qh = queue.handle();
|
||||
let mut app = App::new(&globals, &qh, wins, Theme::default(), scale)?;
|
||||
|
||||
// Two roundtrips: one for the toplevel list, one for each handle's state.
|
||||
queue.roundtrip(&mut app)?;
|
||||
queue.roundtrip(&mut app)?;
|
||||
|
||||
phases.mark("toplevels");
|
||||
|
||||
app.open_sessions(&qh);
|
||||
queue.roundtrip(&mut app)?; // every session's constraints at once
|
||||
phases.mark("constraints");
|
||||
app.start_captures(&qh)?;
|
||||
pump(&mut queue, &mut app, |a| a.captures_settled())?;
|
||||
phases.mark("capture");
|
||||
|
||||
if args.verbose {
|
||||
let ready = app.tiles.iter().filter(|t| t.ready).count();
|
||||
let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
|
||||
eprintln!(
|
||||
"wlgrid: {} window(s), {matched} matched, {ready} captured; \
|
||||
grid {}x{}, surface {}x{} logical at scale {}",
|
||||
app.tiles.len(),
|
||||
app.layout.cols,
|
||||
app.layout.rows,
|
||||
app.layout.width,
|
||||
app.layout.height,
|
||||
app.scale,
|
||||
);
|
||||
}
|
||||
app.show(&qh)?;
|
||||
pump(&mut queue, &mut app, |a| a.configured)?;
|
||||
app.paint();
|
||||
app.place_tiles(&qh);
|
||||
conn.flush()?;
|
||||
phases.mark("mapped");
|
||||
|
||||
pump(&mut queue, &mut app, |a| a.quit)?;
|
||||
|
||||
if let Some(con_id) = app.activate {
|
||||
if args.print {
|
||||
println!("{con_id}");
|
||||
} else {
|
||||
sway::focus(&mut sway_conn, con_id)?;
|
||||
}
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
// --- event plumbing -------------------------------------------------------
|
||||
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for App {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &WlRegistry,
|
||||
_: <WlRegistry as Proxy>::Event,
|
||||
_: &GlobalListContents,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtForeignToplevelListV1, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &ExtForeignToplevelListV1,
|
||||
event: ext_foreign_toplevel_list_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let ext_foreign_toplevel_list_v1::Event::Toplevel { toplevel } = event {
|
||||
app.toplevels.push((toplevel, String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(App, ExtForeignToplevelListV1, [
|
||||
ext_foreign_toplevel_list_v1::EVT_TOPLEVEL_OPCODE => (ExtForeignToplevelHandleV1, ()),
|
||||
]);
|
||||
}
|
||||
|
||||
impl Dispatch<ExtForeignToplevelHandleV1, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
handle: &ExtForeignToplevelHandleV1,
|
||||
event: ext_foreign_toplevel_handle_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let ext_foreign_toplevel_handle_v1::Event::Identifier { identifier } = event
|
||||
&& let Some(entry) = app.toplevels.iter_mut().find(|(h, _)| h == handle)
|
||||
{
|
||||
entry.1 = identifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtImageCopyCaptureSessionV1, usize> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &ExtImageCopyCaptureSessionV1,
|
||||
event: ext_image_copy_capture_session_v1::Event,
|
||||
&i: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(tile) = app.tiles.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
ext_image_copy_capture_session_v1::Event::BufferSize { width, height } => {
|
||||
tile.size = (width, height)
|
||||
}
|
||||
ext_image_copy_capture_session_v1::Event::ShmFormat {
|
||||
format: WEnum::Value(f),
|
||||
} => tile.formats.push(f),
|
||||
ext_image_copy_capture_session_v1::Event::Done => tile.session_done = true,
|
||||
ext_image_copy_capture_session_v1::Event::Stopped => {
|
||||
tile.failed = true;
|
||||
tile.settled = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &ExtImageCopyCaptureFrameV1,
|
||||
event: ext_image_copy_capture_frame_v1::Event,
|
||||
&i: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(tile) = app.tiles.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
ext_image_copy_capture_frame_v1::Event::Transform {
|
||||
transform: WEnum::Value(t),
|
||||
} => tile.transform = t,
|
||||
ext_image_copy_capture_frame_v1::Event::Ready => {
|
||||
tile.ready = true;
|
||||
tile.settled = true;
|
||||
// The protocol wants the frame destroyed once ready; the buffer
|
||||
// stays ours to display.
|
||||
if let Some(frame) = tile.frame.take() {
|
||||
frame.destroy();
|
||||
}
|
||||
}
|
||||
ext_image_copy_capture_frame_v1::Event::Failed { reason } => {
|
||||
eprintln!(
|
||||
"wlgrid: capture failed for {:?} ({reason:?})",
|
||||
tile.win.title
|
||||
);
|
||||
tile.failed = true;
|
||||
tile.settled = true;
|
||||
if let Some(frame) = tile.frame.take() {
|
||||
frame.destroy();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
layer: &ZwlrLayerSurfaceV1,
|
||||
event: zwlr_layer_surface_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
zwlr_layer_surface_v1::Event::Configure { serial, .. } => {
|
||||
layer.ack_configure(serial);
|
||||
app.configured = true;
|
||||
}
|
||||
zwlr_layer_surface_v1::Event::Closed => app.quit = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlSeat, ()> for App {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
seat: &WlSeat,
|
||||
event: wl_seat::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_seat::Event::Capabilities {
|
||||
capabilities: WEnum::Value(caps),
|
||||
} = event
|
||||
&& caps.contains(wl_seat::Capability::Keyboard)
|
||||
{
|
||||
seat.get_keyboard(qh, ());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlKeyboard, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &WlKeyboard,
|
||||
event: wl_keyboard::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_keyboard::Event::Key { key, state, .. } = event {
|
||||
match state {
|
||||
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key),
|
||||
WEnum::Value(wl_keyboard::KeyState::Released)
|
||||
if key == KEY_LEFTSHIFT || key == KEY_RIGHTSHIFT =>
|
||||
{
|
||||
app.shift = false
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interfaces we drive but never listen to.
|
||||
delegate_noop!(App: WlCompositor);
|
||||
delegate_noop!(App: WlSubcompositor);
|
||||
delegate_noop!(App: WlSubsurface);
|
||||
delegate_noop!(App: ignore WlShm);
|
||||
delegate_noop!(App: WlShmPool);
|
||||
delegate_noop!(App: WpViewporter);
|
||||
delegate_noop!(App: WpViewport);
|
||||
delegate_noop!(App: ZwlrLayerShellV1);
|
||||
delegate_noop!(App: ExtImageCopyCaptureManagerV1);
|
||||
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
|
||||
delegate_noop!(App: ExtImageCaptureSourceV1);
|
||||
delegate_noop!(App: ignore WlSurface);
|
||||
delegate_noop!(App: ignore WlBuffer);
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
//! Shared memory: an anonymous memfd handed to wl_shm, plus a tiny ARGB
|
||||
//! painter for the parts we draw ourselves (background, border, selection).
|
||||
//!
|
||||
//! Capture buffers deliberately never get mapped into this process. The
|
||||
//! 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
|
||||
//! address space for nothing.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
|
||||
use memmap2::MmapMut;
|
||||
use rustix::fs::{MemfdFlags, memfd_create};
|
||||
|
||||
use crate::theme::{Argb, Rect};
|
||||
|
||||
/// An anonymous in-memory file of the given size, for wl_shm.create_pool.
|
||||
pub fn memfd(name: &str, len: usize) -> io::Result<File> {
|
||||
let fd = memfd_create(name, MemfdFlags::CLOEXEC)?;
|
||||
let file = File::from(fd);
|
||||
file.set_len(len as u64)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// A mapped memfd we paint into. Holds two slots so we can draw the next frame
|
||||
/// without touching the one the compositor is currently reading.
|
||||
pub struct Chrome {
|
||||
map: MmapMut,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
slot: usize,
|
||||
}
|
||||
|
||||
impl Chrome {
|
||||
pub const SLOTS: usize = 2;
|
||||
|
||||
pub fn new(file: &File, w: i32, h: i32) -> io::Result<Self> {
|
||||
let map = unsafe { MmapMut::map_mut(file)? };
|
||||
Ok(Self { map, w, h, slot: 0 })
|
||||
}
|
||||
|
||||
pub fn stride(w: i32) -> i32 {
|
||||
w * 4
|
||||
}
|
||||
|
||||
pub fn slot_len(w: i32, h: i32) -> usize {
|
||||
(Self::stride(w) * h) as usize
|
||||
}
|
||||
|
||||
/// Flip to the other slot and return its byte offset in the pool, so the
|
||||
/// caller can attach the matching wl_buffer.
|
||||
pub fn next_slot(&mut self) -> usize {
|
||||
self.slot = (self.slot + 1) % Self::SLOTS;
|
||||
self.slot
|
||||
}
|
||||
|
||||
pub fn painter(&mut self) -> Painter<'_> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
let len = Self::slot_len(w, h);
|
||||
let off = self.slot * len;
|
||||
Painter {
|
||||
px: &mut self.map[off..off + len],
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat ARGB8888 painter. Everything in this UI is axis-aligned solid fills, so
|
||||
/// there is no need for a rasteriser.
|
||||
pub struct Painter<'a> {
|
||||
px: &'a mut [u8],
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
pub fn fill(&mut self, c: Argb) {
|
||||
for p in self.px.chunks_exact_mut(4) {
|
||||
p.copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rect(&mut self, r: Rect, c: Argb) {
|
||||
let bytes = c.to_le_bytes();
|
||||
let (x0, y0) = (r.x.max(0), r.y.max(0));
|
||||
let (x1, y1) = ((r.x + r.w).min(self.w), (r.y + r.h).min(self.h));
|
||||
for y in y0..y1 {
|
||||
let row = (y * self.w * 4) as usize;
|
||||
for x in x0..x1 {
|
||||
let o = row + (x * 4) as usize;
|
||||
self.px[o..o + 4].copy_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `width`-thick frame just inside the surface edge.
|
||||
pub fn frame(&mut self, width: i32, c: Argb) {
|
||||
let (w, h) = (self.w, self.h);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w,
|
||||
h: width,
|
||||
},
|
||||
c,
|
||||
);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: h - width,
|
||||
w,
|
||||
h: width,
|
||||
},
|
||||
c,
|
||||
);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h,
|
||||
},
|
||||
c,
|
||||
);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: w - width,
|
||||
y: 0,
|
||||
w: width,
|
||||
h,
|
||||
},
|
||||
c,
|
||||
);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
//! sway stays the source of truth for the window list and for focusing, exactly
|
||||
//! as the shell script this replaces did (`swaymsg -t get_tree` + `[con_id=N]
|
||||
//! focus`). The Wayland side only supplies pixels; the join between the two is
|
||||
//! `foreign_toplevel_identifier`, which sway reports per view.
|
||||
|
||||
use swayipc::{Connection, Node, NodeType};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Win {
|
||||
pub con_id: i64,
|
||||
pub app: String,
|
||||
pub title: String,
|
||||
/// ext-foreign-toplevel-list-v1 identifier; the key we match capture
|
||||
/// sources on.
|
||||
pub ft_id: String,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
/// "title · app", the label rofigrid was given (app dropped when empty).
|
||||
/// Used once tiles are labelled.
|
||||
#[allow(dead_code)]
|
||||
pub fn label(&self) -> String {
|
||||
if self.app.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
format!("{} · {}", self.title, self.app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view in the tree, in tree order (same traversal the jq filter did, so
|
||||
/// the grid keeps the ordering the muscle memory expects).
|
||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Win>, swayipc::Error> {
|
||||
let mut out = Vec::new();
|
||||
collect(&conn.get_tree()?, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect(node: &Node, out: &mut Vec<Win>) {
|
||||
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||
let class = node
|
||||
.window_properties
|
||||
.as_ref()
|
||||
.and_then(|p| p.class.clone());
|
||||
if is_con && (node.app_id.is_some() || class.is_some()) {
|
||||
// A view with no identifier can't be captured, but it still belongs in
|
||||
// the list: it gets a tile with no thumbnail.
|
||||
out.push(Win {
|
||||
con_id: node.id,
|
||||
app: node.app_id.clone().or(class).unwrap_or_default(),
|
||||
title: node.name.clone().unwrap_or_default(),
|
||||
ft_id: node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||
collect(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(conn: &mut Connection, con_id: i64) -> Result<(), swayipc::Error> {
|
||||
for res in conn.run_command(format!("[con_id={con_id}] focus"))? {
|
||||
res?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
//! Look and layout, ported from the rofi setup this replaces (mytheme.rasi +
|
||||
//! the -theme-str rofigrid builds): gruvbox dark, a yellow selection that fills
|
||||
//! the element padding, and a window that hugs the grid.
|
||||
|
||||
/// 0xAARRGGBB, premultiplied (everything here is opaque).
|
||||
pub type Argb = u32;
|
||||
|
||||
pub struct Theme {
|
||||
pub bg: Argb,
|
||||
/// Label colours; used once tiles are labelled.
|
||||
#[allow(dead_code)]
|
||||
pub fg: Argb,
|
||||
pub sel_bg: Argb,
|
||||
#[allow(dead_code)]
|
||||
pub sel_fg: Argb,
|
||||
pub border: Argb,
|
||||
/// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px).
|
||||
pub border_px: i32,
|
||||
/// Thumbnail cell, logical px. 16:9 so wide windows fill it instead of
|
||||
/// letterboxing in a square box.
|
||||
pub tile_w: i32,
|
||||
pub tile_h: i32,
|
||||
/// Padding inside one element, i.e. around its thumbnail (rasi `element`).
|
||||
pub pad: i32,
|
||||
/// Space between elements (rasi `listview { spacing }`).
|
||||
pub gap: i32,
|
||||
/// Margin between the grid and the window edge.
|
||||
pub margin: i32,
|
||||
pub max_cols: i32,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bg: 0xff282828, // gruvbox-dark-bg0
|
||||
fg: 0xffebdbb2, // gruvbox-dark-fg1
|
||||
sel_bg: 0xffd79921, // gruvbox-dark-yellow-dark
|
||||
sel_fg: 0xff282828,
|
||||
border: 0xffd79921,
|
||||
border_px: 2,
|
||||
tile_w: 220,
|
||||
tile_h: 220 * 9 / 16,
|
||||
pad: 12,
|
||||
gap: 15,
|
||||
margin: 12,
|
||||
max_cols: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where every element and thumbnail goes, in logical px.
|
||||
pub struct Layout {
|
||||
pub cols: i32,
|
||||
pub rows: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
elem_w: i32,
|
||||
elem_h: i32,
|
||||
margin: i32,
|
||||
gap: i32,
|
||||
pad: i32,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't
|
||||
/// ragged (6 windows -> 3x2, not 4x2 with two holes). Same rule rofigrid uses.
|
||||
pub fn new(t: &Theme, n: i32) -> Self {
|
||||
let mut cols = (n as f64).sqrt() as i32;
|
||||
if cols * cols < n {
|
||||
cols += 1;
|
||||
}
|
||||
cols = cols.clamp(1, t.max_cols);
|
||||
let rows = (n + cols - 1) / cols;
|
||||
let (elem_w, elem_h) = (t.tile_w + 2 * t.pad, t.tile_h + 2 * t.pad);
|
||||
Self {
|
||||
cols,
|
||||
rows,
|
||||
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
|
||||
height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin,
|
||||
elem_w,
|
||||
elem_h,
|
||||
margin: t.margin,
|
||||
gap: t.gap,
|
||||
pad: t.pad,
|
||||
}
|
||||
}
|
||||
|
||||
/// The element box for index i — what the selection highlight fills.
|
||||
pub fn elem(&self, i: i32) -> Rect {
|
||||
let (col, row) = (i % self.cols, i / self.cols);
|
||||
Rect {
|
||||
x: self.margin + col * (self.elem_w + self.gap),
|
||||
y: self.margin + row * (self.elem_h + self.gap),
|
||||
w: self.elem_w,
|
||||
h: self.elem_h,
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail box for index i, i.e. the element box minus its padding.
|
||||
pub fn tile(&self, i: i32) -> Rect {
|
||||
let e = self.elem(i);
|
||||
Rect {
|
||||
x: e.x + self.pad,
|
||||
y: e.y + self.pad,
|
||||
w: e.w - 2 * self.pad,
|
||||
h: e.h - 2 * self.pad,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
/// Scale (w, h) to fit inside (bw, bh), keeping the aspect ratio, and centre it.
|
||||
/// Windows are usually portrait-ish next to a 16:9 cell, so this letterboxes the
|
||||
/// same way rofi's `element-icon { size: W H }` does.
|
||||
pub fn fit_centred(w: i32, h: i32, box_: Rect) -> Rect {
|
||||
if w <= 0 || h <= 0 {
|
||||
return box_;
|
||||
}
|
||||
let (mut dw, mut dh) = (box_.w, box_.w * h / w);
|
||||
if dh > box_.h {
|
||||
dh = box_.h;
|
||||
dw = box_.h * w / h;
|
||||
}
|
||||
let (dw, dh) = (dw.max(1), dh.max(1));
|
||||
Rect {
|
||||
x: box_.x + (box_.w - dw) / 2,
|
||||
y: box_.y + (box_.h - dh) / 2,
|
||||
w: dw,
|
||||
h: dh,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The grid maths must match rofigrid's, or the window stops hugging the grid.
|
||||
#[test]
|
||||
fn grid_matches_rofigrid() {
|
||||
let t = Theme::default();
|
||||
// (n, cols, rows) from rofigrid: cols = min(ceil(sqrt(n)), 4)
|
||||
for (n, cols, rows) in [
|
||||
(1, 1, 1),
|
||||
(2, 2, 1),
|
||||
(4, 2, 2),
|
||||
(6, 3, 2),
|
||||
(12, 4, 3),
|
||||
(17, 4, 5),
|
||||
] {
|
||||
let l = Layout::new(&t, n);
|
||||
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}");
|
||||
// rofigrid: win_w = cols*(ICON+24) + (cols-1)*15 + 24
|
||||
assert_eq!(
|
||||
l.width,
|
||||
cols * (t.tile_w + 24) + (cols - 1) * 15 + 24,
|
||||
"width n = {n}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_stay_inside_the_window() {
|
||||
let t = Theme::default();
|
||||
for n in 1..=20 {
|
||||
let l = Layout::new(&t, n);
|
||||
for i in 0..n {
|
||||
let e = l.elem(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}");
|
||||
let tile = l.tile(i);
|
||||
assert!(tile.w == t.tile_w && tile.h == t.tile_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_preserves_aspect_and_centres() {
|
||||
let box_ = Rect {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 220,
|
||||
h: 123,
|
||||
};
|
||||
// A portrait window letterboxes: height-bound, centred horizontally.
|
||||
let r = fit_centred(1000, 2000, box_);
|
||||
assert_eq!((r.w, r.h), (61, 123));
|
||||
assert_eq!(r.x, 10 + (220 - 61) / 2);
|
||||
assert_eq!(r.y, 20);
|
||||
// A wide window is width-bound.
|
||||
let r = fit_centred(4000, 1000, box_);
|
||||
assert_eq!((r.w, r.h), (220, 55));
|
||||
assert_eq!(r.y, 20 + (123 - 55) / 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user