Files
wl-tab/src/sway.rs
T

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());
}
}