diff --git a/Cargo.toml b/Cargo.toml index 696be4d..79fb793 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "wl-pick" version = "0.1.0" edition = "2024" -description = "Live window switcher for wlroots compositors: a thumbnail grid with the look of a rofi theme" +description = "A live grid of window and display previews, for picking one" license = "MIT" [dependencies] diff --git a/README.md b/README.md index 9c95a32..0408e87 100644 --- a/README.md +++ b/README.md @@ -35,25 +35,26 @@ is the one thing the rofi version had that this doesn't — see the roadmap. ## Usage ``` -wl-pick [--print] [--verbose] [--hide-labels] [--font FAMILY] [--font-size PX] - [--live all|current|none] [--fps N] [--timeout SECS] +wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N] + [--no-outputs] [--hide-labels] [--font FAMILY] [--font-size PX] + [--timeout SECS] [--verbose] ``` -- `--verbose` prints phase timings and how many windows were captured +- `--format tsv|json|portal` how to report the pick (default `tsv`) +- `--live all|current|none` which tiles keep updating (default `all`; displays + are always a single snapshot) +- `--fps N` cap on live updates per tile per second (default 12) +- `--no-outputs` windows only; displays are included as tiles by default - `--hide-labels` draws an icon-only grid - `--font FAMILY` label font family (default `Berkeley Mono`) - `--font-size PX` label size in logical px -- `--live all|current|none` which tiles keep updating (default `all`) -- `--fps N` cap on updates per tile per second (default 12) -- `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an - exclusive keyboard grab) +- `--timeout SECS` exits after a deadline, in case the keyboard grab ever traps + you +- `--verbose` phase timings, the tile list, and capture stats -wl-pick 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. - -wl-pick never acts on the choice — it has no idea what you want to do with it. -Focusing on sway looks like this: +wl-pick is a chooser: the pick goes to stdout, nothing does if you cancel, and +the exit status is 0 for a pick and 1 for a cancel. It never acts on the choice — +it has no idea what you want to do with it. Focusing on sway looks like this: ```sh #!/usr/bin/env bash @@ -121,9 +122,9 @@ from being expensive: back, so the slot on screen is usually free too — waiting for it to stop being displayed instead deadlocks after two frames. -The cost is memory and bandwidth: two full-resolution buffers per window (110 MB -of shm for ten windows on this display, versus 55 MB with `--live none`) and a -readback per refreshed frame. `--live current` refreshes only the selected tile, +The cost is memory and bandwidth: two full-resolution buffers per window (138 MB +of shm for eight windows and a display here, against 83 MB with `--live none`) +and a readback per refreshed frame. `--live current` refreshes only the selected tile, which is much cheaper and still reads as alive. ## Look @@ -131,8 +132,8 @@ which is much cheaper and still reads as alive. Colours, font metrics and grid geometry come from the rofi theme this replaces (gruvbox dark, a yellow selection filling the element padding, `ceil(sqrt(n))` columns capped at 4, 16:9 tiles, `title · app` centred underneath) and live in -`src/theme.rs`. They will move to a config file so they can't drift from the -`.rasi`. +`src/theme.rs`, which is the one place to change them. They are not +configurable at runtime beyond the font flags. The font is looked up by family name. Your own font directories are scanned first because they are small; the full system scan (~37ms) happens only if the @@ -161,9 +162,24 @@ unaffected. It matters more once previews are live. - dmabuf capture, so the pixels never leave the GPU at all — and live previews stop costing a readback per frame +## Source layout + +``` +main.rs orchestration: list, capture, map, report the pick +cli.rs flags, defaults, and the help text that documents them +sway.rs the window list and display names, over sway's IPC socket +target.rs what a tile stands for, and the three output formats +app.rs the Wayland client state every event dispatches into +capture.rs capture sessions, their buffers, and the live clock +overlay.rs the layer surface, the drawing, and the keyboard +theme.rs colours, grid geometry, aspect fitting +text.rs label shaping on a worker thread +shm.rs memfd allocation and the ARGB painter +``` + ## Building ``` cargo build --release -cargo test # grid geometry, ellipsising, glyph output +cargo test # grid geometry, ellipsising, output formats, glyph output ``` diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..2cc42ef --- /dev/null +++ b/src/app.rs @@ -0,0 +1,326 @@ +//! The client: everything the compositor talks to us through. +//! +//! `App` is the one state the Wayland event queue dispatches into, so it holds +//! the bound globals, the tiles, and the pieces of the overlay. The work itself +//! lives next door: capture.rs drives the sessions, overlay.rs draws and reads +//! the keyboard. + +use std::error::Error; + +use wayland_client::globals::{GlobalList, GlobalListContents}; +use wayland_client::protocol::{ + wl_buffer::WlBuffer, + wl_compositor::WlCompositor, + wl_output::{self, WlOutput}, + wl_registry::WlRegistry, + wl_seat::WlSeat, + wl_shm::WlShm, + wl_shm_pool::WlShmPool, + wl_subcompositor::WlSubcompositor, + wl_subsurface::WlSubsurface, + wl_surface::WlSurface, +}; +use wayland_client::{ + Connection, Dispatch, Proxy, QueueHandle, 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_manager_v1::ExtImageCopyCaptureManagerV1; +use wayland_protocols::wp::viewporter::client::{ + wp_viewport::WpViewport, wp_viewporter::WpViewporter, +}; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; + +use crate::capture::{Live, Tile}; +use crate::shm; +use crate::target::Target; +use crate::text; +use crate::theme::{Layout, Theme}; + +/// What the caller decided before any of this started: the look, and how much +/// live capture to do. Passed as one value because `theme`, `live`, `fps` and +/// `scale` all arrive together and two of them are bare integers. +pub struct Settings { + pub theme: Theme, + pub live: Live, + pub fps: u32, + /// Integer output scale the overlay renders at. + pub scale: i32, +} + +pub struct App { + pub(crate) compositor: WlCompositor, + pub(crate) subcompositor: WlSubcompositor, + pub(crate) shm: WlShm, + pub(crate) viewporter: WpViewporter, + pub(crate) layer_shell: ZwlrLayerShellV1, + pub(crate) copy_mgr: ExtImageCopyCaptureManagerV1, + pub(crate) src_mgr: ExtForeignToplevelImageCaptureSourceManagerV1, + + /// Toplevel handles as the compositor announces them, paired with the + /// identifier that joins them to sway's tree. + pub(crate) toplevels: Vec<(ExtForeignToplevelHandleV1, String)>, + /// Displays, paired with the name the compositor gives them (wl_output v4). + pub(crate) outputs: Vec<(WlOutput, String)>, + pub(crate) output_src_mgr: Option, + pub(crate) tiles: Vec, + + pub(crate) theme: Theme, + pub(crate) layout: Layout, + pub(crate) live: Live, + pub(crate) fps: u32, + pub(crate) scale: i32, + pub(crate) sel: usize, + pub(crate) shift: bool, + + pub(crate) labels: Option, + pub(crate) surface: Option, + pub(crate) chrome: Option, + pub(crate) chrome_buffers: Vec, + pub(crate) configured: bool, + + pub(crate) ending: Ending, + pub(crate) picked: Option, + pub(crate) stats: Stats, +} + +/// Counters worth reporting with --verbose. Live capture is easy to get subtly +/// wrong — a starved buffer pool or a clock that never ticks both look like +/// "nothing updates" — so the numbers that distinguish those stay available. +#[derive(Default)] +pub struct Stats { + /// Frame callbacks received, i.e. how often the live clock fired. + pub(crate) ticks: u32, + /// Re-captures skipped because every buffer was still held. + pub(crate) starved: u32, + /// Bytes of shm handed to the compositor for capture buffers. + pub(crate) pool_bytes: usize, +} + +/// Why the overlay stopped, for --verbose. An exit status of 1 cannot tell a +/// cancel from a surface the compositor took away. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Ending { + Running, + Picked, + Cancelled, + Closed, +} + +impl Ending { + pub fn as_str(self) -> &'static str { + match self { + Ending::Running => "still running", + Ending::Picked => "picked", + Ending::Cancelled => "cancelled", + Ending::Closed => "the compositor closed the overlay", + } + } +} + +impl App { + pub fn new( + globals: &GlobalList, + qh: &QueueHandle, + targets: Vec, + settings: Settings, + ) -> Result> { + let Settings { + theme, + live, + fps, + scale, + } = settings; + 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, + ending: Ending::Running, + picked: None, + stats: Stats::default(), + }; + 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) + } + + pub fn finished(&self) -> bool { + self.ending != Ending::Running + } + + /// The pick, if there was one. + pub fn picked(&self) -> Option<&Target> { + self.picked.as_ref() + } + + /// What the grid is about to show: one line per tile, then the geometry. + pub fn describe(&self) { + for (i, t) in self.tiles.iter().enumerate() { + let mark = if t.ready { "" } else { " (no thumbnail)" }; + eprintln!(" [{i}] {}{mark}", t.target.tsv()); + } + eprintln!( + "wl-pick: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \ + at scale {}, {} MB of capture buffers", + self.tiles.len(), + self.tiles.iter().filter(|t| t.ready).count(), + self.layout.cols, + self.layout.rows, + self.layout.width, + self.layout.height, + self.scale, + self.stats.pool_bytes >> 20, + ); + } + + /// What live capture actually did, once the overlay is closing. + pub fn report(&self, open_for: std::time::Duration) { + let frames: u32 = self.tiles.iter().map(|t| t.frames).sum(); + let secs = open_for.as_secs_f64(); + eprintln!( + "wl-pick: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \ + {} starved; per tile: {}", + self.ending.as_str(), + frames as f64 / secs, + self.stats.ticks, + self.stats.starved, + self.tiles + .iter() + .map(|t| t.frames.to_string()) + .collect::>() + .join(",") + ); + } + + pub fn captures_settled(&self) -> bool { + self.tiles.iter().all(|t| t.settled) + } +} + +// --- enumeration ---------------------------------------------------------- + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WlRegistry, + _: ::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for App { + fn event( + app: &mut Self, + _: &ExtForeignToplevelListV1, + event: ext_foreign_toplevel_list_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for App { + fn event( + app: &mut Self, + handle: &ExtForeignToplevelHandleV1, + event: ext_foreign_toplevel_handle_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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; + } + } +} + +/// 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 for App { + fn event( + app: &mut Self, + output: &WlOutput, + event: wl_output::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event + && let Some(entry) = app.outputs.iter_mut().find(|(o, _)| o == output) + { + entry.1 = name; + } + } +} + +// 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); diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..0432706 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,447 @@ +//! Capturing windows and displays. +//! +//! One session per tile, all opened before a single roundtrip so their buffer +//! constraints arrive together, and every first frame put in flight at once: the +//! compositor is bandwidth-bound reading pixels back, so serialising the +//! captures only adds latency. +//! +//! The pixels are never mapped into this process. A capture buffer goes straight +//! to a subsurface for display, so the compositor writes those pages and samples +//! them again itself. + +use std::error::Error; +use std::os::fd::AsFd; +use std::time::{Duration, Instant}; + +use wayland_client::protocol::{ + wl_buffer::{self, WlBuffer}, + wl_callback, wl_output, wl_shm, + wl_subsurface::WlSubsurface, + wl_surface::WlSurface, +}; +use wayland_client::{Connection, Dispatch, QueueHandle, WEnum}; +use wayland_protocols::ext::image_capture_source::v1::client::ext_image_capture_source_v1::ExtImageCaptureSourceV1; +use wayland_protocols::ext::image_copy_capture::v1::client::{ + ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1}, + ext_image_copy_capture_manager_v1, + ext_image_copy_capture_session_v1::{self, ExtImageCopyCaptureSessionV1}, +}; +use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; + +use crate::app::App; +use crate::shm; +use crate::target::{Kind, Target}; + +/// Which tiles keep updating after the first frame. +#[derive(Clone, Copy, PartialEq, Eq)] +pub 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. +pub struct Slot { + pub(crate) buffer: WlBuffer, + pub(crate) busy: bool, +} + +pub struct Tile { + pub(crate) target: Target, + + pub(crate) session: Option, + /// A capture in flight, and which slot it is filling. + pub(crate) frame: Option, + pub(crate) filling: Option, + pub(crate) slots: Vec, + /// The slot currently attached to the subsurface. + pub(crate) showing: Option, + pub(crate) formats: Vec, + pub(crate) format: Option, + /// Buffer size the session requires: the window's full resolution. + pub(crate) size: (u32, u32), + pub(crate) transform: wl_output::Transform, + pub(crate) session_done: bool, + pub(crate) ready: bool, + pub(crate) settled: bool, + /// When the last capture was asked for, for rate limiting, and how many + /// frames this tile has produced. + pub(crate) asked: Option, + pub(crate) frames: u32, + + pub(crate) surface: Option, + pub(crate) subsurface: Option, + pub(crate) viewport: Option, +} + +impl Tile { + pub fn new(target: Target) -> Self { + Self { + target, + 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, + settled: false, + asked: None, + frames: 0, + surface: None, + subsurface: None, + viewport: None, + } + } + + pub 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. + pub fn rotated(&self) -> bool { + use wl_output::Transform; + matches!( + self.transform, + Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270 + ) + } +} + +impl 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. + pub fn open_sessions(&mut self, qh: &QueueHandle) { + 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 = match tile.target.kind { + Kind::Window => self + .toplevels + .iter() + .find(|(_, id)| !id.is_empty() && *id == tile.target.ft_id) + .map(|(handle, _)| 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. + pub fn start_captures(&mut self, qh: &QueueHandle) -> Result<(), Box> { + const PAGE: usize = 4096; + let mut total = 0usize; + let mut offsets: Vec> = 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.stats.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) -> 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.stats.starved += 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. + pub fn arm_frame_callback(&mut self, qh: &QueueHandle) { + 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. + pub fn tick(&mut self, qh: &QueueHandle) { + self.stats.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); + } + } +} + +// --- event plumbing ------------------------------------------------------- + +impl Dispatch for App { + fn event( + app: &mut Self, + _: &ExtImageCopyCaptureSessionV1, + event: ext_image_copy_capture_session_v1::Event, + &i: &usize, + _: &Connection, + _: &QueueHandle, + ) { + 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.settled = true, + _ => {} + } + } +} + +impl Dispatch for App { + fn event( + app: &mut Self, + _: &ExtImageCopyCaptureFrameV1, + event: ext_image_copy_capture_frame_v1::Event, + &i: &usize, + _: &Connection, + _: &QueueHandle, + ) { + 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. + // Live mode 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.settled = true; + if let Some(slot) = tile.filling.take() { + tile.slots[slot].busy = false; + } + if let Some(frame) = tile.frame.take() { + frame.destroy(); + } + } + _ => {} + } + } +} + +/// 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 for App { + fn event( + app: &mut Self, + _: &WlBuffer, + event: wl_buffer::Event, + &(tile, slot): &(usize, usize), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_buffer::Event::Release = event + && 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 for App { + fn event( + app: &mut Self, + _: &wl_callback::WlCallback, + event: wl_callback::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_callback::Event::Done { .. } = event { + app.tick(qh); + app.arm_frame_callback(qh); + } + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..678b13e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,175 @@ +//! Command line: flags, defaults, and the help text that documents them. + +use std::time::Duration; + +use crate::app::Settings; +use crate::capture::Live; +use crate::target::Format; +use crate::theme::Theme; + +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 TYPEIDTOPLEVEL_IDAPPTITLE, 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 +"; + +pub struct Args { + pub(crate) format: Format, + pub(crate) outputs: bool, + pub(crate) verbose: bool, + pub(crate) hide_labels: bool, + pub(crate) font: Option, + pub(crate) font_size: Option, + pub(crate) live: Live, + pub(crate) fps: u32, + pub(crate) timeout: Option, +} + +impl Args { + /// An exclusive keyboard grab makes a hung overlay unusable, so keep an + /// escape hatch that cannot itself deadlock: a thread that only exits. + pub fn arm_timeout(&self) { + if let Some(d) = self.timeout { + std::thread::spawn(move || { + std::thread::sleep(d); + eprintln!("wl-pick: timeout"); + std::process::exit(2); + }); + } + } + + /// Everything the overlay needs to know up front. `scale` comes from the + /// compositor, not the command line, so it is passed in. + pub fn settings(&self, scale: i32) -> Settings { + Settings { + theme: self.theme(), + live: self.live, + fps: self.fps, + scale, + } + } + + /// The look, with any overrides applied. Line height follows the font size + /// unless the size came from the theme, where it is already tuned. + fn theme(&self) -> Theme { + let base = Theme::default(); + let font_px = self.font_size.unwrap_or(base.font_px); + Theme { + labels: !self.hide_labels, + font: self.font.clone().unwrap_or_else(|| base.font.clone()), + line_h: match self.font_size { + Some(_) => (font_px * 1.3).ceil() as i32, + None => base.line_h, + }, + font_px, + ..base + } + } +} + +pub fn parse_args() -> Result { + 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) +} diff --git a/src/main.rs b/src/main.rs index facfdb5..457e187 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,28 @@ -//! 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). +//! 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. //! -//! 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. +//! The interesting constraint is opening fast, because a picker that lags is a +//! picker you stop using. Two things follow from it. The compositor spends ~55ms +//! copying window pixels back for us, and that time is otherwise spent blocked, +//! so the labels are shaped on a worker thread inside it. And the pixels never +//! pass through this process at all: each capture buffer is handed straight to a +//! subsurface with wp_viewporter naming the rectangle to scale it into, so there +//! is no thumbnail encoding, no scaler, and no full-resolution image in our +//! address space. +//! +//! - `cli` — flags and help +//! - `sway` — the window list, over sway's IPC socket +//! - `target` — what a tile stands for, and how a pick is reported +//! - `app` — the Wayland client state everything dispatches into +//! - `capture` — capture sessions and their buffers +//! - `overlay` — the layer surface, the drawing, the keyboard +//! - `theme`, `text`, `shm` — look, labels, and shared memory +mod app; +mod capture; +mod cli; +mod overlay; mod shm; mod sway; mod target; @@ -15,664 +30,132 @@ mod text; mod theme; use std::error::Error; -use std::os::fd::AsFd; use std::process::ExitCode; -use std::time::{Duration, Instant}; +use std::time::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 wayland_client::globals::registry_queue_init; +use wayland_client::{Connection, EventQueue}; -use target::{Kind, Target}; -use theme::{Layout, Rect, Theme, fit_centred}; +use app::App; +use cli::Args; +use target::Target; +use theme::Layout; -// 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, - - session: Option, - /// A capture in flight, and which slot it is filling. - frame: Option, - filling: Option, - slots: Vec, - /// The slot currently attached to the subsurface. - showing: Option, - formats: Vec, - format: Option, - /// 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, - frames: u32, - - surface: Option, - subsurface: Option, - viewport: Option, -} - -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 main() -> ExitCode { + match run() { + Ok(code) => code, + Err(e) => { + eprintln!("wl-pick: {e}"); + ExitCode::FAILURE } } +} - fn bytes(&self) -> usize { - self.size.0 as usize * 4 * self.size.1 as usize +fn run() -> Result> { + let args = cli::parse_args().map_err(|e| -> Box { e.into() })?; + args.arm_timeout(); + + let start = Instant::now(); + let mut phases = Phases::new(args.verbose); + let (targets, scale) = list(&args)?; + if targets.is_empty() { + return Ok(ExitCode::SUCCESS); } + phases.mark("sway-tree"); - /// 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 + let settings = args.settings(scale); + let theme = &settings.theme; + // 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 labels = theme.labels.then(|| { + let layout = Layout::new(theme, targets.len() as i32); + text::spawn( + targets.iter().map(Target::label).collect(), + theme.font.clone(), + theme.font_px * scale as f32, + (theme.line_h * scale) as f32, + (layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w) * scale) as f32, ) + }); + + let conn = Connection::connect_to_env()?; + let (globals, mut queue) = registry_queue_init::(&conn)?; + let qh = queue.handle(); + let mut app = App::new(&globals, &qh, targets, settings)?; + + // 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) = labels { + app.labels = Some(job.join().map_err(|_| "label thread panicked")?); } + phases.mark("labels"); + if args.verbose { + app.describe(); + } + + 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.finished())?; + if args.verbose { + app.report(start.elapsed()); + } + + let Some(target) = app.picked() else { + return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout + }; + match target.render(args.format) { + Some(line) => println!("{line}"), + // Only the portal format can fail to name something: it identifies a + // window by its foreign-toplevel identifier, and this one has none. + None => { + eprintln!("wl-pick: {:?} has no toplevel identifier", target.title); + return Ok(ExitCode::FAILURE); + } + } + Ok(ExitCode::SUCCESS) } -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, - tiles: Vec, - - theme: Theme, - layout: Layout, - live: Live, - fps: u32, - scale: i32, - sel: usize, - shift: bool, - - labels: Option, - surface: Option, - chrome: Option, - chrome_buffers: Vec, - configured: bool, - - quit: bool, - /// Why the overlay closed, for --verbose. - quit_why: &'static str, - activate: Option, - /// 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, +/// Everything the grid can show, windows first so their positions stay stable as +/// displays come and go. The IPC connection is only needed for this, and is +/// closed again before the overlay maps. +fn list(args: &Args) -> Result<(Vec, i32), Box> { + let mut sway = swayipc::Connection::new()?; + let mut targets = sway::windows(&mut sway)?; + let displays = sway::displays(&mut sway)?; + if args.outputs { + targets.extend(displays.names.iter().cloned().map(Target::output)); + } + Ok((targets, displays.scale)) } -impl App { - fn new( - globals: &GlobalList, - qh: &QueueHandle, - targets: Vec, - theme: Theme, - live: Live, - fps: u32, - scale: i32, - ) -> Result> { - 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) { - 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 = 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) -> Result<(), Box> { - const PAGE: usize = 4096; - let mut total = 0usize; - let mut offsets: Vec> = 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) -> 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) { - 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.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) -> Result<(), Box> { - 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) { - 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(); - } - _ => {} - } +/// Run the event loop until `done`. +fn pump( + queue: &mut EventQueue, + app: &mut App, + done: impl Fn(&App) -> bool, +) -> Result<(), Box> { + while !done(app) { + queue.blocking_dispatch(app)?; } + Ok(()) } /// Phase timings, printed with --verbose. Opening latency is the whole point of @@ -701,590 +184,3 @@ impl Phases { } } } - -/// 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: &mut App, - done: impl Fn(&App) -> bool, -) -> Result<(), Box> { - 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 TYPEIDTOPLEVEL_IDAPPTITLE, 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, - font_size: Option, - live: Live, - fps: u32, - timeout: Option, -} - -fn parse_args() -> Result { - 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> { - let args = parse_args().map_err(|e| -> Box { 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::(&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::>() - .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 for App { - fn event( - _: &mut Self, - _: &WlRegistry, - _: ::Event, - _: &GlobalListContents, - _: &Connection, - _: &QueueHandle, - ) { - } -} - -impl Dispatch for App { - fn event( - app: &mut Self, - _: &ExtForeignToplevelListV1, - event: ext_foreign_toplevel_list_v1::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - handle: &ExtForeignToplevelHandleV1, - event: ext_foreign_toplevel_handle_v1::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - _: &ExtImageCopyCaptureSessionV1, - event: ext_image_copy_capture_session_v1::Event, - &i: &usize, - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - _: &ExtImageCopyCaptureFrameV1, - event: ext_image_copy_capture_frame_v1::Event, - &i: &usize, - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - layer: &ZwlrLayerSurfaceV1, - event: zwlr_layer_surface_v1::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - output: &WlOutput, - event: wl_output::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - if let wl_output::Event::Name { name } = event - && let Some(entry) = app.outputs.iter_mut().find(|(o, _)| o == output) - { - entry.1 = name; - } - } -} - -impl Dispatch for App { - fn event( - _: &mut Self, - seat: &WlSeat, - event: wl_seat::Event, - _: &(), - _: &Connection, - qh: &QueueHandle, - ) { - if let wl_seat::Event::Capabilities { - capabilities: WEnum::Value(caps), - } = event - && caps.contains(wl_seat::Capability::Keyboard) - { - seat.get_keyboard(qh, ()); - } - } -} - -impl Dispatch for App { - fn event( - app: &mut Self, - _: &WlKeyboard, - event: wl_keyboard::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - _: &WlBuffer, - event: wl_buffer::Event, - &(tile, slot): &(usize, usize), - _: &Connection, - _: &QueueHandle, - ) { - 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 for App { - fn event( - app: &mut Self, - _: &wl_callback::WlCallback, - event: wl_callback::Event, - _: &(), - _: &Connection, - qh: &QueueHandle, - ) { - if let wl_callback::Event::Done { .. } = event { - app.tick(qh); - app.arm_frame_callback(qh); - } - } -} diff --git a/src/overlay.rs b/src/overlay.rs new file mode 100644 index 0000000..86fbaf4 --- /dev/null +++ b/src/overlay.rs @@ -0,0 +1,276 @@ +//! The overlay itself: a layer surface for the chrome, one subsurface per tile, +//! and the keyboard. +//! +//! Scaling is the compositor's job. A tile attaches its capture buffer directly +//! and wp_viewporter names the rectangle to fit it into, so nothing here touches +//! a pixel of window content — only the background, selection and labels. + +use std::error::Error; +use std::os::fd::AsFd; + +use wayland_client::protocol::{ + wl_keyboard::{self, WlKeyboard}, + wl_seat::{self, WlSeat}, + wl_shm, +}; +use wayland_client::{Connection, Dispatch, QueueHandle, WEnum}; +use wayland_protocols_wlr::layer_shell::v1::client::{ + zwlr_layer_shell_v1::Layer, + zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1}, +}; + +use crate::app::{App, Ending}; +use crate::shm; +use crate::theme::{Rect, fit_centred}; + +// evdev keycodes: physical positions, so navigation works on any keyboard +// layout without an xkb keymap. Reading typed characters would need one. +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; + +impl App { + /// Map the overlay: a layer surface sized to hug the grid, plus the shm the + /// chrome is painted into. + pub fn show(&mut self, qh: &QueueHandle) -> Result<(), Box> { + 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. + pub fn place_tiles(&mut self, qh: &QueueHandle) { + 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 — a live frame arrives + // whenever its window does — 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. + pub fn paint(&mut self) { + let (scale, sel) = (self.scale, self.sel); + let elem = self.layout.elem(sel as i32).scaled(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, r.scaled(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.ending = Ending::Cancelled, + KEY_ENTER | KEY_KPENTER => { + self.picked = self.tiles.get(self.sel).map(|t| t.target.clone()); + self.ending = Ending::Picked; + } + 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(); + } + _ => {} + } + } +} + +// --- event plumbing ------------------------------------------------------- + +impl Dispatch for App { + fn event( + app: &mut Self, + layer: &ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_layer_surface_v1::Event::Configure { serial, .. } => { + layer.ack_configure(serial); + app.configured = true; + } + zwlr_layer_surface_v1::Event::Closed => app.ending = Ending::Closed, + _ => {} + } + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + seat: &WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(caps), + } = event + && caps.contains(wl_seat::Capability::Keyboard) + { + seat.get_keyboard(qh, ()); + } + } +} + +impl Dispatch for App { + fn event( + app: &mut Self, + _: &WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 + } + _ => {} + } + } + } +} diff --git a/src/sway.rs b/src/sway.rs index bab8d62..c4ab964 100644 --- a/src/sway.rs +++ b/src/sway.rs @@ -38,14 +38,26 @@ fn collect(node: &Node, out: &mut Vec) { } } -/// The largest integer scale in use, which is what the overlay renders at. -pub fn scale(conn: &mut Connection) -> Result { - Ok(conn +/// The active displays, and the scale the overlay should render at: the largest +/// in use, rounded up, since a buffer can be downscaled but not invented. +pub struct Displays { + pub(crate) names: Vec, + pub(crate) scale: i32, +} + +pub fn displays(conn: &mut Connection) -> Result { + let active: Vec<_> = conn .get_outputs()? - .iter() + .into_iter() .filter(|o| o.active) - .map(|o| o.scale.unwrap_or(1.0).ceil() as i32) - .max() - .unwrap_or(1) - .max(1)) + .collect(); + Ok(Displays { + scale: active + .iter() + .map(|o| o.scale.unwrap_or(1.0).ceil() as i32) + .max() + .unwrap_or(1) + .max(1), + names: active.into_iter().map(|o| o.name).collect(), + }) } diff --git a/src/target.rs b/src/target.rs index a6a0a84..c49f290 100644 --- a/src/target.rs +++ b/src/target.rs @@ -6,6 +6,17 @@ use std::fmt; +/// How a pick is written to stdout. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// Tab-separated columns, for `IFS=$'\t' read` or cut(1). + Tsv, + /// The same record as one JSON object. + Json, + /// What xdg-desktop-portal-wlr's `simple` chooser accepts. + Portal, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum Kind { Window, @@ -113,6 +124,16 @@ impl Target { ) } + /// Render for `format`. `None` means this target cannot be named in that + /// format, which only the portal one can fail at. + pub fn render(&self, format: Format) -> Option { + match format { + Format::Tsv => Some(self.tsv()), + Format::Json => Some(self.json()), + Format::Portal => self.portal(), + } + } + /// What xdg-desktop-portal-wlr's `simple` chooser accepts: `Monitor: NAME` /// or `Window: `. A window the compositor never /// gave an identifier for cannot be named this way, hence the Option — and diff --git a/src/theme.rs b/src/theme.rs index eeed233..13f5484 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -155,6 +155,18 @@ pub struct Rect { pub h: i32, } +impl Rect { + /// Logical to physical, for painting into a scaled buffer. + pub fn scaled(self, scale: i32) -> Self { + Self { + x: self.x * scale, + y: self.y * scale, + w: self.w * scale, + h: self.h * scale, + } + } +} + /// Scale (w, h) to fit inside (bw, bh), keeping the aspect ratio, and centre it. /// Windows are usually portrait-ish next to a 16:9 cell, so this letterboxes the /// same way rofi's `element-icon { size: W H }` does.