From 5b1f0f74e8042132729e3fe12b7b3a47ac1e534a Mon Sep 17 00:00:00 2001 From: Milad Alizadeh Date: Sun, 23 Aug 2026 12:33:25 +0100 Subject: [PATCH] Add display tiles, three output formats, and hjkl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: "), 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. --- README.md | 39 ++++++-- src/main.rs | 258 +++++++++++++++++++++++++++++++++++++++----------- src/sway.rs | 64 ++++++------- src/target.rs | 209 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 473 insertions(+), 97 deletions(-) create mode 100644 src/target.rs diff --git a/README.md b/README.md index db57253..d5c608a 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/main.rs b/src/main.rs index c8a3198..aef2feb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, session: Option, @@ -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, tiles: Vec, theme: Theme, @@ -195,7 +217,7 @@ struct App { configured: bool, quit: bool, - activate: Option, + activate: Option, /// Frame-callback ticks, for diagnosing the live clock. ticks: u32, releases: u32, @@ -208,16 +230,16 @@ impl App { fn new( globals: &GlobalList, qh: &QueueHandle, - wins: Vec, + targets: Vec, theme: Theme, live: Live, fps: u32, scale: i32, ) -> Result> { - 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) { 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 = 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) -> Result<(), Box> { const PAGE: usize = 4096; - let slots = if self.live == Live::None { 1 } else { 2 }; let mut total = 0usize; let mut offsets: Vec> = 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 TYPEIDTOPLEVEL_IDAPPTITLE + 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, @@ -669,7 +775,9 @@ struct Args { fn parse_args() -> Result { 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 { 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.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> { 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> { // 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> { let conn = Connection::connect_to_env()?; let (globals, mut queue) = registry_queue_init::(&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> { 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> { ); } - 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 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 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 for App { + fn event( + app: &mut Self, + output: &WlOutput, + event: wl_output::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event + && let Some(entry) = app.outputs.iter_mut().find(|(o, _)| o == output) + { + entry.1 = name; + } + } +} + impl Dispatch 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 diff --git a/src/sway.rs b/src/sway.rs index 8b6c595..b4939a6 100644 --- a/src/sway.rs +++ b/src/sway.rs @@ -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, 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, swayipc::Error> { let mut out = Vec::new(); collect(&conn.get_tree()?, &mut out); Ok(out) } -fn collect(node: &Node, out: &mut Vec) { +fn collect(node: &Node, out: &mut Vec) { 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) { 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 { + 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(()) diff --git a/src/target.rs b/src/target.rs new file mode 100644 index 0000000..a6a0a84 --- /dev/null +++ b/src/target.rs @@ -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, + /// 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| 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: `. 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 { + 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); + } +}