Files
wl-tab/src/main.rs
T
Milad Alizadeh 5b98615c41 Rename to wl-pick
wlgrid described the shape of the thing rather than what it is for, and
the shape is the least interesting part now that it doesn't focus
anything: it shows you what is running and reports which one you pointed
at. wl-pick says that.

The wl- prefix rather than wlr- is deliberate. The capture path is
entirely upstream ext- protocols (ext-image-copy-capture,
ext-image-capture-source, ext-foreign-toplevel-list); the only
wlroots-flavoured piece is layer-shell, which KDE, niri and labwc
implement too. What actually keeps this from running on other
compositors is the sway IPC dependency, not wlroots, so wlr- would
promise a portability that isn't there. Hyphenated because the ecosystem
hyphenates when the suffix is a real word — wl-clipboard, wl-mirror,
wl-screenrec — and reserves the smushed form for coinages like wlsunset.

Also renames the layer-shell namespace and the memfd labels, which show
up in compositor debugging.
2026-08-23 18:35:27 +01:00

1291 lines
44 KiB
Rust

//! wl-pick 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
//! compositor which rectangle to scale it into — so there is no thumbnail
//! encoding, no scaler, and no full-resolution image in our address space.
mod shm;
mod sway;
mod target;
mod text;
mod theme;
use std::error::Error;
use std::os::fd::AsFd;
use std::process::ExitCode;
use std::time::{Duration, Instant};
use wayland_client::globals::{GlobalList, GlobalListContents, registry_queue_init};
use wayland_client::protocol::{
wl_buffer::{self, WlBuffer},
wl_callback,
wl_compositor::WlCompositor,
wl_keyboard::{self, WlKeyboard},
wl_output::{self, WlOutput},
wl_registry::WlRegistry,
wl_seat::{self, WlSeat},
wl_shm::{self, WlShm},
wl_shm_pool::WlShmPool,
wl_subcompositor::WlSubcompositor,
wl_subsurface::WlSubsurface,
wl_surface::WlSurface,
};
use wayland_client::{
Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum, delegate_noop, event_created_child,
};
use wayland_protocols::ext::foreign_toplevel_list::v1::client::{
ext_foreign_toplevel_handle_v1::{self, ExtForeignToplevelHandleV1},
ext_foreign_toplevel_list_v1::{self, ExtForeignToplevelListV1},
};
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},
ext_image_copy_capture_manager_v1::{self, ExtImageCopyCaptureManagerV1},
ext_image_copy_capture_session_v1::{self, ExtImageCopyCaptureSessionV1},
};
use wayland_protocols::wp::viewporter::client::{
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
};
use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_shell_v1::{Layer, ZwlrLayerShellV1},
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
// without an xkb keymap. Typing (and therefore xkb) arrives with filtering.
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;
const KEY_KPENTER: u32 = 96;
const KEY_HOME: u32 = 102;
const KEY_UP: u32 = 103;
const KEY_LEFT: u32 = 105;
const KEY_RIGHT: u32 = 106;
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 {
/// Every tile.
All,
/// Only the selected tile: much cheaper, and still reads as alive.
Current,
/// Nothing: one snapshot each, a picker rather than an expose.
None,
}
/// One capture buffer. `busy` means the compositor still holds it — either it is
/// on screen or a capture is writing into it — so we must not scribble over it.
struct Slot {
buffer: WlBuffer,
busy: bool,
}
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
struct Tile {
target: Target,
handle: Option<ExtForeignToplevelHandleV1>,
session: Option<ExtImageCopyCaptureSessionV1>,
/// A capture in flight, and which slot it is filling.
frame: Option<ExtImageCopyCaptureFrameV1>,
filling: Option<usize>,
slots: Vec<Slot>,
/// The slot currently attached to the subsurface.
showing: Option<usize>,
formats: Vec<wl_shm::Format>,
format: Option<wl_shm::Format>,
/// Buffer size the session requires: the window's full resolution.
size: (u32, u32),
transform: wl_output::Transform,
session_done: bool,
ready: bool,
failed: bool,
settled: bool,
/// When the last capture was asked for, for rate limiting, and how many
/// frames this tile has produced.
asked: Option<Instant>,
frames: u32,
surface: Option<WlSurface>,
subsurface: Option<WlSubsurface>,
viewport: Option<WpViewport>,
}
impl Tile {
fn new(target: Target) -> Self {
Self {
target,
handle: None,
session: None,
frame: None,
filling: None,
slots: Vec::new(),
showing: None,
formats: Vec::new(),
format: None,
size: (0, 0),
transform: wl_output::Transform::Normal,
session_done: false,
ready: false,
failed: false,
settled: false,
asked: None,
frames: 0,
surface: None,
subsurface: None,
viewport: None,
}
}
fn bytes(&self) -> usize {
self.size.0 as usize * 4 * self.size.1 as usize
}
/// Whether the buffer's contents are turned on their side relative to the
/// window, which flips the aspect ratio we have to fit.
fn rotated(&self) -> bool {
use wl_output::Transform;
matches!(
self.transform,
Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270
)
}
}
struct App {
compositor: WlCompositor,
subcompositor: WlSubcompositor,
shm: WlShm,
viewporter: WpViewporter,
layer_shell: ZwlrLayerShellV1,
copy_mgr: ExtImageCopyCaptureManagerV1,
src_mgr: ExtForeignToplevelImageCaptureSourceManagerV1,
/// 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,
layout: Layout,
live: Live,
fps: u32,
scale: i32,
sel: usize,
shift: bool,
labels: Option<text::Labels>,
surface: Option<WlSurface>,
chrome: Option<shm::Chrome>,
chrome_buffers: Vec<WlBuffer>,
configured: bool,
quit: bool,
/// Why the overlay closed, for --verbose.
quit_why: &'static str,
activate: Option<Target>,
/// Frame-callback ticks, for diagnosing the live clock.
ticks: u32,
releases: u32,
blocked_nofree: u32,
/// Bytes of shm handed to the compositor for capture buffers.
pool_bytes: usize,
}
impl App {
fn new(
globals: &GlobalList,
qh: &QueueHandle<Self>,
targets: Vec<Target>,
theme: Theme,
live: Live,
fps: u32,
scale: i32,
) -> Result<Self, Box<dyn Error>> {
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 mut app = Self {
compositor: globals.bind(qh, 1..=6, ())?,
subcompositor: globals.bind(qh, 1..=1, ())?,
shm: globals.bind(qh, 1..=1, ())?,
viewporter: globals.bind(qh, 1..=1, ())?,
layer_shell: globals.bind(qh, 1..=5, ())?,
copy_mgr: globals.bind(qh, 1..=1, ())?,
src_mgr: globals.bind(qh, 1..=1, ())?,
toplevels: Vec::new(),
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,
fps,
scale,
sel: 0,
shift: false,
labels: None,
surface: None,
chrome: None,
chrome_buffers: Vec::new(),
configured: false,
quit: false,
quit_why: "",
activate: None,
ticks: 0,
releases: 0,
blocked_nofree: 0,
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)
}
/// Open one capture session per window whose toplevel we recognise. They are
/// all opened before a single roundtrip, so every session's buffer
/// 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() {
// 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;
};
tile.session = Some(self.copy_mgr.create_session(
&source,
ext_image_copy_capture_manager_v1::Options::empty(),
qh,
i,
));
source.destroy();
}
}
/// Allocate the capture buffers in one pool and put every first frame in
/// flight at once: the compositor is bandwidth-bound reading pixels back, so
/// serialising the captures only adds latency.
///
/// Live mode gets two buffers per window. A capture may not write into the
/// buffer the compositor is currently displaying, so the two alternate:
/// fill B while A is on screen, swap, and wait for A's release before
/// touching it again.
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
const PAGE: usize = 4096;
let mut total = 0usize;
let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len());
for tile in &mut self.tiles {
offsets.push(Vec::new());
if tile.session.is_none() {
continue;
}
if !tile.session_done || tile.size.0 == 0 || tile.size.1 == 0 {
tile.settled = true;
continue;
}
// Any 32-bit format will do: we never read these pixels, we hand the
// buffer straight back for display, so byte order stays the
// compositor's business on both ends.
tile.format = tile
.formats
.iter()
.copied()
.find(|f| matches!(f, wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888))
.or_else(|| tile.formats.first().copied());
if tile.format.is_none() {
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);
total += tile.bytes().div_ceil(PAGE) * PAGE;
}
}
if total == 0 {
return Ok(());
}
self.pool_bytes = total;
// Note: no mmap. The compositor writes these pages and samples them
// again for display; mapping them here would only cost us the faults.
let file = shm::memfd("wl-pick-capture", total)?;
let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ());
for (i, slot_offsets) in offsets.iter().enumerate() {
let (w, h, format) = {
let t = &self.tiles[i];
if t.settled || t.session.is_none() || t.format.is_none() {
continue;
}
(t.size.0 as i32, t.size.1 as i32, t.format.unwrap())
};
for &offset in slot_offsets {
let slot = self.tiles[i].slots.len();
let buffer = pool.create_buffer(offset as i32, w, h, w * 4, format, qh, (i, slot));
self.tiles[i].slots.push(Slot {
buffer,
busy: false,
});
}
self.request_capture(i, qh);
}
pool.destroy(); // the buffers keep the mapping alive
Ok(())
}
/// Ask the compositor for one frame of window `i`, into a free slot.
///
/// After a session's first frame the compositor only answers once the window
/// content changes, so a request left outstanding on an idle window costs
/// nothing: this is damage-driven, and the rate limit only bites on windows
/// that really are animating.
fn request_capture(&mut self, i: usize, qh: &QueueHandle<Self>) -> bool {
let t = &mut self.tiles[i];
if t.frame.is_some() || t.session.is_none() {
return false; // already waiting on one
}
let Some(slot) = t.slots.iter().position(|s| !s.busy) else {
self.blocked_nofree += 1;
return false; // both buffers still held by the compositor
};
let (w, h) = (t.size.0 as i32, t.size.1 as i32);
let frame = t
.session
.as_ref()
.expect("checked above")
.create_frame(qh, i);
frame.attach_buffer(&t.slots[slot].buffer);
frame.damage_buffer(0, 0, w, h);
frame.capture();
t.frame = Some(frame);
t.filling = Some(slot);
t.asked = Some(Instant::now());
true
}
/// A capture landed: show it, and let go of the slot it replaced.
fn frame_ready(&mut self, i: usize) {
let t = &mut self.tiles[i];
let Some(slot) = t.filling.take() else { return };
t.frames += 1;
t.ready = true;
t.settled = true;
t.slots[slot].busy = true; // the compositor reads it until it releases it
let previous = t.showing.replace(slot);
// Before the overlay is mapped there is nothing to attach to yet;
// place_tiles picks up `showing` instead.
if let Some(surface) = t.surface.clone() {
let (w, h) = (t.size.0 as i32, t.size.1 as i32);
surface.attach(Some(&t.slots[slot].buffer), 0, 0);
surface.damage_buffer(0, 0, w, h);
surface.commit();
} else if let Some(prev) = previous {
// Not on screen yet, so the old slot was never actually read.
t.slots[prev].busy = false;
}
}
/// Ask for the next frame callback. A commit is needed for the compositor to
/// schedule one, and an empty commit is enough.
fn arm_frame_callback(&mut self, qh: &QueueHandle<Self>) {
if self.live == Live::None {
return;
}
if let Some(surface) = self.surface.clone() {
surface.frame(qh, ());
surface.commit();
}
}
/// Re-capture whatever is due. Driven by frame callbacks, so it stops when
/// the overlay is not being presented.
fn tick(&mut self, qh: &QueueHandle<Self>) {
self.ticks += 1;
if self.live == Live::None {
return;
}
let interval = Duration::from_secs_f64(1.0 / self.fps.max(1) as f64);
let now = Instant::now();
for i in 0..self.tiles.len() {
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;
}
if t.asked.is_some_and(|a| now.duration_since(a) < interval) {
continue;
}
self.request_capture(i, qh);
}
}
fn captures_settled(&self) -> bool {
self.tiles.iter().all(|t| t.settled)
}
/// Map the overlay: a layer surface sized to hug the grid, plus the shm the
/// chrome is painted into.
fn show(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
let (lw, lh) = (self.layout.width, self.layout.height);
let surface = self.compositor.create_surface(qh, ());
let layer = self.layer_shell.get_layer_surface(
&surface,
None, // let the compositor place it on the active output
Layer::Overlay,
"wl-pick".to_string(),
qh,
(),
);
layer.set_size(lw as u32, lh as u32);
layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
surface.set_buffer_scale(self.scale);
surface.commit();
let (pw, ph) = (lw * self.scale, lh * self.scale);
let len = shm::Chrome::slot_len(pw, ph) * shm::Chrome::SLOTS;
let file = shm::memfd("wl-pick-chrome", len)?;
let pool = self.shm.create_pool(file.as_fd(), len as i32, qh, ());
for slot in 0..shm::Chrome::SLOTS {
self.chrome_buffers.push(pool.create_buffer(
(slot * shm::Chrome::slot_len(pw, ph)) as i32,
pw,
ph,
shm::Chrome::stride(pw),
wl_shm::Format::Argb8888,
qh,
(),
));
}
pool.destroy();
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
self.surface = Some(surface);
Ok(())
}
/// Attach each captured buffer to its own subsurface and let the compositor
/// scale it into the tile rectangle.
fn place_tiles(&mut self, qh: &QueueHandle<Self>) {
let parent = self.surface.clone().expect("show() runs first");
for i in 0..self.tiles.len() {
if !self.tiles[i].ready {
continue;
}
let (bw, bh) = self.tiles[i].size;
let (fit_w, fit_h) = if self.tiles[i].rotated() {
(bh as i32, bw as i32)
} else {
(bw as i32, bh as i32)
};
let dst = fit_centred(fit_w, fit_h, self.layout.tile(i as i32));
let surface = self.compositor.create_surface(qh, ());
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
let viewport = self.viewporter.get_viewport(&surface, qh, ());
subsurface.set_position(dst.x, dst.y);
// Tiles change independently of the chrome (selection moves now,
// live frames later), so they must not wait on a parent commit.
subsurface.set_desync();
// The capture protocol reports the transform the compositor already
// applied to the buffer, which is exactly what this request means,
// so it passes straight through and the compositor un-rotates it.
surface.set_buffer_transform(self.tiles[i].transform);
viewport.set_destination(dst.w, dst.h);
let slot = self.tiles[i].showing.expect("a ready tile has a slot");
surface.attach(Some(&self.tiles[i].slots[slot].buffer), 0, 0);
surface.damage_buffer(0, 0, bw as i32, bh as i32);
surface.commit();
let t = &mut self.tiles[i];
t.surface = Some(surface);
t.subsurface = Some(subsurface);
t.viewport = Some(viewport);
}
// Subsurface placement is *parent* state: it only takes effect when the
// parent commits, desynced children included.
parent.commit();
}
/// Repaint background, selection highlight, labels and border.
fn paint(&mut self) {
let (scale, sel) = (self.scale, self.sel);
let elem = scaled(self.layout.elem(sel as i32), scale);
// Gather geometry before borrowing the chrome and the labels together.
let label_boxes: Vec<(usize, Rect)> = (0..self.tiles.len())
.filter_map(|i| self.layout.label(i as i32).map(|r| (i, scaled(r, scale))))
.collect();
let t = &self.theme;
let (bg, sel_bg, fg, sel_fg, border, border_px) = (
t.bg,
t.sel_bg,
t.fg,
t.sel_fg,
t.border,
t.border_px * scale,
);
let labels = self.labels.as_mut();
let Some(chrome) = self.chrome.as_mut() else {
return;
};
let slot = chrome.next_slot();
let (cw, ch) = (chrome.w, chrome.h);
let mut p = chrome.painter();
p.fill(bg);
// The selection fills the whole element box, padding included — the same
// thing rofi's element background does.
p.rect(elem, sel_bg);
if let Some(labels) = labels {
for (i, at) in label_boxes {
labels.draw(&mut p, i, at, if i == sel { sel_fg } else { fg });
}
}
p.frame(border_px, border);
let surface = self.surface.clone().expect("show() runs first");
surface.attach(self.chrome_buffers.get(slot), 0, 0);
surface.damage_buffer(0, 0, cw, ch);
surface.commit();
}
fn move_sel(&mut self, delta: i32) {
let n = self.tiles.len() as i32;
if n == 0 {
return;
}
self.sel = (self.sel as i32 + delta).rem_euclid(n) as usize;
self.paint();
}
fn move_row(&mut self, rows: i32) {
let n = self.tiles.len() as i32;
let target = self.sel as i32 + rows * self.layout.cols;
if target >= 0 && target < n {
self.sel = target as usize;
self.paint();
}
}
fn key(&mut self, code: u32) {
match code {
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
KEY_ESC | KEY_Q => {
self.quit_why = "cancelled";
self.quit = true;
}
KEY_ENTER | KEY_KPENTER => {
self.activate = self.tiles.get(self.sel).map(|t| t.target.clone());
self.quit_why = "picked";
self.quit = true;
}
KEY_TAB if self.shift => self.move_sel(-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();
}
KEY_END => {
self.sel = self.tiles.len().saturating_sub(1);
self.paint();
}
_ => {}
}
}
}
/// Phase timings, printed with --verbose. Opening latency is the whole point of
/// this tool, so it stays measurable.
struct Phases {
on: bool,
last: Instant,
}
impl Phases {
fn new(on: bool) -> Self {
Self {
on,
last: Instant::now(),
}
}
fn mark(&mut self, label: &str) {
if self.on {
let now = Instant::now();
eprintln!(
"{label:<12} {:6.1}ms",
(now - self.last).as_secs_f64() * 1000.0
);
self.last = now;
}
}
}
/// Logical rect -> physical rect, for painting into the scaled chrome buffer.
fn scaled(r: Rect, scale: i32) -> Rect {
Rect {
x: r.x * scale,
y: r.y * scale,
w: r.w * scale,
h: r.h * scale,
}
}
fn pump(
queue: &mut EventQueue<App>,
app: &mut App,
done: impl Fn(&App) -> bool,
) -> Result<(), Box<dyn Error>> {
while !done(app) {
queue.blocking_dispatch(app)?;
}
Ok(())
}
const HELP: &str = "\
wl-pick — a live grid of window and display previews, for picking one
usage: wl-pick [options]
--format tsv|json|portal how to report the pick [tsv]
--live all|current|none which tiles keep updating live [all]
(displays are always a single snapshot)
--fps N cap on live updates per tile per second [12]
--no-outputs windows only; displays are included by default
--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 anyway after SECS, in case the keyboard
grab ever traps you [off]
-v, --verbose phase timings, 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 and nothing does if you cancel, so exit status is
0 for a pick and 1 for a cancel. Acting on it is the caller's job.
formats:
tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE, e.g.
window 1234 f0e1d2c3b4a59687 firefox Wikipedia
output HDMI-A-1 display HDMI-A-1
ID is the thing to act on: a sway con_id, or the display name.
TOPLEVEL_ID is the ext-foreign-toplevel-list-v1 identifier that
capture tools address a window by (grim -T, the desktop portal),
empty for a display.
json the same fields as one object, every key always present, for jq
portal \"Window: TOPLEVEL_ID\" or \"Monitor: NAME\", what
xdg-desktop-portal-wlr's simple chooser reads:
[screencast]
chooser_type=simple
chooser_cmd=wl-pick --format portal
focusing on sway:
IFS=$'\\t' read -r type id toplevel app title < <(wl-pick) &&
case $type in
window) swaymsg \"[con_id=$id] focus\" ;;
output) swaymsg \"focus output $id\" ;;
esac
";
struct Args {
format: Format,
outputs: bool,
verbose: bool,
hide_labels: bool,
font: Option<String>,
font_size: Option<f32>,
live: Live,
fps: u32,
timeout: Option<Duration>,
}
fn parse_args() -> Result<Args, String> {
let mut args = Args {
format: Format::Tsv,
outputs: true,
verbose: false,
hide_labels: false,
font: None,
font_size: None,
live: Live::All,
fps: 12,
timeout: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--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" => {
args.live = match it.next().ok_or("--live needs all|current|none")?.as_str() {
"all" => Live::All,
"current" => Live::Current,
"none" => Live::None,
other => return Err(format!("bad --live: {other}")),
}
}
"--fps" => {
let v = it.next().ok_or("--fps needs a number")?;
args.fps = v.parse().map_err(|_| format!("bad --fps: {v}"))?;
}
"--font" => args.font = Some(it.next().ok_or("--font needs a family name")?),
"--font-size" => {
let v = it.next().ok_or("--font-size needs px")?;
args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?);
}
"--timeout" => {
let v = it.next().ok_or("--timeout needs seconds")?;
let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?;
args.timeout = Some(Duration::from_secs_f64(secs));
}
"-h" | "--help" => {
print!("{HELP}");
std::process::exit(0);
}
other => return Err(format!("unknown argument: {other}")),
}
}
Ok(args)
}
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("wl-pick: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<ExitCode, Box<dyn Error>> {
let args = parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?;
// An exclusive keyboard grab makes a hung overlay unusable, so keep an
// escape hatch that cannot itself deadlock.
if let Some(d) = args.timeout {
std::thread::spawn(move || {
std::thread::sleep(d);
eprintln!("wl-pick: timeout");
std::process::exit(2);
});
}
let start = Instant::now();
let mut phases = Phases::new(args.verbose);
// 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);
}
phases.mark("sway-tree");
let base = Theme::default();
let font_px = args.font_size.unwrap_or(base.font_px);
let theme = Theme {
labels: !args.hide_labels,
font: args.font.unwrap_or_else(|| base.font.clone()),
line_h: match args.font_size {
Some(_) => (font_px * 1.3).ceil() as i32,
None => base.line_h,
},
font_px,
..base
};
// Start shaping labels now: it costs ~55ms of font loading and glyph
// 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, targets.len() as i32);
let box_w = layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w);
text::spawn(
targets.iter().map(Target::label).collect(),
theme.font.clone(),
theme.font_px * scale as f32,
(theme.line_h * scale) as f32,
(box_w * scale) as f32,
)
});
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, targets, theme, args.live, args.fps, scale)?;
// Two roundtrips: one for the toplevel list, one for each handle's state.
queue.roundtrip(&mut app)?;
queue.roundtrip(&mut app)?;
phases.mark("toplevels");
app.open_sessions(&qh);
queue.roundtrip(&mut app)?; // every session's constraints at once
phases.mark("constraints");
app.start_captures(&qh)?;
pump(&mut queue, &mut app, |a| a.captures_settled())?;
phases.mark("capture");
if let Some(job) = label_job {
app.labels = job.join().map_err(|_| "label thread panicked")?.into();
}
phases.mark("labels");
if args.verbose {
let ready = app.tiles.iter().filter(|t| t.ready).count();
let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
for (i, t) in app.tiles.iter().enumerate() {
eprintln!(
" [{i}] {}{}",
t.target.tsv(),
if t.ready { "" } else { " (no thumbnail)" }
);
}
eprintln!(
"wl-pick: {} window(s), {matched} matched, {ready} captured; \
grid {}x{}, surface {}x{} logical at scale {}, {} MB of capture buffers",
app.tiles.len(),
app.layout.cols,
app.layout.rows,
app.layout.width,
app.layout.height,
app.scale,
app.pool_bytes >> 20,
);
}
app.show(&qh)?;
pump(&mut queue, &mut app, |a| a.configured)?;
app.paint();
app.place_tiles(&qh);
app.arm_frame_callback(&qh);
conn.flush()?;
phases.mark("mapped");
pump(&mut queue, &mut app, |a| a.quit)?;
if args.verbose {
let frames: u32 = app.tiles.iter().map(|t| t.frames).sum();
let live_for = start.elapsed().as_secs_f64();
eprintln!(
"wl-pick: {frames} frame(s) over {live_for:.1}s = {:.1}/s, {} tick(s), \
{} release(s), {} blocked; per tile: {}",
frames as f64 / live_for,
app.ticks,
app.releases,
app.blocked_nofree,
app.tiles
.iter()
.map(|t| t.frames.to_string())
.collect::<Vec<_>>()
.join(",")
);
}
// wl-pick is a chooser: it reports the pick, and what that means is the
// caller's business.
if args.verbose {
eprintln!("wl-pick: {}", app.quit_why);
}
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!("wl-pick: {:?} has no toplevel identifier", target.title);
return Ok(ExitCode::FAILURE);
}
},
}
Ok(ExitCode::SUCCESS)
}
// --- event plumbing -------------------------------------------------------
impl Dispatch<WlRegistry, GlobalListContents> for App {
fn event(
_: &mut Self,
_: &WlRegistry,
_: <WlRegistry as Proxy>::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ExtForeignToplevelListV1, ()> for App {
fn event(
app: &mut Self,
_: &ExtForeignToplevelListV1,
event: ext_foreign_toplevel_list_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_foreign_toplevel_list_v1::Event::Toplevel { toplevel } = event {
app.toplevels.push((toplevel, String::new()));
}
}
event_created_child!(App, ExtForeignToplevelListV1, [
ext_foreign_toplevel_list_v1::EVT_TOPLEVEL_OPCODE => (ExtForeignToplevelHandleV1, ()),
]);
}
impl Dispatch<ExtForeignToplevelHandleV1, ()> for App {
fn event(
app: &mut Self,
handle: &ExtForeignToplevelHandleV1,
event: ext_foreign_toplevel_handle_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_foreign_toplevel_handle_v1::Event::Identifier { identifier } = event
&& let Some(entry) = app.toplevels.iter_mut().find(|(h, _)| h == handle)
{
entry.1 = identifier;
}
}
}
impl Dispatch<ExtImageCopyCaptureSessionV1, usize> for App {
fn event(
app: &mut Self,
_: &ExtImageCopyCaptureSessionV1,
event: ext_image_copy_capture_session_v1::Event,
&i: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(tile) = app.tiles.get_mut(i) else {
return;
};
match event {
ext_image_copy_capture_session_v1::Event::BufferSize { width, height } => {
tile.size = (width, height)
}
ext_image_copy_capture_session_v1::Event::ShmFormat {
format: WEnum::Value(f),
} => tile.formats.push(f),
ext_image_copy_capture_session_v1::Event::Done => tile.session_done = true,
ext_image_copy_capture_session_v1::Event::Stopped => {
tile.failed = true;
tile.settled = true;
}
_ => {}
}
}
}
impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
fn event(
app: &mut Self,
_: &ExtImageCopyCaptureFrameV1,
event: ext_image_copy_capture_frame_v1::Event,
&i: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(tile) = app.tiles.get_mut(i) else {
return;
};
match event {
ext_image_copy_capture_frame_v1::Event::Transform {
transform: WEnum::Value(t),
} => tile.transform = t,
ext_image_copy_capture_frame_v1::Event::Ready => {
// The protocol wants the frame destroyed once ready; the buffer
// stays ours to display.
if let Some(frame) = tile.frame.take() {
frame.destroy();
}
app.frame_ready(i);
}
ext_image_copy_capture_frame_v1::Event::Failed { reason } => {
// Live mode just retries on the next tick; only a failure with no
// frame yet leaves the tile without a thumbnail.
if tile.frames == 0 {
eprintln!(
"wl-pick: capture failed for {:?} ({reason:?})",
tile.target.title
);
tile.failed = true;
}
tile.settled = true;
if let Some(slot) = tile.filling.take() {
tile.slots[slot].busy = false;
}
if let Some(frame) = tile.frame.take() {
frame.destroy();
}
}
_ => {}
}
}
}
impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
fn event(
app: &mut Self,
layer: &ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwlr_layer_surface_v1::Event::Configure { serial, .. } => {
layer.ack_configure(serial);
app.configured = true;
}
zwlr_layer_surface_v1::Event::Closed => {
app.quit_why = "the compositor closed the overlay";
app.quit = true;
}
_ => {}
}
}
}
/// 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,
seat: &WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps),
} = event
&& caps.contains(wl_seat::Capability::Keyboard)
{
seat.get_keyboard(qh, ());
}
}
}
impl Dispatch<WlKeyboard, ()> for App {
fn event(
app: &mut Self,
_: &WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_keyboard::Event::Key { key, state, .. } = event {
match state {
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key),
WEnum::Value(wl_keyboard::KeyState::Released)
if key == KEY_LEFTSHIFT || key == KEY_RIGHTSHIFT =>
{
app.shift = false
}
_ => {}
}
}
}
}
// Interfaces we drive but never listen to.
delegate_noop!(App: WlCompositor);
delegate_noop!(App: WlSubcompositor);
delegate_noop!(App: WlSubsurface);
delegate_noop!(App: ignore WlShm);
delegate_noop!(App: WlShmPool);
delegate_noop!(App: WpViewporter);
delegate_noop!(App: WpViewport);
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
// release timing does not matter.
delegate_noop!(App: ignore WlBuffer);
/// A released capture buffer is a slot we may capture into again.
///
/// Release is the whole contract: with wl_shm the compositor copies the pixels
/// out at commit and hands the buffer straight back, so the slot currently on
/// screen is usually free too. (Waiting for it to stop being the displayed slot
/// instead would deadlock — that release never comes twice.)
impl Dispatch<WlBuffer, (usize, usize)> for App {
fn event(
app: &mut Self,
_: &WlBuffer,
event: wl_buffer::Event,
&(tile, slot): &(usize, usize),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_buffer::Event::Release = event {
app.releases += 1;
if let Some(t) = app.tiles.get_mut(tile) {
t.slots[slot].busy = false;
}
}
}
}
/// Frame callbacks are the clock for live updates: they arrive as the compositor
/// presents the overlay, so re-captures stop when it is not being shown.
impl Dispatch<wl_callback::WlCallback, ()> for App {
fn event(
app: &mut Self,
_: &wl_callback::WlCallback,
event: wl_callback::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
app.tick(qh);
app.arm_frame_callback(qh);
}
}
}