Commit Graph
16 Commits
Author SHA1 Message Date
Milad Alizadeh 94d8145079 Release 0.2.0
Minor, not patch: colours and sizing move into a config file and the grid
scrolls, so behaviour people could depend on has changed shape.

- ~/.config/wl-pick/config sets colours, border, thumbnail size and the
  flags, as flat `key = value` lines; a flag still beats the file.
- Sizes take sway's units. 70ppt is a percentage of the display the grid
  appears on, resolved per run, so one config suits monitors of different
  sizes. The overlay maps on the focused display explicitly, at that
  display's scale, rather than taking the largest scale in use.
- Give only tile-width and the height follows the display's aspect, which
  is roughly the shape of the windows on it.
- More rows than fit now scroll, with a scrollbar, PgUp/PgDn, and the
  selection always revealed. Tiles out of view are unmapped and skipped
  by live capture.

Also corrects rust-version, which said 1.88 while cosmic-text has needed
1.89 all along: cargo was resolving 0.1.1 against a floor its own
dependency could not meet, and said so if you looked. It now locks
cleanly.
2026-08-31 18:22:57 +01:00
Milad Alizadeh 30c478872a Scroll when there are more tiles than fit
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.
2026-08-31 18:20:03 +01:00
Milad Alizadeh 487acb8b6a Add a config file, and size the grid to the display
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.
2026-08-31 17:29:06 +01:00
Milad Alizadeh c22e3cdd31 Release 0.1.1
The published 0.1.0 defaults the label font to Berkeley Mono, which falls
back to a non-monospaced face on any machine that does not have it. That
is a defect for every user but one, so it is a patch release rather than a
new feature: no flag was added, removed or renamed.

Also silences a lint that arrived with Rust 1.98. Clippy suggests
`as_chunks` in place of `chunks_exact`, but that API is newer than the
1.88 this crate declares as its minimum, so taking the suggestion would
break the builds the manifest promises to support.
2026-08-28 10:49:14 +01:00
Milad Alizadeh 11dc30ddc1 Default to the system monospace font
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.
2026-08-24 12:37:55 +01:00
Milad Alizadeh 15b74d311d Add the metadata crates.io needs
repository, readme, keywords and categories, so the crate page says what
this is and is findable; the MIT text the license field was already
claiming; and rust-version, because let-chains mean 1.88 and an older
toolchain should say so rather than fail to parse.

`cargo package` builds the packaged tarball cleanly: 17 files, nothing
stray, and the release binary links nothing beyond libc, so installing it
needs no system development packages.
2026-08-24 12:03:40 +01:00
Milad Alizadeh 25d90bdcec Pick with the mouse
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.
2026-08-23 20:25:57 +01:00
Milad Alizadeh 8d8a27c4c1 Split the client into modules, and clear out what iteration left behind
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.
2026-08-23 19:17:04 +01:00
Milad Alizadeh 5b98615c41 Rename to wl-pick
wlgrid described the shape of the thing rather than what it is for, and
the shape is the least interesting part now that it doesn't focus
anything: it shows you what is running and reports which one you pointed
at. wl-pick says that.

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

Also renames the layer-shell namespace and the memfd labels, which show
up in compositor debugging.
2026-08-23 18:35:27 +01:00
Milad Alizadeh 71ba77c989 Tighten --help
75 lines to 51. The format section had grown into prose that explained
the same thing twice, and two of the three examples were variations on
one idea, so one worked example survives.

The samples were also taken verbatim from the machine this was developed
on — a real toplevel identifier, a real output name, real app ids — which
reads like configuration rather than illustration. They are generic now.
2026-08-23 16:29:50 +01:00
Milad Alizadeh 3d54b368d2 Make wlgrid a chooser only, and say what each format is
--focus and sway::focus are gone. Showing the grid and reporting the
choice is the whole job; deciding what the choice means belongs to
whoever called it, and keeping that decision here only invited more of
it (focus which way? move? swap? scratchpad?). The sway IPC connection
is now scoped to building the list and closed before the overlay maps.

--help no longer just names the three formats, it shows them. Each gets
a real sample line, what every column means, and where the format is
meant to be used: tsv for `IFS=$'\t' read` or cut, json for jq, portal
for xdg-desktop-portal-wlr's simple chooser, with the config stanza to
paste. Plus worked examples of focusing a window, handling either kind
of pick, and screenshotting one with grim -T.
2026-08-23 14:07:11 +01:00
Milad Alizadeh 6d8ae655b9 Say why the overlay closed, and document focusing both kinds of tile
--verbose now reports whether the overlay was picked from, cancelled, or
closed by the compositor. Chasing a "keys do nothing" symptom that turned
out to be a locked session, that distinction was the piece of information
I kept lacking: an exit code of 1 cannot tell a cancel from a surface the
compositor took away.

README now shows the two commands a caller needs — [con_id=N] focus for a
window, focus output NAME for a display — and that --focus runs them.
2026-08-23 13:26:54 +01:00
Milad Alizadeh 5b1f0f74e8 Add display tiles, three output formats, and hjkl
Displays are capture sources too — ext-image-capture-source-v1 makes one
from a wl_output just as it does from a toplevel handle — so they are now
tiles as well, appended after the windows and labelled "NAME · display".
They are snapshot-only: a live display tile contains this overlay, which
contains the display tile, and refreshing that never settles while
costing a whole screen per frame. They also only get one buffer for the
same reason, which is worth ~29MB here.

