From dbfc06519ac3534e5d67b98a1e0878f282b10c9d Mon Sep 17 00:00:00 2001 From: Milad Alizadeh Date: Sun, 23 Aug 2026 10:07:09 +0100 Subject: [PATCH] Add wlgrid: a window-thumbnail grid overlay for wlroots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + Cargo.lock | 337 +++++++++++++++++++++ Cargo.toml | 17 ++ README.md | 93 ++++++ src/main.rs | 811 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/shm.rs | 137 +++++++++ src/sway.rs | 65 +++++ src/theme.rs | 201 +++++++++++++ 8 files changed, 1662 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/main.rs create mode 100644 src/shm.rs create mode 100644 src/sway.rs create mode 100644 src/theme.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c50edae --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c33167b --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d0a767 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..28d990a --- /dev/null +++ b/src/main.rs @@ -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, + + session: Option, + frame: Option, + buffer: Option, + formats: Vec, + format: Option, + /// 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, + subsurface: Option, + viewport: Option, +} + +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, + + theme: Theme, + layout: Layout, + scale: i32, + sel: usize, + shift: bool, + + surface: Option, + chrome: Option, + chrome_buffers: Vec, + configured: bool, + + quit: bool, + activate: Option, +} + +impl App { + fn new( + globals: &GlobalList, + qh: &QueueHandle, + wins: Vec, + theme: Theme, + scale: i32, + ) -> Result> { + 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) { + 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) -> Result<(), Box> { + 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) -> Result<(), Box> { + 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) { + 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: &mut App, + done: impl Fn(&App) -> bool, +) -> Result<(), Box> { + while !done(app) { + queue.blocking_dispatch(app)?; + } + Ok(()) +} + +struct Args { + print: bool, + verbose: bool, + timeout: Option, +} + +fn parse_args() -> Result { + 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> { + let args = parse_args().map_err(|e| -> Box { 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::(&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 for App { + fn event( + _: &mut Self, + _: &WlRegistry, + _: ::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for App { + fn event( + app: &mut Self, + _: &ExtForeignToplevelListV1, + event: ext_foreign_toplevel_list_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for App { + fn event( + app: &mut Self, + handle: &ExtForeignToplevelHandleV1, + event: ext_foreign_toplevel_handle_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for App { + fn event( + app: &mut Self, + _: &ExtImageCopyCaptureSessionV1, + event: ext_image_copy_capture_session_v1::Event, + &i: &usize, + _: &Connection, + _: &QueueHandle, + ) { + 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 for App { + fn event( + app: &mut Self, + _: &ExtImageCopyCaptureFrameV1, + event: ext_image_copy_capture_frame_v1::Event, + &i: &usize, + _: &Connection, + _: &QueueHandle, + ) { + 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 for App { + fn event( + app: &mut Self, + layer: &ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for App { + fn event( + _: &mut Self, + seat: &WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(caps), + } = event + && caps.contains(wl_seat::Capability::Keyboard) + { + seat.get_keyboard(qh, ()); + } + } +} + +impl Dispatch for App { + fn event( + app: &mut Self, + _: &WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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); diff --git a/src/shm.rs b/src/shm.rs new file mode 100644 index 0000000..83c58af --- /dev/null +++ b/src/shm.rs @@ -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 { + 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 { + 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, + ); + } +} diff --git a/src/sway.rs b/src/sway.rs new file mode 100644 index 0000000..8b6c595 --- /dev/null +++ b/src/sway.rs @@ -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, swayipc::Error> { + let mut out = Vec::new(); + collect(&conn.get_tree()?, &mut out); + Ok(out) +} + +fn collect(node: &Node, out: &mut Vec) { + 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(()) +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..0c1e490 --- /dev/null +++ b/src/theme.rs @@ -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); + } +}