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
+32 -7
View File
@@ -3,7 +3,8 @@
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 focuses the window you pick.
It replaces a `wlthumbs | rofi` pipeline. The difference is that no thumbnails It replaces a `wlthumbs | rofi` pipeline, and doubles as a screencast source
picker for the desktop portal. The difference is that no thumbnails
exist: each window is captured straight into a `wl_shm` buffer that is handed to 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 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 to scale it into. There is no image encoding, no scaler, and no full-resolution
@@ -48,19 +49,43 @@ wlgrid [--print] [--verbose] [--hide-labels] [--font FAMILY] [--font-size PX]
- `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an - `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an
exclusive keyboard grab) exclusive keyboard grab)
Bind it in sway: 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.
```sh
# sway scripting: act on the con_id
swaymsg "[con_id=$(wlgrid | cut -f2)] focus"
# or let wlgrid do it, for a bare keybinding
bindsym $mod+Tab exec wlgrid --focus
``` ```
bindsym $mod+Tab exec wlgrid
Three formats, because the identifiers different consumers need differ:
| `--format` | output |
|---|---|
| `tsv` (default) | `TYPE⇥ID⇥TOPLEVEL_ID⇥APP⇥TITLE` — `ID` is the sway `con_id`, or the output name for a display; `TOPLEVEL_ID` is the ext-foreign-toplevel-list-v1 identifier that `grim -T` and the portal capture by |
| `json` | the same record with every key always present, for `jq` |
| `portal` | `Monitor: NAME` or `Window: TOPLEVEL_ID` |
`portal` is exactly what xdg-desktop-portal-wlr's `simple` chooser reads, so
wlgrid can be the picker for `getDisplayMedia` and friends — with live previews
of both windows and displays:
```ini
[screencast]
chooser_type=simple
chooser_cmd=wlgrid --format portal
``` ```
| key | | | key | |
|---|---| |---|---|
| `→` `←` / `Tab` `Shift+Tab` | next / previous window | | `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
| `↑` `↓` | move a row | | `↓` `↑` / `j` `k` | move a row |
| `Home` `End` | first / last | | `Home` `End` | first / last |
| `Enter` | focus the selection | | `Enter` | pick |
| `Escape` / `q` | cancel, leaving focus alone | | `Escape` / `q` | cancel |
Navigation reads raw evdev keycodes, so it is layout-independent — but it also 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) means virtual-keyboard clients such as `wtype` (which invent their own keymap)
+202 -56
View File
@@ -10,6 +10,7 @@
mod shm; mod shm;
mod sway; mod sway;
mod target;
mod text; mod text;
mod theme; mod theme;
@@ -24,7 +25,7 @@ use wayland_client::protocol::{
wl_callback, wl_callback,
wl_compositor::WlCompositor, wl_compositor::WlCompositor,
wl_keyboard::{self, WlKeyboard}, wl_keyboard::{self, WlKeyboard},
wl_output, wl_output::{self, WlOutput},
wl_registry::WlRegistry, wl_registry::WlRegistry,
wl_seat::{self, WlSeat}, wl_seat::{self, WlSeat},
wl_shm::{self, WlShm}, 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::{ use wayland_protocols::ext::image_capture_source::v1::client::{
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1, ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
ext_image_capture_source_v1::ExtImageCaptureSourceV1, ext_image_capture_source_v1::ExtImageCaptureSourceV1,
ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
}; };
use wayland_protocols::ext::image_copy_capture::v1::client::{ use wayland_protocols::ext::image_copy_capture::v1::client::{
ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1}, 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}, zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
}; };
use target::{Kind, Target};
use theme::{Layout, Rect, Theme, fit_centred}; use theme::{Layout, Rect, Theme, fit_centred};
// evdev keycodes: physical positions, so navigation works on any keyboard layout // 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_ESC: u32 = 1;
const KEY_TAB: u32 = 15; const KEY_TAB: u32 = 15;
const KEY_Q: u32 = 16; 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_ENTER: u32 = 28;
const KEY_LEFTSHIFT: u32 = 42; const KEY_LEFTSHIFT: u32 = 42;
const KEY_RIGHTSHIFT: u32 = 54; const KEY_RIGHTSHIFT: u32 = 54;
@@ -76,6 +84,17 @@ const KEY_END: u32 = 107;
const KEY_DOWN: u32 = 108; const KEY_DOWN: u32 = 108;
/// One window: its sway identity, its capture plumbing, and its subsurface. /// 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. /// Which tiles keep updating after the first frame.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum Live { enum Live {
@@ -96,7 +115,7 @@ struct Slot {
#[allow(dead_code)] // `handle` is held to keep the toplevel alive #[allow(dead_code)] // `handle` is held to keep the toplevel alive
struct Tile { struct Tile {
win: sway::Win, target: Target,
handle: Option<ExtForeignToplevelHandleV1>, handle: Option<ExtForeignToplevelHandleV1>,
session: Option<ExtImageCopyCaptureSessionV1>, session: Option<ExtImageCopyCaptureSessionV1>,
@@ -126,9 +145,9 @@ struct Tile {
} }
impl Tile { impl Tile {
fn new(win: sway::Win) -> Self { fn new(target: Target) -> Self {
Self { Self {
win, target,
handle: None, handle: None,
session: None, session: None,
frame: None, frame: None,
@@ -178,6 +197,9 @@ struct App {
/// Toplevel handles as the compositor announces them, paired with the /// Toplevel handles as the compositor announces them, paired with the
/// identifier that joins them to sway's tree. /// identifier that joins them to sway's tree.
toplevels: Vec<(ExtForeignToplevelHandleV1, String)>, 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>, tiles: Vec<Tile>,
theme: Theme, theme: Theme,
@@ -195,7 +217,7 @@ struct App {
configured: bool, configured: bool,
quit: bool, quit: bool,
activate: Option<i64>, activate: Option<Target>,
/// Frame-callback ticks, for diagnosing the live clock. /// Frame-callback ticks, for diagnosing the live clock.
ticks: u32, ticks: u32,
releases: u32, releases: u32,
@@ -208,16 +230,16 @@ impl App {
fn new( fn new(
globals: &GlobalList, globals: &GlobalList,
qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
wins: Vec<sway::Win>, targets: Vec<Target>,
theme: Theme, theme: Theme,
live: Live, live: Live,
fps: u32, fps: u32,
scale: i32, scale: i32,
) -> Result<Self, Box<dyn Error>> { ) -> 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 // Bind everything up front so a compositor missing a protocol fails
// here, with a name, rather than halfway through a capture. // here, with a name, rather than halfway through a capture.
let app = Self { let mut app = Self {
compositor: globals.bind(qh, 1..=6, ())?, compositor: globals.bind(qh, 1..=6, ())?,
subcompositor: globals.bind(qh, 1..=1, ())?, subcompositor: globals.bind(qh, 1..=1, ())?,
shm: globals.bind(qh, 1..=1, ())?, shm: globals.bind(qh, 1..=1, ())?,
@@ -226,7 +248,10 @@ impl App {
copy_mgr: globals.bind(qh, 1..=1, ())?, copy_mgr: globals.bind(qh, 1..=1, ())?,
src_mgr: globals.bind(qh, 1..=1, ())?, src_mgr: globals.bind(qh, 1..=1, ())?,
toplevels: Vec::new(), 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, theme,
layout, layout,
live, live,
@@ -247,6 +272,16 @@ impl App {
pool_bytes: 0, pool_bytes: 0,
}; };
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?; 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, ())?; let _: WlSeat = globals.bind(qh, 1..=7, ())?;
Ok(app) Ok(app)
} }
@@ -256,19 +291,33 @@ impl App {
/// constraints arrive together instead of costing a round trip each. /// constraints arrive together instead of costing a round trip each.
fn open_sessions(&mut self, qh: &QueueHandle<Self>) { fn open_sessions(&mut self, qh: &QueueHandle<Self>) {
for (i, tile) in self.tiles.iter_mut().enumerate() { for (i, tile) in self.tiles.iter_mut().enumerate() {
let Some(handle) = self // A window's source comes from its toplevel handle, a display's from
.toplevels // its wl_output; everything after that is identical.
.iter() let source: Option<ExtImageCaptureSourceV1> = match tile.target.kind {
.find(|(_, id)| !id.is_empty() && *id == tile.win.ft_id) Kind::Window => self
.map(|(h, _)| h.clone()) .toplevels
else { .iter()
// No identifier match: the tile stays label-only, and must not .find(|(_, id)| !id.is_empty() && *id == tile.target.ft_id)
// be waited on. .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; tile.settled = true;
continue; continue;
}; };
let source: ExtImageCaptureSourceV1 = self.src_mgr.create_source(&handle, qh, ());
tile.handle = Some(handle);
tile.session = Some(self.copy_mgr.create_session( tile.session = Some(self.copy_mgr.create_session(
&source, &source,
ext_image_copy_capture_manager_v1::Options::empty(), ext_image_copy_capture_manager_v1::Options::empty(),
@@ -289,7 +338,6 @@ impl App {
/// touching it again. /// touching it again.
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> { fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
const PAGE: usize = 4096; const PAGE: usize = 4096;
let slots = if self.live == Live::None { 1 } else { 2 };
let mut total = 0usize; let mut total = 0usize;
let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len()); let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len());
for tile in &mut self.tiles { for tile in &mut self.tiles {
@@ -314,6 +362,13 @@ impl App {
tile.settled = true; tile.settled = true;
continue; 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"); let last = offsets.last_mut().expect("just pushed");
for _ in 0..slots { for _ in 0..slots {
last.push(total); last.push(total);
@@ -428,6 +483,11 @@ impl App {
if self.live == Live::Current && i != self.sel { if self.live == Live::Current && i != self.sel {
continue; 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]; let t = &self.tiles[i];
if t.slots.is_empty() || t.frame.is_some() { if t.slots.is_empty() || t.frame.is_some() {
continue; continue;
@@ -587,14 +647,14 @@ impl App {
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true, KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
KEY_ESC | KEY_Q => self.quit = true, KEY_ESC | KEY_Q => self.quit = true,
KEY_ENTER | KEY_KPENTER => { 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; self.quit = true;
} }
KEY_TAB if self.shift => self.move_sel(-1), KEY_TAB if self.shift => self.move_sel(-1),
KEY_TAB | KEY_RIGHT => self.move_sel(1), KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1),
KEY_LEFT => self.move_sel(-1), KEY_LEFT | KEY_H => self.move_sel(-1),
KEY_DOWN => self.move_row(1), KEY_DOWN | KEY_J => self.move_row(1),
KEY_UP => self.move_row(-1), KEY_UP | KEY_K => self.move_row(-1),
KEY_HOME => { KEY_HOME => {
self.sel = 0; self.sel = 0;
self.paint(); self.paint();
@@ -656,8 +716,54 @@ fn pump(
Ok(()) 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 { struct Args {
print: bool, format: Format,
focus: bool,
outputs: bool,
verbose: bool, verbose: bool,
hide_labels: bool, hide_labels: bool,
font: Option<String>, font: Option<String>,
@@ -669,7 +775,9 @@ struct Args {
fn parse_args() -> Result<Args, String> { fn parse_args() -> Result<Args, String> {
let mut args = Args { let mut args = Args {
print: false, format: Format::Tsv,
focus: false,
outputs: true,
verbose: false, verbose: false,
hide_labels: false, hide_labels: false,
font: None, font: None,
@@ -681,7 +789,17 @@ 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() {
"--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, "-v" | "--verbose" => args.verbose = true,
"--hide-labels" => args.hide_labels = true, "--hide-labels" => args.hide_labels = true,
"--live" => { "--live" => {
@@ -707,11 +825,7 @@ fn parse_args() -> Result<Args, String> {
args.timeout = Some(Duration::from_secs_f64(secs)); args.timeout = Some(Duration::from_secs_f64(secs));
} }
"-h" | "--help" => { "-h" | "--help" => {
println!( print!("{HELP}");
"usage: wlgrid [--print] [--verbose] [--hide-labels] \
[--font FAMILY] [--font-size PX] \
[--live all|current|none] [--fps N] [--timeout SECS]"
);
std::process::exit(0); std::process::exit(0);
} }
other => return Err(format!("unknown argument: {other}")), other => return Err(format!("unknown argument: {other}")),
@@ -745,18 +859,17 @@ 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()?; let mut sway_conn = swayipc::Connection::new()?;
let wins = sway::windows(&mut sway_conn)?; let mut targets = sway::windows(&mut sway_conn)?;
if wins.is_empty() { 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); 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"); 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 // rasterising, and the captures below are ~55ms of waiting on the
// compositor, so the two overlap almost exactly. // compositor, so the two overlap almost exactly.
let label_job = theme.labels.then(|| { 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); let box_w = layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w);
text::spawn( text::spawn(
wins.iter().map(sway::Win::label).collect(), targets.iter().map(Target::label).collect(),
theme.font.clone(), theme.font.clone(),
theme.font_px * scale as f32, theme.font_px * scale as f32,
(theme.line_h * 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 conn = Connection::connect_to_env()?;
let (globals, mut queue) = registry_queue_init::<App>(&conn)?; let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
let qh = queue.handle(); 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. // Two roundtrips: one for the toplevel list, one for each handle's state.
queue.roundtrip(&mut app)?; 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(); let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
for (i, t) in app.tiles.iter().enumerate() { for (i, t) in app.tiles.iter().enumerate() {
eprintln!( eprintln!(
" [{i}] con_id={} {}{}", " [{i}] {}{}",
t.win.con_id, t.target.tsv(),
t.win.label(),
if t.ready { "" } else { " (no thumbnail)" } if t.ready { "" } else { " (no thumbnail)" }
); );
} }
@@ -862,12 +974,26 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
); );
} }
if let Some(con_id) = app.activate { // wlgrid is a chooser: it reports the pick and leaves acting on it to the
if args.print { // caller (--focus is a convenience for a bare keybinding).
println!("{con_id}"); let Some(target) = app.activate else {
} else { return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
sway::focus(&mut sway_conn, con_id)?; };
} 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) Ok(ExitCode::SUCCESS)
} }
@@ -981,7 +1107,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
if tile.frames == 0 { if tile.frames == 0 {
eprintln!( eprintln!(
"wlgrid: capture failed for {:?} ({reason:?})", "wlgrid: capture failed for {:?} ({reason:?})",
tile.win.title tile.target.title
); );
tile.failed = true; 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 { impl Dispatch<WlSeat, ()> for App {
fn event( fn event(
_: &mut Self, _: &mut Self,
@@ -1072,6 +1217,7 @@ delegate_noop!(App: ZwlrLayerShellV1);
delegate_noop!(App: ExtImageCopyCaptureManagerV1); delegate_noop!(App: ExtImageCopyCaptureManagerV1);
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1); delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
delegate_noop!(App: ExtImageCaptureSourceV1); delegate_noop!(App: ExtImageCaptureSourceV1);
delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
delegate_noop!(App: ignore WlSurface); delegate_noop!(App: ignore WlSurface);
// The chrome's own buffers: two slots alternating on keypresses, so their // The chrome's own buffers: two slots alternating on keypresses, so their
+30 -34
View File
@@ -5,38 +5,17 @@
use swayipc::{Connection, Node, NodeType}; use swayipc::{Connection, Node, NodeType};
#[derive(Clone, Debug)] use crate::target::Target;
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 { /// Every view in the tree, in tree order (the same traversal the jq filter did,
/// "title · app", the label rofigrid was given (app dropped when empty). /// so the grid keeps the ordering the muscle memory expects).
/// Used once tiles are labelled. pub fn windows(conn: &mut Connection) -> Result<Vec<Target>, swayipc::Error> {
#[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(); let mut out = Vec::new();
collect(&conn.get_tree()?, &mut out); collect(&conn.get_tree()?, &mut out);
Ok(out) Ok(out)
} }
fn collect(node: &Node, out: &mut Vec<Win>) { fn collect(node: &Node, out: &mut Vec<Target>) {
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon); let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
let class = node let class = node
.window_properties .window_properties
@@ -45,20 +24,37 @@ fn collect(node: &Node, out: &mut Vec<Win>) {
if is_con && (node.app_id.is_some() || class.is_some()) { 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 // A view with no identifier can't be captured, but it still belongs in
// the list: it gets a tile with no thumbnail. // the list: it gets a tile with no thumbnail.
out.push(Win { out.push(Target::window(
con_id: node.id, node.id,
app: node.app_id.clone().or(class).unwrap_or_default(), node.foreign_toplevel_identifier.clone().unwrap_or_default(),
title: node.name.clone().unwrap_or_default(), node.app_id.clone().or(class).unwrap_or_default(),
ft_id: node.foreign_toplevel_identifier.clone().unwrap_or_default(), node.name.clone().unwrap_or_default(),
}); ));
} }
for child in node.nodes.iter().chain(node.floating_nodes.iter()) { for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
collect(child, out); collect(child, out);
} }
} }
pub fn focus(conn: &mut Connection, con_id: i64) -> Result<(), swayipc::Error> { /// The largest integer scale in use, which is what the overlay renders at.
for res in conn.run_command(format!("[con_id={con_id}] focus"))? { pub fn scale(conn: &mut Connection) -> Result<i32, swayipc::Error> {
Ok(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))
}
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?; res?;
} }
Ok(()) Ok(())
+209
View File
@@ -0,0 +1,209 @@
//! What a tile stands for: a window, or a whole display.
//!
//! Both are capture sources as far as the protocol is concerned — one from a
//! foreign-toplevel handle, one from a `wl_output` — so the grid treats them
//! alike and only differs in how it labels them and what picking one does.
use std::fmt;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Window,
Output,
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Window => "window",
Kind::Output => "output",
})
}
}
#[derive(Clone)]
pub struct Target {
pub kind: Kind,
/// The thing a caller acts on: a sway `con_id` for a window, an output name
/// for a display.
pub id: String,
/// sway container id, when there is one.
pub con_id: Option<i64>,
/// ext-foreign-toplevel-list identifier, for matching a capture source.
pub ft_id: String,
pub app: String,
pub title: String,
}
impl Target {
pub fn window(con_id: i64, ft_id: String, app: String, title: String) -> Self {
Self {
kind: Kind::Window,
id: con_id.to_string(),
con_id: Some(con_id),
ft_id,
app,
title,
}
}
/// A display. `app` is "display" so the label says what kind of tile it is
/// without needing a second visual language for it.
pub fn output(name: String) -> Self {
Self {
kind: Kind::Output,
id: name.clone(),
con_id: None,
ft_id: String::new(),
app: "display".to_string(),
title: name,
}
}
/// "title · app", the label the rofi grid used.
pub fn label(&self) -> String {
if self.app.is_empty() {
self.title.clone()
} else {
format!("{} · {}", self.title, self.app)
}
}
/// One tab-separated row, so a shell caller can
/// `IFS=$'\t' read -r type id toplevel app title`.
///
/// Both identifiers are there because both get used: sway scripting acts on
/// the con_id (`[con_id=N] focus`), while tools that capture a window —
/// grim -T, the desktop portal — want the foreign-toplevel identifier.
pub fn tsv(&self) -> String {
format!(
"{}\t{}\t{}\t{}\t{}",
self.kind,
self.id,
self.ft_id,
clean(&self.app),
clean(&self.title)
)
}
/// The same record as JSON, with every key always present so `jq` can rely
/// on it. Written by hand: one object is not worth a serialiser.
pub fn json(&self) -> String {
let opt = |v: Option<String>| match v {
Some(s) => format!("\"{}\"", esc(&s)),
None => "null".to_string(),
};
let (con_id, output) = match self.kind {
Kind::Window => (
self.con_id.map(|n| n.to_string()).unwrap_or("null".into()),
"null".to_string(),
),
Kind::Output => ("null".to_string(), opt(Some(self.id.clone()))),
};
let toplevel = match self.ft_id.is_empty() {
true => "null".to_string(),
false => format!("\"{}\"", esc(&self.ft_id)),
};
format!(
"{{\"type\":\"{}\",\"con_id\":{con_id},\"toplevel_id\":{toplevel},\
\"output\":{output},\"app\":\"{}\",\"title\":\"{}\"}}",
self.kind,
esc(&self.app),
esc(&self.title)
)
}
/// What xdg-desktop-portal-wlr's `simple` chooser accepts: `Monitor: NAME`
/// or `Window: <foreign-toplevel identifier>`. A window the compositor never
/// gave an identifier for cannot be named this way, hence the Option — and
/// an empty stdout is exactly how that chooser says "declined".
pub fn portal(&self) -> Option<String> {
match self.kind {
Kind::Output => Some(format!("Monitor: {}", self.id)),
Kind::Window if !self.ft_id.is_empty() => Some(format!("Window: {}", self.ft_id)),
Kind::Window => None,
}
}
}
/// Titles are arbitrary application strings; a tab or newline in one would split
/// the row a caller is parsing.
fn clean(s: &str) -> String {
s.chars()
.map(|c| {
if (c as u32) < 0x20 || c == '\u{7f}' {
' '
} else {
c
}
})
.collect()
}
/// JSON string escaping, per RFC 8259.
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn win() -> Target {
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into())
}
#[test]
fn tsv_is_one_line_with_both_identifiers() {
let row = win().tsv();
assert_eq!(row, "window\t42\tabc123\tkitty\tzsh in a tab");
assert_eq!(row.split('\t').count(), 5);
assert!(!row.contains('\n'));
}
#[test]
fn json_keeps_every_key_and_escapes() {
let j = win().json();
assert!(j.contains("\"type\":\"window\""), "{j}");
assert!(j.contains("\"con_id\":42"), "{j}");
assert!(j.contains("\"toplevel_id\":\"abc123\""), "{j}");
assert!(j.contains("\"output\":null"), "{j}");
// Control characters survive as escapes, not raw bytes.
assert!(j.contains("zsh\\tin\\na tab"), "{j}");
let o = Target::output("DP-1".into()).json();
assert!(o.contains("\"con_id\":null"), "{o}");
assert!(o.contains("\"output\":\"DP-1\""), "{o}");
}
#[test]
fn portal_speaks_xdpw() {
assert_eq!(win().portal().as_deref(), Some("Window: abc123"));
assert_eq!(
Target::output("DP-1".into()).portal().as_deref(),
Some("Monitor: DP-1")
);
// No identifier means the portal cannot be told about this window.
let anon = Target::window(7, String::new(), "x".into(), "y".into());
assert_eq!(anon.portal(), None);
}
#[test]
fn displays_say_what_they_are() {
let t = Target::output("DP-1".into());
assert_eq!(t.label(), "DP-1 · display");
assert_eq!(t.con_id, None);
}
}