Skip to main content

rdi_core/
desktop.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::{DesktopError, IconAnimationSpec, IconSnapshot, MonitorInfo, Point, Rect};
4
5/// A read-only snapshot in physical virtual-screen pixels. Gaps between monitors
6/// are included in `bounds`, but are never usable grid cells.
7#[derive(Clone, Debug, PartialEq)]
8pub struct DesktopInfo {
9    pub bounds: Rect,
10    pub monitors: Vec<MonitorInfo>,
11    pub grids: Vec<IconGrid>,
12}
13
14/// One monitor's icon grid. `origin` is the first usable icon-position anchor,
15/// not the bitmap or label corner. Cell size includes icon/label spacing.
16/// An unavailable origin has zero rows/columns and cannot be used for snapping.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct IconGrid {
19    pub monitor_id: String,
20    pub work_area: Rect,
21    pub icon_size: Point,
22    pub cell_size: Point,
23    pub origin: Option<Point>,
24    pub origin_inferred: bool,
25    pub columns: u32,
26    pub rows: u32,
27}
28
29impl IconGrid {
30    fn footprint(&self) -> Point {
31        Point::new(self.cell_size.x.max(self.icon_size.x), self.cell_size.y.max(self.icon_size.y))
32    }
33
34    pub fn new(
35        monitor_id: String,
36        work_area: Rect,
37        icon_size: Point,
38        cell_size: Point,
39        origin: Option<Point>,
40        origin_inferred: bool,
41    ) -> Result<Self, DesktopError> {
42        if cell_size.x <= 0 || cell_size.y <= 0 || icon_size.x <= 0 || icon_size.y <= 0
43            || work_area.right <= work_area.left || work_area.bottom <= work_area.top
44        {
45            return Err(DesktopError::InvalidGrid("non-positive desktop geometry".into()));
46        }
47        let (columns, rows) = if let Some(origin) = origin {
48            if !work_area.contains(origin) {
49                return Err(DesktopError::InvalidGrid("grid origin outside work area".into()));
50            }
51            (
52                ((i64::from(work_area.right) - i64::from(origin.x)) / i64::from(cell_size.x)) as u32,
53                ((i64::from(work_area.bottom) - i64::from(origin.y)) / i64::from(cell_size.y)) as u32,
54            )
55        } else {
56            (0, 0)
57        };
58        Ok(Self { monitor_id, work_area, icon_size, cell_size, origin, origin_inferred, columns, rows })
59    }
60
61    /// Infer the unique most common two-axis grid phase. Empty or tied samples
62    /// return None; never substitute the work-area corner for an unknown phase.
63    pub fn infer_origin(work: Rect, spacing: Point, positions: &[Point]) -> Option<Point> {
64        if spacing.x <= 0 || spacing.y <= 0 { return None; }
65        let mut counts = HashMap::new();
66        for position in positions.iter().filter(|position| work.contains(**position)) {
67            let phase = (
68                (i64::from(position.x) - i64::from(work.left)).rem_euclid(i64::from(spacing.x)),
69                (i64::from(position.y) - i64::from(work.top)).rem_euclid(i64::from(spacing.y)),
70            );
71            *counts.entry(phase).or_insert(0usize) += 1;
72        }
73        let maximum = counts.values().copied().max()?;
74        let mut winners = counts.into_iter().filter(|(_, count)| *count == maximum);
75        let ((horizontal, vertical), _) = winners.next()?;
76        if winners.next().is_some() { return None; }
77        Some(Point::new(
78            i32::try_from(i64::from(work.left) + horizontal).ok()?,
79            i32::try_from(i64::from(work.top) + vertical).ok()?,
80        ))
81    }
82}
83
84fn overlaps(left: Point, left_size: Point, right: Point, right_size: Point) -> bool {
85    (i64::from(left.x) - i64::from(right.x)).abs() < i64::from(left_size.x.max(right_size.x))
86        && (i64::from(left.y) - i64::from(right.y)).abs() < i64::from(left_size.y.max(right_size.y))
87}
88
89fn distance_to_rect(point: Point, bounds: Rect) -> i128 {
90    let horizontal = i128::from(point.x) - i128::from(point.x.clamp(bounds.left, bounds.right - 1));
91    let vertical = i128::from(point.y) - i128::from(point.y.clamp(bounds.top, bounds.bottom - 1));
92    horizontal * horizontal + vertical * vertical
93}
94
95/// Resolve all destinations atomically in spec order, using nearest available
96/// cells on the target's monitor (nearest work area for off-screen targets).
97/// Stationary footprints remain reserved; active source cells can be reused.
98pub fn resolve_grid_targets(
99    specs: &mut [IconAnimationSpec],
100    icons: &[IconSnapshot],
101    desktop: &DesktopInfo,
102) -> Result<(), DesktopError> {
103    let mut ids = HashSet::new();
104    for spec in specs.iter() {
105        if !ids.insert(&spec.id) {
106            return Err(DesktopError::InvalidGrid(format!("duplicate animation icon {}", spec.id)));
107        }
108    }
109    let existing: HashSet<_> = icons.iter().map(|icon| &icon.id).collect();
110    if specs.iter().all(|spec| !existing.contains(&spec.id)) { return Ok(()); }
111    if desktop.grids.is_empty() {
112        return Err(DesktopError::InvalidGrid("desktop has no icon grids".into()));
113    }
114    for grid in &desktop.grids {
115        let checked = IconGrid::new(grid.monitor_id.clone(), grid.work_area, grid.icon_size,
116            grid.cell_size, grid.origin, grid.origin_inferred)?;
117        if checked.columns != grid.columns || checked.rows != grid.rows
118            || u64::from(grid.columns) * u64::from(grid.rows) > 1_000_000
119        {
120            return Err(DesktopError::InvalidGrid("invalid or excessive grid capacity".into()));
121        }
122    }
123    let mut occupied: Vec<_> = icons.iter().filter(|icon| !ids.contains(&icon.id))
124        .map(|icon| {
125            let size = desktop.grids.iter()
126                .min_by_key(|grid| distance_to_rect(icon.position, grid.work_area))
127                .unwrap().footprint();
128            (icon.position, size)
129        }).collect();
130    let mut targets = Vec::with_capacity(specs.len());
131    for spec in specs.iter() {
132        if !existing.contains(&spec.id) {
133            targets.push(spec.target);
134            continue;
135        }
136        let grid = desktop.grids.iter()
137            .min_by_key(|grid| distance_to_rect(spec.target, grid.work_area)).unwrap();
138        let origin = grid.origin.ok_or_else(|| DesktopError::InvalidGrid(
139            format!("grid origin unavailable for {}", grid.monitor_id)))?;
140        let footprint = grid.footprint();
141        let mut best: Option<(i128, Point)> = None;
142        for column in 0..grid.columns {
143            for row in 0..grid.rows {
144                let candidate = Point::new(
145                    (i64::from(origin.x) + i64::from(column) * i64::from(grid.cell_size.x)) as i32,
146                    (i64::from(origin.y) + i64::from(row) * i64::from(grid.cell_size.y)) as i32,
147                );
148                if i64::from(candidate.x) + i64::from(footprint.x) > i64::from(grid.work_area.right)
149                    || i64::from(candidate.y) + i64::from(footprint.y) > i64::from(grid.work_area.bottom)
150                    || occupied.iter().any(|(point, size)| overlaps(candidate, footprint, *point, *size)) {
151                    continue;
152                }
153                let horizontal = i128::from(candidate.x) - i128::from(spec.target.x);
154                let vertical = i128::from(candidate.y) - i128::from(spec.target.y);
155                let distance = horizontal * horizontal + vertical * vertical;
156                if best.is_none_or(|(previous, _)| distance < previous) {
157                    best = Some((distance, candidate));
158                }
159            }
160        }
161        let target = best.ok_or_else(|| DesktopError::InvalidGrid(
162            format!("no free grid cell on {} for {}", grid.monitor_id, spec.id)))?.1;
163        occupied.push((target, footprint));
164        targets.push(target);
165    }
166    for (spec, target) in specs.iter_mut().zip(targets) { spec.target = target; }
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::{Curve, Duration};
174
175    fn icon(id: &str, position: Point) -> IconSnapshot {
176        IconSnapshot::new(id.into(), id, None, false, position)
177    }
178
179    fn spec(id: &str, target: Point) -> IconAnimationSpec {
180        IconAnimationSpec::new(id.into(), target, Duration::fixed(std::time::Duration::ZERO), Curve::linear())
181    }
182
183    fn desktop() -> DesktopInfo {
184        let bounds = Rect::new(-300, -200, 0, 0);
185        DesktopInfo { bounds, monitors: vec![], grids: vec![IconGrid::new(
186            "left".into(), bounds, Point::new(40, 40), Point::new(100, 100),
187            Some(Point::new(-300, -200)), false).unwrap()] }
188    }
189
190    #[test]
191    fn grid_snaps_conflicts_and_protects_off_grid_stationary_icons() {
192        let desktop = desktop();
193        let icons = vec![icon("first", Point::new(-300, -200)),
194            icon("second", Point::new(-300, -100)), icon("fixed", Point::new(-150, -150))];
195        let mut specs = vec![spec("first", Point::new(-120, -110)), spec("second", Point::new(-120, -110))];
196        resolve_grid_targets(&mut specs, &icons, &desktop).unwrap();
197        assert_eq!(specs[0].target, Point::new(-300, -100));
198        assert_eq!(specs[1].target, Point::new(-300, -200));
199    }
200
201    #[test]
202    fn grid_failure_is_atomic_and_missing_icons_do_not_consume_cells() {
203        let mut desktop = desktop();
204        desktop.grids[0] = IconGrid::new("tiny".into(), Rect::new(0, 0, 100, 100),
205            Point::new(40, 40), Point::new(100, 100), Some(Point::new(0, 0)), false).unwrap();
206        let icons = vec![icon("first", Point::new(0, 0)), icon("second", Point::new(10, 10))];
207        let mut specs = vec![spec("first", Point::new(12, 12)), spec("second", Point::new(22, 22))];
208        assert!(resolve_grid_targets(&mut specs, &icons, &desktop).is_err());
209        assert_eq!(specs[0].target, Point::new(12, 12));
210        let mut specs = vec![spec("missing", Point::new(12, 12)), spec("first", Point::new(22, 22))];
211        resolve_grid_targets(&mut specs, &icons[..1], &desktop).unwrap();
212        assert_eq!(specs[1].target, Point::new(0, 0));
213    }
214
215    #[test]
216    fn grid_allows_swaps_and_clamps_extreme_targets() {
217        let icons = vec![icon("first", Point::new(-300, -200)), icon("second", Point::new(-100, -100))];
218        let mut specs = vec![spec("first", icons[1].position), spec("second", icons[0].position)];
219        resolve_grid_targets(&mut specs, &icons, &desktop()).unwrap();
220        assert_eq!(specs[0].target, icons[1].position);
221        assert_eq!(specs[1].target, icons[0].position);
222        specs[0].target = Point::new(i32::MAX, i32::MIN);
223        resolve_grid_targets(&mut specs, &icons, &desktop()).unwrap();
224        assert_eq!(specs[0].target, Point::new(-100, -200));
225    }
226
227    #[test]
228    fn grid_infers_phase_without_requiring_first_row_or_column() {
229        let work = Rect::new(-400, -300, 0, 0);
230        let spacing = Point::new(100, 100);
231        let points = [Point::new(-180, -90), Point::new(-80, -190), Point::new(-65, -70)];
232        assert_eq!(IconGrid::infer_origin(work, spacing, &points), Some(Point::new(-380, -290)));
233        assert_eq!(IconGrid::infer_origin(work, spacing, &points[1..]), None);
234        assert_eq!(IconGrid::infer_origin(work, spacing, &[]), None);
235    }
236
237    #[test]
238    fn grid_uses_target_monitor_spacing_and_reserves_large_icon_slots() {
239        let mut desktop = desktop();
240        desktop.grids.push(IconGrid::new("right".into(), Rect::new(0, 0, 600, 600),
241            Point::new(240, 240), Point::new(200, 200), Some(Point::new(0, 0)), false).unwrap());
242        let icons = vec![icon("first", Point::new(-300, -200)), icon("second", Point::new(-300, -100))];
243        let mut specs = vec![spec("first", Point::new(1, 1)), spec("second", Point::new(1, 1))];
244        assert!(resolve_grid_targets(&mut specs, &icons, &desktop).is_err());
245        desktop.grids[1] = IconGrid::new("right".into(), Rect::new(0, 0, 800, 800),
246            Point::new(240, 240), Point::new(200, 200), Some(Point::new(0, 0)), false).unwrap();
247        resolve_grid_targets(&mut specs, &icons, &desktop).unwrap();
248        assert_eq!(specs[0].target, Point::new(0, 0));
249        assert_eq!(specs[1].target, Point::new(0, 400));
250    }
251
252    #[test]
253    fn grid_rejects_duplicates_unknown_origins_and_invalid_sizes() {
254        let icons = vec![icon("first", Point::new(-300, -200))];
255        let mut specs = vec![spec("first", icons[0].position), spec("first", icons[0].position)];
256        assert!(resolve_grid_targets(&mut specs, &icons, &desktop()).is_err());
257        specs.pop();
258        let mut desktop = desktop();
259        desktop.grids[0].origin = None;
260        desktop.grids[0].columns = 0;
261        desktop.grids[0].rows = 0;
262        assert!(resolve_grid_targets(&mut specs, &icons, &desktop).is_err());
263        desktop.grids[0].cell_size.x = 0;
264        assert!(resolve_grid_targets(&mut specs, &icons, &desktop).is_err());
265    }
266}