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.
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
A window switcher for wlroots compositors: a grid overlay of **live** window
|
||||
previews that looks like a rofi theme, and focuses the window you pick.
|
||||
|
||||
It replaces a `wlthumbs | rofi` pipeline. The difference is that no thumbnails
|
||||
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
|
||||
@@ -48,19 +49,43 @@ wlgrid [--print] [--verbose] [--hide-labels] [--font FAMILY] [--font-size PX]
|
||||
- `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an
|
||||
exclusive keyboard grab)
|
||||
|
||||
Bind it in sway:
|
||||
wlgrid is a chooser: it reports what you picked and leaves acting on it to the
|
||||
caller. The pick goes to stdout, nothing does if you cancel, and the exit status
|
||||
is 0 for a pick and 1 for a cancel.
|
||||
|
||||
```sh
|
||||
# sway scripting: act on the con_id
|
||||
swaymsg "[con_id=$(wlgrid | cut -f2)] focus"
|
||||
|
||||
# or let wlgrid do it, for a bare keybinding
|
||||
bindsym $mod+Tab exec wlgrid --focus
|
||||
```
|
||||
bindsym $mod+Tab exec wlgrid
|
||||
|
||||
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
|
||||
wlgrid can be the picker for `getDisplayMedia` and friends — with live previews
|
||||
of both windows and displays:
|
||||
|
||||
```ini
|
||||
[screencast]
|
||||
chooser_type=simple
|
||||
chooser_cmd=wlgrid --format portal
|
||||
```
|
||||
|
||||
| key | |
|
||||
|---|---|
|
||||
| `→` `←` / `Tab` `Shift+Tab` | next / previous window |
|
||||
| `↑` `↓` | move a row |
|
||||
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
|
||||
| `↓` `↑` / `j` `k` | move a row |
|
||||
| `Home` `End` | first / last |
|
||||
| `Enter` | focus the selection |
|
||||
| `Escape` / `q` | cancel, leaving focus alone |
|
||||
| `Enter` | pick |
|
||||
| `Escape` / `q` | cancel |
|
||||
|
||||
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)
|
||||
|
||||
+202
-56
@@ -10,6 +10,7 @@
|
||||
|
||||
mod shm;
|
||||
mod sway;
|
||||
mod target;
|
||||
mod text;
|
||||
mod theme;
|
||||
|
||||
@@ -24,7 +25,7 @@ use wayland_client::protocol::{
|
||||
wl_callback,
|
||||
wl_compositor::WlCompositor,
|
||||
wl_keyboard::{self, WlKeyboard},
|
||||
wl_output,
|
||||
wl_output::{self, WlOutput},
|
||||
wl_registry::WlRegistry,
|
||||
wl_seat::{self, WlSeat},
|
||||
wl_shm::{self, WlShm},
|
||||
@@ -43,6 +44,7 @@ use wayland_protocols::ext::foreign_toplevel_list::v1::client::{
|
||||
use wayland_protocols::ext::image_capture_source::v1::client::{
|
||||
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
|
||||
ext_image_capture_source_v1::ExtImageCaptureSourceV1,
|
||||
ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
|
||||
};
|
||||
use wayland_protocols::ext::image_copy_capture::v1::client::{
|
||||
ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1},
|
||||
@@ -57,6 +59,7 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
|
||||
zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
|
||||
};
|
||||
|
||||
use target::{Kind, Target};
|
||||
use theme::{Layout, Rect, Theme, fit_centred};
|
||||
|
||||
// evdev keycodes: physical positions, so navigation works on any keyboard layout
|
||||
@@ -64,6 +67,11 @@ use theme::{Layout, Rect, Theme, fit_centred};
|
||||
const KEY_ESC: u32 = 1;
|
||||
const KEY_TAB: u32 = 15;
|
||||
const KEY_Q: u32 = 16;
|
||||
// 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_ENTER: u32 = 28;
|
||||
const KEY_LEFTSHIFT: u32 = 42;
|
||||
const KEY_RIGHTSHIFT: u32 = 54;
|
||||
@@ -76,6 +84,17 @@ const KEY_END: u32 = 107;
|
||||
const KEY_DOWN: u32 = 108;
|
||||
|
||||
/// One window: its sway identity, its capture plumbing, and its subsurface.
|
||||
/// How the pick is written to stdout.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Format {
|
||||
/// type, id, toplevel id, app, title — one tab-separated line.
|
||||
Tsv,
|
||||
/// The same record as a JSON object.
|
||||
Json,
|
||||
/// What xdg-desktop-portal-wlr's `simple` chooser accepts.
|
||||
Portal,
|
||||
}
|
||||
|
||||
/// Which tiles keep updating after the first frame.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Live {
|
||||
@@ -96,7 +115,7 @@ struct Slot {
|
||||
|
||||
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
|
||||
struct Tile {
|
||||
win: sway::Win,
|
||||
target: Target,
|
||||
handle: Option<ExtForeignToplevelHandleV1>,
|
||||
|
||||
session: Option<ExtImageCopyCaptureSessionV1>,
|
||||
@@ -126,9 +145,9 @@ struct Tile {
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
fn new(win: sway::Win) -> Self {
|
||||
fn new(target: Target) -> Self {
|
||||
Self {
|
||||
win,
|
||||
target,
|
||||
handle: None,
|
||||
session: None,
|
||||
frame: None,
|
||||
@@ -178,6 +197,9 @@ struct App {
|
||||
/// Toplevel handles as the compositor announces them, paired with the
|
||||
/// identifier that joins them to sway's tree.
|
||||
toplevels: Vec<(ExtForeignToplevelHandleV1, String)>,
|
||||
/// Displays, paired with the name the compositor gives them (wl_output v4).
|
||||
outputs: Vec<(WlOutput, String)>,
|
||||
output_src_mgr: Option<ExtOutputImageCaptureSourceManagerV1>,
|
||||
tiles: Vec<Tile>,
|
||||
|
||||
theme: Theme,
|
||||
@@ -195,7 +217,7 @@ struct App {
|
||||
configured: bool,
|
||||
|
||||
quit: bool,
|
||||
activate: Option<i64>,
|
||||
activate: Option<Target>,
|
||||
/// Frame-callback ticks, for diagnosing the live clock.
|
||||
ticks: u32,
|
||||
releases: u32,
|
||||
@@ -208,16 +230,16 @@ impl App {
|
||||
fn new(
|
||||
globals: &GlobalList,
|
||||
qh: &QueueHandle<Self>,
|
||||
wins: Vec<sway::Win>,
|
||||
targets: Vec<Target>,
|
||||
theme: Theme,
|
||||
live: Live,
|
||||
fps: u32,
|
||||
scale: i32,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let layout = Layout::new(&theme, wins.len() as i32);
|
||||
let layout = Layout::new(&theme, targets.len() as i32);
|
||||
// Bind everything up front so a compositor missing a protocol fails
|
||||
// here, with a name, rather than halfway through a capture.
|
||||
let app = Self {
|
||||
let mut app = Self {
|
||||
compositor: globals.bind(qh, 1..=6, ())?,
|
||||
subcompositor: globals.bind(qh, 1..=1, ())?,
|
||||
shm: globals.bind(qh, 1..=1, ())?,
|
||||
@@ -226,7 +248,10 @@ impl App {
|
||||
copy_mgr: globals.bind(qh, 1..=1, ())?,
|
||||
src_mgr: globals.bind(qh, 1..=1, ())?,
|
||||
toplevels: Vec::new(),
|
||||
tiles: wins.into_iter().map(Tile::new).collect(),
|
||||
outputs: Vec::new(),
|
||||
// Optional: a compositor without it simply gets no display tiles.
|
||||
output_src_mgr: globals.bind(qh, 1..=1, ()).ok(),
|
||||
tiles: targets.into_iter().map(Tile::new).collect(),
|
||||
theme,
|
||||
layout,
|
||||
live,
|
||||
@@ -247,6 +272,16 @@ impl App {
|
||||
pool_bytes: 0,
|
||||
};
|
||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
||||
// One wl_output per display, bound at v4 so it tells us its name.
|
||||
for global in globals.contents().clone_list() {
|
||||
if global.interface == WlOutput::interface().name {
|
||||
let version = global.version.min(4);
|
||||
if version >= 4 {
|
||||
let output: WlOutput = globals.registry().bind(global.name, version, qh, ());
|
||||
app.outputs.push((output, String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||
Ok(app)
|
||||
}
|
||||
@@ -256,19 +291,33 @@ impl App {
|
||||
/// constraints arrive together instead of costing a round trip each.
|
||||
fn open_sessions(&mut self, qh: &QueueHandle<Self>) {
|
||||
for (i, tile) in self.tiles.iter_mut().enumerate() {
|
||||
let Some(handle) = self
|
||||
.toplevels
|
||||
.iter()
|
||||
.find(|(_, id)| !id.is_empty() && *id == tile.win.ft_id)
|
||||
.map(|(h, _)| h.clone())
|
||||
else {
|
||||
// No identifier match: the tile stays label-only, and must not
|
||||
// be waited on.
|
||||
// A window's source comes from its toplevel handle, a display's from
|
||||
// its wl_output; everything after that is identical.
|
||||
let source: Option<ExtImageCaptureSourceV1> = match tile.target.kind {
|
||||
Kind::Window => self
|
||||
.toplevels
|
||||
.iter()
|
||||
.find(|(_, id)| !id.is_empty() && *id == tile.target.ft_id)
|
||||
.map(|(handle, _)| {
|
||||
tile.handle = Some(handle.clone());
|
||||
self.src_mgr.create_source(handle, qh, ())
|
||||
}),
|
||||
Kind::Output => self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|(_, n)| *n == tile.target.id)
|
||||
.and_then(|(output, _)| {
|
||||
self.output_src_mgr
|
||||
.as_ref()
|
||||
.map(|mgr| mgr.create_source(output, qh, ()))
|
||||
}),
|
||||
};
|
||||
let Some(source) = source else {
|
||||
// Nothing to capture from: the tile stays label-only, and must
|
||||
// not be waited on.
|
||||
tile.settled = true;
|
||||
continue;
|
||||
};
|
||||
let source: ExtImageCaptureSourceV1 = self.src_mgr.create_source(&handle, qh, ());
|
||||
tile.handle = Some(handle);
|
||||
tile.session = Some(self.copy_mgr.create_session(
|
||||
&source,
|
||||
ext_image_copy_capture_manager_v1::Options::empty(),
|
||||
@@ -289,7 +338,6 @@ impl App {
|
||||
/// touching it again.
|
||||
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
const PAGE: usize = 4096;
|
||||
let slots = if self.live == Live::None { 1 } else { 2 };
|
||||
let mut total = 0usize;
|
||||
let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len());
|
||||
for tile in &mut self.tiles {
|
||||
@@ -314,6 +362,13 @@ 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 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let last = offsets.last_mut().expect("just pushed");
|
||||
for _ in 0..slots {
|
||||
last.push(total);
|
||||
@@ -428,6 +483,11 @@ impl App {
|
||||
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;
|
||||
}
|
||||
let t = &self.tiles[i];
|
||||
if t.slots.is_empty() || t.frame.is_some() {
|
||||
continue;
|
||||
@@ -587,14 +647,14 @@ impl App {
|
||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||
KEY_ESC | KEY_Q => self.quit = true,
|
||||
KEY_ENTER | KEY_KPENTER => {
|
||||
self.activate = self.tiles.get(self.sel).map(|t| t.win.con_id);
|
||||
self.activate = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||
self.quit = true;
|
||||
}
|
||||
KEY_TAB if self.shift => self.move_sel(-1),
|
||||
KEY_TAB | KEY_RIGHT => self.move_sel(1),
|
||||
KEY_LEFT => self.move_sel(-1),
|
||||
KEY_DOWN => self.move_row(1),
|
||||
KEY_UP => self.move_row(-1),
|
||||
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1),
|
||||
KEY_LEFT | KEY_H => self.move_sel(-1),
|
||||
KEY_DOWN | KEY_J => self.move_row(1),
|
||||
KEY_UP | KEY_K => self.move_row(-1),
|
||||
KEY_HOME => {
|
||||
self.sel = 0;
|
||||
self.paint();
|
||||
@@ -656,8 +716,54 @@ fn pump(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const HELP: &str = "\
|
||||
wlgrid — a live grid of window and display previews, for picking one
|
||||
|
||||
usage: wlgrid [options]
|
||||
|
||||
--format tsv|json|portal how to report the pick [tsv]
|
||||
--focus also focus the pick, via sway [off]
|
||||
--live all|current|none which tiles keep updating live [all]
|
||||
(display tiles are always a single snapshot)
|
||||
--fps N cap on live updates per tile per second [12]
|
||||
--no-outputs windows only; by default whole displays are
|
||||
included too, labelled \"NAME · display\"
|
||||
--hide-labels draw an icon-only grid
|
||||
--font FAMILY label font family [Berkeley Mono]
|
||||
--font-size PX label size in logical px [13.3]
|
||||
--timeout SECS exit after SECS regardless [off]
|
||||
(a safety valve: the overlay grabs the keyboard)
|
||||
-v, --verbose phase timings, the tile list, and capture stats
|
||||
-h, --help this
|
||||
|
||||
keys: arrows, hjkl, or Tab/Shift+Tab move; Home/End jump; Enter picks;
|
||||
Escape or q cancels
|
||||
|
||||
The pick goes to stdout, nothing does if you cancel; exit status is 0 for a
|
||||
pick and 1 for a cancel.
|
||||
|
||||
tsv TYPE<TAB>ID<TAB>TOPLEVEL_ID<TAB>APP<TAB>TITLE
|
||||
TYPE is \"window\" or \"output\". ID is the sway con_id, or the
|
||||
output name for a display. TOPLEVEL_ID is the
|
||||
ext-foreign-toplevel-list-v1 identifier, which is what tools like
|
||||
grim -T capture by; it is empty for displays.
|
||||
json the same record, every key always present, for jq
|
||||
portal \"Monitor: NAME\" or \"Window: TOPLEVEL_ID\", i.e. exactly what
|
||||
xdg-desktop-portal-wlr's simple chooser reads:
|
||||
|
||||
[screencast]
|
||||
chooser_type=simple
|
||||
chooser_cmd=wlgrid --format portal
|
||||
|
||||
examples:
|
||||
swaymsg \"[con_id=$(wlgrid | cut -f2)] focus\"
|
||||
wlgrid --format json | jq -r .title
|
||||
";
|
||||
|
||||
struct Args {
|
||||
print: bool,
|
||||
format: Format,
|
||||
focus: bool,
|
||||
outputs: bool,
|
||||
verbose: bool,
|
||||
hide_labels: bool,
|
||||
font: Option<String>,
|
||||
@@ -669,7 +775,9 @@ struct Args {
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut args = Args {
|
||||
print: false,
|
||||
format: Format::Tsv,
|
||||
focus: false,
|
||||
outputs: true,
|
||||
verbose: false,
|
||||
hide_labels: false,
|
||||
font: None,
|
||||
@@ -681,7 +789,17 @@ fn parse_args() -> Result<Args, String> {
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--print" => args.print = true,
|
||||
"--focus" => args.focus = true,
|
||||
"--format" => {
|
||||
args.format = match it.next().ok_or("--format needs tsv|json|portal")?.as_str() {
|
||||
"tsv" => Format::Tsv,
|
||||
"json" => Format::Json,
|
||||
"portal" => Format::Portal,
|
||||
other => return Err(format!("bad --format: {other}")),
|
||||
}
|
||||
}
|
||||
"--outputs" => args.outputs = true,
|
||||
"--no-outputs" => args.outputs = false,
|
||||
"-v" | "--verbose" => args.verbose = true,
|
||||
"--hide-labels" => args.hide_labels = true,
|
||||
"--live" => {
|
||||
@@ -707,11 +825,7 @@ fn parse_args() -> Result<Args, String> {
|
||||
args.timeout = Some(Duration::from_secs_f64(secs));
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"usage: wlgrid [--print] [--verbose] [--hide-labels] \
|
||||
[--font FAMILY] [--font-size PX] \
|
||||
[--live all|current|none] [--fps N] [--timeout SECS]"
|
||||
);
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}")),
|
||||
@@ -745,18 +859,17 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let start = Instant::now();
|
||||
let mut phases = Phases::new(args.verbose);
|
||||
let mut sway_conn = swayipc::Connection::new()?;
|
||||
let wins = sway::windows(&mut sway_conn)?;
|
||||
if wins.is_empty() {
|
||||
let mut targets = sway::windows(&mut sway_conn)?;
|
||||
let scale = sway::scale(&mut sway_conn)?;
|
||||
if args.outputs {
|
||||
// Displays go last, after the windows, so window positions stay stable.
|
||||
for output in sway_conn.get_outputs()?.iter().filter(|o| o.active) {
|
||||
targets.push(Target::output(output.name.clone()));
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let scale = sway_conn
|
||||
.get_outputs()?
|
||||
.iter()
|
||||
.filter(|o| o.active)
|
||||
.map(|o| o.scale.unwrap_or(1.0).ceil() as i32)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
phases.mark("sway-tree");
|
||||
|
||||
@@ -777,10 +890,10 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
// rasterising, and the captures below are ~55ms of waiting on the
|
||||
// compositor, so the two overlap almost exactly.
|
||||
let label_job = theme.labels.then(|| {
|
||||
let layout = Layout::new(&theme, wins.len() as i32);
|
||||
let layout = Layout::new(&theme, targets.len() as i32);
|
||||
let box_w = layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w);
|
||||
text::spawn(
|
||||
wins.iter().map(sway::Win::label).collect(),
|
||||
targets.iter().map(Target::label).collect(),
|
||||
theme.font.clone(),
|
||||
theme.font_px * scale as f32,
|
||||
(theme.line_h * scale) as f32,
|
||||
@@ -791,7 +904,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let conn = Connection::connect_to_env()?;
|
||||
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
||||
let qh = queue.handle();
|
||||
let mut app = App::new(&globals, &qh, wins, theme, args.live, args.fps, scale)?;
|
||||
let mut app = App::new(&globals, &qh, targets, theme, args.live, args.fps, scale)?;
|
||||
|
||||
// Two roundtrips: one for the toplevel list, one for each handle's state.
|
||||
queue.roundtrip(&mut app)?;
|
||||
@@ -816,9 +929,8 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
|
||||
for (i, t) in app.tiles.iter().enumerate() {
|
||||
eprintln!(
|
||||
" [{i}] con_id={} {}{}",
|
||||
t.win.con_id,
|
||||
t.win.label(),
|
||||
" [{i}] {}{}",
|
||||
t.target.tsv(),
|
||||
if t.ready { "" } else { " (no thumbnail)" }
|
||||
);
|
||||
}
|
||||
@@ -862,12 +974,26 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(con_id) = app.activate {
|
||||
if args.print {
|
||||
println!("{con_id}");
|
||||
} else {
|
||||
sway::focus(&mut sway_conn, con_id)?;
|
||||
}
|
||||
// wlgrid is a chooser: it reports the pick and leaves acting on it to the
|
||||
// caller (--focus is a convenience for a bare keybinding).
|
||||
let Some(target) = app.activate else {
|
||||
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
||||
};
|
||||
match args.format {
|
||||
Format::Tsv => println!("{}", target.tsv()),
|
||||
Format::Json => println!("{}", target.json()),
|
||||
Format::Portal => match target.portal() {
|
||||
Some(line) => println!("{line}"),
|
||||
None => {
|
||||
// The portal can only name a window by its foreign-toplevel
|
||||
// identifier, and this one has none; silence means declined.
|
||||
eprintln!("wlgrid: {:?} has no toplevel identifier", target.title);
|
||||
return Ok(ExitCode::FAILURE);
|
||||
}
|
||||
},
|
||||
}
|
||||
if args.focus {
|
||||
sway::focus(&mut sway_conn, &target)?;
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
@@ -981,7 +1107,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
||||
if tile.frames == 0 {
|
||||
eprintln!(
|
||||
"wlgrid: capture failed for {:?} ({reason:?})",
|
||||
tile.win.title
|
||||
tile.target.title
|
||||
);
|
||||
tile.failed = true;
|
||||
}
|
||||
@@ -1018,6 +1144,25 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// wl_output tells us its name (v4), which is how a display tile is labelled
|
||||
/// and how `focus output NAME` finds it again.
|
||||
impl Dispatch<WlOutput, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
output: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event
|
||||
&& let Some(entry) = app.outputs.iter_mut().find(|(o, _)| o == output)
|
||||
{
|
||||
entry.1 = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlSeat, ()> for App {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
@@ -1072,6 +1217,7 @@ delegate_noop!(App: ZwlrLayerShellV1);
|
||||
delegate_noop!(App: ExtImageCopyCaptureManagerV1);
|
||||
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
|
||||
delegate_noop!(App: ExtImageCaptureSourceV1);
|
||||
delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
|
||||
delegate_noop!(App: ignore WlSurface);
|
||||
|
||||
// The chrome's own buffers: two slots alternating on keypresses, so their
|
||||
|
||||
+30
-34
@@ -5,38 +5,17 @@
|
||||
|
||||
use swayipc::{Connection, Node, NodeType};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Win {
|
||||
pub con_id: i64,
|
||||
pub app: String,
|
||||
pub title: String,
|
||||
/// ext-foreign-toplevel-list-v1 identifier; the key we match capture
|
||||
/// sources on.
|
||||
pub ft_id: String,
|
||||
}
|
||||
use crate::target::Target;
|
||||
|
||||
impl Win {
|
||||
/// "title · app", the label rofigrid was given (app dropped when empty).
|
||||
/// Used once tiles are labelled.
|
||||
#[allow(dead_code)]
|
||||
pub fn label(&self) -> String {
|
||||
if self.app.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
format!("{} · {}", self.title, self.app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view in the tree, in tree order (same traversal the jq filter did, so
|
||||
/// the grid keeps the ordering the muscle memory expects).
|
||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Win>, swayipc::Error> {
|
||||
/// Every view in the tree, in tree order (the same traversal the jq filter did,
|
||||
/// so the grid keeps the ordering the muscle memory expects).
|
||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Target>, swayipc::Error> {
|
||||
let mut out = Vec::new();
|
||||
collect(&conn.get_tree()?, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect(node: &Node, out: &mut Vec<Win>) {
|
||||
fn collect(node: &Node, out: &mut Vec<Target>) {
|
||||
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||
let class = node
|
||||
.window_properties
|
||||
@@ -45,20 +24,37 @@ fn collect(node: &Node, out: &mut Vec<Win>) {
|
||||
if is_con && (node.app_id.is_some() || class.is_some()) {
|
||||
// A view with no identifier can't be captured, but it still belongs in
|
||||
// the list: it gets a tile with no thumbnail.
|
||||
out.push(Win {
|
||||
con_id: node.id,
|
||||
app: node.app_id.clone().or(class).unwrap_or_default(),
|
||||
title: node.name.clone().unwrap_or_default(),
|
||||
ft_id: node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||
});
|
||||
out.push(Target::window(
|
||||
node.id,
|
||||
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||
node.app_id.clone().or(class).unwrap_or_default(),
|
||||
node.name.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||
collect(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(conn: &mut Connection, con_id: i64) -> Result<(), swayipc::Error> {
|
||||
for res in conn.run_command(format!("[con_id={con_id}] focus"))? {
|
||||
/// The largest integer scale in use, which is what the overlay renders at.
|
||||
pub fn scale(conn: &mut Connection) -> Result<i32, swayipc::Error> {
|
||||
Ok(conn
|
||||
.get_outputs()?
|
||||
.iter()
|
||||
.filter(|o| o.active)
|
||||
.map(|o| o.scale.unwrap_or(1.0).ceil() as i32)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1))
|
||||
}
|
||||
|
||||
pub fn focus(conn: &mut Connection, target: &Target) -> Result<(), swayipc::Error> {
|
||||
let cmd = match target.con_id {
|
||||
Some(con_id) => format!("[con_id={con_id}] focus"),
|
||||
// Picking a display means going to it.
|
||||
None => format!("focus output {}", target.id),
|
||||
};
|
||||
for res in conn.run_command(cmd)? {
|
||||
res?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
//! 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
|
||||
//! alike and only differs in how it labels them and what picking one does.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Window,
|
||||
Output,
|
||||
}
|
||||
|
||||
impl fmt::Display for Kind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Kind::Window => "window",
|
||||
Kind::Output => "output",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Target {
|
||||
pub kind: Kind,
|
||||
/// The thing a caller acts on: a sway `con_id` for a window, an output name
|
||||
/// for a display.
|
||||
pub id: String,
|
||||
/// sway container id, when there is one.
|
||||
pub con_id: Option<i64>,
|
||||
/// ext-foreign-toplevel-list identifier, for matching a capture source.
|
||||
pub ft_id: String,
|
||||
pub app: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn window(con_id: i64, ft_id: String, app: String, title: String) -> Self {
|
||||
Self {
|
||||
kind: Kind::Window,
|
||||
id: con_id.to_string(),
|
||||
con_id: Some(con_id),
|
||||
ft_id,
|
||||
app,
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
/// A display. `app` is "display" so the label says what kind of tile it is
|
||||
/// without needing a second visual language for it.
|
||||
pub fn output(name: String) -> Self {
|
||||
Self {
|
||||
kind: Kind::Output,
|
||||
id: name.clone(),
|
||||
con_id: None,
|
||||
ft_id: String::new(),
|
||||
app: "display".to_string(),
|
||||
title: name,
|
||||
}
|
||||
}
|
||||
|
||||
/// "title · app", the label the rofi grid used.
|
||||
pub fn label(&self) -> String {
|
||||
if self.app.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
format!("{} · {}", self.title, self.app)
|
||||
}
|
||||
}
|
||||
|
||||
/// One tab-separated row, so a shell caller can
|
||||
/// `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.
|
||||
pub fn tsv(&self) -> String {
|
||||
format!(
|
||||
"{}\t{}\t{}\t{}\t{}",
|
||||
self.kind,
|
||||
self.id,
|
||||
self.ft_id,
|
||||
clean(&self.app),
|
||||
clean(&self.title)
|
||||
)
|
||||
}
|
||||
|
||||
/// The same record as JSON, with every key always present so `jq` can rely
|
||||
/// on it. Written by hand: one object is not worth a serialiser.
|
||||
pub fn json(&self) -> String {
|
||||
let opt = |v: Option<String>| match v {
|
||||
Some(s) => format!("\"{}\"", esc(&s)),
|
||||
None => "null".to_string(),
|
||||
};
|
||||
let (con_id, output) = match self.kind {
|
||||
Kind::Window => (
|
||||
self.con_id.map(|n| n.to_string()).unwrap_or("null".into()),
|
||||
"null".to_string(),
|
||||
),
|
||||
Kind::Output => ("null".to_string(), opt(Some(self.id.clone()))),
|
||||
};
|
||||
let toplevel = match self.ft_id.is_empty() {
|
||||
true => "null".to_string(),
|
||||
false => format!("\"{}\"", esc(&self.ft_id)),
|
||||
};
|
||||
format!(
|
||||
"{{\"type\":\"{}\",\"con_id\":{con_id},\"toplevel_id\":{toplevel},\
|
||||
\"output\":{output},\"app\":\"{}\",\"title\":\"{}\"}}",
|
||||
self.kind,
|
||||
esc(&self.app),
|
||||
esc(&self.title)
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// an empty stdout is exactly how that chooser says "declined".
|
||||
pub fn portal(&self) -> Option<String> {
|
||||
match self.kind {
|
||||
Kind::Output => Some(format!("Monitor: {}", self.id)),
|
||||
Kind::Window if !self.ft_id.is_empty() => Some(format!("Window: {}", self.ft_id)),
|
||||
Kind::Window => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Titles are arbitrary application strings; a tab or newline in one would split
|
||||
/// the row a caller is parsing.
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if (c as u32) < 0x20 || c == '\u{7f}' {
|
||||
' '
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// JSON string escaping, per RFC 8259.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn win() -> Target {
|
||||
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_is_one_line_with_both_identifiers() {
|
||||
let row = win().tsv();
|
||||
assert_eq!(row, "window\t42\tabc123\tkitty\tzsh in a tab");
|
||||
assert_eq!(row.split('\t').count(), 5);
|
||||
assert!(!row.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_keeps_every_key_and_escapes() {
|
||||
let j = win().json();
|
||||
assert!(j.contains("\"type\":\"window\""), "{j}");
|
||||
assert!(j.contains("\"con_id\":42"), "{j}");
|
||||
assert!(j.contains("\"toplevel_id\":\"abc123\""), "{j}");
|
||||
assert!(j.contains("\"output\":null"), "{j}");
|
||||
// Control characters survive as escapes, not raw bytes.
|
||||
assert!(j.contains("zsh\\tin\\na tab"), "{j}");
|
||||
|
||||
let o = Target::output("DP-1".into()).json();
|
||||
assert!(o.contains("\"con_id\":null"), "{o}");
|
||||
assert!(o.contains("\"output\":\"DP-1\""), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_speaks_xdpw() {
|
||||
assert_eq!(win().portal().as_deref(), Some("Window: abc123"));
|
||||
assert_eq!(
|
||||
Target::output("DP-1".into()).portal().as_deref(),
|
||||
Some("Monitor: DP-1")
|
||||
);
|
||||
// No identifier means the portal cannot be told about this window.
|
||||
let anon = Target::window(7, String::new(), "x".into(), "y".into());
|
||||
assert_eq!(anon.portal(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displays_say_what_they_are() {
|
||||
let t = Target::output("DP-1".into());
|
||||
assert_eq!(t.label(), "DP-1 · display");
|
||||
assert_eq!(t.con_id, None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user