Skip to main content

screen_app/recp/
bubble_position.rs

1//! M-BUBBLE.3 / AUT-276 — webcam-bubble position math + corner snap.
2//!
3//! Pure-Rust helpers: no Tauri, no I/O, no async. The Tauri-side
4//! caller in `commands.rs` invokes [`default_position`] when the
5//! bubble first opens without a saved position, [`is_on_any_monitor`]
6//! to validate a restored position is still visible after a display
7//! unplug, and [`snap_to_nearest_corner`] for the snap-on-drag UX
8//! (wired in a follow-up — for v0 these helpers ship tested but the
9//! `Moved` event wiring stays inert to avoid the set-position →
10//! Moved → set-position loop without a debounce in place).
11
12use serde::{Deserialize, Serialize};
13
14use super::tray_positioning::MonitorBounds;
15
16/// On-disk-serialisable bubble window position. Stored as logical
17/// pixels (the units `tauri::WebviewWindow::set_position` accepts).
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct BubblePosition {
20    /// Window's top-left x in screen coordinates.
21    pub x: i32,
22    /// Window's top-left y in screen coordinates.
23    pub y: i32,
24}
25
26/// Which corner of a monitor a window is closest to. Returned by
27/// [`snap_to_nearest_corner`] (via `Option<(i32, i32, Corner)>`) so the
28/// caller can render a "snapped to top-right" visual hint distinct
29/// from "free-floating."
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Corner {
32    /// Monitor's top-left corner.
33    TopLeft,
34    /// Monitor's top-right corner.
35    TopRight,
36    /// Monitor's bottom-left corner.
37    BottomLeft,
38    /// Monitor's bottom-right corner.
39    BottomRight,
40}
41
42/// Default first-open position: bottom-LEFT of the supplied monitor,
43/// inset by `inset_px` from both edges so the bubble doesn't kiss the
44/// dock / taskbar. Matches the Screenplay-style design reference where
45/// the webcam bubble lives in the bottom-left corner of the screen by
46/// default (the user can drag it anywhere; the new position is
47/// persisted via `bubble-position.txt`).
48#[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/// `true` iff the bubble (top-left at `pos`, size `(w, h)`) is at
62/// least partially visible on any of the supplied monitors. Used after
63/// a display unplug to decide whether the persisted position is still
64/// usable or should fall back to [`default_position`].
65///
66/// A position counts as visible if the window's rectangle intersects
67/// the monitor's rectangle (any pixel overlap — not requiring full
68/// containment, since the user may have intentionally placed the
69/// bubble half-off-screen and we shouldn't reset their choice).
70#[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        // Standard axis-aligned rectangle intersection.
89        win_left < mon_right && win_right > mon_left && win_top < mon_bottom && win_bottom > mon_top
90    })
91}
92
93/// Internal helper type — pairs a corner identity with the screen
94/// coordinates of two matching points (the window's corner and the
95/// monitor's corner) used to compute the Manhattan distance between
96/// them. Aliased so the array literal stays readable + clippy's
97/// `type_complexity` lint is satisfied.
98type CornerCandidate = (Corner, (i32, i32), (i32, i32));
99
100/// If the bubble's top-left is within `snap_radius_px` of any
101/// monitor's nearest corner (measured corner-to-corner of the window
102/// vs monitor), snap to that corner's exact position and return the
103/// snapped `(BubblePosition, Corner)`. Otherwise `None`.
104///
105/// "Nearest corner of the monitor for this window position" means:
106///
107/// * Top-left  → window's top-left  near monitor's top-left
108/// * Top-right → window's top-right near monitor's top-right
109/// * Bottom-* → analogous
110///
111/// So the comparison is between matching corners of the window and
112/// the monitor. Snapping rewrites the window's top-left so the
113/// matched corners align exactly (with optional inset — see
114/// `inset_px`).
115#[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            // Manhattan distance keeps the math integer-only and is a
147            // good-enough proxy for "user dragged near this corner."
148            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        // 200×200 window, 16px inset → top-left at (16, 1080-200-16).
196        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        // Secondary monitor at x=1920.
203        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        // Half off-screen left.
224        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        // Saved position assumed a secondary monitor that's now gone.
246        let m = vec![mon(0, 0, 1920, 1080)];
247        let saved = BubblePosition { x: 2500, y: 500 }; // was on the now-gone secondary
248        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        // Window at (1700, 850): bottom-right is (1900, 1050) — close
255        // to monitor's bottom-right (1920, 1080). Manhattan distance
256        // = 20 + 30 = 50, well within radius 50.
257        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        // Bottom-right snap: x = 1920 - 200 - 16 = 1704, y = 1080 - 200 - 16 = 864.
262        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        // Window at (8, 12) — both corners within snap radius.
269        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        // Dead-centre of a 1920×1080 monitor — far from every corner.
279        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        // Position closer to bottom-right than top-right.
287        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        // Secondary monitor at x=1920.
296        let m = mon(1920, 0, 1920, 1080);
297        // Window placed near secondary's top-left.
298        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}