The bigger change is what wlgrid reports. It was focusing the pick itself
and printing only under --print, which suits a keybinding and nothing
else. Now it is a chooser: it always reports the pick, never acts unless
asked (--focus), exits 1 when cancelled, and can say it three ways.

--format portal emits what xdg-desktop-portal-wlr's simple chooser reads
("Monitor: NAME" / "Window: <foreign-toplevel identifier>"), so wlgrid
can be the picker behind getDisplayMedia, with live previews of windows
and displays. That contract is also why tsv carries both identifiers: the
portal and grim -T want the toplevel identifier, sway scripting wants the
con_id. --format json gives the whole record for jq.

Navigation also takes hjkl, and --help now describes every flag with its
default plus the output formats.
2026-08-23 12:33:25 +01:00
Milad Alizadeh 33d75222cf Make the previews live
Capture sessions stay open and tiles refresh, so the grid shows what the
windows are actually doing rather than a snapshot from when it opened.
--live all|current|none picks how much of that happens, --fps caps it.

Three things keep it cheap:

The protocol is damage-driven. After a session's first frame the
compositor only produces another once the content changes, so a request
left outstanding on an idle window costs nothing. Measured with one
animating window out of ten: 52,52,1,1,1,1,1,1,13,1 frames over 4s — the
static windows delivered their first frame and then nothing.

The overlay's own wl_surface.frame callbacks are the clock, so refreshes
stop when it isn't being presented and no timer or poll loop is needed.
Per-tile throttling on top of that measured 12.1/s at --fps 12.

Each window gets two buffers, since a capture must not write into one the
compositor is reading, and wl_buffer.release says which is free. That
release is the whole contract: with wl_shm the compositor copies the
pixels at commit and hands the buffer straight back, so the slot on
screen is usually free too. Waiting for it to stop being the displayed
slot instead — which is what this first did — deadlocks after two frames,
with both slots stuck busy (687 blocked attempts, 2 frames per tile).

Live mode doubles the shm handed to the compositor (110MB for ten windows
here, against 55MB with --live none); our own RSS is unaffected because
those pages are still never mapped. --verbose now reports frames, ticks,
releases, blocked attempts and pool size, which is what localised the
release bug.
2026-08-23 10:54:06 +01:00
Milad Alizadeh 2cd076699d Label the tiles
"title · app" centred under each thumbnail, in the same font the rofi
theme used (Berkeley Mono at pango's "small"), with the selected tile's
label inverted onto the yellow the way rofi's element background did.
Long titles are ellipsised to the cell width.

Text comes from cosmic-text, which brings real shaping. The catch is
cost: building a font system and rasterising the first glyphs takes
~55ms, nearly doubling a 65ms startup. But the capture phase is ~55ms of
sitting blocked while the compositor copies pixels, so labels are shaped
on a worker thread started before the captures and joined after them.
The measured labels phase is now 0.0ms — it costs nothing in wall clock.

Two thirds of that font cost was FontSystem::new() scanning all 1793
system faces, which even new_with_fonts() does. So the database is built
by hand: the user's own font directories first, since they are small,
and the full system scan only when the family isn't found there. An
unknown family still resolves, because that fallback is exactly the
system scan (verified: --font "No Such Font" renders in Noto).

--hide-labels restores the icon-only grid, and --font/--font-size make
the family and size settable. Adds a test that a label actually puts
pixels on the surface, one for ellipsising, and one pinning the label row
into the element geometry.
2026-08-23 10:39:37 +01:00
Milad Alizadeh dbfc06519a Add wlgrid: a window-thumbnail grid overlay for wlroots
A switcher to replace a wlthumbs + rofi pipeline, with the same look
(gruvbox, ceil(sqrt(n)) columns capped at 4, 16:9 tiles, a yellow
selection filling the element padding) but no thumbnails anywhere.

Each window is captured straight into a wl_shm buffer that is handed to
its own wl_subsurface, with wp_viewporter giving the compositor the
rectangle to scale it into. So there is no PNG encode, no scaler, no
full-resolution bitmap in this process, and the capture buffers are never
even mapped here — the compositor writes those pages and samples them
again for display. Opens in ~65ms for 8 windows (55ms of which is the
compositor reading pixels back out of the GPU) and holds ~9MB of RSS.

All capture sessions are opened before a single roundtrip and every frame
goes in flight together, the same batching wlthumbs uses, because the
readback is bandwidth-bound rather than latency-bound.

sway remains the source of truth: the window list, the con_ids and the
focusing all come from its IPC socket, joined to the Wayland side by
foreign_toplevel_identifier. Navigation reads raw evdev keycodes so it is
layout-independent, which does mean virtual-keyboard clients that invent
their own keymap can't drive it; that resolves when filtering brings xkb.

Labels, type-to-filter and live previews are next.
2026-08-23 10:07:09 +01:00