Make wlgrid a chooser only, and say what each format is

--focus and sway::focus are gone. Showing the grid and reporting the
choice is the whole job; deciding what the choice means belongs to
whoever called it, and keeping that decision here only invited more of
it (focus which way? move? swap? scratchpad?). The sway IPC connection
is now scoped to building the list and closed before the overlay maps.

--help no longer just names the three formats, it shows them. Each gets
a real sample line, what every column means, and where the format is
meant to be used: tsv for `IFS=$'\t' read` or cut, json for jq, portal
for xdg-desktop-portal-wlr's simple chooser, with the config stanza to
paste. Plus worked examples of focusing a window, handling either kind
of pick, and screenshotting one with grim -T.
This commit is contained in:
Milad Alizadeh
2026-08-23 14:07:11 +01:00
parent 6d8ae655b9
commit 3d54b368d2
3 changed files with 86 additions and 61 deletions
+14 -13
View File
@@ -1,7 +1,7 @@
# wlgrid
A window switcher for wlroots compositors: a grid overlay of **live** window
previews that looks like a rofi theme, and focuses the window you pick.
previews that looks like a rofi theme, and tells you which one you picked.
It replaces a `wlthumbs | rofi` pipeline, and doubles as a screencast source
picker for the desktop portal. The difference is that no thumbnails
@@ -39,7 +39,6 @@ wlgrid [--print] [--verbose] [--hide-labels] [--font FAMILY] [--font-size PX]
[--live all|current|none] [--fps N] [--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
- `--hide-labels` draws an icon-only grid
- `--font FAMILY` label font family (default `Berkeley Mono`)
@@ -53,14 +52,12 @@ wlgrid is a chooser: it reports what you picked and leaves acting on it to the
caller. The pick goes to stdout, nothing does if you cancel, and the exit status
is 0 for a pick and 1 for a cancel.
wlgrid never acts on the choice — it has no idea what you want to do with it.
Focusing on sway looks like this:
```sh
# Simplest: let wlgrid do the focusing, for a bare keybinding.
bindsym $mod+Tab exec wlgrid --focus
# Windows only, in a script:
swaymsg "[con_id=$(wlgrid --no-outputs | cut -f2)] focus"
# Both windows and displays: the TYPE column says which command to use.
#!/usr/bin/env bash
# ~/.local/bin/winmenu, bound to $mod+Tab
IFS=$'\t' read -r type id toplevel app title < <(wlgrid) || exit 0
case $type in
window) swaymsg "[con_id=$id] focus" ;;
@@ -68,8 +65,11 @@ case $type in
esac
```
`--focus` runs exactly those two commands for you. (Focusing a display only does
something visible when you have more than one.)
Windows only, as a one-liner:
```sh
swaymsg "[con_id=$(wlgrid --no-outputs | cut -f2)] focus"
```
Three formats, because the identifiers different consumers need differ:
@@ -145,8 +145,9 @@ 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.
only sway is tested. sway is also the source of truth for the window list, over
its IPC socket, which is the one thing that would need replacing to run
elsewhere (`ext-foreign-toplevel-list-v1` already reports app id and title).
Known upstream issue: holding per-toplevel capture sessions open makes windows
blurry on **fractionally scaled** outputs
+67 -33
View File
@@ -1,7 +1,7 @@
//! 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).
//! wlgrid shows a live grid of every window and display as a layer-shell overlay
//! and reports which one you picked. That is all it does: acting on the choice
//! belongs to whatever called it. It replaces a wlthumbs + rofi pipeline, so the
//! look comes straight from that 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
@@ -728,8 +728,7 @@ wlgrid — a live grid of window and display previews, for picking one
usage: wlgrid [options]
--format tsv|json|portal how to report the pick [tsv]
--focus also focus the pick, via sway [off]
--format tsv|json|portal how to report the pick [tsv]; see FORMATS
--live all|current|none which tiles keep updating live [all]
(display tiles are always a single snapshot)
--fps N cap on live updates per tile per second [12]
@@ -749,27 +748,61 @@ keys: arrows, hjkl, or Tab/Shift+Tab move; Home/End jump; Enter picks;
The pick goes to stdout, nothing does if you cancel; exit status is 0 for a
pick and 1 for a cancel.
tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE
TYPE is \"window\" or \"output\". ID is the sway con_id, or the
output name for a display. TOPLEVEL_ID is the
ext-foreign-toplevel-list-v1 identifier, which is what tools like
grim -T capture by; it is empty for displays.
json the same record, every key always present, for jq
portal \"Monitor: NAME\" or \"Window: TOPLEVEL_ID\", i.e. exactly what
xdg-desktop-portal-wlr's simple chooser reads:
FORMATS
tsv One line of tab-separated columns; the default, meant for
`IFS=$'\\t' read` or cut(1):
TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE
window 68 8e38849641c86be05bb1a68c163a1c41 dev.zed.Zed jobchi
output DP-1 display DP-1
TYPE is \"window\" or \"output\", so a caller knows which kind of
thing it got. ID is the one to act on: a sway con_id for a
window, the name for a display. TOPLEVEL_ID is the
ext-foreign-toplevel-list-v1 identifier, which is what capture
tools address a window by (grim -T, the desktop portal); it is
empty for a display. APP is the app id, or \"display\".
json The same record as one object, with every key always present so
jq can rely on it:
{\"type\":\"window\",\"con_id\":68,\"toplevel_id\":\"8e3884...\",
\"output\":null,\"app\":\"dev.zed.Zed\",\"title\":\"jobchi\"}
portal What xdg-desktop-portal-wlr's \"simple\" chooser reads, which lets
wlgrid be the picker a screencast pops up (getDisplayMedia in a
browser, for instance) with live previews of both windows and
whole displays:
Window: 8e38849641c86be05bb1a68c163a1c41
Monitor: DP-1
Put this in ~/.config/xdg-desktop-portal-wlr/config:
[screencast]
chooser_type=simple
chooser_cmd=wlgrid --format portal
examples:
swaymsg \"[con_id=$(wlgrid | cut -f2)] focus\"
wlgrid --format json | jq -r .title
EXAMPLES, on sway
Focus a window:
swaymsg \"[con_id=$(wlgrid --no-outputs | cut -f2)] focus\"
Focus a window or move to a display, whichever was picked:
IFS=$'\\t' read -r type id toplevel app title < <(wlgrid) &&
case $type in
window) swaymsg \"[con_id=$id] focus\" ;;
output) swaymsg \"focus output $id\" ;;
esac
Screenshot whatever you pick:
grim -T \"$(wlgrid --no-outputs | cut -f3)\" shot.png
";
struct Args {
format: Format,
focus: bool,
outputs: bool,
verbose: bool,
hide_labels: bool,
@@ -783,7 +816,6 @@ struct Args {
fn parse_args() -> Result<Args, String> {
let mut args = Args {
format: Format::Tsv,
focus: false,
outputs: true,
verbose: false,
hide_labels: false,
@@ -796,7 +828,6 @@ fn parse_args() -> Result<Args, String> {
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--focus" => args.focus = true,
"--format" => {
args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() {
"tsv" => Format::Tsv,
@@ -865,15 +896,21 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
let start = Instant::now();
let mut phases = Phases::new(args.verbose);
let mut sway_conn = swayipc::Connection::new()?;
let mut targets = sway::windows(&mut sway_conn)?;
let scale = sway::scale(&mut sway_conn)?;
if args.outputs {
// Displays go last, after the windows, so window positions stay stable.
for output in sway_conn.get_outputs()?.iter().filter(|o| o.active) {
targets.push(Target::output(output.name.clone()));
// The IPC connection is only needed to build the list, so it is closed again
// before the overlay maps.
let (targets, scale) = {
let mut sway = swayipc::Connection::new()?;
let mut targets = sway::windows(&mut sway)?;
let scale = sway::scale(&mut sway)?;
if args.outputs {
// Displays go last, after the windows, so window positions are
// stable as windows come and go.
for output in sway.get_outputs()?.iter().filter(|o| o.active) {
targets.push(Target::output(output.name.clone()));
}
}
}
(targets, scale)
};
if targets.is_empty() {
return Ok(ExitCode::SUCCESS);
}
@@ -981,8 +1018,8 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
);
}
// wlgrid is a chooser: it reports the pick and leaves acting on it to the
// caller (--focus is a convenience for a bare keybinding).
// wlgrid is a chooser: it reports the pick, and what that means is the
// caller's business.
if args.verbose {
eprintln!("wlgrid: {}", app.quit_why);
}
@@ -1002,9 +1039,6 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
}
},
}
if args.focus {
sway::focus(&mut sway_conn, &target)?;
}
Ok(ExitCode::SUCCESS)
}
+5 -15
View File
@@ -1,7 +1,9 @@
//! 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
//! sway is the source of truth for the window list (`swaymsg -t get_tree`); the
//! Wayland side only supplies pixels. The join between the two is
//! `foreign_toplevel_identifier`, which sway reports per view.
//!
//! Acting on the choice is deliberately not here: wlgrid reports what was picked
//! and the caller decides what that means.
use swayipc::{Connection, Node, NodeType};
@@ -47,15 +49,3 @@ pub fn scale(conn: &mut Connection) -> Result<i32, swayipc::Error> {
.unwrap_or(1)
.max(1))
}
pub fn focus(conn: &mut Connection, target: &Target) -> Result<(), swayipc::Error> {
let cmd = match target.con_id {
Some(con_id) => format!("[con_id={con_id}] focus"),
// Picking a display means going to it.
None => format!("focus output {}", target.id),
};
for res in conn.run_command(cmd)? {
res?;
}
Ok(())
}