1use crate::wl::Region;
10
11pub struct WindowRef {
14 pub app_id: String,
16 pub title: String,
18 pub rect: Region,
20}
21
22pub trait FocusBackend {
24 fn focused_output(&self) -> Option<String>;
26 fn active_window_rect(&self) -> Option<Region>;
28 fn window_at(&self, _x: i32, _y: i32) -> Option<WindowRef> {
31 None
32 }
33 fn name(&self) -> &'static str;
35}
36
37pub fn detect() -> Option<Box<dyn FocusBackend>> {
40 if std::env::var_os("SWAYSOCK").is_some() {
41 return Some(Box::new(Sway));
42 }
43 if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
44 return Some(Box::new(Hyprland));
45 }
46 if std::env::var_os("NIRI_SOCKET").is_some() {
47 return Some(Box::new(Niri));
48 }
49 None
50}
51
52struct Sway;
54
55impl Sway {
56 fn query(kind: &str) -> Option<serde_json::Value> {
57 let out = std::process::Command::new("swaymsg")
58 .args(["-t", kind, "-r"])
59 .output()
60 .ok()?;
61 out.status.success().then_some(())?;
62 serde_json::from_slice(&out.stdout).ok()
63 }
64}
65
66impl FocusBackend for Sway {
67 fn name(&self) -> &'static str {
68 "sway"
69 }
70
71 fn focused_output(&self) -> Option<String> {
72 let outputs = Self::query("get_outputs")?;
73 outputs
74 .as_array()?
75 .iter()
76 .find(|o| o["focused"].as_bool() == Some(true))?["name"]
77 .as_str()
78 .map(String::from)
79 }
80
81 fn active_window_rect(&self) -> Option<Region> {
82 let tree = Self::query("get_tree")?;
83 let node = find_focused(&tree)?;
84 let is_window = node.get("app_id").is_some_and(|a| !a.is_null())
87 || node.get("window_properties").is_some()
88 || (matches!(
89 node.get("type").and_then(|t| t.as_str()),
90 Some("con") | Some("floating_con")
91 ) && node.get("name").is_some_and(|n| !n.is_null()));
92 if !is_window {
93 return None;
94 }
95 rect_of(node)
96 }
97
98 fn window_at(&self, x: i32, y: i32) -> Option<WindowRef> {
99 sway_window_at(&Self::query("get_tree")?, x, y)
100 }
101}
102
103fn sway_is_window(node: &serde_json::Value) -> bool {
105 node.get("app_id").is_some_and(|a| !a.is_null()) || node.get("window_properties").is_some()
106}
107
108fn sway_content_rect(node: &serde_json::Value) -> Option<Region> {
112 let rect = rect_of(node)?;
113 if let Some(wr) = node.get("window_rect")
114 && let (Some(w), Some(h)) = (wr["width"].as_u64(), wr["height"].as_u64())
115 && w > 0
116 && h > 0
117 {
118 return Some(Region {
119 x: rect.x + wr["x"].as_i64().unwrap_or(0) as i32,
120 y: rect.y + wr["y"].as_i64().unwrap_or(0) as i32,
121 w: w as u32,
122 h: h as u32,
123 });
124 }
125 Some(rect)
126}
127
128fn sway_window_at(node: &serde_json::Value, x: i32, y: i32) -> Option<WindowRef> {
130 if node.get("visible").and_then(|v| v.as_bool()) == Some(false) {
134 return None;
135 }
136 for key in ["floating_nodes", "nodes"] {
138 if let Some(children) = node.get(key).and_then(|c| c.as_array()) {
139 for child in children {
140 if rect_of(child).is_some_and(|r| contains(&r, x, y))
141 && let Some(found) = sway_window_at(child, x, y)
142 {
143 return Some(found);
144 }
145 }
146 }
147 }
148 if sway_is_window(node) && rect_of(node).is_some_and(|r| contains(&r, x, y)) {
149 let app_id = node["app_id"]
150 .as_str()
151 .or_else(|| node["window_properties"]["class"].as_str())
152 .unwrap_or_default()
153 .to_string();
154 return Some(WindowRef {
155 app_id,
156 title: node["name"].as_str().unwrap_or_default().to_string(),
157 rect: sway_content_rect(node)?,
158 });
159 }
160 None
161}
162
163fn contains(r: &Region, x: i32, y: i32) -> bool {
165 x >= r.x && x < r.x + r.w as i32 && y >= r.y && y < r.y + r.h as i32
166}
167
168fn find_focused(node: &serde_json::Value) -> Option<&serde_json::Value> {
170 if node.get("focused").and_then(|f| f.as_bool()) == Some(true) {
171 return Some(node);
172 }
173 for key in ["nodes", "floating_nodes"] {
174 if let Some(children) = node.get(key).and_then(|c| c.as_array()) {
175 for child in children {
176 if let Some(found) = find_focused(child) {
177 return Some(found);
178 }
179 }
180 }
181 }
182 None
183}
184
185fn rect_of(node: &serde_json::Value) -> Option<Region> {
187 let r = node.get("rect")?;
188 Some(Region {
189 x: r["x"].as_i64()? as i32,
190 y: r["y"].as_i64()? as i32,
191 w: r["width"].as_u64()? as u32,
192 h: r["height"].as_u64()? as u32,
193 })
194}
195
196struct Hyprland;
198
199impl Hyprland {
200 fn query(cmd: &str) -> Option<serde_json::Value> {
201 let out = std::process::Command::new("hyprctl")
202 .args(["-j", cmd])
203 .output()
204 .ok()?;
205 out.status.success().then_some(())?;
206 serde_json::from_slice(&out.stdout).ok()
207 }
208}
209
210impl FocusBackend for Hyprland {
211 fn name(&self) -> &'static str {
212 "Hyprland"
213 }
214
215 fn focused_output(&self) -> Option<String> {
216 hypr_focused_output(&Self::query("monitors")?)
217 }
218
219 fn active_window_rect(&self) -> Option<Region> {
220 hypr_active_window_rect(&Self::query("activewindow")?)
221 }
222}
223
224fn hypr_focused_output(monitors: &serde_json::Value) -> Option<String> {
227 monitors
228 .as_array()?
229 .iter()
230 .find(|m| m["focused"].as_bool() == Some(true))?
231 .get("name")?
232 .as_str()
233 .map(String::from)
234}
235
236fn hypr_active_window_rect(w: &serde_json::Value) -> Option<Region> {
240 let at = w.get("at")?.as_array()?;
241 let size = w.get("size")?.as_array()?;
242 Some(Region {
243 x: at.first()?.as_i64()? as i32,
244 y: at.get(1)?.as_i64()? as i32,
245 w: size.first()?.as_i64()? as u32,
246 h: size.get(1)?.as_i64()? as u32,
247 })
248}
249
250struct Niri;
252
253impl Niri {
254 fn query(action: &str) -> Option<serde_json::Value> {
255 let out = std::process::Command::new("niri")
256 .args(["msg", "--json", action])
257 .output()
258 .ok()?;
259 out.status.success().then_some(())?;
260 serde_json::from_slice(&out.stdout).ok()
261 }
262}
263
264impl FocusBackend for Niri {
265 fn name(&self) -> &'static str {
266 "niri"
267 }
268
269 fn focused_output(&self) -> Option<String> {
270 niri_focused_output(&Self::query("focused-output")?)
271 }
272
273 fn active_window_rect(&self) -> Option<Region> {
274 None
279 }
280}
281
282fn niri_focused_output(o: &serde_json::Value) -> Option<String> {
285 o.get("name")?.as_str().map(String::from)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 const HYPR_MONITORS: &str = r#"[
295 {"id":0,"name":"DP-1","make":"Dell","model":"X","width":2560,"height":1440,
296 "x":0,"y":0,"refreshRate":59.95,"scale":1.0,"focused":false},
297 {"id":1,"name":"HDMI-A-1","make":"LG","model":"Y","width":1920,"height":1080,
298 "x":2560,"y":0,"refreshRate":60.0,"scale":1.0,"focused":true}
299 ]"#;
300
301 const HYPR_ACTIVEWINDOW: &str =
303 r#"{"address":"0x55","class":"foot","title":"foot","at":[120,340],"size":[800,600]}"#;
304
305 const SWAY_TREE: &str = r#"{
309 "type":"root","rect":{"x":0,"y":0,"width":3840,"height":1440},
310 "nodes":[{
311 "type":"output","name":"DP-4","rect":{"x":0,"y":0,"width":3840,"height":1440},
312 "nodes":[
313 {
314 "type":"workspace","name":"1","visible":true,
315 "rect":{"x":0,"y":0,"width":3840,"height":1440},
316 "nodes":[{
317 "type":"con","app_id":"firefox","name":"Page Title","visible":true,
318 "rect":{"x":100,"y":100,"width":800,"height":600},
319 "window_rect":{"x":0,"y":20,"width":800,"height":580}
320 }]
321 },
322 {
323 "type":"workspace","name":"2","visible":false,
324 "rect":{"x":0,"y":0,"width":3840,"height":1440},
325 "nodes":[{
326 "type":"con","app_id":"vim","name":"editor","visible":false,
327 "rect":{"x":100,"y":100,"width":800,"height":600}
328 }]
329 }
330 ]
331 }]
332 }"#;
333
334 #[test]
335 fn sway_window_at_finds_visible_window_and_content_rect() {
336 let v: serde_json::Value = serde_json::from_str(SWAY_TREE).unwrap();
337 let w = sway_window_at(&v, 200, 200).expect("window under the point");
338 assert_eq!(w.app_id, "firefox");
340 assert_eq!(w.title, "Page Title");
341 assert_eq!(
343 w.rect,
344 Region {
345 x: 100,
346 y: 120,
347 w: 800,
348 h: 580
349 }
350 );
351 assert!(sway_window_at(&v, 2000, 1300).is_none());
353 }
354
355 #[test]
356 fn hypr_focused_output_picks_focused_monitor() {
357 let v: serde_json::Value = serde_json::from_str(HYPR_MONITORS).unwrap();
358 assert_eq!(hypr_focused_output(&v).as_deref(), Some("HDMI-A-1"));
359 }
360
361 #[test]
362 fn hypr_active_window_rect_reads_at_and_size() {
363 let v: serde_json::Value = serde_json::from_str(HYPR_ACTIVEWINDOW).unwrap();
364 assert_eq!(
365 hypr_active_window_rect(&v),
366 Some(Region {
367 x: 120,
368 y: 340,
369 w: 800,
370 h: 600
371 })
372 );
373 }
374
375 #[test]
376 fn hypr_no_active_window_is_none() {
377 let v: serde_json::Value = serde_json::from_str("{}").unwrap();
379 assert!(hypr_active_window_rect(&v).is_none());
380 }
381
382 #[test]
383 fn niri_focused_output_reads_name() {
384 let v: serde_json::Value = serde_json::from_str(
386 r#"{"name":"eDP-1","make":"BOE","model":"Z",
387 "logical":{"x":0,"y":0,"width":1920,"height":1080,"scale":1.0}}"#,
388 )
389 .unwrap();
390 assert_eq!(niri_focused_output(&v).as_deref(), Some("eDP-1"));
391 assert!(niri_focused_output(&serde_json::Value::Null).is_none());
393 }
394}