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 # wlgrid
A window switcher for wlroots compositors: a grid overlay of **live** window 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 It replaces a `wlthumbs | rofi` pipeline, and doubles as a screencast source
picker for the desktop portal. The difference is that no thumbnails 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] [--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 - `--verbose` prints phase timings and how many windows were captured
- `--hide-labels` draws an icon-only grid - `--hide-labels` draws an icon-only grid
- `--font FAMILY` label font family (default `Berkeley Mono`) - `--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 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. 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 ```sh
# Simplest: let wlgrid do the focusing, for a bare keybinding. #!/usr/bin/env bash
bindsym $mod+Tab exec wlgrid --focus # ~/.local/bin/winmenu, bound to $mod+Tab
# 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.
IFS=$'\t' read -r type id toplevel app title < <(wlgrid) || exit 0 IFS=$'\t' read -r type id toplevel app title < <(wlgrid) || exit 0
case $type in case $type in
window) swaymsg "[con_id=$id] focus" ;; window) swaymsg "[con_id=$id] focus" ;;
@@ -68,8 +65,11 @@ case $type in
esac esac
``` ```
`--focus` runs exactly those two commands for you. (Focusing a display only does Windows only, as a one-liner:
something visible when you have more than one.)
```sh
swaymsg "[con_id=$(wlgrid --no-outputs | cut -f2)] focus"
```
Three formats, because the identifiers different consumers need differ: 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-image-capture-source-v1` (with the foreign-toplevel source manager),
`ext-foreign-toplevel-list-v1`, `wlr-layer-shell-unstable-v1` and `ext-foreign-toplevel-list-v1`, `wlr-layer-shell-unstable-v1` and
`wp_viewporter` — sway 1.11+, and in principle Hyprland, labwc and jay, though `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 only sway is tested. sway is also the source of truth for the window list, over
for focusing, over its IPC socket. 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 Known upstream issue: holding per-toplevel capture sessions open makes windows
blurry on **fractionally scaled** outputs 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 //! wlgrid shows a live grid of every window and display as a layer-shell overlay
//! and focuses the one you pick. It replaces a wlthumbs + rofi pipeline, so it //! and reports which one you picked. That is all it does: acting on the choice
//! keeps that pipeline's contract: sway owns the window list and the focusing, //! belongs to whatever called it. It replaces a wlthumbs + rofi pipeline, so the
//! and the look comes straight from the rofi theme (see theme.rs). //! look comes straight from that rofi theme (see theme.rs).
//! //!
//! The pixels never pass through this process. Each window is captured into an //! 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 //! 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] usage: wlgrid [options]
--format tsv|json|portal how to report the pick [tsv] --format tsv|json|portal how to report the pick [tsv]; see FORMATS
--focus also focus the pick, via sway [off]
--live all|current|none which tiles keep updating live [all] --live all|current|none which tiles keep updating live [all]
(display tiles are always a single snapshot) (display tiles are always a single snapshot)
--fps N cap on live updates per tile per second [12] --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 The pick goes to stdout, nothing does if you cancel; exit status is 0 for a
pick and 1 for a cancel. pick and 1 for a cancel.
tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE FORMATS
TYPE is \"window\" or \"output\". ID is the sway con_id, or the
output name for a display. TOPLEVEL_ID is the tsv One line of tab-separated columns; the default, meant for
ext-foreign-toplevel-list-v1 identifier, which is what tools like `IFS=$'\\t' read` or cut(1):
grim -T capture by; it is empty for displays.
json the same record, every key always present, for jq TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE
portal \"Monitor: NAME\" or \"Window: TOPLEVEL_ID\", i.e. exactly what
xdg-desktop-portal-wlr's simple chooser reads: 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] [screencast]
chooser_type=simple chooser_type=simple
chooser_cmd=wlgrid --format portal chooser_cmd=wlgrid --format portal
examples: EXAMPLES, on sway
swaymsg \"[con_id=$(wlgrid | cut -f2)] focus\"
wlgrid --format json | jq -r .title 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 { struct Args {
format: Format, format: Format,
focus: bool,
outputs: bool, outputs: bool,
verbose: bool, verbose: bool,
hide_labels: bool, hide_labels: bool,
@@ -783,7 +816,6 @@ struct Args {
fn parse_args() -> Result<Args, String> { fn parse_args() -> Result<Args, String> {
let mut args = Args { let mut args = Args {
format: Format::Tsv, format: Format::Tsv,
focus: false,
outputs: true, outputs: true,
verbose: false, verbose: false,
hide_labels: false, hide_labels: false,
@@ -796,7 +828,6 @@ fn parse_args() -> Result<Args, String> {
let mut it = std::env::args().skip(1); let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
match arg.as_str() { match arg.as_str() {
"--focus" => args.focus = true,
"--format" => { "--format" => {
args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() { args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() {
"tsv" => Format::Tsv, "tsv" => Format::Tsv,
@@ -865,15 +896,21 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
let start = Instant::now(); let start = Instant::now();
let mut phases = Phases::new(args.verbose); let mut phases = Phases::new(args.verbose);
let mut sway_conn = swayipc::Connection::new()?; // The IPC connection is only needed to build the list, so it is closed again
let mut targets = sway::windows(&mut sway_conn)?; // before the overlay maps.
let scale = sway::scale(&mut sway_conn)?; let (targets, scale) = {
if args.outputs { let mut sway = swayipc::Connection::new()?;
// Displays go last, after the windows, so window positions stay stable. let mut targets = sway::windows(&mut sway)?;
for output in sway_conn.get_outputs()?.iter().filter(|o| o.active) { let scale = sway::scale(&mut sway)?;
targets.push(Target::output(output.name.clone())); 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() { if targets.is_empty() {
return Ok(ExitCode::SUCCESS); 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 // wlgrid is a chooser: it reports the pick, and what that means is the
// caller (--focus is a convenience for a bare keybinding). // caller's business.
if args.verbose { if args.verbose {
eprintln!("wlgrid: {}", app.quit_why); 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) 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 //! sway is the source of truth for the window list (`swaymsg -t get_tree`); the
//! as the shell script this replaces did (`swaymsg -t get_tree` + `[con_id=N] //! Wayland side only supplies pixels. The join between the two is
//! focus`). The Wayland side only supplies pixels; the join between the two is
//! `foreign_toplevel_identifier`, which sway reports per view. //! `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}; use swayipc::{Connection, Node, NodeType};
@@ -47,15 +49,3 @@ pub fn scale(conn: &mut Connection) -> Result<i32, swayipc::Error> {
.unwrap_or(1) .unwrap_or(1)
.max(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(())
}