Compare commits
12
Commits
01c1b31636
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce2854d72 | ||
|
|
e1d8760a43 | ||
|
|
f78fd7ee41 | ||
|
|
6559956c3c | ||
|
|
77efdf03e9 | ||
|
|
314e474341 | ||
|
|
0afdcf4c83 | ||
|
|
21cbb5450e | ||
|
|
3abf610fbf | ||
|
|
66c2d49ffb | ||
|
|
3ad0b59d8c | ||
|
|
782a21f864 |
Generated
+1
-1
@@ -605,7 +605,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl-pick"
|
||||
name = "wl-tab"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"cosmic-text",
|
||||
|
||||
+5
-6
@@ -1,14 +1,13 @@
|
||||
[package]
|
||||
name = "wl-pick"
|
||||
name = "wl-tab"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
description = "A live grid of window and display previews, for picking one"
|
||||
description = "An simple alt-tab window switcher for sway"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/mil-ad/wl-pick"
|
||||
readme = "README.md"
|
||||
keywords = ["wayland", "sway", "wlroots", "screencast", "switcher"]
|
||||
repository = "https://git.krzak.org/N0VA/wl-tab"
|
||||
readme = "README.adoc"
|
||||
keywords = ["wayland", "sway", "wlroots", "alt-tab", "switcher"]
|
||||
categories = ["command-line-utilities", "os::unix-apis"]
|
||||
# The floor is cosmic-text, which needs 1.89; let-chains here need 1.88.
|
||||
rust-version = "1.89"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Milad Alizadeh
|
||||
Copyright (c) 2026 N0\A
|
||||
Copyright (c) 2026-2026 Milad Alizadeh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
= wl-tab
|
||||
A simple alt-tab switcher for sway
|
||||
|
||||
Originally forked from https://github.com/mil-ad/wl-pick[wl-pick]
|
||||
@@ -1,279 +0,0 @@
|
||||
# wl-pick
|
||||
|
||||
A window switcher for wlroots compositors: a grid overlay of **live** window
|
||||
previews that looks like a rofi theme, and tells you which one you picked.
|
||||
|
||||
It replaces a `wlthumbs | rofi` pipeline, and doubles as a screencast source
|
||||
picker for the desktop portal. The difference is that no thumbnails
|
||||
exist: each window is captured straight into a `wl_shm` buffer that is handed to
|
||||
its own `wl_subsurface`, and `wp_viewporter` tells the compositor which rectangle
|
||||
to scale it into. There is no image encoding, no scaler, and no full-resolution
|
||||
bitmap in this process — which is also why it appears in about 60 ms and holds
|
||||
~18 MB of RSS however many windows are open.
|
||||
|
||||
```
|
||||
sway-tree 0.6ms window list + con_ids over sway IPC
|
||||
toplevels 0.2ms ext-foreign-toplevel-list handles
|
||||
constraints 1.5ms every capture session's buffer size, in one roundtrip
|
||||
capture 52.5ms 8 windows, all frames in flight at once
|
||||
labels 0.0ms shaped on a worker thread while the captures ran
|
||||
mapped 4.9ms layer surface + subsurfaces on screen
|
||||
```
|
||||
|
||||
The capture phase is the compositor reading full-resolution window pixels out of
|
||||
the GPU. It is bandwidth-bound (~1.1 GB/s here) and unaffected by how large the
|
||||
thumbnails are — which also makes it a free window to do other work in. Loading
|
||||
a font and rasterising its first glyphs costs ~20ms, so labels are shaped on a
|
||||
worker thread started before the captures and joined after them, and cost
|
||||
nothing in wall clock.
|
||||
|
||||
## Status
|
||||
|
||||
Working: a labelled grid of live previews with keyboard navigation. Type-to-filter
|
||||
is the one thing the rofi version had that this doesn't — see the roadmap.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
||||
[--outputs|--no-outputs] [--labels|--no-labels]
|
||||
[--order mru|tree] [--alt-tab|--no-alt-tab]
|
||||
[--font FAMILY] [--font-size PX]
|
||||
[--timeout SECS] [--verbose]
|
||||
```
|
||||
|
||||
- `--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)
|
||||
- `--outputs` / `--no-outputs` whether whole displays are tiles too (default
|
||||
off). Both directions exist so either can override the config file
|
||||
- `--labels` / `--no-labels` whether a label is drawn under each thumbnail
|
||||
(default on); `--hide-labels` is the old spelling and still works
|
||||
- `--order mru|tree` window ordering: Most-Recently-Used focus order or tree
|
||||
layout order (default `mru`)
|
||||
- `--alt-tab` / `--no-alt-tab` switcher mode: automatically pick on modifier
|
||||
release (default `auto`, enabled whenever a modifier was held on enter)
|
||||
- `--font FAMILY` label font family (default: the system monospace font)
|
||||
- `--font-size PX` label size in logical px
|
||||
- `--config PATH` config file (default `~/.config/wl-pick/config`)
|
||||
- `--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: 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
|
||||
# ~/.local/bin/winmenu, bound to $mod+Tab
|
||||
IFS=$'\t' read -r type id toplevel app title < <(wl-pick) || exit 0
|
||||
case $type in
|
||||
window) swaymsg "[con_id=$id] focus" ;;
|
||||
output) swaymsg "focus output $id" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
Windows only, as a one-liner:
|
||||
|
||||
```sh
|
||||
swaymsg "[con_id=$(wl-pick --no-outputs | cut -f2)] focus"
|
||||
```
|
||||
|
||||
Three formats, because the identifiers different consumers need differ:
|
||||
|
||||
| `--format` | output |
|
||||
|---|---|
|
||||
| `tsv` (default) | `TYPE⇥ID⇥TOPLEVEL_ID⇥APP⇥TITLE` — `ID` is the sway `con_id`, or the output name for a display; `TOPLEVEL_ID` is the ext-foreign-toplevel-list-v1 identifier that `grim -T` and the portal capture by |
|
||||
| `json` | the same record with every key always present, for `jq` |
|
||||
| `portal` | `Monitor: NAME` or `Window: TOPLEVEL_ID` |
|
||||
|
||||
`portal` is exactly what xdg-desktop-portal-wlr's `simple` chooser reads, so
|
||||
wl-pick can be the picker for `getDisplayMedia` and friends — with live previews
|
||||
of both windows and displays:
|
||||
|
||||
```ini
|
||||
[screencast]
|
||||
chooser_type=simple
|
||||
chooser_cmd=wl-pick --format portal
|
||||
```
|
||||
|
||||
**Starting a second wl-pick replaces the first.** The new overlay takes the
|
||||
keyboard grab, and the one that loses it exits without printing anything — so
|
||||
hitting the keybinding twice leaves you with one overlay, not a stranded
|
||||
process. The catch is that sway answers a capture request for a toplevel
|
||||
another client is already capturing with silence — no frame, no failure — so
|
||||
the replacement's thumbnails are mostly blank until the first instance has
|
||||
gone. Every wait before the overlay is interactive is capped at two seconds
|
||||
for that reason: a tile that never arrives is drawn as a bare label, and the
|
||||
grid still works.
|
||||
|
||||
| key | |
|
||||
|---|---|
|
||||
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
|
||||
| `↓` `↑` / `j` `k` | move a row |
|
||||
| `Home` `End` / `PgUp` `PgDn` | first / last, or a screen at a time |
|
||||
| `Enter` / release modifier | pick the selection (release Alt/Super in Alt+Tab mode) |
|
||||
| `Escape` / `q` | cancel |
|
||||
| click | pick that tile |
|
||||
| scroll | next / previous tile |
|
||||
|
||||
Hovering deliberately does not move the selection — the keyboard keeps it, and a
|
||||
click acts on whatever is under the cursor. Clicking the margin, a gap, or an
|
||||
empty cell of a ragged last row does nothing. Tiles are subsurfaces, so a click
|
||||
on a thumbnail identifies its tile by surface; only clicks on the chrome around
|
||||
them need hit-testing.
|
||||
|
||||
Navigation reads raw evdev keycodes, so it is layout-independent — but it also
|
||||
means virtual-keyboard clients such as `wtype` (which invent their own keymap)
|
||||
cannot drive it. That goes away with xkb support, which filtering needs anyway.
|
||||
|
||||
## Live previews
|
||||
|
||||
Capture sessions stay open, so a tile can be refreshed. Three things keep that
|
||||
from being expensive:
|
||||
|
||||
- **It is damage-driven.** After a session's first frame the compositor only
|
||||
produces another once the window content changes, so a request left
|
||||
outstanding on an idle window costs nothing. Measured over 4s with one
|
||||
animating window out of ten: `52,52,1,1,1,1,1,1,13,1` frames — the static
|
||||
windows delivered exactly their first frame and nothing more.
|
||||
- **Frame callbacks are the clock.** Re-captures are driven by the overlay's own
|
||||
`wl_surface.frame` callbacks, so they stop when it isn't being presented, and
|
||||
`--fps` throttles per tile on top of that (12 fps measured as 12.1).
|
||||
- **Two buffers per window, alternating.** A capture must not write into a buffer
|
||||
the compositor is reading, so each window gets two and `wl_buffer.release`
|
||||
decides which is free. Note that release is the entire contract: with `wl_shm`
|
||||
the compositor copies the pixels out at commit and hands the buffer straight
|
||||
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 (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.
|
||||
|
||||
## Config
|
||||
|
||||
`~/.config/wl-pick/config`, or `--config PATH`. Flat `key = value` lines with
|
||||
`#` comments, everything optional, and a flag always beats the file. No TOML
|
||||
dependency, because there is nothing to nest.
|
||||
|
||||
```ini
|
||||
background = #282828 # the grid's backdrop
|
||||
foreground = #ebdbb2 # label text
|
||||
selection = #d79921 # the highlighted tile
|
||||
selection-text = #282828 # its label
|
||||
border = #d79921
|
||||
border-width = 2px
|
||||
|
||||
max-width = 90ppt # the box the grid may fill
|
||||
max-height = 90ppt
|
||||
max-columns = 4 # thumbnails are that box divided by these
|
||||
max-rows = 4
|
||||
|
||||
font = monospace
|
||||
font-size = 13.3
|
||||
labels = yes
|
||||
outputs = no # include whole displays as tiles (default no)
|
||||
order = mru # mru (default) or tree
|
||||
alt-tab = auto # auto (default), yes or no
|
||||
live = all
|
||||
fps = 12
|
||||
format = tsv
|
||||
timeout = 0 # seconds; 0 means none
|
||||
```
|
||||
|
||||
Sizes take sway's units: `600px` is absolute, `90ppt` a percentage — and the
|
||||
percentage resolves against **the display the grid actually appears on**, every
|
||||
time it runs. On a mixed setup one file gives 90% of a 1280-wide laptop panel and
|
||||
90% of a 3840-wide monitor, rather than a pixel count that suits one and looks
|
||||
wrong on the other. The overlay maps explicitly on that display, at its scale, so
|
||||
mixed-DPI renders crisply either way.
|
||||
|
||||
All four sizing settings are **caps**:
|
||||
|
||||
- `max-width` and `max-height` bound the overlay.
|
||||
- `max-columns` and `max-rows` bound the grid inside it.
|
||||
|
||||
A thumbnail is simply that box divided by those caps, which means **its size
|
||||
never 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` scroll, with a scrollbar in the right
|
||||
margin, `PgUp`/`PgDn`, and the selection always kept in view. Tiles scrolled out
|
||||
of sight are unmapped, so live capture skips them too.
|
||||
|
||||
Turning labels off gives that row back to the thumbnails rather than shrinking
|
||||
the window, since the box is what you asked for either way.
|
||||
|
||||
## Look
|
||||
|
||||
The defaults come from the rofi theme this replaces: gruvbox dark, a yellow
|
||||
selection filling the element padding, `ceil(sqrt(n))` columns capped at 4,
|
||||
`title · app` centred underneath. Padding, gaps and margins are still fixed, in
|
||||
`src/theme.rs`.
|
||||
|
||||
The label font defaults to the system monospace font — whatever `fc-match
|
||||
monospace` answers, which is what the rest of the desktop uses. (cosmic-text's
|
||||
own generic resolves through a built-in preference that is usually not
|
||||
installed, and then lands on an arbitrary face, so it is asked directly
|
||||
instead; if fontconfig isn't available, a short list of common distribution
|
||||
defaults is tried.) `--font` names a family instead, and `--verbose` reports
|
||||
which family the labels were actually shaped with.
|
||||
|
||||
Naming a family scans your own font directories first because they are small;
|
||||
the full system scan (~37ms) happens only if it isn't found there. An unknown
|
||||
family falls back to whatever cosmic-text picks rather than failing. Long titles
|
||||
are ellipsised to the cell.
|
||||
|
||||
## Requirements
|
||||
|
||||
A wlroots compositor advertising `ext-image-copy-capture-v1`,
|
||||
`ext-image-capture-source-v1` (with the foreign-toplevel source manager),
|
||||
`ext-foreign-toplevel-list-v1`, `wlr-layer-shell-unstable-v1` and
|
||||
`wp_viewporter` — sway 1.11+, and in principle Hyprland, labwc and jay, though
|
||||
only sway is tested. sway is also the source of truth for the window list, over
|
||||
its IPC socket, which is the one thing that would need replacing to run
|
||||
elsewhere (`ext-foreign-toplevel-list-v1` already reports app id and title).
|
||||
|
||||
The socket is found from `SWAYSOCK`/`I3SOCK` when those point at something that
|
||||
exists, and otherwise by looking for the running sway's socket in
|
||||
`$XDG_RUNTIME_DIR`. Inheriting a stale path is easy — any process that outlives
|
||||
the sway that started it hands one to every shell it spawns — and a picker on a
|
||||
keybinding should not be the thing that notices.
|
||||
|
||||
Known upstream issue: holding per-toplevel capture sessions open makes windows
|
||||
blurry on **fractionally scaled** outputs
|
||||
([sway#9113](https://github.com/swaywm/sway/issues/9113)). Integer scales are
|
||||
unaffected. It matters more once previews are live.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- type-to-filter with fzf-quality fuzzy matching (and the xkb keyboard input it
|
||||
needs, which would also let virtual-keyboard clients drive the overlay)
|
||||
- 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 and hit-testing, ellipsising, output
|
||||
# formats, glyph output
|
||||
```
|
||||
+9
-17
@@ -46,8 +46,7 @@ use wayland_protocols::wp::viewporter::client::{
|
||||
};
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
||||
|
||||
use crate::capture::{Live, Tile};
|
||||
use crate::config::AltTabMode;
|
||||
use crate::capture::Tile;
|
||||
use crate::overlay;
|
||||
use crate::shm;
|
||||
use crate::target::Target;
|
||||
@@ -55,18 +54,14 @@ 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.
|
||||
/// live capture to do. `live` is always on for the alt-tab switcher mode.
|
||||
pub struct Settings {
|
||||
pub theme: Theme,
|
||||
pub live: Live,
|
||||
pub fps: u32,
|
||||
/// Integer scale of the display the overlay renders on, and its name, so
|
||||
/// the overlay maps there rather than wherever the compositor would put it.
|
||||
pub scale: i32,
|
||||
pub output: String,
|
||||
#[allow(dead_code)]
|
||||
pub alt_tab: AltTabMode,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
@@ -88,7 +83,6 @@ pub struct App {
|
||||
|
||||
pub(crate) theme: Theme,
|
||||
pub(crate) layout: Layout,
|
||||
pub(crate) live: Live,
|
||||
pub(crate) fps: u32,
|
||||
pub(crate) scale: i32,
|
||||
pub(crate) sel: usize,
|
||||
@@ -136,8 +130,8 @@ pub struct App {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
@@ -167,7 +161,7 @@ impl Ending {
|
||||
Ending::Picked => "picked",
|
||||
Ending::Cancelled => "cancelled",
|
||||
Ending::Closed => "the compositor closed the overlay",
|
||||
Ending::Unfocused => "lost the keyboard to another surface",
|
||||
Ending::Unfocused => "another surface holds the keyboard",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +176,6 @@ impl App {
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let Settings {
|
||||
theme,
|
||||
live,
|
||||
fps,
|
||||
scale,
|
||||
output,
|
||||
@@ -206,7 +199,6 @@ impl App {
|
||||
tiles: targets.into_iter().map(Tile::new).collect(),
|
||||
theme,
|
||||
layout,
|
||||
live,
|
||||
fps,
|
||||
scale,
|
||||
sel,
|
||||
@@ -268,7 +260,7 @@ impl App {
|
||||
eprintln!(" [{i}] {}{mark}", t.target.tsv());
|
||||
}
|
||||
eprintln!(
|
||||
"wl-pick: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
|
||||
"wl-tab: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
|
||||
at scale {}, {} MB of capture buffers, labels in {:?}",
|
||||
self.tiles.len(),
|
||||
self.tiles.iter().filter(|t| t.ready).count(),
|
||||
@@ -282,7 +274,7 @@ impl App {
|
||||
);
|
||||
if self.layout.scrollable() {
|
||||
eprintln!(
|
||||
"wl-pick: {} of {} rows fit; the rest scroll",
|
||||
"wl-tab: {} of {} rows fit; the rest scroll",
|
||||
self.layout.visible_rows, self.layout.rows
|
||||
);
|
||||
}
|
||||
@@ -293,7 +285,7 @@ impl App {
|
||||
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), \
|
||||
"wl-tab: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \
|
||||
{} starved; per tile: {}",
|
||||
self.ending.as_str(),
|
||||
frames as f64 / secs,
|
||||
@@ -322,7 +314,7 @@ impl App {
|
||||
.map(|t| t.target.title.as_str())
|
||||
.collect();
|
||||
eprintln!(
|
||||
"wl-pick: no frame for {} of {} tiles ({}); \
|
||||
"wl-tab: no frame for {} of {} tiles ({}); \
|
||||
another capture client may hold these sources",
|
||||
stuck.len(),
|
||||
self.tiles.len(),
|
||||
|
||||
+12
-42
@@ -7,12 +7,14 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! them again itself. Live previews are always on.
|
||||
|
||||
use std::error::Error;
|
||||
use std::os::fd::AsFd;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::Target;
|
||||
|
||||
use wayland_client::protocol::{
|
||||
wl_buffer::{self, WlBuffer},
|
||||
wl_callback, wl_output, wl_shm,
|
||||
@@ -30,32 +32,10 @@ use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::shm;
|
||||
use crate::target::{Kind, Target};
|
||||
use crate::target::Kind;
|
||||
|
||||
/// Which tiles keep updating after the first frame.
|
||||
#[derive(Clone, Copy, Debug, 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,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.trim() {
|
||||
"all" => Ok(Live::All),
|
||||
"current" => Ok(Live::Current),
|
||||
"none" => Ok(Live::None),
|
||||
other => Err(format!("{other:?} is not all, current or 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.
|
||||
/// 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,
|
||||
@@ -202,9 +182,8 @@ impl App {
|
||||
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 {
|
||||
// Always 2 buffers for live preview; display gets full-screen buffer.
|
||||
let slots = if tile.target.kind == Kind::Output {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
@@ -222,7 +201,7 @@ impl App {
|
||||
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 file = shm::memfd("wl-tab-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) = {
|
||||
@@ -301,9 +280,6 @@ impl App {
|
||||
/// 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<Self>) {
|
||||
if self.live == Live::None {
|
||||
return;
|
||||
}
|
||||
if let Some(surface) = self.surface.clone() {
|
||||
surface.frame(qh, ());
|
||||
surface.commit();
|
||||
@@ -314,22 +290,16 @@ impl App {
|
||||
/// the overlay is not being presented.
|
||||
pub fn tick(&mut self, qh: &QueueHandle<Self>) {
|
||||
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;
|
||||
}
|
||||
// Nor is there any point refreshing a tile that is scrolled out of
|
||||
// sight — that is a readback for pixels nobody sees.
|
||||
// sight - that is a readback for pixels nobody sees.
|
||||
if self.layout.tile(i as i32, self.scroll).is_none() {
|
||||
continue;
|
||||
}
|
||||
@@ -402,7 +372,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
||||
// frame yet leaves the tile without a thumbnail.
|
||||
if tile.frames == 0 {
|
||||
eprintln!(
|
||||
"wl-pick: capture failed for {:?} ({reason:?})",
|
||||
"wl-tab: capture failed for {:?} ({reason:?})",
|
||||
tile.target.title
|
||||
);
|
||||
}
|
||||
@@ -424,7 +394,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
||||
/// 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.)
|
||||
/// instead would deadlock - that release never comes twice.)
|
||||
impl Dispatch<WlBuffer, (usize, usize)> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
|
||||
+28
-55
@@ -4,40 +4,30 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::Settings;
|
||||
use crate::capture::Live;
|
||||
use crate::config::{AltTabMode, Config, Length};
|
||||
use crate::config::{Config, Length};
|
||||
use crate::sway::{Display, Order};
|
||||
use crate::target::Format;
|
||||
use crate::theme::Theme;
|
||||
|
||||
const HELP: &str = "\
|
||||
wl-pick — a live grid of window and display previews, for picking one
|
||||
wl-tab - a simple alt-tab switcher for sway
|
||||
|
||||
usage: wl-pick [options]
|
||||
usage: wl-tab [options]
|
||||
|
||||
--config PATH config file [~/.config/wl-pick/config]
|
||||
--config PATH config file [~/.config/wl-tab/config]
|
||||
--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]
|
||||
--outputs, --no-outputs include whole displays as tiles [no]
|
||||
--labels, --no-labels a label under each thumbnail [yes]
|
||||
--order mru|tree window ordering: mru or layout tree [mru]
|
||||
--alt-tab, --no-alt-tab alt-tab switcher mode (commit on release) [auto]
|
||||
--focus, --no-focus focus the picked target in sway directly [no]
|
||||
--font FAMILY label font family [the system monospace font]
|
||||
--focus, --no-focus focus the picked target directly [no]
|
||||
--font NAME label font family, optionally with a style, as
|
||||
in 'FiraCode Mono' [system monospace]
|
||||
--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; PgUp/PgDn and Home/End jump;
|
||||
Enter picks; Escape or q cancels; in Alt+Tab mode, releasing
|
||||
the modifier (Alt/Super) picks the selection.
|
||||
mouse: click a tile to pick it, scroll to move. Hovering does not move the
|
||||
selection, and a click outside a tile does nothing.
|
||||
|
||||
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.
|
||||
|
||||
@@ -48,20 +38,17 @@ config:
|
||||
percentage of the display the grid appears on, so one file suits monitors
|
||||
of different sizes.
|
||||
|
||||
background = #282828 # the grid's backdrop
|
||||
foreground = #ebdbb2 # label text
|
||||
selection = #d79921 # the highlighted tile
|
||||
selection-text = #282828 # its label
|
||||
border = #d79921
|
||||
background = #222222 # the grid's backdrop
|
||||
foreground = #888888 # label text
|
||||
selection = #285577 # the highlighted tile
|
||||
selection-text = #ffffff # its label
|
||||
border = #4c7899
|
||||
border-width = 2px
|
||||
|
||||
max-width = 90ppt # the box the grid may fill
|
||||
max-height = 90ppt
|
||||
max-columns = 4 # thumbnails are the box divided by these,
|
||||
max-rows = 4 # so their size never depends on how many
|
||||
# windows are open; further rows scroll
|
||||
|
||||
font = monospace # also --font
|
||||
font = monospace # a family, optionally with a style
|
||||
font-size = 13.3
|
||||
labels = yes
|
||||
outputs = no # include whole displays as tiles
|
||||
@@ -92,18 +79,7 @@ formats:
|
||||
|
||||
[screencast]
|
||||
chooser_type=simple
|
||||
chooser_cmd=wl-pick --format portal
|
||||
|
||||
focusing on sway:
|
||||
|
||||
wl-pick --focus or:
|
||||
|
||||
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
|
||||
";
|
||||
chooser_cmd=wl-tab --format portal";
|
||||
|
||||
/// What the command line asked for. Every setting is optional so the config file
|
||||
/// can fill the gaps: a flag beats the file, the file beats the default.
|
||||
@@ -116,11 +92,9 @@ pub struct Args {
|
||||
pub(crate) labels: Option<bool>,
|
||||
pub(crate) font: Option<String>,
|
||||
pub(crate) font_size: Option<f32>,
|
||||
pub(crate) live: Option<Live>,
|
||||
pub(crate) fps: Option<u32>,
|
||||
pub(crate) timeout: Option<Duration>,
|
||||
pub(crate) order: Option<Order>,
|
||||
pub(crate) alt_tab: Option<AltTabMode>,
|
||||
pub(crate) focus: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -144,7 +118,7 @@ pub fn arm_timeout(timeout: Option<Duration>) {
|
||||
if let Some(d) = timeout {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(d);
|
||||
eprintln!("wl-pick: timeout");
|
||||
eprintln!("wl-tab: timeout");
|
||||
std::process::exit(2);
|
||||
});
|
||||
}
|
||||
@@ -204,11 +178,9 @@ impl Args {
|
||||
display: (display.width, display.height),
|
||||
settings: Settings {
|
||||
theme,
|
||||
live: self.live.or(cfg.live).unwrap_or(Live::All),
|
||||
fps: self.fps.or(cfg.fps).unwrap_or(12),
|
||||
scale: display.scale,
|
||||
output: display.name.clone(),
|
||||
alt_tab: self.alt_tab.or(cfg.alt_tab).unwrap_or(AltTabMode::Auto),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -233,8 +205,6 @@ fn parse(it: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
let v = it.next().ok_or("--order needs mru|tree")?;
|
||||
args.order = Some(Order::parse(&v)?);
|
||||
}
|
||||
"--alt-tab" => args.alt_tab = Some(AltTabMode::Yes),
|
||||
"--no-alt-tab" => args.alt_tab = Some(AltTabMode::No),
|
||||
"--focus" => args.focus = Some(true),
|
||||
"--no-focus" => args.focus = Some(false),
|
||||
"--config" => {
|
||||
@@ -245,15 +215,11 @@ fn parse(it: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
// --hide-labels was the only spelling before --labels existed, and
|
||||
// is still accepted for whatever it is wired into.
|
||||
"--no-labels" | "--hide-labels" => args.labels = Some(false),
|
||||
"--live" => {
|
||||
let v = it.next().ok_or("--live needs all|current|none")?;
|
||||
args.live = Some(Live::parse(&v)?);
|
||||
}
|
||||
"--fps" => {
|
||||
let v = it.next().ok_or("--fps needs a number")?;
|
||||
args.fps = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?);
|
||||
}
|
||||
"--font" => args.font = Some(it.next().ok_or("--font needs a family name")?),
|
||||
"--font" => args.font = Some(it.next().ok_or("--font needs a font 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}"))?);
|
||||
@@ -307,8 +273,6 @@ mod tests {
|
||||
assert_eq!(args(&["--labels"]).labels, Some(true));
|
||||
assert_eq!(args(&["--no-labels"]).labels, Some(false));
|
||||
assert_eq!(args(&["--hide-labels"]).labels, Some(false), "old spelling");
|
||||
assert_eq!(args(&["--alt-tab"]).alt_tab, Some(AltTabMode::Yes));
|
||||
assert_eq!(args(&["--no-alt-tab"]).alt_tab, Some(AltTabMode::No));
|
||||
// Unset is what lets the file have its say.
|
||||
assert_eq!(args(&[]).outputs, None);
|
||||
assert_eq!(args(&[]).labels, None);
|
||||
@@ -330,10 +294,19 @@ mod tests {
|
||||
// With no flag the file decides, and with no file either, the default (outputs: false).
|
||||
assert!(!args(&[]).resolve(&file(false), &display()).outputs);
|
||||
assert!(!args(&[]).resolve(&Config::default(), &display()).outputs);
|
||||
assert!(args(&["--outputs"]).resolve(&Config::default(), &display()).outputs);
|
||||
assert_eq!(args(&[]).resolve(&Config::default(), &display()).order, Order::Mru);
|
||||
assert!(
|
||||
args(&["--outputs"])
|
||||
.resolve(&Config::default(), &display())
|
||||
.outputs
|
||||
);
|
||||
assert_eq!(
|
||||
args(&["--order", "tree"]).resolve(&Config::default(), &display()).order,
|
||||
args(&[]).resolve(&Config::default(), &display()).order,
|
||||
Order::Mru
|
||||
);
|
||||
assert_eq!(
|
||||
args(&["--order", "tree"])
|
||||
.resolve(&Config::default(), &display())
|
||||
.order,
|
||||
Order::Tree
|
||||
);
|
||||
}
|
||||
|
||||
+7
-46
@@ -1,43 +1,23 @@
|
||||
//! The config file: `~/.config/wl-pick/config`.
|
||||
//! The config file: `~/.config/wl-tab/config`.
|
||||
//!
|
||||
//! Flat `key = value` lines with `#` comments — no sections, no nesting, so a
|
||||
//! Flat `key = value` lines with `#` comments - no sections, no nesting, so a
|
||||
//! TOML parser would be a dependency bought for nothing. Every setting is
|
||||
//! optional; anything absent keeps its default, and a command-line flag beats
|
||||
//! the file.
|
||||
//!
|
||||
//! Sizes take sway's syntax: `600px` is absolute, `70ppt` is 70 percent of the
|
||||
//! display the grid appears on. That matters on a multi-monitor setup, where a
|
||||
//! pixel size that suits one screen is wrong on the next — percentages are
|
||||
//! pixel size that suits one screen is wrong on the next - percentages are
|
||||
//! resolved against whichever display the overlay actually maps on, each time
|
||||
//! it runs.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::capture::Live;
|
||||
use crate::sway::Order;
|
||||
use crate::target::Format;
|
||||
use crate::theme::Argb;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum AltTabMode {
|
||||
#[default]
|
||||
Auto,
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
impl AltTabMode {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.trim() {
|
||||
"auto" => Ok(AltTabMode::Auto),
|
||||
"yes" | "true" | "on" | "1" => Ok(AltTabMode::Yes),
|
||||
"no" | "false" | "off" | "0" => Ok(AltTabMode::No),
|
||||
other => Err(format!("{other:?} is not auto, yes or no")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A size, either absolute or relative to the display it will be shown on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Length {
|
||||
@@ -114,12 +94,10 @@ pub struct Config {
|
||||
pub font_size: Option<f32>,
|
||||
pub labels: Option<bool>,
|
||||
pub outputs: Option<bool>,
|
||||
pub live: Option<Live>,
|
||||
pub fps: Option<u32>,
|
||||
pub format: Option<Format>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub order: Option<Order>,
|
||||
pub alt_tab: Option<AltTabMode>,
|
||||
pub focus: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -171,7 +149,7 @@ impl Config {
|
||||
"font-size" => self.font_size = Some(number(value)?),
|
||||
"labels" => self.labels = Some(boolean(value)?),
|
||||
"outputs" => self.outputs = Some(boolean(value)?),
|
||||
"live" => self.live = Some(Live::parse(value)?),
|
||||
|
||||
"fps" => self.fps = Some(number(value)?),
|
||||
"format" => self.format = Some(Format::parse(value)?),
|
||||
// Zero is how you say "no timeout"; an immediate deadline would
|
||||
@@ -181,7 +159,6 @@ impl Config {
|
||||
self.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs));
|
||||
}
|
||||
"order" => self.order = Some(Order::parse(value)?),
|
||||
"alt-tab" => self.alt_tab = Some(AltTabMode::parse(value)?),
|
||||
"focus" => self.focus = Some(boolean(value)?),
|
||||
other => return Err(format!("unknown setting {other:?}")),
|
||||
}
|
||||
@@ -209,13 +186,13 @@ fn number<T: std::str::FromStr>(s: &str) -> Result<T, String> {
|
||||
.map_err(|_| format!("{s:?} is not a number"))
|
||||
}
|
||||
|
||||
/// `$XDG_CONFIG_HOME/wl-pick/config`, or `~/.config/wl-pick/config`.
|
||||
/// `$XDG_CONFIG_HOME/wl-tab/config`, or `~/.config/wl-tab/config`.
|
||||
fn default_path() -> PathBuf {
|
||||
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.unwrap_or_default();
|
||||
dir.join("wl-pick").join("config")
|
||||
dir.join("wl-tab").join("config")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -264,12 +241,10 @@ max-width = 70ppt
|
||||
max-columns = 4
|
||||
max-rows = 3
|
||||
|
||||
live = current
|
||||
fps = 30
|
||||
labels = no
|
||||
timeout = 0
|
||||
order = mru
|
||||
alt-tab = yes
|
||||
",
|
||||
)
|
||||
.expect("should parse");
|
||||
@@ -283,8 +258,6 @@ alt-tab = yes
|
||||
assert_eq!(cfg.labels, Some(false));
|
||||
assert_eq!(cfg.timeout, None, "zero means no timeout");
|
||||
assert_eq!(cfg.order, Some(Order::Mru));
|
||||
assert_eq!(cfg.alt_tab, Some(AltTabMode::Yes));
|
||||
assert!(cfg.live.is_some());
|
||||
// Untouched settings stay unset, so defaults survive.
|
||||
assert_eq!(cfg.foreground, None);
|
||||
assert_eq!(cfg.max_height, None);
|
||||
@@ -315,7 +288,7 @@ alt-tab = yes
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_not_an_error() {
|
||||
let missing = Path::new("/nonexistent/wl-pick/config");
|
||||
let missing = Path::new("/nonexistent/wl-tab/config");
|
||||
assert!(
|
||||
Config::load(Some(missing)).is_err(),
|
||||
"named file must exist"
|
||||
@@ -324,16 +297,4 @@ alt-tab = yes
|
||||
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/nonexistent") };
|
||||
assert!(Config::load(None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_tab_mode_parses() {
|
||||
assert_eq!(AltTabMode::parse("auto"), Ok(AltTabMode::Auto));
|
||||
assert_eq!(AltTabMode::parse("yes"), Ok(AltTabMode::Yes));
|
||||
assert_eq!(AltTabMode::parse("true"), Ok(AltTabMode::Yes));
|
||||
assert_eq!(AltTabMode::parse("1"), Ok(AltTabMode::Yes));
|
||||
assert_eq!(AltTabMode::parse("no"), Ok(AltTabMode::No));
|
||||
assert_eq!(AltTabMode::parse("false"), Ok(AltTabMode::No));
|
||||
assert_eq!(AltTabMode::parse("0"), Ok(AltTabMode::No));
|
||||
assert!(AltTabMode::parse("maybe").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,4 +1,4 @@
|
||||
//! wl-pick shows a live grid of every window and display as a layer-shell
|
||||
//! wl-tab 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.
|
||||
//!
|
||||
@@ -11,13 +11,13 @@
|
||||
//! 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
|
||||
//! - `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.
|
||||
@@ -61,7 +61,7 @@ fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("wl-pick: {e}");
|
||||
eprintln!("wl-tab: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
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
|
||||
// good ends the run: that is how a second wl-tab, 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.
|
||||
@@ -259,7 +259,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
// 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);
|
||||
eprintln!("wl-tab: {:?} has no toplevel identifier", target.title);
|
||||
return Ok(ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
/// 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
|
||||
/// 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,
|
||||
|
||||
+25
-36
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! a pixel of window content - only the background, selection and labels.
|
||||
|
||||
use std::error::Error;
|
||||
use std::os::fd::AsFd;
|
||||
@@ -34,11 +34,6 @@ const KEY_TAB: u32 = 15;
|
||||
const KEY_Q: u32 = 16;
|
||||
const KEY_ENTER: u32 = 28;
|
||||
const KEY_LEFTCTRL: u32 = 29;
|
||||
// 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_LEFTSHIFT: u32 = 42;
|
||||
const KEY_RIGHTSHIFT: u32 = 54;
|
||||
const KEY_LEFTALT: u32 = 56;
|
||||
@@ -59,12 +54,7 @@ const KEY_RIGHTMETA: u32 = 126;
|
||||
fn is_trigger_modifier(code: u32) -> bool {
|
||||
matches!(
|
||||
code,
|
||||
KEY_LEFTALT
|
||||
| KEY_RIGHTALT
|
||||
| KEY_LEFTMETA
|
||||
| KEY_RIGHTMETA
|
||||
| KEY_LEFTCTRL
|
||||
| KEY_RIGHTCTRL
|
||||
KEY_LEFTALT | KEY_RIGHTALT | KEY_LEFTMETA | KEY_RIGHTMETA | KEY_LEFTCTRL | KEY_RIGHTCTRL
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,10 +71,6 @@ fn is_repeatable_key(code: u32) -> bool {
|
||||
| KEY_END
|
||||
| KEY_PGUP
|
||||
| KEY_PGDN
|
||||
| KEY_H
|
||||
| KEY_J
|
||||
| KEY_K
|
||||
| KEY_L
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +103,7 @@ impl App {
|
||||
&surface,
|
||||
output,
|
||||
Layer::Overlay,
|
||||
"wl-pick".to_string(),
|
||||
"wl-tab".to_string(),
|
||||
qh,
|
||||
(),
|
||||
);
|
||||
@@ -128,7 +114,7 @@ impl App {
|
||||
|
||||
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 file = shm::memfd("wl-tab-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(
|
||||
@@ -153,7 +139,7 @@ impl App {
|
||||
/// Put every visible tile where the viewport says, and unmap the rest.
|
||||
///
|
||||
/// Runs again after each scroll, so a tile scrolled off screen gets a null
|
||||
/// buffer — the way to hide a subsurface — rather than being left behind.
|
||||
/// buffer - the way to hide a subsurface - rather than being left behind.
|
||||
/// Scaling stays the compositor's job: the capture buffer is attached as it
|
||||
/// is, and wp_viewporter names the rectangle to fit it into.
|
||||
pub fn sync_tiles(&mut self, qh: &QueueHandle<Self>) {
|
||||
@@ -178,8 +164,8 @@ impl App {
|
||||
let surface = self.compositor.create_surface(qh, ());
|
||||
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
||||
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
||||
// Tiles change independently of the chrome — a live frame
|
||||
// arrives whenever its window does — so they must not wait on a
|
||||
// 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
|
||||
@@ -253,7 +239,7 @@ impl App {
|
||||
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
|
||||
// The selection fills the whole element box, padding included - the same
|
||||
// thing rofi's element background does. It can be scrolled out of sight.
|
||||
if let Some(elem) = elem {
|
||||
p.rect(elem, sel_bg);
|
||||
@@ -307,7 +293,7 @@ impl App {
|
||||
}
|
||||
|
||||
/// The tile under the pointer, if it is over one. A tile's own subsurface
|
||||
/// answers directly; over the parent surface — padding, labels, gaps — the
|
||||
/// answers directly; over the parent surface - padding, labels, gaps - the
|
||||
/// layout is asked instead.
|
||||
fn tile_at_pointer(&self) -> Option<usize> {
|
||||
let hover = self.hover.as_ref()?;
|
||||
@@ -322,8 +308,8 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// Press and release on the same tile picks it. Anywhere else — the margin,
|
||||
/// a gap, an empty cell of the last row — does nothing at all.
|
||||
/// Press and release on the same tile picks it. Anywhere else - the margin,
|
||||
/// a gap, an empty cell of the last row - does nothing at all.
|
||||
fn click(&mut self, pressed: bool) {
|
||||
if pressed {
|
||||
self.pressed = self.tile_at_pointer();
|
||||
@@ -358,10 +344,10 @@ impl App {
|
||||
self.ending = Ending::Picked;
|
||||
}
|
||||
KEY_TAB if self.shift => self.move_sel(-1, qh),
|
||||
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1, qh),
|
||||
KEY_LEFT | KEY_H => self.move_sel(-1, qh),
|
||||
KEY_DOWN | KEY_J => self.move_row(1, qh),
|
||||
KEY_UP | KEY_K => self.move_row(-1, qh),
|
||||
KEY_TAB | KEY_RIGHT => self.move_sel(1, qh),
|
||||
KEY_LEFT => self.move_sel(-1, qh),
|
||||
KEY_DOWN => self.move_sel(1, qh),
|
||||
KEY_UP => self.move_sel(-1, qh),
|
||||
KEY_HOME => self.select(0, qh),
|
||||
KEY_END => self.select(self.tiles.len().saturating_sub(1), qh),
|
||||
KEY_PGUP => self.move_row(-self.layout.visible_rows, qh),
|
||||
@@ -396,10 +382,10 @@ impl App {
|
||||
// Re-run the navigation action without re-arming the delay.
|
||||
match code {
|
||||
KEY_TAB if self.shift => self.move_sel(-1, qh),
|
||||
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1, qh),
|
||||
KEY_LEFT | KEY_H => self.move_sel(-1, qh),
|
||||
KEY_DOWN | KEY_J => self.move_row(1, qh),
|
||||
KEY_UP | KEY_K => self.move_row(-1, qh),
|
||||
KEY_TAB | KEY_RIGHT => self.move_sel(1, qh),
|
||||
KEY_LEFT => self.move_sel(-1, qh),
|
||||
KEY_DOWN => self.move_sel(1, qh),
|
||||
KEY_UP => self.move_sel(-1, qh),
|
||||
KEY_HOME => self.select(0, qh),
|
||||
KEY_END => self.select(self.tiles.len().saturating_sub(1), qh),
|
||||
KEY_PGUP => self.move_row(-self.layout.visible_rows, qh),
|
||||
@@ -415,7 +401,10 @@ impl App {
|
||||
.map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap()))
|
||||
.collect();
|
||||
|
||||
if held_keys.iter().any(|&k| k == KEY_LEFTSHIFT || k == KEY_RIGHTSHIFT) {
|
||||
if held_keys
|
||||
.iter()
|
||||
.any(|&k| k == KEY_LEFTSHIFT || k == KEY_RIGHTSHIFT)
|
||||
{
|
||||
self.shift = true;
|
||||
}
|
||||
|
||||
@@ -534,7 +523,7 @@ impl Dispatch<WlKeyboard, ()> for App {
|
||||
wl_keyboard::Event::Enter { keys, .. } => app.keyboard_enter(keys, qh),
|
||||
wl_keyboard::Event::Leave { .. } => {
|
||||
app.focused = false;
|
||||
// Clear any held repeat — we no longer have the keyboard.
|
||||
// Clear any held repeat - we no longer have the keyboard.
|
||||
app.repeat_key = None;
|
||||
app.repeat_next = None;
|
||||
}
|
||||
@@ -543,7 +532,7 @@ impl Dispatch<WlKeyboard, ()> for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hovering does not move the selection — that belongs to the keyboard — so the
|
||||
/// Hovering does not move the selection - that belongs to the keyboard - so the
|
||||
/// pointer only tracks where it is and what it clicked. Scrolling is a
|
||||
/// deliberate gesture, so that does move the selection.
|
||||
impl Dispatch<WlPointer, ()> for App {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! Capture buffers deliberately never get mapped into this process. The
|
||||
//! compositor writes the window pixels and then samples them again for display,
|
||||
//! so we only need the fd — mapping them would fault ~7 MB per window into our
|
||||
//! so we only need the fd - mapping them would fault ~7 MB per window into our
|
||||
//! address space for nothing.
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
+5
-9
@@ -2,7 +2,7 @@
|
||||
//! Wayland side only supplies pixels. The join between the two is
|
||||
//! `foreign_toplevel_identifier`, which sway reports per view.
|
||||
//!
|
||||
//! Acting on the choice is deliberately not here: wl-pick reports what was picked
|
||||
//! Acting on the choice is deliberately not here: wl-tab reports what was picked
|
||||
//! and the caller decides what that means.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
@@ -33,7 +33,7 @@ pub fn connect() -> Result<Connection, String> {
|
||||
let live = live_sockets();
|
||||
let [path] = live.as_slice() else {
|
||||
return Err(if live.is_empty() {
|
||||
"cannot reach sway; wl-pick reads the window list from its IPC \
|
||||
"cannot reach sway; wl-tab reads the window list from its IPC \
|
||||
socket, and no running sway has one"
|
||||
.to_string()
|
||||
} else {
|
||||
@@ -123,7 +123,7 @@ fn history_path() -> PathBuf {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("wl-pick-history")
|
||||
.join("wl-tab-history")
|
||||
}
|
||||
|
||||
pub fn record_focus(con_id: i64) {
|
||||
@@ -186,11 +186,7 @@ pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayi
|
||||
return (1, idx, 0);
|
||||
}
|
||||
}
|
||||
if t.visible {
|
||||
(2, 0, 0)
|
||||
} else {
|
||||
(3, 0, 0)
|
||||
}
|
||||
if t.visible { (2, 0, 0) } else { (3, 0, 0) }
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -228,7 +224,7 @@ fn collect_tree(node: &Node, out: &mut Vec<Target>) {
|
||||
/// One active display: what the overlay needs to size itself against.
|
||||
///
|
||||
/// The overlay maps on the focused display, so percentages and the buffer scale
|
||||
/// are resolved against *that* one — on a mixed-DPI, mixed-size setup the
|
||||
/// are resolved against *that* one - on a mixed-DPI, mixed-size setup the
|
||||
/// numbers differ per monitor, and taking the largest of everything would be
|
||||
/// wrong on all but one.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
//! What a tile stands for: a window, or a whole display.
|
||||
//!
|
||||
//! Both are capture sources as far as the protocol is concerned — one from a
|
||||
//! foreign-toplevel handle, one from a `wl_output` — so the grid treats them
|
||||
//! Both are capture sources as far as the protocol is concerned - one from a
|
||||
//! foreign-toplevel handle, one from a `wl_output` - so the grid treats them
|
||||
//! alike and only differs in how it labels them and what picking one does.
|
||||
|
||||
use std::fmt;
|
||||
@@ -110,8 +110,8 @@ impl Target {
|
||||
/// `IFS=$'\t' read -r type id toplevel app title`.
|
||||
///
|
||||
/// Both identifiers are there because both get used: sway scripting acts on
|
||||
/// the con_id (`[con_id=N] focus`), while tools that capture a window —
|
||||
/// grim -T, the desktop portal — want the foreign-toplevel identifier.
|
||||
/// the con_id (`[con_id=N] focus`), while tools that capture a window -
|
||||
/// grim -T, the desktop portal - want the foreign-toplevel identifier.
|
||||
pub fn tsv(&self) -> String {
|
||||
format!(
|
||||
"{}\t{}\t{}\t{}\t{}",
|
||||
@@ -162,7 +162,7 @@ impl Target {
|
||||
|
||||
/// What xdg-desktop-portal-wlr's `simple` chooser accepts: `Monitor: NAME`
|
||||
/// or `Window: <foreign-toplevel identifier>`. A window the compositor never
|
||||
/// gave an identifier for cannot be named this way, hence the Option — and
|
||||
/// gave an identifier for cannot be named this way, hence the Option - and
|
||||
/// an empty stdout is exactly how that chooser says "declined".
|
||||
pub fn portal(&self) -> Option<String> {
|
||||
match self.kind {
|
||||
|
||||
+245
-31
@@ -1,18 +1,19 @@
|
||||
//! Labels.
|
||||
//!
|
||||
//! Building a font system and rasterising the first glyphs costs ~55ms, which is
|
||||
//! almost exactly the window the compositor spends copying window pixels back
|
||||
//! for us. So all of it happens on a worker thread started before the captures
|
||||
//! Building a font system and rasterising the first glyphs costs ~55ms, about
|
||||
//! what the compositor spends copying window pixels back for a handful of
|
||||
//! windows. So all of it happens on a worker thread started before the captures
|
||||
//! and joined after them: by the time anything is drawn, every label is shaped
|
||||
//! and its glyphs are already in the cache, and painting one costs ~0.1ms.
|
||||
//!
|
||||
//! Sizes here are physical pixels — the caller scales logical units first,
|
||||
//! Sizes here are physical pixels - the caller scales logical units first,
|
||||
//! because the chrome buffer it paints into is physical too.
|
||||
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use cosmic_text::{
|
||||
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap, fontdb,
|
||||
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight,
|
||||
Wrap, fontdb,
|
||||
};
|
||||
|
||||
use crate::shm::Painter;
|
||||
@@ -54,7 +55,7 @@ const MONO_CANDIDATES: &[&str] = &[
|
||||
|
||||
/// Load the smallest font database that can render `family`.
|
||||
///
|
||||
/// `FontSystem::new()` scans every system font, which costs ~37ms — most of the
|
||||
/// `FontSystem::new()` scans every system font, which costs ~37ms - most of the
|
||||
/// startup budget. A user's own font directories are tiny by comparison, so try
|
||||
/// those first and only pay for the full scan when the family really isn't there
|
||||
/// (which is also what makes an unknown family fall back gracefully). The
|
||||
@@ -66,7 +67,7 @@ fn font_db(family: &str) -> FontSystem {
|
||||
db.load_fonts_dir(format!("{home}/.fonts"));
|
||||
db.load_fonts_dir(format!("{home}/.local/share/fonts"));
|
||||
}
|
||||
if has_family(&db, family) {
|
||||
if interpret(&db, family).is_some() {
|
||||
// The locale only orders CJK fallbacks; labels are ids and titles.
|
||||
return FontSystem::new_with_locale_and_db("en-US".to_string(), db);
|
||||
}
|
||||
@@ -79,31 +80,178 @@ fn is_generic(family: &str) -> bool {
|
||||
family.eq_ignore_ascii_case(SYSTEM_MONO)
|
||||
}
|
||||
|
||||
fn has_family(db: &fontdb::Database, family: &str) -> bool {
|
||||
fn family_named(db: &fontdb::Database, want: &str) -> Option<String> {
|
||||
db.faces()
|
||||
.any(|f| f.families.iter().any(|(name, _)| name == family))
|
||||
.flat_map(|face| face.families.iter())
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(want))
|
||||
.map(|(name, _)| name.clone())
|
||||
}
|
||||
|
||||
/// Turn the generic default into a real family name.
|
||||
/// A resolved family and the face style requested within it.
|
||||
///
|
||||
/// cosmic-text's own generic resolves through fontdb's built-in preference
|
||||
/// ("FreeMono"), which is usually absent and then lands on an arbitrary face — so
|
||||
/// ask fontconfig instead, since that is what the rest of the desktop uses. A
|
||||
/// named family passes through untouched; if it turns out to be missing,
|
||||
/// cosmic-text falls back on its own.
|
||||
fn resolve_family(db: &fontdb::Database, family: &str) -> String {
|
||||
if !is_generic(family) {
|
||||
return family.to_string();
|
||||
/// ("FreeMono"), which is usually absent and then lands on an arbitrary face - so
|
||||
/// ask fontconfig instead, since that is what the rest of the desktop uses.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Choice {
|
||||
family: String,
|
||||
weight: Weight,
|
||||
stretch: Stretch,
|
||||
}
|
||||
|
||||
impl Choice {
|
||||
fn plain(family: &str) -> Self {
|
||||
Self {
|
||||
family: family.to_string(),
|
||||
weight: Weight::NORMAL,
|
||||
stretch: Stretch::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn attrs(&self) -> Attrs<'_> {
|
||||
Attrs::new()
|
||||
.family(Family::Name(&self.family))
|
||||
.weight(self.weight)
|
||||
.stretch(self.stretch)
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
let mut parts = vec![self.family.as_str()];
|
||||
if self.weight != Weight::NORMAL {
|
||||
parts.extend(spelling(WEIGHTS, self.weight));
|
||||
}
|
||||
if self.stretch != Stretch::Normal {
|
||||
parts.extend(spelling(WIDTHS, self.stretch));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret(db: &fontdb::Database, request: &str) -> Option<Choice> {
|
||||
split_request(request, |name| family_named(db, name))
|
||||
}
|
||||
|
||||
fn split_request(request: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<Choice> {
|
||||
let words: Vec<&str> = request.split_whitespace().collect();
|
||||
for split in (1..=words.len()).rev() {
|
||||
let Some(family) = lookup(&words[..split].join(" ")) else {
|
||||
continue;
|
||||
};
|
||||
let mut choice = Choice::plain(&family);
|
||||
if read_style(&words[split..], &mut choice) {
|
||||
return Some(choice);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_style(words: &[&str], choice: &mut Choice) -> bool {
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
let pair = words.get(i..i + 2).map(|two| two.concat());
|
||||
if pair
|
||||
.as_deref()
|
||||
.is_some_and(|pair| apply_style(pair, choice))
|
||||
{
|
||||
i += 2;
|
||||
} else if apply_style(words[i], choice) {
|
||||
i += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_style(word: &str, choice: &mut Choice) -> bool {
|
||||
if let Some(weight) = lookup(WEIGHTS, word) {
|
||||
choice.weight = weight;
|
||||
} else if let Some(stretch) = lookup(WIDTHS, word) {
|
||||
choice.stretch = stretch;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn normalise(word: &str) -> String {
|
||||
word.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
const WEIGHTS: &[(&str, Weight)] = &[
|
||||
("Thin", Weight::THIN),
|
||||
("Hairline", Weight::THIN),
|
||||
("ExtraLight", Weight::EXTRA_LIGHT),
|
||||
("UltraLight", Weight::EXTRA_LIGHT),
|
||||
("Light", Weight::LIGHT),
|
||||
("Regular", Weight::NORMAL),
|
||||
("Normal", Weight::NORMAL),
|
||||
("Book", Weight::NORMAL),
|
||||
("Medium", Weight::MEDIUM),
|
||||
("SemiBold", Weight::SEMIBOLD),
|
||||
("DemiBold", Weight::SEMIBOLD),
|
||||
("Bold", Weight::BOLD),
|
||||
("ExtraBold", Weight::EXTRA_BOLD),
|
||||
("UltraBold", Weight::EXTRA_BOLD),
|
||||
("Black", Weight::BLACK),
|
||||
("Heavy", Weight::BLACK),
|
||||
];
|
||||
|
||||
const WIDTHS: &[(&str, Stretch)] = &[
|
||||
("UltraCondensed", Stretch::UltraCondensed),
|
||||
("ExtraCondensed", Stretch::ExtraCondensed),
|
||||
("Condensed", Stretch::Condensed),
|
||||
("SemiCondensed", Stretch::SemiCondensed),
|
||||
("Normal", Stretch::Normal),
|
||||
("SemiExpanded", Stretch::SemiExpanded),
|
||||
("Expanded", Stretch::Expanded),
|
||||
("ExtraExpanded", Stretch::ExtraExpanded),
|
||||
("UltraExpanded", Stretch::UltraExpanded),
|
||||
];
|
||||
|
||||
fn lookup<T: Copy>(table: &[(&str, T)], word: &str) -> Option<T> {
|
||||
let word = normalise(word);
|
||||
table
|
||||
.iter()
|
||||
.find(|(spelling, _)| normalise(spelling) == word)
|
||||
.map(|(_, value)| *value)
|
||||
}
|
||||
|
||||
fn spelling<T: PartialEq>(table: &[(&'static str, T)], value: T) -> Option<&'static str> {
|
||||
table
|
||||
.iter()
|
||||
.find(|(_, known)| *known == value)
|
||||
.map(|(word, _)| *word)
|
||||
}
|
||||
|
||||
fn choose(db: &fontdb::Database, request: &str) -> Choice {
|
||||
if !is_generic(request) {
|
||||
if let Some(choice) = interpret(db, request) {
|
||||
return choice;
|
||||
}
|
||||
let fallback = system_mono(db);
|
||||
eprintln!(
|
||||
"wl-tab: no font matching {request:?}, using {:?}; `fc-match -f '%{{family}}\\n' {request:?}` names the family",
|
||||
fallback.family
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
system_mono(db)
|
||||
}
|
||||
|
||||
fn system_mono(db: &fontdb::Database) -> Choice {
|
||||
fc_match_mono()
|
||||
.filter(|name| has_family(db, name))
|
||||
.filter(|name| family_named(db, name).is_some())
|
||||
.or_else(|| {
|
||||
MONO_CANDIDATES
|
||||
.iter()
|
||||
.find(|name| has_family(db, name))
|
||||
.find(|name| family_named(db, name).is_some())
|
||||
.map(|name| name.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| family.to_string())
|
||||
.map_or_else(|| Choice::plain(SYSTEM_MONO), |name| Choice::plain(&name))
|
||||
}
|
||||
|
||||
/// What fontconfig says "monospace" means here. A system without the fontconfig
|
||||
@@ -122,8 +270,8 @@ fn fc_match_mono() -> Option<String> {
|
||||
fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels {
|
||||
let mut fs = font_db(&family);
|
||||
let mut cache = SwashCache::new();
|
||||
let family = resolve_family(fs.db(), &family);
|
||||
let attrs = Attrs::new().family(Family::Name(&family));
|
||||
let choice = choose(fs.db(), &family);
|
||||
let attrs = choice.attrs();
|
||||
let metrics = Metrics::new(font_px, line_h);
|
||||
|
||||
let mut lines = Vec::with_capacity(texts.len());
|
||||
@@ -141,11 +289,11 @@ fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f
|
||||
fs,
|
||||
cache,
|
||||
lines,
|
||||
family,
|
||||
family: choice.name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten `text` until it fits in `box_w`, ending with an ellipsis — window
|
||||
/// Shorten `text` until it fits in `box_w`, ending with an ellipsis - window
|
||||
/// titles are arbitrarily long, and rofi ellipsised them too.
|
||||
fn ellipsize(
|
||||
fs: &mut FontSystem,
|
||||
@@ -242,14 +390,80 @@ mod tests {
|
||||
#[test]
|
||||
fn the_generic_default_resolves_to_a_real_monospace_family() {
|
||||
let fs = font_db(SYSTEM_MONO);
|
||||
let resolved = resolve_family(fs.db(), SYSTEM_MONO);
|
||||
assert_ne!(resolved, SYSTEM_MONO, "should have named a real family");
|
||||
assert!(
|
||||
has_family(fs.db(), &resolved),
|
||||
"{resolved:?} is not in the database"
|
||||
let choice = choose(fs.db(), SYSTEM_MONO);
|
||||
assert_ne!(
|
||||
choice.family, SYSTEM_MONO,
|
||||
"should have named a real family"
|
||||
);
|
||||
// A named family passes through, present or not.
|
||||
assert_eq!(resolve_family(fs.db(), "Some Font"), "Some Font");
|
||||
assert!(
|
||||
family_named(fs.db(), &choice.family).is_some(),
|
||||
"{:?} is not in the database",
|
||||
choice.family
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_font_falls_back_to_the_default() {
|
||||
let fs = font_db(SYSTEM_MONO);
|
||||
let choice = choose(fs.db(), "No Such Family At All");
|
||||
assert_eq!(choice, choose(fs.db(), SYSTEM_MONO));
|
||||
}
|
||||
|
||||
fn db(families: &[&'static str]) -> impl Fn(&str) -> Option<String> {
|
||||
let families = families.to_vec();
|
||||
move |want| {
|
||||
families
|
||||
.iter()
|
||||
.find(|name| name.eq_ignore_ascii_case(want))
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_font_name_splits_into_family_and_style() {
|
||||
let choice =
|
||||
split_request("Berkeley Mono Medium SemiCondensed", db(&["Berkeley Mono"])).unwrap();
|
||||
assert_eq!(choice.family, "Berkeley Mono");
|
||||
assert_eq!(choice.weight, Weight::MEDIUM);
|
||||
assert_eq!(choice.stretch, Stretch::SemiCondensed);
|
||||
assert_eq!(choice.name(), "Berkeley Mono Medium SemiCondensed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_that_ends_in_a_style_word_wins() {
|
||||
let choice =
|
||||
split_request("Fira Code Light", db(&["Fira Code", "Fira Code Light"])).unwrap();
|
||||
assert_eq!(choice.family, "Fira Code Light");
|
||||
assert_eq!(choice.weight, Weight::NORMAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_words_accept_joined_spaced_and_mixed_case_forms() {
|
||||
let cases = [
|
||||
("Iosevka demibold", Weight::SEMIBOLD, Stretch::Normal),
|
||||
("Iosevka Extra Light", Weight::EXTRA_LIGHT, Stretch::Normal),
|
||||
(
|
||||
"Iosevka ULTRACONDENSED",
|
||||
Weight::NORMAL,
|
||||
Stretch::UltraCondensed,
|
||||
),
|
||||
("Iosevka Bold Condensed", Weight::BOLD, Stretch::Condensed),
|
||||
];
|
||||
for (request, weight, stretch) in cases {
|
||||
let choice = split_request(request, db(&["Iosevka"])).unwrap();
|
||||
assert_eq!(
|
||||
(choice.weight, choice.stretch),
|
||||
(weight, stretch),
|
||||
"{request:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_trailing_words_do_not_match() {
|
||||
let known = db(&["Berkeley Mono"]);
|
||||
assert_eq!(split_request("Berkeley Mono Nonsense", &known), None);
|
||||
assert_eq!(split_request("Comic Sans", &known), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+13
-12
@@ -1,13 +1,14 @@
|
||||
//! Look and layout.
|
||||
//!
|
||||
//! The colours and spacing come from the rofi setup this replaces (mytheme.rasi
|
||||
//! plus the -theme-str rofigrid built): gruvbox dark, a yellow selection filling
|
||||
//! the element padding, `title · app` centred under each thumbnail.
|
||||
//! The colours and spacing come from sway's default client colours:
|
||||
//! unfocused background `#222222` and text `#888888` for the grid
|
||||
//! backdrop and labels, focused `#285577`/`#ffffff` for the selection,
|
||||
//! and focused border `#4c7899`.
|
||||
//!
|
||||
//! Sizing works from caps rather than from a thumbnail size. The config gives a
|
||||
//! box the grid may fill and a column and row limit; a thumbnail is that box
|
||||
//! divided by those limits. So a thumbnail is the same size whether one window
|
||||
//! is open or thirty — the overlay hugs whatever is there, and rows past the
|
||||
//! is open or thirty - the overlay hugs whatever is there, and rows past the
|
||||
//! limit scroll.
|
||||
|
||||
/// 0xAARRGGBB, premultiplied (everything here is opaque).
|
||||
@@ -24,7 +25,7 @@ pub struct Theme {
|
||||
pub border_px: i32,
|
||||
/// The box the grid may not exceed, in logical px. Thumbnails are sized to
|
||||
/// divide it by the column and row caps below, so a thumbnail is the same
|
||||
/// size whether one window is open or thirty — only the window around them
|
||||
/// size whether one window is open or thirty - only the window around them
|
||||
/// shrinks to hug what is there.
|
||||
pub max_w: i32,
|
||||
pub max_h: i32,
|
||||
@@ -53,11 +54,11 @@ pub struct Theme {
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bg: 0xff282828, // gruvbox-dark-bg0
|
||||
fg: 0xffebdbb2, // gruvbox-dark-fg1
|
||||
sel_bg: 0xffd79921, // gruvbox-dark-yellow-dark
|
||||
sel_fg: 0xff282828,
|
||||
border: 0xffd79921,
|
||||
bg: 0xff222222, // sway unfocused background
|
||||
fg: 0xff888888, // sway unfocused text
|
||||
sel_bg: 0xff285577, // sway focused background
|
||||
sel_fg: 0xffffffff, // sway focused text
|
||||
border: 0xff4c7899, // sway focused border
|
||||
border_px: 2,
|
||||
// No cap of their own: Layout clamps to the display, and the
|
||||
// command line resolves the configured percentage over the top.
|
||||
@@ -108,8 +109,8 @@ impl Layout {
|
||||
/// it does not change with how many windows are open: one window gets a
|
||||
/// normal thumbnail in a small overlay, thirty get the same thumbnail and
|
||||
/// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of
|
||||
/// windows makes a tidy grid rather than one long row — the rule rofigrid
|
||||
/// used — and the overlay hugs whatever is there.
|
||||
/// windows makes a tidy grid rather than one long row - the rule rofigrid
|
||||
/// used - and the overlay hugs whatever is there.
|
||||
/// Lay out `n` tiles in a single row for a display of the given logical size.
|
||||
///
|
||||
/// The overlay and the window previews resize automatically to fit all `n`
|
||||
|
||||
Reference in New Issue
Block a user