use crate::wl::Region;
pub struct WindowRef {
pub app_id: String,
pub title: String,
pub rect: Region,
}
pub trait FocusBackend {
fn focused_output(&self) -> Option<String>;
fn active_window_rect(&self) -> Option<Region>;
fn window_at(&self, _x: i32, _y: i32) -> Option<WindowRef> {
None
}
fn name(&self) -> &'static str;
}
pub fn detect() -> Option<Box<dyn FocusBackend>> {
if std::env::var_os("SWAYSOCK").is_some() {
return Some(Box::new(Sway));
}
if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
return Some(Box::new(Hyprland));
}
if std::env::var_os("NIRI_SOCKET").is_some() {
return Some(Box::new(Niri));
}
None
}
struct Sway;
impl Sway {
fn query(kind: &str) -> Option<serde_json::Value> {
let out = std::process::Command::new("swaymsg")
.args(["-t", kind, "-r"])
.output()
.ok()?;
out.status.success().then_some(())?;
serde_json::from_slice(&out.stdout).ok()
}
}
impl FocusBackend for Sway {
fn name(&self) -> &'static str {
"sway"
}
fn focused_output(&self) -> Option<String> {
let outputs = Self::query("get_outputs")?;
outputs
.as_array()?
.iter()
.find(|o| o["focused"].as_bool() == Some(true))?["name"]
.as_str()
.map(String::from)
}
fn active_window_rect(&self) -> Option<Region> {
let tree = Self::query("get_tree")?;
let node = find_focused(&tree)?;
let is_window = node.get("app_id").is_some_and(|a| !a.is_null())
|| node.get("window_properties").is_some()
|| (matches!(
node.get("type").and_then(|t| t.as_str()),
Some("con") | Some("floating_con")
) && node.get("name").is_some_and(|n| !n.is_null()));
if !is_window {
return None;
}
rect_of(node)
}
fn window_at(&self, x: i32, y: i32) -> Option<WindowRef> {
sway_window_at(&Self::query("get_tree")?, x, y)
}
}
fn sway_is_window(node: &serde_json::Value) -> bool {
node.get("app_id").is_some_and(|a| !a.is_null()) || node.get("window_properties").is_some()
}
fn sway_content_rect(node: &serde_json::Value) -> Option<Region> {
let rect = rect_of(node)?;
if let Some(wr) = node.get("window_rect")
&& let (Some(w), Some(h)) = (wr["width"].as_u64(), wr["height"].as_u64())
&& w > 0
&& h > 0
{
return Some(Region {
x: rect.x + wr["x"].as_i64().unwrap_or(0) as i32,
y: rect.y + wr["y"].as_i64().unwrap_or(0) as i32,
w: w as u32,
h: h as u32,
});
}
Some(rect)
}
fn sway_window_at(node: &serde_json::Value, x: i32, y: i32) -> Option<WindowRef> {
if node.get("visible").and_then(|v| v.as_bool()) == Some(false) {
return None;
}
for key in ["floating_nodes", "nodes"] {
if let Some(children) = node.get(key).and_then(|c| c.as_array()) {
for child in children {
if rect_of(child).is_some_and(|r| contains(&r, x, y))
&& let Some(found) = sway_window_at(child, x, y)
{
return Some(found);
}
}
}
}
if sway_is_window(node) && rect_of(node).is_some_and(|r| contains(&r, x, y)) {
let app_id = node["app_id"]
.as_str()
.or_else(|| node["window_properties"]["class"].as_str())
.unwrap_or_default()
.to_string();
return Some(WindowRef {
app_id,
title: node["name"].as_str().unwrap_or_default().to_string(),
rect: sway_content_rect(node)?,
});
}
None
}
fn contains(r: &Region, x: i32, y: i32) -> bool {
x >= r.x && x < r.x + r.w as i32 && y >= r.y && y < r.y + r.h as i32
}
fn find_focused(node: &serde_json::Value) -> Option<&serde_json::Value> {
if node.get("focused").and_then(|f| f.as_bool()) == Some(true) {
return Some(node);
}
for key in ["nodes", "floating_nodes"] {
if let Some(children) = node.get(key).and_then(|c| c.as_array()) {
for child in children {
if let Some(found) = find_focused(child) {
return Some(found);
}
}
}
}
None
}
fn rect_of(node: &serde_json::Value) -> Option<Region> {
let r = node.get("rect")?;
Some(Region {
x: r["x"].as_i64()? as i32,
y: r["y"].as_i64()? as i32,
w: r["width"].as_u64()? as u32,
h: r["height"].as_u64()? as u32,
})
}
struct Hyprland;
impl Hyprland {
fn query(cmd: &str) -> Option<serde_json::Value> {
let out = std::process::Command::new("hyprctl")
.args(["-j", cmd])
.output()
.ok()?;
out.status.success().then_some(())?;
serde_json::from_slice(&out.stdout).ok()
}
}
impl FocusBackend for Hyprland {
fn name(&self) -> &'static str {
"Hyprland"
}
fn focused_output(&self) -> Option<String> {
hypr_focused_output(&Self::query("monitors")?)
}
fn active_window_rect(&self) -> Option<Region> {
hypr_active_window_rect(&Self::query("activewindow")?)
}
}
fn hypr_focused_output(monitors: &serde_json::Value) -> Option<String> {
monitors
.as_array()?
.iter()
.find(|m| m["focused"].as_bool() == Some(true))?
.get("name")?
.as_str()
.map(String::from)
}
fn hypr_active_window_rect(w: &serde_json::Value) -> Option<Region> {
let at = w.get("at")?.as_array()?;
let size = w.get("size")?.as_array()?;
Some(Region {
x: at.first()?.as_i64()? as i32,
y: at.get(1)?.as_i64()? as i32,
w: size.first()?.as_i64()? as u32,
h: size.get(1)?.as_i64()? as u32,
})
}
struct Niri;
impl Niri {
fn query(action: &str) -> Option<serde_json::Value> {
let out = std::process::Command::new("niri")
.args(["msg", "--json", action])
.output()
.ok()?;
out.status.success().then_some(())?;
serde_json::from_slice(&out.stdout).ok()
}
}
impl FocusBackend for Niri {
fn name(&self) -> &'static str {
"niri"
}
fn focused_output(&self) -> Option<String> {
niri_focused_output(&Self::query("focused-output")?)
}
fn active_window_rect(&self) -> Option<Region> {
None
}
}
fn niri_focused_output(o: &serde_json::Value) -> Option<String> {
o.get("name")?.as_str().map(String::from)
}
#[cfg(test)]
mod tests {
use super::*;
const HYPR_MONITORS: &str = r#"[
{"id":0,"name":"DP-1","make":"Dell","model":"X","width":2560,"height":1440,
"x":0,"y":0,"refreshRate":59.95,"scale":1.0,"focused":false},
{"id":1,"name":"HDMI-A-1","make":"LG","model":"Y","width":1920,"height":1080,
"x":2560,"y":0,"refreshRate":60.0,"scale":1.0,"focused":true}
]"#;
const HYPR_ACTIVEWINDOW: &str =
r#"{"address":"0x55","class":"foot","title":"foot","at":[120,340],"size":[800,600]}"#;
const SWAY_TREE: &str = r#"{
"type":"root","rect":{"x":0,"y":0,"width":3840,"height":1440},
"nodes":[{
"type":"output","name":"DP-4","rect":{"x":0,"y":0,"width":3840,"height":1440},
"nodes":[
{
"type":"workspace","name":"1","visible":true,
"rect":{"x":0,"y":0,"width":3840,"height":1440},
"nodes":[{
"type":"con","app_id":"firefox","name":"Page Title","visible":true,
"rect":{"x":100,"y":100,"width":800,"height":600},
"window_rect":{"x":0,"y":20,"width":800,"height":580}
}]
},
{
"type":"workspace","name":"2","visible":false,
"rect":{"x":0,"y":0,"width":3840,"height":1440},
"nodes":[{
"type":"con","app_id":"vim","name":"editor","visible":false,
"rect":{"x":100,"y":100,"width":800,"height":600}
}]
}
]
}]
}"#;
#[test]
fn sway_window_at_finds_visible_window_and_content_rect() {
let v: serde_json::Value = serde_json::from_str(SWAY_TREE).unwrap();
let w = sway_window_at(&v, 200, 200).expect("window under the point");
assert_eq!(w.app_id, "firefox");
assert_eq!(w.title, "Page Title");
assert_eq!(
w.rect,
Region {
x: 100,
y: 120,
w: 800,
h: 580
}
);
assert!(sway_window_at(&v, 2000, 1300).is_none());
}
#[test]
fn hypr_focused_output_picks_focused_monitor() {
let v: serde_json::Value = serde_json::from_str(HYPR_MONITORS).unwrap();
assert_eq!(hypr_focused_output(&v).as_deref(), Some("HDMI-A-1"));
}
#[test]
fn hypr_active_window_rect_reads_at_and_size() {
let v: serde_json::Value = serde_json::from_str(HYPR_ACTIVEWINDOW).unwrap();
assert_eq!(
hypr_active_window_rect(&v),
Some(Region {
x: 120,
y: 340,
w: 800,
h: 600
})
);
}
#[test]
fn hypr_no_active_window_is_none() {
let v: serde_json::Value = serde_json::from_str("{}").unwrap();
assert!(hypr_active_window_rect(&v).is_none());
}
#[test]
fn niri_focused_output_reads_name() {
let v: serde_json::Value = serde_json::from_str(
r#"{"name":"eDP-1","make":"BOE","model":"Z",
"logical":{"x":0,"y":0,"width":1920,"height":1080,"scale":1.0}}"#,
)
.unwrap();
assert_eq!(niri_focused_output(&v).as_deref(), Some("eDP-1"));
assert!(niri_focused_output(&serde_json::Value::Null).is_none());
}
}