Files
wl-tab/src/main.rs
T
2026-09-10 07:33:57 +02:00

340 lines
12 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.
//!
//! 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
// `slice::as_chunks` and friends, which clippy suggests in place of
// `chunks_exact`, are newer than the toolchain this crate says it supports.
#![allow(clippy::chunks_exact_to_as_chunks)]
mod app;
mod capture;
mod cli;
mod config;
mod overlay;
mod shm;
mod sway;
mod target;
mod text;
mod theme;
use std::error::Error;
use std::process::ExitCode;
use std::time::{Duration, Instant};
use rustix::event::{PollFd, PollFlags, Timespec};
use wayland_client::globals::registry_queue_init;
use wayland_client::{Connection, EventQueue};
use app::{App, Ending};
use config::Config;
use target::Target;
use theme::Layout;
/// How long the phases before the overlay is interactive may take. Capture
/// measures ~90ms for fourteen windows, so this is a wide margin around
/// anything healthy, and only a stall reaches it.
const STARTUP_BUDGET: Duration = Duration::from_secs(2);
/// How long a keyboard leave is given to turn out to be a focus refresh rather
/// than a real loss. sway's pair arrives microseconds apart; this is only long
/// enough to be sure, and short enough that a real handover looks instant.
const REFOCUS_GRACE: Duration = Duration::from_millis(150);
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 = cli::parse_args().map_err(Box::<dyn Error>::from)?;
let config = Config::load(args.config.as_deref()).map_err(Box::<dyn Error>::from)?;
let start = Instant::now();
let mut phases = Phases::new(args.verbose);
// One IPC conversation: the window list, and the displays the grid sizes
// itself against. It is closed again before the overlay maps.
let (targets, opts) = {
let mut sway = sway::connect()?;
// The displays come first: the grid is sized against the one it will
// appear on, so every percentage in the config resolves per monitor.
let displays = sway::displays(&mut sway)?;
let display = sway::focused(&displays).ok_or("sway reports no active display")?;
let opts = args.resolve(&config, display);
let mut targets = sway::windows(&mut sway, opts.order)?;
if opts.outputs {
// Displays go last, after the windows, so window positions are
// stable as windows come and go.
targets.extend(displays.iter().map(|d| Target::output(d.name.clone())));
}
(targets, opts)
};
if targets.is_empty() {
return Ok(ExitCode::SUCCESS);
}
phases.mark("sway-tree");
cli::arm_timeout(opts.timeout);
let (display, settings) = (opts.display, opts.settings);
let theme = &settings.theme;
let scale = settings.scale;
// The grid is measured once here: the label shaping below and the overlay
// itself must agree about how wide a label may be.
let layout = Layout::new(theme, targets.len() as i32, display);
// 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 = layout.label(0, 0).map(|label| {
text::spawn(
targets.iter().map(Target::label).collect(),
theme.font.clone(),
theme.font_px * scale as f32,
(theme.line_h * scale) as f32,
(label.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, settings, layout)?;
// 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)?;
// Tiles that never delivered are shown as labels without a thumbnail,
// exactly as an outright capture failure is. Better a grid you can use
// than a process you have to hunt down.
if !pump_for(
&conn,
&mut queue,
&mut app,
|a| a.captures_settled(),
STARTUP_BUDGET,
)? {
app.report_unsettled();
}
phases.mark("capture");
if let Some(job) = labels {
app.labels = Some(job.join().map_err(|_| "label thread panicked")?);
}
phases.mark("labels");
if opts.verbose {
app.describe();
}
app.show(&qh)?;
if !pump_for(
&conn,
&mut queue,
&mut app,
|a| a.configured,
STARTUP_BUDGET,
)? {
return Err("the compositor never configured the overlay".into());
}
app.paint();
app.sync_tiles(&qh);
app.arm_frame_callback(&qh);
conn.flush()?;
phases.mark("mapped");
// The keyboard grab is what makes the overlay usable, so losing it for
// good ends the run: that is how a second wl-pick, started from the same
// keybinding, replaces the first instead of leaving it stranded on screen.
// A leave only counts once it has failed to come back, because sway also
// cycles focus off and on in a single batch as the pointer crosses us.
loop {
if app.finished() {
break;
}
// When a navigation key is held, we need to fire repeat events on a
// timer rather than blocking indefinitely. Use a timed poll so we
// wake up when the next repeat is due without burning the CPU.
if let Some(next) = app.repeat_next {
let now = Instant::now();
if now >= next {
let qh = queue.handle();
app.fire_repeat(&qh);
conn.flush()?;
} else {
// Poll for events with a timeout set to when the next repeat fires.
let left = next - now;
queue.dispatch_pending(&mut app)?;
if !app.finished() {
conn.flush()?;
if let Some(guard) = conn.prepare_read() {
let fd = guard.connection_fd();
let mut fds = [PollFd::new(&fd, PollFlags::IN)];
let timeout = Timespec {
tv_sec: left.as_secs() as _,
tv_nsec: left.subsec_nanos() as _,
};
match rustix::event::poll(&mut fds, Some(&timeout)) {
Ok(0) => {
// Timeout expired: do NOT call guard.read() because
// nothing is pending on the socket. Dropping guard cancels read.
}
Ok(_) | Err(rustix::io::Errno::INTR) => {
let _ = guard.read();
}
Err(e) => return Err(Box::new(e)),
}
}
}
continue;
}
} else {
queue.blocking_dispatch(&mut app)?;
}
if !app.finished()
&& !app.focused
&& !pump_for(
&conn,
&mut queue,
&mut app,
|a| a.focused || a.finished(),
REFOCUS_GRACE,
)?
{
app.ending = Ending::Unfocused;
}
if app.finished() {
break;
}
}
if opts.verbose {
app.report(start.elapsed());
}
let Some(target) = app.picked() else {
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
};
if opts.focus {
if let Ok(mut sway) = sway::connect() {
match target.kind {
target::Kind::Window => {
if let Some(con_id) = target.con_id {
let _ = sway.run_command(format!("[con_id={con_id}] focus"));
sway::record_focus(con_id);
}
}
target::Kind::Output => {
let _ = sway.run_command(format!("focus output {}", target.id));
}
}
}
}
match target.render(opts.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)
}
/// Run the event loop until `done`, or until `limit` has passed. Returns
/// whether `done` came true in time.
///
/// Every wait before the overlay is interactive is bounded, because a
/// compositor is entitled to simply never answer. sway does exactly that for a
/// capture request on a toplevel another client is already capturing: no frame,
/// no `failed`, no `stopped`, just silence - and an unbounded wait on that is a
/// picker with no window that has to be killed from another terminal.
fn pump_for(
conn: &Connection,
queue: &mut EventQueue<App>,
app: &mut App,
done: impl Fn(&App) -> bool,
limit: Duration,
) -> Result<bool, Box<dyn Error>> {
let deadline = Instant::now() + limit;
loop {
queue.dispatch_pending(app)?;
if done(app) {
return Ok(true);
}
conn.flush()?;
// No guard means events arrived while we were asking; go read them.
let Some(guard) = conn.prepare_read() else {
continue;
};
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
return Ok(false);
};
let fd = guard.connection_fd();
let mut fds = [PollFd::new(&fd, PollFlags::IN)];
let timeout = Timespec {
tv_sec: left.as_secs() as _,
tv_nsec: left.subsec_nanos() as _,
};
match rustix::event::poll(&mut fds, Some(&timeout)) {
Ok(0) => return Ok(false),
// An interrupted poll has simply not waited its full time yet.
Ok(_) | Err(rustix::io::Errno::INTR) => {}
Err(e) => return Err(Box::new(e)),
}
guard.read()?;
}
}
/// 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;
}
}
}