1use serde::{Deserialize, Serialize};
13
14use super::tray_positioning::MonitorBounds;
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct BubblePosition {
20 pub x: i32,
22 pub y: i32,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Corner {
32 TopLeft,
34 TopRight,
36 BottomLeft,
38 BottomRight,
40}
41
42#[must_use]
49pub fn default_position(
50 _window_width_unused: i32,
51 window_height: i32,
52 monitor: MonitorBounds,
53 inset_px: i32,
54) -> BubblePosition {
55 BubblePosition {
56 x: monitor.x + inset_px,
57 y: monitor.y + monitor.height - window_height - inset_px,
58 }
59}
60
61#[must_use]
71pub fn is_on_any_monitor(
72 pos: BubblePosition,
73 window_width: i32,
74 window_height: i32,
75 monitors: &[MonitorBounds],
76) -> bool {
77 let win_left = pos.x;
78 let win_top = pos.y;
79 let win_right = pos.x + window_width;
80 let win_bottom = pos.y + window_height;
81
82 monitors.iter().any(|m| {
83 let mon_left = m.x;
84 let mon_top = m.y;
85 let mon_right = m.x + m.width;
86 let mon_bottom = m.y + m.height;
87
88 win_left < mon_right && win_right > mon_left && win_top < mon_bottom && win_bottom > mon_top
90 })
91}
92
93type CornerCandidate = (Corner, (i32, i32), (i32, i32));
99
100#[must_use]
116pub fn snap_to_nearest_corner(
117 pos: BubblePosition,
118 window_width: i32,
119 window_height: i32,
120 monitor: MonitorBounds,
121 snap_radius_px: i32,
122 inset_px: i32,
123) -> Option<(BubblePosition, Corner)> {
124 let win_top_left = (pos.x, pos.y);
125 let win_top_right = (pos.x + window_width, pos.y);
126 let win_bot_left = (pos.x, pos.y + window_height);
127 let win_bot_right = (pos.x + window_width, pos.y + window_height);
128
129 let mon_top_left = (monitor.x, monitor.y);
130 let mon_top_right = (monitor.x + monitor.width, monitor.y);
131 let mon_bot_left = (monitor.x, monitor.y + monitor.height);
132 let mon_bot_right = (monitor.x + monitor.width, monitor.y + monitor.height);
133
134 let candidates: [CornerCandidate; 4] = [
135 (Corner::TopLeft, win_top_left, mon_top_left),
136 (Corner::TopRight, win_top_right, mon_top_right),
137 (Corner::BottomLeft, win_bot_left, mon_bot_left),
138 (Corner::BottomRight, win_bot_right, mon_bot_right),
139 ];
140
141 let (corner, _win, _mon, distance) = candidates
142 .iter()
143 .map(|(c, win, mon)| {
144 let dx = win.0 - mon.0;
145 let dy = win.1 - mon.1;
146 let dist = dx.abs() + dy.abs();
149 (*c, *win, *mon, dist)
150 })
151 .min_by_key(|(_, _, _, d)| *d)?;
152
153 if distance > snap_radius_px {
154 return None;
155 }
156
157 let snapped = match corner {
158 Corner::TopLeft => BubblePosition {
159 x: monitor.x + inset_px,
160 y: monitor.y + inset_px,
161 },
162 Corner::TopRight => BubblePosition {
163 x: monitor.x + monitor.width - window_width - inset_px,
164 y: monitor.y + inset_px,
165 },
166 Corner::BottomLeft => BubblePosition {
167 x: monitor.x + inset_px,
168 y: monitor.y + monitor.height - window_height - inset_px,
169 },
170 Corner::BottomRight => BubblePosition {
171 x: monitor.x + monitor.width - window_width - inset_px,
172 y: monitor.y + monitor.height - window_height - inset_px,
173 },
174 };
175
176 Some((snapped, corner))
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 fn mon(x: i32, y: i32, w: i32, h: i32) -> MonitorBounds {
184 MonitorBounds {
185 x,
186 y,
187 width: w,
188 height: h,
189 }
190 }
191
192 #[test]
193 fn default_position_lands_bottom_left_with_inset() {
194 let m = mon(0, 0, 1920, 1080);
195 let pos = default_position(200, 200, m, 16);
197 assert_eq!(pos, BubblePosition { x: 16, y: 864 });
198 }
199
200 #[test]
201 fn default_position_respects_monitor_offset() {
202 let m = mon(1920, 0, 1920, 1080);
204 let pos = default_position(200, 200, m, 16);
205 assert_eq!(pos.x, 1920 + 16);
206 assert_eq!(pos.y, 864);
207 }
208
209 #[test]
210 fn is_on_any_monitor_true_for_position_fully_inside() {
211 let m = vec![mon(0, 0, 1920, 1080)];
212 assert!(is_on_any_monitor(
213 BubblePosition { x: 100, y: 100 },
214 200,
215 200,
216 &m
217 ));
218 }
219
220 #[test]
221 fn is_on_any_monitor_true_for_partial_overlap() {
222 let m = vec![mon(0, 0, 1920, 1080)];
223 assert!(is_on_any_monitor(
225 BubblePosition { x: -100, y: 100 },
226 200,
227 200,
228 &m
229 ));
230 }
231
232 #[test]
233 fn is_on_any_monitor_false_for_fully_off_screen() {
234 let m = vec![mon(0, 0, 1920, 1080)];
235 assert!(!is_on_any_monitor(
236 BubblePosition { x: 2000, y: 100 },
237 200,
238 200,
239 &m
240 ));
241 }
242
243 #[test]
244 fn is_on_any_monitor_handles_unplugged_secondary() {
245 let m = vec![mon(0, 0, 1920, 1080)];
247 let saved = BubblePosition { x: 2500, y: 500 }; assert!(!is_on_any_monitor(saved, 200, 200, &m));
249 }
250
251 #[test]
252 fn snap_to_nearest_corner_snaps_to_bottom_right_when_near() {
253 let m = mon(0, 0, 1920, 1080);
254 let result =
258 snap_to_nearest_corner(BubblePosition { x: 1700, y: 850 }, 200, 200, m, 50, 16);
259 let (snapped, corner) = result.expect("should snap");
260 assert_eq!(corner, Corner::BottomRight);
261 assert_eq!(snapped, BubblePosition { x: 1704, y: 864 });
263 }
264
265 #[test]
266 fn snap_to_nearest_corner_snaps_to_top_left_when_near() {
267 let m = mon(0, 0, 1920, 1080);
268 let result = snap_to_nearest_corner(BubblePosition { x: 8, y: 12 }, 200, 200, m, 50, 16);
270 let (snapped, corner) = result.expect("should snap");
271 assert_eq!(corner, Corner::TopLeft);
272 assert_eq!(snapped, BubblePosition { x: 16, y: 16 });
273 }
274
275 #[test]
276 fn snap_to_nearest_corner_returns_none_when_far_from_all_corners() {
277 let m = mon(0, 0, 1920, 1080);
278 let result = snap_to_nearest_corner(BubblePosition { x: 860, y: 440 }, 200, 200, m, 50, 16);
280 assert!(result.is_none());
281 }
282
283 #[test]
284 fn snap_chooses_nearest_corner_when_two_in_range() {
285 let m = mon(0, 0, 1920, 1080);
286 let result =
288 snap_to_nearest_corner(BubblePosition { x: 1700, y: 800 }, 200, 200, m, 200, 16);
289 let (_, corner) = result.expect("should snap");
290 assert_eq!(corner, Corner::BottomRight);
291 }
292
293 #[test]
294 fn snap_respects_monitor_offset() {
295 let m = mon(1920, 0, 1920, 1080);
297 let result = snap_to_nearest_corner(BubblePosition { x: 1928, y: 8 }, 200, 200, m, 50, 16);
299 let (snapped, corner) = result.expect("should snap");
300 assert_eq!(corner, Corner::TopLeft);
301 assert_eq!(snapped, BubblePosition { x: 1936, y: 16 });
302 }
303}