tree_space/workspace.rs
1//! Minimal Hyprland IPC client, used to pin the panel to a workspace and to
2//! move it between workspaces.
3//!
4//! Hyprland exposes two Unix sockets under `$XDG_RUNTIME_DIR/hypr/<signature>/`:
5//! `.socket.sock` (commands, the same protocol `hyprctl` speaks) and
6//! `.socket2.sock` (a one-way event stream). tree-space uses the command socket
7//! to read the active workspace, to list workspaces, and to `dispatch workspace`
8//! when moving itself, and subscribes to the event socket to learn when the
9//! active workspace changes so it can hide or show itself.
10//!
11//! None of this exists outside a Hyprland session; [`Hyprland::connect`] then
12//! returns `None` and callers fall back to "always visible".
13
14use std::io::{BufRead, BufReader, Read, Write};
15use std::os::unix::net::UnixStream;
16use std::path::PathBuf;
17
18/// Connection details for a running Hyprland instance.
19#[derive(Debug, Clone)]
20pub struct Hyprland {
21 /// Command socket (`.socket.sock`).
22 command: PathBuf,
23 /// Event socket (`.socket2.sock`).
24 event: PathBuf,
25}
26
27impl Hyprland {
28 /// Locate the running instance's sockets from the environment. Returns
29 /// `None` when not under Hyprland, or when the sockets are not present.
30 pub fn connect() -> Option<Self> {
31 let dir = std::env::var_os("XDG_RUNTIME_DIR")?;
32 let signature = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?;
33 let base = PathBuf::from(dir).join("hypr").join(signature);
34 let command = base.join(".socket.sock");
35 let event = base.join(".socket2.sock");
36 if !command.exists() || !event.exists() {
37 return None;
38 }
39 Some(Self { command, event })
40 }
41
42 /// Send one command and return its response. `hyprctl`-style `j/` commands
43 /// return JSON; dispatch commands return `ok`.
44 fn command(&self, command: &str) -> Option<String> {
45 let mut stream = UnixStream::connect(&self.command).ok()?;
46 stream.write_all(command.as_bytes()).ok()?;
47 stream.shutdown(std::net::Shutdown::Write).ok()?;
48 let mut response = String::new();
49 stream.read_to_string(&mut response).ok()?;
50 Some(response)
51 }
52
53 /// The name of the workspace currently on the focused monitor.
54 pub fn active_workspace(&self) -> Option<String> {
55 let raw = self.command("j/activeworkspace")?;
56 let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
57 value.get("name")?.as_str().map(str::to_owned)
58 }
59
60 /// Existing workspace names, ordered by numeric id (so "previous"/"next"
61 /// follow the compositor's ordering, not creation order).
62 pub fn workspace_names(&self) -> Vec<String> {
63 let raw = self.command("j/workspaces").unwrap_or_default();
64 let list: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
65 let mut named: Vec<(i64, String)> = list
66 .iter()
67 .filter_map(|workspace| {
68 let id = workspace.get("id")?.as_i64()?;
69 let name = workspace.get("name")?.as_str()?;
70 Some((id, name.to_owned()))
71 })
72 .collect();
73 named.sort_by_key(|(id, _)| *id);
74 named.into_iter().map(|(_, name)| name).collect()
75 }
76
77 /// Ask the compositor to switch to `name`.
78 ///
79 /// The portable form is `dispatch workspace <name>`, but a Lua-configured
80 /// Hyprland (Omarchy) routes `dispatch` through `hl.dispatch` and rejects
81 /// it; there the `hl.dsp.focus` dispatcher is required. Try the portable
82 /// form first and fall back when the compositor reports an error.
83 pub fn dispatch_workspace(&self, name: &str) {
84 let classic = self.command(&format!("dispatch workspace {name}"));
85 if classic.as_deref().is_some_and(|response| response.trim() == "ok") {
86 return;
87 }
88 let _ = self.command(&format!("dispatch hl.dsp.focus({{ workspace = \"{name}\" }})"));
89 }
90
91 /// Spawn a thread that calls `on_change` with the newly focused workspace's
92 /// name whenever it changes. The thread exits when the socket closes (e.g.
93 /// the compositor restarts).
94 pub fn spawn_watcher(&self, on_change: impl Fn(String) + Send + 'static) {
95 let path = self.event.clone();
96 std::thread::spawn(move || {
97 let Ok(stream) = UnixStream::connect(&path) else { return };
98 for line in BufReader::new(stream).lines() {
99 let Ok(line) = line else { break };
100 // `workspace>>NAME` on a workspace switch, and
101 // `focusedmon>>MON,NAME` when focus moves between monitors.
102 let workspace = line
103 .strip_prefix("workspace>>")
104 .or_else(|| line.strip_prefix("focusedmon>>").and_then(|rest| rest.split_once(',').map(|(_, ws)| ws)));
105 if let Some(name) = workspace {
106 on_change(name.to_owned());
107 }
108 }
109 });
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn connect_returns_none_without_hyprland_env() {
119 // This test process is not a Hyprland instance (no signature), so
120 // connecting must fail cleanly rather than panic.
121 // Guard: if the test *is* run inside Hyprland, skip the assertion.
122 if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_none() {
123 assert!(Hyprland::connect().is_none());
124 }
125 }
126}