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
+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(())
}