Skip to main content

computer_use/
window_groups.rs

1//! Group on-screen windows by the screen that contains each window's center.
2//!
3//! The structure is intentionally platform-agnostic so the JSON shape is the
4//! same on every backend. The macOS backend fills it from `NSScreen` and the
5//! CG window list. Linux and Windows backends fill it from `xrandr` and the
6//! Win32 enumeration. Anything genuinely unavailable returns `unsupported`.
7
8use serde::Serialize;
9
10use crate::target::WindowInfo;
11
12/// One screen with its on-screen windows.
13#[derive(Debug, Clone, PartialEq, Serialize)]
14pub struct SpaceScreen {
15    /// Screen index in display order. `0` is the primary screen on most
16    /// desktops but the field is not a guarantee of primacy.
17    pub index: u32,
18    /// Logical name when the platform provides one (NSScreen localized name).
19    /// Empty when unknown.
20    pub name: String,
21    /// Left edge in the virtual desktop.
22    pub x: i32,
23    /// Top edge in the virtual desktop.
24    pub y: i32,
25    /// Logical width in screen points.
26    pub width: u32,
27    /// Logical height in screen points.
28    pub height: u32,
29    /// Whether this is the primary display.
30    pub is_primary: bool,
31    /// Windows whose center lies inside this screen, ordered by `id` ascending.
32    pub windows: Vec<WindowInfo>,
33}
34
35/// Full spaces report. One entry per screen. Linux and Windows backends fill
36/// `note` when Mission Control or virtual desktops are not part of the
37/// platform.
38#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct SpacesReport {
40    /// One entry per connected display.
41    pub screens: Vec<SpaceScreen>,
42    /// Free-form note explaining what is and is not supported on this platform.
43    /// Empty when the report is whole.
44    pub note: String,
45}
46
47/// Group `windows` by which screen in `displays` contains each window's center.
48/// Windows whose center is outside every screen fall into a synthetic group
49/// attached to the first screen; this is rare (a multi-screen arrangement
50/// where a window is mid-drag) but the algorithm must not lose the window.
51pub fn group_windows(
52    displays: &[crate::types::DisplayInfo],
53    windows: &[WindowInfo],
54) -> Vec<SpaceScreen> {
55    let mut out: Vec<SpaceScreen> = displays
56        .iter()
57        .enumerate()
58        .map(|(index, display)| SpaceScreen {
59            index: index as u32,
60            name: display.name.clone(),
61            x: display.x,
62            y: display.y,
63            width: display.width,
64            height: display.height,
65            is_primary: display.is_primary,
66            windows: Vec::new(),
67        })
68        .collect();
69    if out.is_empty() {
70        return out;
71    }
72    let mut orphan: Vec<WindowInfo> = Vec::new();
73    for window in windows {
74        let center_x = window.bounds.x + (window.bounds.width as i32) / 2;
75        let center_y = window.bounds.y + (window.bounds.height as i32) / 2;
76        let owner = out.iter_mut().find(|screen| {
77            point_inside(
78                center_x,
79                center_y,
80                screen.x,
81                screen.y,
82                screen.width,
83                screen.height,
84            )
85        });
86        match owner {
87            Some(screen) => screen.windows.push(window.clone()),
88            None => orphan.push(window.clone()),
89        }
90    }
91    if !orphan.is_empty() {
92        let first = &mut out[0];
93        first.windows.append(&mut orphan);
94    }
95    for screen in out.iter_mut() {
96        screen.windows.sort_by_key(|window| window.id);
97    }
98    out
99}
100
101fn point_inside(x: i32, y: i32, sx: i32, sy: i32, sw: u32, sh: u32) -> bool {
102    x >= sx && y >= sy && x < sx + (sw as i32) && y < sy + (sh as i32)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::target::WindowBounds;
109    use crate::types::DisplayInfo;
110
111    fn window(id: u32, x: i32, y: i32, w: u32, h: u32) -> WindowInfo {
112        WindowInfo {
113            id,
114            pid: 1,
115            app: "Test".to_string(),
116            title: format!("win {id}"),
117            bounds: WindowBounds {
118                x,
119                y,
120                width: w,
121                height: h,
122            },
123            z: 0,
124            layer: 0,
125            on_screen: true,
126            ax_matched: false,
127        }
128    }
129
130    fn display(index: u32, x: i32, y: i32, w: u32, h: u32) -> DisplayInfo {
131        DisplayInfo {
132            id: index,
133            name: format!("Display {index}"),
134            x,
135            y,
136            width: w,
137            height: h,
138            scale: 1.0,
139            is_primary: index == 0,
140        }
141    }
142
143    #[test]
144    fn group_assigns_windows_by_center() {
145        let screens = vec![
146            display(0, 0, 0, 1000, 1000),
147            display(1, 1000, 0, 1000, 1000),
148        ];
149        let windows = vec![
150            window(1, 100, 100, 200, 200),
151            window(2, 1100, 100, 200, 200),
152        ];
153        let groups = group_windows(&screens, &windows);
154        assert_eq!(groups.len(), 2);
155        assert_eq!(groups[0].windows.len(), 1);
156        assert_eq!(groups[0].windows[0].id, 1);
157        assert_eq!(groups[1].windows.len(), 1);
158        assert_eq!(groups[1].windows[0].id, 2);
159    }
160
161    #[test]
162    fn orphan_window_falls_into_the_first_screen() {
163        let screens = vec![display(0, 0, 0, 1000, 1000)];
164        let windows = vec![window(7, 5000, 5000, 200, 200)];
165        let groups = group_windows(&screens, &windows);
166        assert_eq!(groups.len(), 1);
167        assert_eq!(groups[0].windows.len(), 1);
168        assert_eq!(groups[0].windows[0].id, 7);
169    }
170
171    #[test]
172    fn empty_display_list_yields_empty_group() {
173        let groups = group_windows(&[], &[]);
174        assert!(groups.is_empty());
175    }
176}