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:
Milad Alizadeh
2026-08-23 12:33:25 +01:00
parent 33d75222cf
commit 5b1f0f74e8
4 changed files with 473 additions and 97 deletions
+30 -34
View File
@@ -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(())