Running wl-pick twice left the second instance hung with no window at all
and no way to end it but kill.
The hang was in the capture phase, not the overlay. sway answers a capture
request for a toplevel that another client is already capturing with
silence: no frame, no failed, no stopped. captures_settled() waits for every
tile to reach one of those three, so it waited forever, holding its buffers,
having never created a layer surface. Any concurrent capture client will do
this, not just a second wl-pick.
So the phases before the overlay is interactive now have a deadline. pump_for
polls the connection with one, and a tile that never arrives is drawn as a
bare label, exactly as an outright capture failure already was.
report_unsettled names those tiles on stderr, which is the diagnostic whose
absence made this hard to find.
The first instance had a second problem: sway hands the keyboard to the new
overlay and sends the old one wl_keyboard.leave, but the dispatcher only
handled Key, so the loser sat on screen holding a grab it no longer had,
deaf to every key. That is the process that stays around. Losing the grab
for good now ends the run, so a second wl-pick started from the same
keybinding replaces the first rather than stranding it.
For good, because sway also sends leave followed immediately by enter on the
same surface -- microseconds apart -- as a focus refresh when the pointer
crosses the overlay. Treating a bare leave as terminal made a lone instance
quit itself after less than a second. Focus is tracked in the dispatcher and
the main loop only gives up once it has failed to come back.
pump had no callers left after that: every wait is now either budgeted or
focus-aware.
The caps model landed in pieces, and the pieces left seams. This joins them
up, and fixes two things the reread turned up.
Deferred subsurface syncing is gone. `needs_tiles` existed because `select`
had no queue handle to sync with, so main.rs grew a bespoke event loop to
notice the flag afterwards. The dispatch handlers are handed a handle
already: pass it down and let `select` do the work itself. main.rs is back
to one uniform `pump`.
The layout is built once, in run(), and passed to App::new, rather than
built there and again inside it. `display` moves from Settings, which is
what App needs, to Options, which is what the caller needs it for.
`timeout = 0` meant an immediate deadline, so uncommenting the line in the
shipped config would have made wl-pick exit before you saw it. Zero now
means no timeout, which is what the comment beside it always claimed.
`timeout` was also settable but documented nowhere -- not in --help, not in
the README. Both now list every key config.rs accepts.
Theme::default's max-width/max-height were placeholder pixel counts that
happened to match one monitor. They are i32::MAX now: no cap of their own,
with Layout clamping to the display.
Shrinking the grid to fit meant a configured tile-width was quietly
ignored the moment enough windows were open — a setting that silently
does not apply is a bug, not a policy. Tiles are now the size the config
asks for and the extra rows scroll.
The layout became a viewport: it reports how many rows fit, elem/tile/
label take a scroll offset and return nothing for tiles above or below
the fold, and hit-testing follows the offset so a click lands on what is
under the cursor rather than what used to be there. Any keyboard move
goes through select(), which scrolls the least that keeps the selection
visible; PgUp/PgDn jump a screen. A scrollbar appears in the right margin
only when there is something to scroll, so it reads as a hint rather than
furniture.
Tiles scrolled out of sight get a null buffer, which unmaps their
subsurface, and the live clock skips them — a long list no longer spends
readback bandwidth on pixels nobody can see. Re-placing the subsurfaces
happens once per dispatch in the event loop rather than inside the key
handler, so holding an arrow key coalesces.
Shrinking survives for one case only: a tile too large for even a single
row or column, where otherwise nothing could be drawn. The old
shrink-to-fit test was rewritten around that, since its premise (twenty
tiles at full size cannot fit) is now answered by scrolling instead.
Verified against the session: 16 tiles at tile-width = 40ppt gives a 2x8
grid with 2 rows visible; three Downs scroll to row 2 and Enter returns
index 6, matching its own list; 82% of the viewport changes across the
scroll; and the scrollbar thumb measures 634px tall at y=24 unscrolled
and y=658 at scroll 2, both what the geometry predicts.
Colours, border and thumbnail size come from ~/.config/wl-pick/config
now, since ten more flags would have made a keybinding unreadable — the
split the rofi setup this replaces already used: look in a file,
behaviour on the command line. Flat `key = value` lines, so no TOML
dependency for something with nothing to nest, and a flag still beats the
file.
Sizes take sway's units. `600px` is absolute; `70ppt` is a percentage of
the display the grid appears on, resolved on every run rather than baked
in, so one config suits monitors of different sizes. That needed the
overlay to know which display it is on, so it now asks sway for the
focused one and maps there explicitly, at that display's scale, instead
of letting the compositor choose and taking the largest scale in use —
which was wrong on any mixed-DPI setup.
tile-width and tile-height are maxima. Given only a width, the height
follows the display's aspect: a 16:9 cell, inherited from a rofi theme
written for a landscape screen, wasted about half of every cell on a
portrait monitor. And if the grid would outgrow the display, tiles now
shrink together, keeping their shape, so thirty windows produce small
tiles rather than a surface larger than the screen. The surface is capped
at the display as a backstop, because on a small screen the padding and
label rows can exceed it no matter how small the tiles get.
Two bugs the tests caught while writing this:
- Stripping comments at the first '#' ate colour values, so
`selection = #d79921 # note` parsed as empty. A comment is now a '#'
followed by whitespace or end of line; a colour is '#' then a hex
digit, so the two cannot collide.
- Twelve tiles at the old fixed size fit a 1280x800 screen, so the first
version of the shrink test proved nothing. It now uses numbers that
genuinely overflow.
Also: failing to reach sway said only "No such file or directory", which
tells a first-time user nothing; it now names sway and what it wanted.
The default was Berkeley Mono, which is one machine's licensed font and
reads as configuration rather than a default. It is now the system's
monospace font, so a fresh install looks right anywhere and --font is
there for anyone who wants their own.
Getting "the system monospace font" is less direct than it sounds.
Family::Name("monospace") resolves to Noto Sans — not monospaced at all.
cosmic-text's generic Family::Monospace goes through fontdb's built-in
preference, "FreeMono", which is usually absent, and then lands on an
arbitrary face (Adwaita Mono here). Enabling cosmic-text's fontconfig
feature changes nothing, because fontdb's config parser does not pick up
the alias files a distribution actually ships.
So fontconfig is asked directly: `fc-match -f %{family} monospace`, which
is the same answer every other application on the system gets — Noto Sans
Mono here. It costs 10ms on the worker thread that is already waiting for
the compositor, so nothing in wall clock. Without the fontconfig tools a
short list of common distribution defaults is tried instead.
Weight and stretch overrides went too: they were tuned for Berkeley Mono
Medium SemiCondensed, and a family name carries that anyway — the full
name resolves to exactly that face, verified by rendering both.
--verbose now reports the family the labels were shaped with, since the
default legitimately differs from machine to machine.
Click a tile to pick it, scroll to move the selection. Hovering does not
move the selection: the keyboard keeps that, and a click acts on whatever
is under the cursor instead. A click on the margin, on a gap, or on an
empty cell of a ragged last row does nothing.
Tiles are already subsurfaces, so a click on a thumbnail identifies its
tile by which surface the event arrived on — no hit-testing needed. Only
the chrome around them (padding, labels, gaps) needs Layout::hit, which
inverts the same maths elem() lays out with, and is tested against it.
Picking waits for press and release on the same tile, so sliding off a
tile before letting go is not a pick.
wp_cursor_shape_v1 sets the pointer shape, which spares us a cursor theme
and libwayland-cursor; without it the cursor keeps whatever shape the
window underneath gave it. It is optional — a compositor without it just
gets whatever shape was already there.
main.rs had grown to 1290 lines holding everything: the CLI, the client
state, the capture engine, the drawing, the input handling and twelve
Dispatch impls. It is now orchestration only, and the concerns live where
you would look for them — cli, app, capture, overlay — with the module
docs saying what each owns. No behaviour changed; the code moved.
Iterating in response to review left residue, now gone:
- Tile::handle was only ever written. Dropping a wayland-rs proxy does
not destroy the object, so nothing needed it held.
- Tile::failed likewise: an earlier captures_settled() read it, and
`settled` is what everything waits on now.
- A blanket #[allow(dead_code)] on Tile hid both of those. Fields are
pub(crate) rather than pub so the lint keeps working.
- Tile's doc comment had drifted onto the Format enum during a patch.
- Three consecutive `if args.verbose` blocks became describe() and
report(), and the loose ticks/releases/starved/pool_bytes counters
became one Stats. `releases` went: frames already imply it.
- quit + quit_why + activate became one Ending enum and picked, so
"closed by the compositor" is a state rather than a string.
- App::new took seven positional arguments, two of them bare integers in
a row; the four that always travel together are now Settings.
- sway::scale and the display listing each called get_outputs; one call
does both.
- scaled(rect, n) became Rect::scaled(n), and Format lives with Target
where render() dispatches on it.
README had drifted too: the usage line still advertised --print, which
no longer exists, and omitted --format and --no-outputs. Its flag list is
now checked against --help, the memory figures are re-measured, and there
is a source layout for anyone arriving cold.