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
|
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.
|
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
|
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
|
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
|
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
|
- `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an
|
||||||
exclusive keyboard grab)
|
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 | |
|
| key | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `→` `←` / `Tab` `Shift+Tab` | next / previous window |
|
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
|
||||||
| `↑` `↓` | move a row |
|
| `↓` `↑` / `j` `k` | move a row |
|
||||||
| `Home` `End` | first / last |
|
| `Home` `End` | first / last |
|
||||||
| `Enter` | focus the selection |
|
| `Enter` | pick |
|
||||||
| `Escape` / `q` | cancel, leaving focus alone |
|
| `Escape` / `q` | cancel |
|
||||||
|
|
||||||
Navigation reads raw evdev keycodes, so it is layout-independent — but it also
|
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)
|
means virtual-keyboard clients such as `wtype` (which invent their own keymap)
|
||||||
|
|||||||
+199
-53
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
mod shm;
|
mod shm;
|
||||||
mod sway;
|
mod sway;
|
||||||
|
mod target;
|
||||||
mod text;
|
mod text;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ use wayland_client::protocol::{
|
|||||||
wl_callback,
|
wl_callback,
|
||||||
wl_compositor::WlCompositor,
|
wl_compositor::WlCompositor,
|
||||||
wl_keyboard::{self, WlKeyboard},
|
wl_keyboard::{self, WlKeyboard},
|
||||||
wl_output,
|
wl_output::{self, WlOutput},
|
||||||
wl_registry::WlRegistry,
|
wl_registry::WlRegistry,
|
||||||
wl_seat::{self, WlSeat},
|
wl_seat::{self, WlSeat},
|
||||||
wl_shm::{self, WlShm},
|
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::{
|
use wayland_protocols::ext::image_capture_source::v1::client::{
|
||||||
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
|
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
|
||||||
ext_image_capture_source_v1::ExtImageCaptureSourceV1,
|
ext_image_capture_source_v1::ExtImageCaptureSourceV1,
|
||||||
|
ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
|
||||||
};
|
};
|
||||||
use wayland_protocols::ext::image_copy_capture::v1::client::{
|
use wayland_protocols::ext::image_copy_capture::v1::client::{
|
||||||
ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1},
|
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},
|
zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use target::{Kind, Target};
|
||||||
use theme::{Layout, Rect, Theme, fit_centred};
|
use theme::{Layout, Rect, Theme, fit_centred};
|
||||||
|
|
||||||
// evdev keycodes: physical positions, so navigation works on any keyboard layout
|
// 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_ESC: u32 = 1;
|
||||||
const KEY_TAB: u32 = 15;
|
const KEY_TAB: u32 = 15;
|
||||||
const KEY_Q: u32 = 16;
|
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_ENTER: u32 = 28;
|
||||||
const KEY_LEFTSHIFT: u32 = 42;
|
const KEY_LEFTSHIFT: u32 = 42;
|
||||||
const KEY_RIGHTSHIFT: u32 = 54;
|
const KEY_RIGHTSHIFT: u32 = 54;
|
||||||
@@ -76,6 +84,17 @@ const KEY_END: u32 = 107;
|
|||||||
const KEY_DOWN: u32 = 108;
|
const KEY_DOWN: u32 = 108;
|
||||||
|
|
||||||
/// One window: its sway identity, its capture plumbing, and its subsurface.
|
/// 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.
|
/// Which tiles keep updating after the first frame.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Live {
|
enum Live {
|
||||||
@@ -96,7 +115,7 @@ struct Slot {
|
|||||||
|
|
||||||
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
|
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
|
||||||
struct Tile {
|
struct Tile {
|
||||||
win: sway::Win,
|
target: Target,
|
||||||
handle: Option<ExtForeignToplevelHandleV1>,
|
handle: Option<ExtForeignToplevelHandleV1>,
|
||||||
|
|
||||||
session: Option<ExtImageCopyCaptureSessionV1>,
|
session: Option<ExtImageCopyCaptureSessionV1>,
|
||||||
@@ -126,9 +145,9 @@ struct Tile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Tile {
|
impl Tile {
|
||||||
fn new(win: sway::Win) -> Self {
|
fn new(target: Target) -> Self {
|
||||||
Self {
|
Self {
|
||||||
win,
|
target,
|
||||||
handle: None,
|
handle: None,
|
||||||
session: None,
|
session: None,
|
||||||
frame: None,
|
frame: None,
|
||||||
@@ -178,6 +197,9 @@ struct App {
|
|||||||
/// Toplevel handles as the compositor announces them, paired with the
|
/// Toplevel handles as the compositor announces them, paired with the
|
||||||
/// identifier that joins them to sway's tree.
|
/// identifier that joins them to sway's tree.
|
||||||
toplevels: Vec<(ExtForeignToplevelHandleV1, String)>,
|
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>,
|
tiles: Vec<Tile>,
|
||||||
|
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
@@ -195,7 +217,7 @@ struct App {
|
|||||||
configured: bool,
|
configured: bool,
|
||||||
|
|
||||||
quit: bool,
|
quit: bool,
|
||||||
activate: Option<i64>,
|
activate: Option<Target>,
|
||||||
/// Frame-callback ticks, for diagnosing the live clock.
|
/// Frame-callback ticks, for diagnosing the live clock.
|
||||||
ticks: u32,
|
ticks: u32,
|
||||||
releases: u32,
|
releases: u32,
|
||||||
@@ -208,16 +230,16 @@ impl App {
|
|||||||
fn new(
|
fn new(
|
||||||
globals: &GlobalList,
|
globals: &GlobalList,
|
||||||
qh: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
wins: Vec<sway::Win>,
|
targets: Vec<Target>,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
live: Live,
|
live: Live,
|
||||||
fps: u32,
|
fps: u32,
|
||||||
scale: i32,
|
scale: i32,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> 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
|
// Bind everything up front so a compositor missing a protocol fails
|
||||||
// here, with a name, rather than halfway through a capture.
|
// here, with a name, rather than halfway through a capture.
|
||||||
let app = Self {
|
let mut app = Self {
|
||||||
compositor: globals.bind(qh, 1..=6, ())?,
|
compositor: globals.bind(qh, 1..=6, ())?,
|
||||||
subcompositor: globals.bind(qh, 1..=1, ())?,
|
subcompositor: globals.bind(qh, 1..=1, ())?,
|
||||||
shm: globals.bind(qh, 1..=1, ())?,
|
shm: globals.bind(qh, 1..=1, ())?,
|
||||||
@@ -226,7 +248,10 @@ impl App {
|
|||||||
copy_mgr: globals.bind(qh, 1..=1, ())?,
|
copy_mgr: globals.bind(qh, 1..=1, ())?,
|
||||||
src_mgr: globals.bind(qh, 1..=1, ())?,
|
src_mgr: globals.bind(qh, 1..=1, ())?,
|
||||||
toplevels: Vec::new(),
|
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,
|
theme,
|
||||||
layout,
|
layout,
|
||||||
live,
|
live,
|
||||||
@@ -247,6 +272,16 @@ impl App {
|
|||||||
pool_bytes: 0,
|
pool_bytes: 0,
|
||||||
};
|
};
|
||||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
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, ())?;
|
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
@@ -256,19 +291,33 @@ impl App {
|
|||||||
/// constraints arrive together instead of costing a round trip each.
|
/// constraints arrive together instead of costing a round trip each.
|
||||||
fn open_sessions(&mut self, qh: &QueueHandle<Self>) {
|
fn open_sessions(&mut self, qh: &QueueHandle<Self>) {
|
||||||
for (i, tile) in self.tiles.iter_mut().enumerate() {
|
for (i, tile) in self.tiles.iter_mut().enumerate() {
|
||||||
let Some(handle) = self
|
// 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
|
.toplevels
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, id)| !id.is_empty() && *id == tile.win.ft_id)
|
.find(|(_, id)| !id.is_empty() && *id == tile.target.ft_id)
|
||||||
.map(|(h, _)| h.clone())
|
.map(|(handle, _)| {
|
||||||
else {
|
tile.handle = Some(handle.clone());
|
||||||
// No identifier match: the tile stays label-only, and must not
|
self.src_mgr.create_source(handle, qh, ())
|
||||||
// be waited on.
|
}),
|
||||||
|
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;
|
tile.settled = true;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let source: ExtImageCaptureSourceV1 = self.src_mgr.create_source(&handle, qh, ());
|
|
||||||
tile.handle = Some(handle);
|
|
||||||
tile.session = Some(self.copy_mgr.create_session(
|
tile.session = Some(self.copy_mgr.create_session(
|
||||||
&source,
|
&source,
|
||||||
ext_image_copy_capture_manager_v1::Options::empty(),
|
ext_image_copy_capture_manager_v1::Options::empty(),
|
||||||
@@ -289,7 +338,6 @@ impl App {
|
|||||||
/// touching it again.
|
/// touching it again.
|
||||||
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||||
const PAGE: usize = 4096;
|
const PAGE: usize = 4096;
|
||||||
let slots = if self.live == Live::None { 1 } else { 2 };
|
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len());
|
let mut offsets: Vec<Vec<usize>> = Vec::with_capacity(self.tiles.len());
|
||||||
for tile in &mut self.tiles {
|
for tile in &mut self.tiles {
|
||||||
@@ -314,6 +362,13 @@ impl App {
|
|||||||
tile.settled = true;
|
tile.settled = true;
|
||||||
continue;
|
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");
|
let last = offsets.last_mut().expect("just pushed");
|
||||||
for _ in 0..slots {
|
for _ in 0..slots {
|
||||||
last.push(total);
|
last.push(total);
|
||||||
@@ -428,6 +483,11 @@ impl App {
|
|||||||
if self.live == Live::Current && i != self.sel {
|
if self.live == Live::Current && i != self.sel {
|
||||||
continue;
|
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];
|
let t = &self.tiles[i];
|
||||||
if t.slots.is_empty() || t.frame.is_some() {
|
if t.slots.is_empty() || t.frame.is_some() {
|
||||||
continue;
|
continue;
|
||||||
@@ -587,14 +647,14 @@ impl App {
|
|||||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||||
KEY_ESC | KEY_Q => self.quit = true,
|
KEY_ESC | KEY_Q => self.quit = true,
|
||||||
KEY_ENTER | KEY_KPENTER => {
|
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;
|
self.quit = true;
|
||||||
}
|
}
|
||||||
KEY_TAB if self.shift => self.move_sel(-1),
|
KEY_TAB if self.shift => self.move_sel(-1),
|
||||||
KEY_TAB | KEY_RIGHT => self.move_sel(1),
|
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1),
|
||||||
KEY_LEFT => self.move_sel(-1),
|
KEY_LEFT | KEY_H => self.move_sel(-1),
|
||||||
KEY_DOWN => self.move_row(1),
|
KEY_DOWN | KEY_J => self.move_row(1),
|
||||||
KEY_UP => self.move_row(-1),
|
KEY_UP | KEY_K => self.move_row(-1),
|
||||||
KEY_HOME => {
|
KEY_HOME => {
|
||||||
self.sel = 0;
|
self.sel = 0;
|
||||||
self.paint();
|
self.paint();
|
||||||
@@ -656,8 +716,54 @@ fn pump(
|
|||||||
Ok(())
|
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 {
|
struct Args {
|
||||||
print: bool,
|
format: Format,
|
||||||
|
focus: bool,
|
||||||
|
outputs: bool,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
hide_labels: bool,
|
hide_labels: bool,
|
||||||
font: Option<String>,
|
font: Option<String>,
|
||||||
@@ -669,7 +775,9 @@ struct Args {
|
|||||||
|
|
||||||
fn parse_args() -> Result<Args, String> {
|
fn parse_args() -> Result<Args, String> {
|
||||||
let mut args = Args {
|
let mut args = Args {
|
||||||
print: false,
|
format: Format::Tsv,
|
||||||
|
focus: false,
|
||||||
|
outputs: true,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
hide_labels: false,
|
hide_labels: false,
|
||||||
font: None,
|
font: None,
|
||||||
@@ -681,7 +789,17 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
let mut it = std::env::args().skip(1);
|
let mut it = std::env::args().skip(1);
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
match arg.as_str() {
|
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,
|
"-v" | "--verbose" => args.verbose = true,
|
||||||
"--hide-labels" => args.hide_labels = true,
|
"--hide-labels" => args.hide_labels = true,
|
||||||
"--live" => {
|
"--live" => {
|
||||||
@@ -707,11 +825,7 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
args.timeout = Some(Duration::from_secs_f64(secs));
|
args.timeout = Some(Duration::from_secs_f64(secs));
|
||||||
}
|
}
|
||||||
"-h" | "--help" => {
|
"-h" | "--help" => {
|
||||||
println!(
|
print!("{HELP}");
|
||||||
"usage: wlgrid [--print] [--verbose] [--hide-labels] \
|
|
||||||
[--font FAMILY] [--font-size PX] \
|
|
||||||
[--live all|current|none] [--fps N] [--timeout SECS]"
|
|
||||||
);
|
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
other => return Err(format!("unknown argument: {other}")),
|
other => return Err(format!("unknown argument: {other}")),
|
||||||
@@ -745,18 +859,17 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut phases = Phases::new(args.verbose);
|
let mut phases = Phases::new(args.verbose);
|
||||||
let mut sway_conn = swayipc::Connection::new()?;
|
let mut sway_conn = swayipc::Connection::new()?;
|
||||||
let wins = sway::windows(&mut sway_conn)?;
|
let mut targets = sway::windows(&mut sway_conn)?;
|
||||||
if wins.is_empty() {
|
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);
|
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");
|
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
|
// rasterising, and the captures below are ~55ms of waiting on the
|
||||||
// compositor, so the two overlap almost exactly.
|
// compositor, so the two overlap almost exactly.
|
||||||
let label_job = theme.labels.then(|| {
|
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);
|
let box_w = layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w);
|
||||||
text::spawn(
|
text::spawn(
|
||||||
wins.iter().map(sway::Win::label).collect(),
|
targets.iter().map(Target::label).collect(),
|
||||||
theme.font.clone(),
|
theme.font.clone(),
|
||||||
theme.font_px * scale as f32,
|
theme.font_px * scale as f32,
|
||||||
(theme.line_h * 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 conn = Connection::connect_to_env()?;
|
||||||
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
||||||
let qh = queue.handle();
|
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.
|
// Two roundtrips: one for the toplevel list, one for each handle's state.
|
||||||
queue.roundtrip(&mut app)?;
|
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();
|
let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
|
||||||
for (i, t) in app.tiles.iter().enumerate() {
|
for (i, t) in app.tiles.iter().enumerate() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" [{i}] con_id={} {}{}",
|
" [{i}] {}{}",
|
||||||
t.win.con_id,
|
t.target.tsv(),
|
||||||
t.win.label(),
|
|
||||||
if t.ready { "" } else { " (no thumbnail)" }
|
if t.ready { "" } else { " (no thumbnail)" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -862,12 +974,26 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(con_id) = app.activate {
|
// wlgrid is a chooser: it reports the pick and leaves acting on it to the
|
||||||
if args.print {
|
// caller (--focus is a convenience for a bare keybinding).
|
||||||
println!("{con_id}");
|
let Some(target) = app.activate else {
|
||||||
} else {
|
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
||||||
sway::focus(&mut sway_conn, con_id)?;
|
};
|
||||||
|
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)
|
Ok(ExitCode::SUCCESS)
|
||||||
}
|
}
|
||||||
@@ -981,7 +1107,7 @@ impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
|||||||
if tile.frames == 0 {
|
if tile.frames == 0 {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"wlgrid: capture failed for {:?} ({reason:?})",
|
"wlgrid: capture failed for {:?} ({reason:?})",
|
||||||
tile.win.title
|
tile.target.title
|
||||||
);
|
);
|
||||||
tile.failed = true;
|
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 {
|
impl Dispatch<WlSeat, ()> for App {
|
||||||
fn event(
|
fn event(
|
||||||
_: &mut Self,
|
_: &mut Self,
|
||||||
@@ -1072,6 +1217,7 @@ delegate_noop!(App: ZwlrLayerShellV1);
|
|||||||
delegate_noop!(App: ExtImageCopyCaptureManagerV1);
|
delegate_noop!(App: ExtImageCopyCaptureManagerV1);
|
||||||
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
|
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
|
||||||
delegate_noop!(App: ExtImageCaptureSourceV1);
|
delegate_noop!(App: ExtImageCaptureSourceV1);
|
||||||
|
delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
|
||||||
delegate_noop!(App: ignore WlSurface);
|
delegate_noop!(App: ignore WlSurface);
|
||||||
|
|
||||||
// The chrome's own buffers: two slots alternating on keypresses, so their
|
// The chrome's own buffers: two slots alternating on keypresses, so their
|
||||||
|
|||||||
+30
-34
@@ -5,38 +5,17 @@
|
|||||||
|
|
||||||
use swayipc::{Connection, Node, NodeType};
|
use swayipc::{Connection, Node, NodeType};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
use crate::target::Target;
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Win {
|
/// Every view in the tree, in tree order (the same traversal the jq filter did,
|
||||||
/// "title · app", the label rofigrid was given (app dropped when empty).
|
/// so the grid keeps the ordering the muscle memory expects).
|
||||||
/// Used once tiles are labelled.
|
pub fn windows(conn: &mut Connection) -> Result<Vec<Target>, swayipc::Error> {
|
||||||
#[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> {
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
collect(&conn.get_tree()?, &mut out);
|
collect(&conn.get_tree()?, &mut out);
|
||||||
Ok(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 is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||||
let class = node
|
let class = node
|
||||||
.window_properties
|
.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()) {
|
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
|
// A view with no identifier can't be captured, but it still belongs in
|
||||||
// the list: it gets a tile with no thumbnail.
|
// the list: it gets a tile with no thumbnail.
|
||||||
out.push(Win {
|
out.push(Target::window(
|
||||||
con_id: node.id,
|
node.id,
|
||||||
app: node.app_id.clone().or(class).unwrap_or_default(),
|
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||||
title: node.name.clone().unwrap_or_default(),
|
node.app_id.clone().or(class).unwrap_or_default(),
|
||||||
ft_id: node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
node.name.clone().unwrap_or_default(),
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||||
collect(child, out);
|
collect(child, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus(conn: &mut Connection, con_id: i64) -> Result<(), swayipc::Error> {
|
/// The largest integer scale in use, which is what the overlay renders at.
|
||||||
for res in conn.run_command(format!("[con_id={con_id}] focus"))? {
|
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?;
|
res?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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