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:
Milad Alizadeh
2026-08-23 10:07:09 +01:00
commit dbfc06519a
8 changed files with 1662 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+337
View File
@@ -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
View File
@@ -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
+93
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}