Files
wl-tab/src/main.rs
T
Milad Alizadeh 86319aa309 Size the grid by caps, not by tile size
tile-width and tile-height are gone. In their place the config states a
box and a grid, all four settings caps of the same kind:

    max-width   = 90ppt      # the box the grid may fill
    max-height  = 90ppt
    max-columns = 4          # the grid inside it
    max-rows    = 4

A thumbnail is that box divided by those caps. The property that buys is
that a thumbnail's size no longer depends on how many windows are open:
one window gets the same thumbnail as thirty, in a smaller overlay,
because the overlay hugs whatever is actually there. Rows past max-rows
still scroll.

It also removes two things that were hard to explain. tile-height used to
default to the display's aspect through a rule you could only learn from
the documentation — the shape now falls out of the box and the grid.
And ppt meant "of the display width" on one key and "of the display
height" on another; the two remaining lengths take the axis their name
implies.

The 90% fill constant went with them: it was an invisible cap doing the
job max-width now does out loud, and its old value is the default.

Turning labels off now gives that row to the thumbnails rather than
shrinking the window, which follows from the box being what you asked
for. Verified on a 1280x1440 display: 90ppt gives a 1149x1293 overlay,
60ppt gives 765x861, and caps of 2x2 in the same box give larger
thumbnails that scroll after two rows.
2026-09-06 10:46:15 +01:00

210 lines
6.9 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::Instant;
use wayland_client::globals::registry_queue_init;
use wayland_client::{Connection, EventQueue};
use app::App;
use config::Config;
use target::Target;
use theme::Layout;
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(|e| -> Box<dyn Error> { e.into() })?;
let config =
Config::load(args.config.as_deref()).map_err(|e| -> Box<dyn Error> { e.into() })?;
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 = swayipc::Connection::new().map_err(|e| {
format!("cannot reach sway ({e}); wl-pick reads the window list from its IPC socket")
})?;
// 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)?;
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 settings = opts.settings;
let theme = &settings.theme;
let scale = settings.scale;
// 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, settings.display);
text::spawn(
targets.iter().map(Target::label).collect(),
theme.font.clone(),
theme.font_px * scale as f32,
(theme.line_h * scale) as f32,
// The label box is a tile wide; with no tiles there is nothing to
// shape anyway.
(layout.label(0, 0).map(|r| r.w).unwrap_or(1) * 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)?;
// 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 opts.verbose {
app.describe();
}
app.show(&qh)?;
pump(&mut queue, &mut app, |a| a.configured)?;
app.paint();
app.sync_tiles(&qh);
app.arm_frame_callback(&qh);
conn.flush()?;
phases.mark("mapped");
// Scrolling re-places the subsurfaces; doing it here rather than inside the
// key handler coalesces a held-down arrow into one update per dispatch.
while !app.finished() {
queue.blocking_dispatch(&mut app)?;
if std::mem::take(&mut app.needs_tiles) {
app.sync_tiles(&qh);
conn.flush()?;
}
}
if opts.verbose {
app.report(start.elapsed());
}
let Some(target) = app.picked() else {
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
};
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`.
fn pump(
queue: &mut EventQueue<App>,
app: &mut App,
done: impl Fn(&App) -> bool,
) -> Result<(), Box<dyn Error>> {
while !done(app) {
queue.blocking_dispatch(app)?;
}
Ok(())
}
/// 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;
}
}
}