use std::io::{BufRead, BufReader};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc::{self, Receiver, Sender};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusEvent {
Focused { address: String, class: String },
Closed { address: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitialFocus {
pub address: String,
pub class: String,
}
#[derive(Debug, thiserror::Error)]
pub enum FocusError {
#[error(
"Hyprland IPC is unavailable: $XDG_RUNTIME_DIR or $HYPRLAND_INSTANCE_SIGNATURE is unset"
)]
Env,
#[error("could not connect to Hyprland IPC socket: {0}")]
Connect(String),
#[error("could not spawn IPC reader thread: {0}")]
Thread(String),
}
pub fn start() -> Result<(Option<InitialFocus>, Receiver<FocusEvent>), FocusError> {
let socket_path = socket2_path()?;
let stream = UnixStream::connect(&socket_path)
.map_err(|e| FocusError::Connect(format!("{}: {e}", socket_path.display())))?;
let initial = query_active_window();
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("hyprcorrect-focus".into())
.spawn(move || read_events(stream, &tx))
.map_err(|e| FocusError::Thread(e.to_string()))?;
Ok((initial, rx))
}
fn socket2_path() -> Result<PathBuf, FocusError> {
let runtime = std::env::var_os("XDG_RUNTIME_DIR").ok_or(FocusError::Env)?;
let instance = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").ok_or(FocusError::Env)?;
Ok(PathBuf::from(runtime)
.join("hypr")
.join(instance)
.join(".socket2.sock"))
}
fn query_active_window() -> Option<InitialFocus> {
let output = Command::new("hyprctl")
.args(["activewindow", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = std::str::from_utf8(&output.stdout).ok()?;
let address = extract_json_string(text, "address")?;
let class = extract_json_string(text, "class").unwrap_or_default();
let address = normalize_address(&address);
if address.is_empty() {
None
} else {
Some(InitialFocus { address, class })
}
}
fn extract_json_string(text: &str, field: &str) -> Option<String> {
let needle = format!("\"{field}\"");
let after_key = text.split(&needle).nth(1)?;
let after_colon = after_key.split_once(':')?.1.trim_start();
let value = after_colon.strip_prefix('"')?;
let (s, _) = value.split_once('"')?;
Some(s.to_string())
}
fn read_events(stream: UnixStream, tx: &Sender<FocusEvent>) {
let reader = BufReader::new(stream);
let mut last_class: Option<String> = None;
for line in reader.lines() {
let Ok(line) = line else { return };
let Some((kind, payload)) = line.split_once(">>") else {
continue;
};
match kind {
"activewindow" => {
last_class = Some(
payload
.split_once(',')
.map_or(payload, |(class, _)| class)
.to_string(),
);
}
"activewindowv2" => {
let address = normalize_address(payload);
let class = last_class.clone().unwrap_or_default();
if tx.send(FocusEvent::Focused { address, class }).is_err() {
return; }
}
"closewindow" => {
let address = normalize_address(payload);
if tx.send(FocusEvent::Closed { address }).is_err() {
return;
}
}
_ => {}
}
}
}
fn normalize_address(addr: &str) -> String {
addr.trim()
.strip_prefix("0x")
.unwrap_or_else(|| addr.trim())
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
fn run(lines: &[&str]) -> Vec<FocusEvent> {
let (tx, rx) = mpsc::channel();
let mut last_class: Option<String> = None;
for line in lines {
let Some((kind, payload)) = line.split_once(">>") else {
continue;
};
match kind {
"activewindow" => {
last_class = Some(
payload
.split_once(',')
.map_or(payload, |(c, _)| c)
.to_string(),
);
}
"activewindowv2" => {
let _ = tx.send(FocusEvent::Focused {
address: normalize_address(payload),
class: last_class.clone().unwrap_or_default(),
});
}
"closewindow" => {
let _ = tx.send(FocusEvent::Closed {
address: normalize_address(payload),
});
}
_ => {}
}
}
drop(tx);
rx.iter().collect()
}
#[test]
fn pairs_text_class_with_v2_address() {
let events = run(&[
"workspace>>2",
"activewindow>>kitty,fish",
"activewindowv2>>563c9141fe00",
]);
assert_eq!(
events,
vec![FocusEvent::Focused {
address: "563c9141fe00".into(),
class: "kitty".into(),
}]
);
}
#[test]
fn close_emits_closed() {
let events = run(&["closewindow>>0xAbCdEf"]);
assert_eq!(
events,
vec![FocusEvent::Closed {
address: "abcdef".into(),
}]
);
}
#[test]
fn ignores_unknown_events() {
let events = run(&[
"workspace>>2",
"openwindow>>abc,1,kitty,fish",
"monitor>>DP-1",
]);
assert!(events.is_empty());
}
#[test]
fn extract_json_string_handles_typical_hyprctl_json() {
let json = r#"{"address": "0x563c9141fe00", "class":"kitty"}"#;
assert_eq!(
extract_json_string(json, "address"),
Some("0x563c9141fe00".into())
);
assert_eq!(extract_json_string(json, "class"), Some("kitty".into()));
}
#[test]
fn normalize_strips_prefix_and_lowercases() {
assert_eq!(normalize_address("0xAbCdEf"), "abcdef");
assert_eq!(normalize_address("563c9141fe00"), "563c9141fe00");
assert_eq!(normalize_address(" 0x563C9141FE00 "), "563c9141fe00");
}
}