292 lines
9.5 KiB
Rust
292 lines
9.5 KiB
Rust
//! sway is the source of truth for the window list (`swaymsg -t get_tree`); the
|
|
//! Wayland side only supplies pixels. The join between the two is
|
|
//! `foreign_toplevel_identifier`, which sway reports per view.
|
|
//!
|
|
//! Acting on the choice is deliberately not here: wl-pick reports what was picked
|
|
//! and the caller decides what that means.
|
|
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::PathBuf;
|
|
|
|
use swayipc::{Connection, Node, NodeType};
|
|
|
|
use crate::target::Target;
|
|
|
|
/// Open the IPC connection, recovering when the environment lies about where
|
|
/// the socket is.
|
|
///
|
|
/// swayipc takes the path from `I3SOCK` or `SWAYSOCK` and only falls back to
|
|
/// asking sway directly when *neither is set* -- a variable that is set but
|
|
/// stale is used as-is, and fails. That happens whenever something in a
|
|
/// shell's ancestry outlived the sway that started it: one long-running daemon
|
|
/// is enough, and every shell it spawns inherits a path to a socket that no
|
|
/// longer exists. Since the running compositor is the one we want either way,
|
|
/// go and find its socket instead of failing.
|
|
pub fn connect() -> Result<Connection, String> {
|
|
// The environment still wins when it points at something real. Going
|
|
// through swayipc's own lookup instead would spawn `sway
|
|
// --get-socketpath` whenever the variables are unset, which prints a
|
|
// complaint of its own before we can say anything useful.
|
|
if let Some(conn) = env_socket().and_then(|p| UnixStream::connect(p).ok()) {
|
|
return Ok(Connection::from(conn));
|
|
}
|
|
let live = live_sockets();
|
|
let [path] = live.as_slice() else {
|
|
return Err(if live.is_empty() {
|
|
"cannot reach sway; wl-pick reads the window list from its IPC \
|
|
socket, and no running sway has one"
|
|
.to_string()
|
|
} else {
|
|
// Several live compositors, so any choice would be a guess: a
|
|
// nested sway is a real thing to be running.
|
|
format!(
|
|
"several sway sockets to choose from ({}); set SWAYSOCK to the one you mean",
|
|
live.iter()
|
|
.map(|p| p.display().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
});
|
|
};
|
|
UnixStream::connect(path)
|
|
.map(Connection::from)
|
|
.map_err(|e| format!("cannot reach sway on {} ({e})", path.display()))
|
|
}
|
|
|
|
/// The pid out of a `sway-ipc.<uid>.<pid>.sock` name, and nothing else.
|
|
fn socket_pid(name: &str) -> Option<&str> {
|
|
name.strip_prefix("sway-ipc.")?
|
|
.strip_suffix(".sock")?
|
|
.rsplit('.')
|
|
.next()
|
|
.filter(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
|
|
}
|
|
|
|
/// The socket the environment names, if it is actually there. sway's own
|
|
/// variable comes second because swayipc reads them in this order.
|
|
fn env_socket() -> Option<PathBuf> {
|
|
["I3SOCK", "SWAYSOCK"]
|
|
.into_iter()
|
|
.filter_map(std::env::var_os)
|
|
.map(PathBuf::from)
|
|
.find(|path| path.exists())
|
|
}
|
|
|
|
/// Sockets in the runtime directory whose sway is still running. They are named
|
|
/// `sway-ipc.<uid>.<pid>.sock`, so the pid says which are worth trying -- and
|
|
/// pids get reused, so it has to actually be a sway.
|
|
fn live_sockets() -> Vec<PathBuf> {
|
|
let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return Vec::new();
|
|
};
|
|
let mut found: Vec<PathBuf> = entries
|
|
.flatten()
|
|
.map(|e| e.path())
|
|
.filter(|path| {
|
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
return false;
|
|
};
|
|
socket_pid(name)
|
|
.and_then(|pid| std::fs::read_to_string(format!("/proc/{pid}/comm")).ok())
|
|
.is_some_and(|comm| comm.trim() == "sway")
|
|
})
|
|
.collect();
|
|
// Stable order, so the message about several of them does not shuffle.
|
|
found.sort();
|
|
found
|
|
}
|
|
|
|
/// How to order windows in the grid.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
pub enum Order {
|
|
/// Most-Recently-Used (MRU) focus order: current window first, then previous, etc.
|
|
#[default]
|
|
Mru,
|
|
/// Traversal of sway's layout tree (workspace by workspace).
|
|
Tree,
|
|
}
|
|
|
|
impl Order {
|
|
pub fn parse(s: &str) -> Result<Self, String> {
|
|
match s.trim() {
|
|
"mru" => Ok(Order::Mru),
|
|
"tree" => Ok(Order::Tree),
|
|
other => Err(format!("{other:?} is not mru or tree")),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn history_path() -> PathBuf {
|
|
std::env::var_os("XDG_RUNTIME_DIR")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join("wl-pick-history")
|
|
}
|
|
|
|
pub fn record_focus(con_id: i64) {
|
|
let path = history_path();
|
|
let mut ids: Vec<i64> = std::fs::read_to_string(&path)
|
|
.ok()
|
|
.map(|s| {
|
|
s.lines()
|
|
.filter_map(|l| l.trim().parse::<i64>().ok())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
ids.retain(|&id| id != con_id);
|
|
ids.insert(0, con_id);
|
|
ids.truncate(50);
|
|
let content = ids
|
|
.iter()
|
|
.map(|id| id.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let _ = std::fs::write(&path, content);
|
|
}
|
|
|
|
pub fn read_focus_history() -> Vec<i64> {
|
|
std::fs::read_to_string(history_path())
|
|
.ok()
|
|
.map(|s| {
|
|
s.lines()
|
|
.filter_map(|l| l.trim().parse::<i64>().ok())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Views in the tree, either in MRU focus order or tree layout order.
|
|
pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayipc::Error> {
|
|
let mut out = Vec::new();
|
|
let tree = conn.get_tree()?;
|
|
collect_tree(&tree, &mut out);
|
|
|
|
if out.is_empty() {
|
|
return Ok(out);
|
|
}
|
|
|
|
// Record currently focused window into history
|
|
if let Some(pos) = out.iter().position(|t| t.focused) {
|
|
if let Some(con_id) = out[pos].con_id {
|
|
record_focus(con_id);
|
|
}
|
|
}
|
|
|
|
if order == Order::Mru {
|
|
let history = read_focus_history();
|
|
out.sort_by_key(|t| {
|
|
if t.focused {
|
|
return (0, 0, 0);
|
|
}
|
|
if let Some(con_id) = t.con_id {
|
|
if let Some(idx) = history.iter().position(|&id| id == con_id) {
|
|
return (1, idx, 0);
|
|
}
|
|
}
|
|
if t.visible {
|
|
(2, 0, 0)
|
|
} else {
|
|
(3, 0, 0)
|
|
}
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn collect_target(node: &Node) -> Option<Target> {
|
|
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
|
let class = node
|
|
.window_properties
|
|
.as_ref()
|
|
.and_then(|p| p.class.clone());
|
|
if is_con && (node.app_id.is_some() || class.is_some()) {
|
|
Some(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(),
|
|
node.focused,
|
|
node.visible.unwrap_or(true),
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn collect_tree(node: &Node, out: &mut Vec<Target>) {
|
|
if let Some(target) = collect_target(node) {
|
|
out.push(target);
|
|
}
|
|
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
|
collect_tree(child, out);
|
|
}
|
|
}
|
|
|
|
/// One active display: what the overlay needs to size itself against.
|
|
///
|
|
/// The overlay maps on the focused display, so percentages and the buffer scale
|
|
/// are resolved against *that* one — on a mixed-DPI, mixed-size setup the
|
|
/// numbers differ per monitor, and taking the largest of everything would be
|
|
/// wrong on all but one.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Display {
|
|
pub name: String,
|
|
/// Logical size, which is what layer-shell and pointer events speak in.
|
|
pub width: i32,
|
|
pub height: i32,
|
|
/// Integer scale to render at: a buffer can be downscaled, not invented.
|
|
pub scale: i32,
|
|
pub focused: bool,
|
|
}
|
|
|
|
pub fn displays(conn: &mut Connection) -> Result<Vec<Display>, swayipc::Error> {
|
|
Ok(conn
|
|
.get_outputs()?
|
|
.into_iter()
|
|
.filter(|o| o.active)
|
|
.map(|o| Display {
|
|
name: o.name,
|
|
width: o.rect.width,
|
|
height: o.rect.height,
|
|
scale: (o.scale.unwrap_or(1.0).ceil() as i32).max(1),
|
|
focused: o.focused,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// The display the overlay will appear on: the focused one, or any active one if
|
|
/// sway reports none focused.
|
|
pub fn focused(displays: &[Display]) -> Option<&Display> {
|
|
displays
|
|
.iter()
|
|
.find(|d| d.focused)
|
|
.or_else(|| displays.first())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::socket_pid;
|
|
|
|
#[test]
|
|
fn a_socket_name_gives_up_its_pid() {
|
|
assert_eq!(socket_pid("sway-ipc.1000.573773.sock"), Some("573773"));
|
|
// Anything that is not a live sway's socket must not be tried: the
|
|
// runtime directory is full of other people's sockets.
|
|
assert_eq!(socket_pid("wayland-1"), None);
|
|
assert_eq!(socket_pid("sway-ipc.1000.573773.sock.bak"), None);
|
|
assert_eq!(socket_pid("i3-ipc.1000.5.sock"), None);
|
|
assert_eq!(socket_pid("sway-ipc.1000..sock"), None);
|
|
assert_eq!(socket_pid("sway-ipc.1000.notapid.sock"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn order_parses() {
|
|
assert_eq!(super::Order::parse("mru"), Ok(super::Order::Mru));
|
|
assert_eq!(super::Order::parse("tree"), Ok(super::Order::Tree));
|
|
assert!(super::Order::parse("invalid").is_err());
|
|
}
|
|
}
|