Add display tiles, three output formats, and hjkl

Displays are capture sources too — ext-image-capture-source-v1 makes one
from a wl_output just as it does from a toplevel handle — so they are now
tiles as well, appended after the windows and labelled "NAME · display".
They are snapshot-only: a live display tile contains this overlay, which
contains the display tile, and refreshing that never settles while
costing a whole screen per frame. They also only get one buffer for the
same reason, which is worth ~29MB here.

The bigger change is what wlgrid reports. It was focusing the pick itself
and printing only under --print, which suits a keybinding and nothing
else. Now it is a chooser: it always reports the pick, never acts unless
asked (--focus), exits 1 when cancelled, and can say it three ways.

--format portal emits what xdg-desktop-portal-wlr's simple chooser reads
("Monitor: NAME" / "Window: <foreign-toplevel identifier>"), so wlgrid
can be the picker behind getDisplayMedia, with live previews of windows
and displays. That contract is also why tsv carries both identifiers: the
portal and grim -T want the toplevel identifier, sway scripting wants the
con_id. --format json gives the whole record for jq.

Navigation also takes hjkl, and --help now describes every flag with its
default plus the output formats.
This commit is contained in:
Milad Alizadeh
2026-08-23 12:33:25 +01:00
parent 33d75222cf
commit 5b1f0f74e8
4 changed files with 473 additions and 97 deletions
+202 -56
View File
@@ -10,6 +10,7 @@
mod shm;
mod sway;
mod target;
mod text;
mod theme;
@@ -24,7 +25,7 @@ use wayland_client::protocol::{
wl_callback,
wl_compositor::WlCompositor,
wl_keyboard::{self, WlKeyboard},
wl_output,
wl_output::{self, WlOutput},
wl_registry::WlRegistry,
wl_seat::{self, WlSeat},
wl_shm::{self, WlShm},
@@ -43,6 +44,7 @@ use wayland_protocols::ext::foreign_toplevel_list::v1::client::{
use wayland_protocols::ext::image_capture_source::v1::client::{
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
ext_image_capture_source_v1::ExtImageCaptureSourceV1,
ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
};
use wayland_protocols::ext::image_copy_capture::v1::client::{
ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1},
@@ -57,6 +59,7 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
};
use target::{Kind, Target};
use theme::{Layout, Rect, Theme, fit_centred};
// evdev keycodes: physical positions, so navigation works on any keyboard layout
@@ -64,6 +67,11 @@ use theme::{Layout, Rect, Theme, fit_centred};
const KEY_ESC: u32 = 1;
const KEY_TAB: u32 = 15;
const KEY_Q: u32 = 16;
// hjkl, by physical position: the same keys as vim on a qwerty layout.
const KEY_H: u32 = 35;
const KEY_J: u32 = 36;
const KEY_K: u32 = 37;
const KEY_L: u32 = 38;
const KEY_ENTER: u32 = 28;
const KEY_LEFTSHIFT: u32 = 42;
const KEY_RIGHTSHIFT: u32 = 54;
@@ -76,6 +84,17 @@ const KEY_END: u32 = 107;
const KEY_DOWN: u32 = 108;
/// One window: its sway identity, its capture plumbing, and its subsurface.
/// How the pick is written to stdout.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Format {
/// type, id, toplevel id, app, title — one tab-separated line.
Tsv,
/// The same record as a JSON object.
Json,
/// What xdg-desktop-portal-wlr's `simple` chooser accepts.
Portal,
}
/// Which tiles keep updating after the first frame.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Live {
@@ -96,7 +115,7 @@ struct Slot {
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
struct Tile {
win: sway::Win,
target: Target,
handle: Option<ExtForeignToplevelHandleV1>,
session: Option<ExtImageCopyCaptureSessionV1>,
@@ -126,9 +145,9 @@ struct Tile {
}
impl Tile {
fn new(win: sway::Win) -> Self {
fn new(target: Target) -> Self {
Self {
win,
target,
handle: None,
session: None,
frame: None,
@@ -178,6 +197,9 @@ struct App {
/// Toplevel handles as the compositor announces them, paired with the
/// identifier that joins them to sway's tree.
toplevels: Vec<(ExtForeignToplevelHandleV1, String)>,
/// Displays, paired with the name the compositor gives them (wl_output v4).
outputs: Vec<(WlOutput, String)>,
output_src_mgr: Option<ExtOutputImageCaptureSourceManagerV1>,
tiles: Vec<Tile>,
theme: Theme,
@@ -195,7 +217,7 @@ struct App {
configured: bool,
quit: bool,
activate: Option<i64>,
activate: Option<Target>,
/// Frame-callback ticks, for diagnosing the live clock.
ticks: u32,
releases: u32,
@@ -208,16 +230,16 @@ impl App {
fn new(
globals: &GlobalList,
qh: &QueueHandle<Self>,
wins: Vec<sway::Win>,
targets: Vec<Target>,
theme: Theme,
live: Live,
fps: u32,
scale: i32,
) -> Result<Self, Box<dyn Error>> {
let layout = Layout::new(&theme, wins.len() as i32);
let layout = Layout::new(&theme, targets.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 {
let mut app = Self {
compositor: globals.bind(qh, 1..=6, ())?,
subcompositor: globals.bind(qh, 1..=1, ())?,
shm: globals.bind(qh, 1..=1, ())?,
@@ -226,7 +248,10 @@ impl App {
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(),
outputs: Vec::new(),
// Optional: a compositor without it simply gets no display tiles.
output_src_mgr: globals.bind(qh, 1..=1, ()).ok(),
tiles: targets.into_iter().map(Tile::new).collect(),
theme,
layout,
live,
@@ -247,6 +272,16 @@ impl App {
pool_bytes: 0,
};
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
// One wl_output per display, bound at v4 so it tells us its name.
for global in globals.contents().clone_list() {
if global.interface == WlOutput::interface().name {
let version = global.version.min(4);
if version >= 4 {
let output: WlOutput = globals.registry().bind(global.name, version, qh, ());
app.outputs.push((output, String::new()));
}
}
}
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
Ok(app)
}
@@ -256,19 +291,33 @@ impl App {
/// 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.
// A window's source comes from its toplevel handle, a display's from
// its wl_output; everything after that is identical.
let source: Option<ExtImageCaptureSourceV1> = match tile.target.kind {
Kind::Window => self
.toplevels
.iter()
.find(|(_, id)| !id.is_empty() && *id == tile.target.ft_id)
.map(|(handle, _)| {
tile.handle = Some(handle.clone());
self.src_mgr.create_source(handle, qh, ())
}),
Kind::Output => self
.outputs
.iter()
.find(|(_, n)| *n == tile.target.id)
.and_then(|(output, _)| {
self.output_src_mgr
.as_ref()
.map(|mgr| mgr.create_source(output, qh, ()))
}),
};
let Some(source) = source else {
// Nothing to capture from: 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(),
@@ -289,7 +338,6 @@ impl App {
/// touching it again.
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
const PAGE: usize = 4096;
let slots = if self.live == Live::None { 1 } else { 2 };
let mut total = 0usize;
let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len());
for tile in &mut self.tiles {
@@ -314,6 +362,13 @@ impl App {
tile.settled = true;
continue;
}
// Only a tile that will be re-captured needs a second buffer, and a
// display's is the size of the whole screen.
let slots = if self.live == Live::None || tile.target.kind == Kind::Output {
1
} else {
2
};
let last = offsets.last_mut().expect("just pushed");
for _ in 0..slots {
last.push(total);
@@ -428,6 +483,11 @@ impl App {
if self.live == Live::Current && i != self.sel {
continue;
}
// A display tile shows this overlay, which shows the display tile:
// refreshing it never settles and costs a whole screen per frame.
if self.tiles[i].target.kind == Kind::Output {
continue;
}
let t = &self.tiles[i];
if t.slots.is_empty() || t.frame.is_some() {
continue;
@@ -587,14 +647,14 @@ impl App {
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.activate = self.tiles.get(self.sel).map(|t| t.target.clone());
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_TAB | KEY_RIGHT | KEY_L => self.move_sel(1),
KEY_LEFT | KEY_H => self.move_sel(-1),
KEY_DOWN | KEY_J => self.move_row(1),
KEY_UP | KEY_K => self.move_row(-1),
KEY_HOME => {
self.sel = 0;
self.paint();
@@ -656,8 +716,54 @@ fn pump(
Ok(())
}
const HELP: &str = "\
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]
--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]
--no-outputs windows only; by default whole displays are
included too, labelled \"NAME · display\"
--hide-labels draw an icon-only grid
--font FAMILY label font family [Berkeley Mono]
--font-size PX label size in logical px [13.3]
--timeout SECS exit after SECS regardless [off]
(a safety valve: the overlay grabs the keyboard)
-v, --verbose phase timings, the tile list, and capture stats
-h, --help this
keys: arrows, hjkl, or Tab/Shift+Tab move; Home/End jump; Enter picks;
Escape or q cancels
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:
[screencast]
chooser_type=simple
chooser_cmd=wlgrid --format portal
examples:
swaymsg \"[con_id=$(wlgrid | cut -f2)] focus\"
wlgrid --format json | jq -r .title
";
struct Args {
print: bool,
format: Format,
focus: bool,
outputs: bool,
verbose: bool,
hide_labels: bool,
font: Option<String>,
@@ -669,7 +775,9 @@ struct Args {
fn parse_args() -> Result<Args, String> {
let mut args = Args {
print: false,
format: Format::Tsv,
focus: false,
outputs: true,
verbose: false,
hide_labels: false,
font: None,
@@ -681,7 +789,17 @@ fn parse_args() -> Result<Args, String> {
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--print" => args.print = true,
"--focus" => args.focus = true,
"--format" => {
args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() {
"tsv" => Format::Tsv,
"json" => Format::Json,
"portal" => Format::Portal,
other => return Err(format!("bad --format: {other}")),
}
}
"--outputs" => args.outputs = true,
"--no-outputs" => args.outputs = false,
"-v" | "--verbose" => args.verbose = true,
"--hide-labels" => args.hide_labels = true,
"--live" => {
@@ -707,11 +825,7 @@ fn parse_args() -> Result<Args, String> {
args.timeout = Some(Duration::from_secs_f64(secs));
}
"-h" | "--help" => {
println!(
"usage: wlgrid [--print] [--verbose] [--hide-labels] \
[--font FAMILY] [--font-size PX] \
[--live all|current|none] [--fps N] [--timeout SECS]"
);
print!("{HELP}");
std::process::exit(0);
}
other => return Err(format!("unknown argument: {other}")),
@@ -745,18 +859,17 @@ 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 wins = sway::windows(&mut sway_conn)?;
if wins.is_empty() {
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()));
}
}
if targets.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");
@@ -777,10 +890,10 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
// rasterising, and the captures below are ~55ms of waiting on the
// compositor, so the two overlap almost exactly.
let label_job = theme.labels.then(|| {
let layout = Layout::new(&theme, wins.len() as i32);
let layout = Layout::new(&theme, targets.len() as i32);
let box_w = layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w);
text::spawn(
wins.iter().map(sway::Win::label).collect(),
targets.iter().map(Target::label).collect(),
theme.font.clone(),
theme.font_px * scale as f32,
(theme.line_h * scale) as f32,
@@ -791,7 +904,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
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, args.live, args.fps, scale)?;
let mut app = App::new(&globals, &qh, targets, theme, args.live, args.fps, scale)?;
// Two roundtrips: one for the toplevel list, one for each handle's state.
queue.roundtrip(&mut app)?;
@@ -816,9 +929,8 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
for (i, t) in app.tiles.iter().enumerate() {
eprintln!(
" [{i}] con_id={} {}{}",
t.win.con_id,
t.win.label(),
" [{i}] {}{}",
t.target.tsv(),
if t.ready { "" } else { " (no thumbnail)" }
);
}
@@ -862,12 +974,26 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
);
}
if let Some(con_id) = app.activate {
if args.print {
println!("{con_id}");
} else {
sway::focus(&mut sway_conn, con_id)?;
}
// wlgrid is a chooser: it reports the pick and leaves acting on it to the
// caller (--focus is a convenience for a bare keybinding).
let Some(target) = app.activate else {
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
};
match args.format {
Format::Tsv => println!("{}", target.tsv()),
Format::Json => println!("{}", target.json()),
Format::Portal => match target.portal() {
Some(line) => println!("{line}"),
None => {
// The portal can only name a window by its foreign-toplevel
// identifier, and this one has none; silence means declined.
eprintln!("wlgrid: {:?} has no toplevel identifier", target.title);
return Ok(ExitCode::FAILURE);
}
},
}
if args.focus {
sway::focus(&mut sway_conn, &target)?;
}
Ok(ExitCode::SUCCESS)
}
@@ -981,7 +1107,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
if tile.frames == 0 {
eprintln!(
"wlgrid: capture failed for {:?} ({reason:?})",
tile.win.title
tile.target.title
);
tile.failed = true;
}
@@ -1018,6 +1144,25 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
}
}
/// wl_output tells us its name (v4), which is how a display tile is labelled
/// and how `focus output NAME` finds it again.
impl Dispatch<WlOutput, ()> for App {
fn event(
app: &mut Self,
output: &WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event
&& let Some(entry) = app.outputs.iter_mut().find(|(o, _)| o == output)
{
entry.1 = name;
}
}
}
impl Dispatch<WlSeat, ()> for App {
fn event(
_: &mut Self,
@@ -1072,6 +1217,7 @@ delegate_noop!(App: ZwlrLayerShellV1);
delegate_noop!(App: ExtImageCopyCaptureManagerV1);
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
delegate_noop!(App: ExtImageCaptureSourceV1);
delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
delegate_noop!(App: ignore WlSurface);
// The chrome's own buffers: two slots alternating on keypresses, so their