Skip to main content

wlr_capture/
focus.rs

1//! Focus-aware capture helpers: "the active window" and "the current output".
2//!
3//! Wayland deliberately gives a regular client no way to query the global pointer
4//! position or which surface/output has the focus — so, like `grimshot`, we rely
5//! on the compositor's own IPC. This is a small trait with per-compositor backends
6//! selected from the environment: Sway (`swaymsg`), Hyprland (`hyprctl`) and niri
7//! (`niri msg`).
8
9use crate::wl::Region;
10
11/// A window's identity + content geometry, for binding a region mirror to the window
12/// under it (`app_id` + `title` match a `wl::Toplevel`; `rect` is its content area).
13pub struct WindowRef {
14    /// The window's application id (matches a [`wl::Toplevel`](crate::wl::Toplevel)).
15    pub app_id: String,
16    /// The window title (matches a [`wl::Toplevel`](crate::wl::Toplevel)).
17    pub title: String,
18    /// The window's content area in the global logical space.
19    pub rect: Region,
20}
21
22/// A compositor-specific source of focus information.
23pub trait FocusBackend {
24    /// Name of the focused output, if any.
25    fn focused_output(&self) -> Option<String>;
26    /// Logical rectangle of the active (focused) window, if any.
27    fn active_window_rect(&self) -> Option<Region>;
28    /// The window under the given global logical point, if any. Used to make a region
29    /// mirror follow the window beneath it. Default `None` (only Sway implements it).
30    fn window_at(&self, _x: i32, _y: i32) -> Option<WindowRef> {
31        None
32    }
33    /// Human-readable backend name, for error messages.
34    fn name(&self) -> &'static str;
35}
36
37/// Pick a focus backend from the environment. `None` if no supported compositor
38/// IPC is present (Wayland has no portable fallback — see the module docs).
39pub 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
52/// Sway / wlroots `swaymsg` backend.
53struct 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        // Only windows have an app_id / window properties; a focused empty
85        // workspace is not an "active window".
86        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
103/// Whether a sway node is a window (vs a container/workspace/output).
104fn 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
108/// A node's content rectangle in global logical coordinates: its `rect` shifted by the
109/// `window_rect` (content offset within the node), so the crop lines up with what the
110/// foreign-toplevel capture actually contains (no server-side borders).
111fn 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
128/// The deepest window node whose `rect` contains the global logical point `(x, y)`.
129fn sway_window_at(node: &serde_json::Value, x: i32, y: i32) -> Option<WindowRef> {
130    // Skip anything not actually on screen — sway keeps the geometry of windows on
131    // hidden workspaces (and tabbed/stacked-behind windows) in the tree, so without
132    // this we'd match a window the point only "contains" on a workspace you can't see.
133    if node.get("visible").and_then(|v| v.as_bool()) == Some(false) {
134        return None;
135    }
136    // Descend into children first so the innermost (leaf) window wins.
137    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
163/// Whether `(x, y)` falls inside a logical region.
164fn 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
168/// The single node with `"focused": true` in a sway tree (the active container).
169fn 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
185/// Read a sway `rect` object into a logical [`Region`].
186fn 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
196/// Hyprland `hyprctl -j` backend.
197struct 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
224/// Pick the focused monitor's name from `hyprctl -j monitors` (an array of monitors,
225/// one with `"focused": true`).
226fn 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
236/// Read the active window's rectangle from `hyprctl -j activewindow`: `at: [x, y]`
237/// and `size: [w, h]` in global logical coordinates. An empty object (`{}`) — nothing
238/// focused — yields `None`.
239fn 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
250/// niri `niri msg --json` backend.
251struct 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        // niri's IPC does not expose a window's rectangle in global logical
275        // coordinates (scrollable tiling lets windows extend off-screen), so the
276        // active-window source is unavailable — callers get a clear error and can
277        // use `--current-output` or `-g` instead.
278        None
279    }
280}
281
282/// Pick the focused output's name from `niri msg --json focused-output` (the Output
283/// object, or `null` when none).
284fn 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    // A trimmed but faithful `hyprctl -j monitors` sample (two monitors, the second
293    // focused) — locks the field names (`focused`, `name`) the parser relies on.
294    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    // `hyprctl -j activewindow` gives `at`/`size` pairs in global logical coords.
302    const HYPR_ACTIVEWINDOW: &str =
303        r#"{"address":"0x55","class":"foot","title":"foot","at":[120,340],"size":[800,600]}"#;
304
305    // A trimmed sway `get_tree`: an output with a visible workspace (firefox, with a
306    // 20px title-bar `window_rect`) and a *hidden* workspace whose window (vim) covers
307    // the same coordinates — sway keeps its geometry even though it's off screen.
308    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        // The visible window wins, never the one on the hidden workspace.
339        assert_eq!(w.app_id, "firefox");
340        assert_eq!(w.title, "Page Title");
341        // Content rect = node rect shifted by the 20px title bar.
342        assert_eq!(
343            w.rect,
344            Region {
345                x: 100,
346                y: 120,
347                w: 800,
348                h: 580
349            }
350        );
351        // A point on the empty desktop hits no window.
352        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        // Hyprland returns `{}` when nothing is focused.
378        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        // Shape per niri's `focused-output` (the Output object). Unverified live.
385        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        // `null` (no focused output) → None.
392        assert!(niri_focused_output(&serde_json::Value::Null).is_none());
393    }
394}