Skip to main content

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}