srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/placement.rs
blob: e2b3d040470a5f45769a15d0f12f19c605e7c4b2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
//! Smart window placement: Windows-11-style grid placement, cascade fallback,
//! and Windows-Snap-style edge magnetism for drags.
//!
//! This reimplements the intent of the legacy C++ `SmartPlacement` class, but
//! fixes several bugs found in the original (see docs/PRIOR_ART.md):
//! - grid placement used a `static` round-robin counter that hardcoded a
//!   2-column layout and never tracked real cell occupancy; here we scan the
//!   actual grid for the first cell that doesn't overlap an existing window.
//! - cascade placement didn't cascade at all (it reused the first free-space
//!   sample); here new windows step diagonally by `cascade_offset` and wrap.
//! - snap-to-edge always returned a fixed centered rect; here it computes a
//!   real Windows-Snap-style half/quarter/maximize zone from drag position.

use crate::geometry::Rect;
use crate::monitor::Monitor;

pub const MIN_WINDOW_WIDTH: u32 = 200;
pub const MIN_WINDOW_HEIGHT: u32 = 150;

#[derive(Debug, Clone, Copy)]
pub struct PlacementConfig {
    pub grid_margin: u32,
    pub cascade_offset: i32,
    /// How close (in logical pixels) a dragged window's edge has to end up
    /// to a monitor edge on release before `snap_zone` triggers a
    /// half/quarter/maximize. A single edge match with no corner match
    /// (e.g. top-only) maximizes the *whole* window - see `snap_zone`'s
    /// `(false, false, true, false) => area` arm - so this value directly
    /// controls how easy it is to accidentally full-maximize a window while
    /// just repositioning it near the top of the screen, not only how
    /// generous the corner/half-snap zones are.
    pub snap_threshold: i32,
    pub max_grid: u32,
}

impl Default for PlacementConfig {
    fn default() -> Self {
        // `snap_threshold` was 50, then 20 - both live-tested and reported
        // as still snapping from an ordinary "move it near an edge" drag,
        // not just a deliberate release-at-the-edge one. `update_drag`'s
        // clamp used to also cap a dragged window's reach to the
        // exclusive-zone-shrunk usable area rather than the monitor's true
        // edge (see `Monitor::full_geometry`), which made this worse than
        // the number alone suggests: the window could get within 20px of
        // `snap_zone`'s comparison edge well before the cursor was
        // anywhere near the real screen edge. 8 keeps snapping reachable
        // (a window's own edge, not the cursor, is what's measured) while
        // requiring it to actually be at the edge, not just closer to it
        // than to the middle of the screen.
        Self { grid_margin: 10, cascade_offset: 30, snap_threshold: 8, max_grid: 4 }
    }
}

/// The six fixed screen positions offered by the Snap-Layouts flyout
/// (`crates/wayland/src/snap_flyout.rs`, opened by right-clicking a
/// titlebar's maximize button) - the click-driven equivalent of dragging a
/// window to that same edge/corner and releasing near it, addressed
/// directly by name instead of by proximity to a screen edge. Deliberately
/// only this subset of what `SmartPlacement::snap_zone` below already
/// computes from a drag position: full-maximize is excluded since it is
/// already the maximize button's own direct left-click action one click
/// away, and this scopes the flyout to "where should *this* window go" the
/// way most third-party snap tools (e.g. macOS's Rectangle) work, rather
/// than the full Windows 11 multi-window arrangement picker - a
/// meaningfully bigger feature (choosing a preset that places *several*
/// windows into complementary zones at once) that was not what was asked
/// for here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapZoneKind {
    LeftHalf,
    RightHalf,
    TopLeftQuarter,
    TopRightQuarter,
    BottomLeftQuarter,
    BottomRightQuarter,
}

impl SnapZoneKind {
    /// Grid order the flyout lays its cells out in - see
    /// `snap_flyout.rs`'s own doc comment for the actual layout.
    pub const ALL: [SnapZoneKind; 6] = [
        SnapZoneKind::LeftHalf,
        SnapZoneKind::RightHalf,
        SnapZoneKind::TopLeftQuarter,
        SnapZoneKind::TopRightQuarter,
        SnapZoneKind::BottomLeftQuarter,
        SnapZoneKind::BottomRightQuarter,
    ];

    pub fn label(self) -> &'static str {
        match self {
            SnapZoneKind::LeftHalf => "Left Half",
            SnapZoneKind::RightHalf => "Right Half",
            SnapZoneKind::TopLeftQuarter => "Top Left",
            SnapZoneKind::TopRightQuarter => "Top Right",
            SnapZoneKind::BottomLeftQuarter => "Bottom Left",
            SnapZoneKind::BottomRightQuarter => "Bottom Right",
        }
    }

    /// The rect this zone resolves to on `area` (a monitor's *usable*,
    /// exclusive-zone-shrunk geometry - matching `snap_zone` below, a
    /// half/quarter snap sits beside a bar/dock like any other tiled or
    /// deliberately-placed window, unlike maximize/fullscreen which
    /// deliberately covers it).
    pub fn rect(self, area: Rect) -> Rect {
        let half_w = area.width / 2;
        let half_h = area.height / 2;
        match self {
            SnapZoneKind::LeftHalf => Rect::new(area.x, area.y, half_w, area.height),
            SnapZoneKind::RightHalf => Rect::new(area.x + half_w as i32, area.y, half_w, area.height),
            SnapZoneKind::TopLeftQuarter => Rect::new(area.x, area.y, half_w, half_h),
            SnapZoneKind::TopRightQuarter => Rect::new(area.x + half_w as i32, area.y, half_w, half_h),
            SnapZoneKind::BottomLeftQuarter => Rect::new(area.x, area.y + half_h as i32, half_w, half_h),
            SnapZoneKind::BottomRightQuarter => Rect::new(area.x + half_w as i32, area.y + half_h as i32, half_w, half_h),
        }
    }
}

pub struct SmartPlacement;

impl SmartPlacement {
    /// Place a new window of `size` given the geometries of windows already
    /// occupying `monitor`. Tries a grid cell first, falling back to cascade.
    /// `cascade_step` is a monotonically increasing counter the caller owns
    /// (`WindowManager::next_cascade_step`) - see `cascade`'s own doc
    /// comment for why this can't just be `existing.len()` the way `grid`'s
    /// own cell count legitimately still is.
    ///
    /// Skips grid entirely when `existing` is empty, going straight to
    /// cascade instead: grid's real job is dividing space fairly among
    /// *concurrent* windows, and with nothing else open there is nothing
    /// to divide against - a 1x1 grid is mathematically one single cell
    /// no matter how it's rotated, so it can never vary by session
    /// history the way this reported bug needs. This is the actual
    /// overwhelmingly common case in practice (open one app, use it,
    /// close it, open the next), which is exactly why the bug this fixes
    /// ("every window opens in the same spot") was reported as the normal
    /// experience, not an edge case.
    pub fn place(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig, cascade_step: u32) -> Rect {
        if existing.is_empty() {
            return Self::cascade(monitor, size, cfg, cascade_step);
        }
        Self::grid(monitor, existing, size, cfg).unwrap_or_else(|| Self::cascade(monitor, size, cfg, cascade_step))
    }

    fn grid(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Option<Rect> {
        let count = existing.len() + 1;
        let grid_size = (count as f64).sqrt().ceil() as u32;
        let grid_size = grid_size.clamp(1, cfg.max_grid);
        let area = monitor.geometry;

        let margins = cfg.grid_margin * (grid_size + 1);
        if area.width <= margins || area.height <= margins {
            return None;
        }
        let cell_w = (area.width - margins) / grid_size;
        let cell_h = (area.height - margins) / grid_size;
        if cell_w < MIN_WINDOW_WIDTH || cell_h < MIN_WINDOW_HEIGHT {
            return None;
        }

        for gy in 0..grid_size {
            for gx in 0..grid_size {
                let x = area.x + cfg.grid_margin as i32 + (gx * (cell_w + cfg.grid_margin)) as i32;
                let y = area.y + cfg.grid_margin as i32 + (gy * (cell_h + cfg.grid_margin)) as i32;
                let candidate = Rect::new(x, y, cell_w, cell_h);
                if !existing.iter().any(|w| w.overlaps(&candidate)) {
                    return Some(Rect::new(x, y, size.0.min(cell_w), size.1.min(cell_h)));
                }
            }
        }
        None
    }

    /// Diagonal cascade, stepping by `cascade_offset` per window opened so
    /// far and wrapping back to the origin once it would run off the
    /// monitor.
    ///
    /// Driven by `cascade_step` - a counter the caller keeps incrementing
    /// across the whole session - rather than `existing.len()` (how many
    /// windows happen to be open on this workspace *right now*), which is
    /// what this used to take. That reads as reasonable ("cascade further
    /// when more windows are open") but has a real, reported bug baked in:
    /// the overwhelmingly common way people actually use a desktop is one
    /// app at a time - open, use, close, open the next - and `existing`
    /// is empty at the start of every single one of those opens, so `step`
    /// was `0` every time regardless of how many windows had already been
    /// opened-and-closed that session. Reported live as "every window
    /// spawns in the exact same place and size, not at all like Windows" --
    /// confirmed by reading this function, not guessed: real Windows
    /// cascades the *next* window further even after you close the
    /// previous one, which needs a counter that survives a window closing,
    /// not one derived from whoever is still open at the moment of the
    /// next placement.
    fn cascade(monitor: &Monitor, size: (u32, u32), cfg: &PlacementConfig, cascade_step: u32) -> Rect {
        let area = monitor.geometry;
        let width = size.0.min(area.width);
        let height = size.1.min(area.height);

        let max_steps_x = ((area.width as i32 - width as i32) / cfg.cascade_offset.max(1)).max(1);
        let max_steps_y = ((area.height as i32 - height as i32) / cfg.cascade_offset.max(1)).max(1);
        let max_steps = max_steps_x.min(max_steps_y).max(1);

        let step = (cascade_step as i32) % max_steps;
        let x = (area.x + cfg.cascade_offset + step * cfg.cascade_offset).min(area.right() - width as i32).max(area.x);
        let y = (area.y + cfg.cascade_offset + step * cfg.cascade_offset).min(area.bottom() - height as i32).max(area.y);
        Rect::new(x, y, width, height)
    }

    /// Given a window being dragged (its live geometry) and the monitor it's
    /// on, returns the Windows-Snap zone it should resize to if it's within
    /// `snap_threshold` pixels of a screen edge or corner, or `None` if it's
    /// not near any snap zone.
    pub fn snap_zone(dragged: Rect, monitor: &Monitor, cfg: &PlacementConfig) -> Option<Rect> {
        let area = monitor.geometry;
        let t = cfg.snap_threshold;
        let near_left = (dragged.x - area.x).abs() <= t;
        let near_right = (area.right() - dragged.right()).abs() <= t;
        let near_top = (dragged.y - area.y).abs() <= t;
        let near_bottom = (area.bottom() - dragged.bottom()).abs() <= t;

        let half_w = area.width / 2;
        let half_h = area.height / 2;

        Some(match (near_left, near_right, near_top, near_bottom) {
            (true, false, true, false) => Rect::new(area.x, area.y, half_w, half_h),
            (false, true, true, false) => Rect::new(area.x + half_w as i32, area.y, half_w, half_h),
            (true, false, false, true) => Rect::new(area.x, area.y + half_h as i32, half_w, half_h),
            (false, true, false, true) => Rect::new(area.x + half_w as i32, area.y + half_h as i32, half_w, half_h),
            (true, false, false, false) => Rect::new(area.x, area.y, half_w, area.height),
            (false, true, false, false) => Rect::new(area.x + half_w as i32, area.y, half_w, area.height),
            (false, false, true, false) => area,
            _ => return None,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn monitor() -> Monitor {
        Monitor::new(0, "test", Rect::new(0, 0, 1920, 1080))
    }

    #[test]
    fn a_window_opened_alone_cascades_rather_than_using_a_pointless_1x1_grid() {
        // `place` skips `grid` entirely when nothing else is open - see
        // its own doc comment for why: a grid with nothing to divide space
        // against is always exactly one cell, which can never vary by
        // session history, and "one app open at a time" is the ordinary
        // case, not an edge one.
        let cfg = PlacementConfig::default();
        let r = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0);
        assert_eq!(r.x, cfg.cascade_offset);
        assert_eq!(r.y, cfg.cascade_offset);
    }

    #[test]
    fn opening_the_same_app_alone_twice_in_a_row_lands_in_different_spots() {
        // The concrete reported symptom, exercised through the real
        // `place` entry point (not `cascade` directly, unlike the more
        // targeted unit test below) - opening one window, closing it, and
        // opening another must not silently collapse back to the exact
        // same spot just because `existing` is empty again both times.
        let cfg = PlacementConfig::default();
        let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0);
        let second = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 1);
        assert_ne!(first, second);
    }

    #[test]
    fn grid_avoids_occupied_cells() {
        let cfg = PlacementConfig::default();
        let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0);
        let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg, 1);
        assert!(!first.overlaps(&second), "second window must not overlap the first: {first:?} vs {second:?}");
    }

    #[test]
    fn cascade_kicks_in_once_grid_is_full() {
        let cfg = PlacementConfig { max_grid: 1, ..Default::default() };
        // max_grid=1 means the grid is always a single cell, so a second
        // window can never find a free grid cell and must cascade.
        let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0);
        let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg, 1);
        assert_ne!(first, second);
        // First window is grid-placed (offset by grid_margin); the second no
        // longer fits any grid cell and falls back to cascade, which steps
        // from the monitor origin by `cascade_offset` per window opened so
        // far this session (the caller's own counter, passed in as `1` here).
        assert_eq!(second.x, cfg.cascade_offset * 2);
        assert_eq!(second.y, cfg.cascade_offset * 2);
    }

    #[test]
    fn cascade_step_keeps_advancing_even_if_the_previous_window_closed() {
        // The actual reported bug this counter exists to fix: opening one
        // window at a time (closing each before the next) used to reset
        // `existing` to empty every time, so `step` - driven by `existing.
        // len()` - was always 0 regardless of how many windows had already
        // been opened-and-closed. A cascade_step the caller keeps
        // incrementing across the session, independent of what is
        // currently open, is what actually fixes it. Calls `cascade`
        // directly (not `place`): `place`'s own grid-first fallback would
        // succeed for an empty `existing` regardless of this test's own
        // point (a grid's cell *count* legitimately does depend on live
        // occupancy - see `place`'s own doc comment on why only `cascade`
        // takes this counter), so a `place`-level test couldn't actually
        // isolate cascade's own behavior here.
        let cfg = PlacementConfig::default();
        let first = SmartPlacement::cascade(&monitor(), (400, 300), &cfg, 0);
        let second = SmartPlacement::cascade(&monitor(), (400, 300), &cfg, 1);
        assert_ne!(first, second, "an unchanged cascade_step of 0 vs 1 must not collapse to the same spot");
    }

    #[test]
    fn snap_left_edge_yields_left_half() {
        let cfg = PlacementConfig::default();
        let dragged = Rect::new(2, 100, 400, 300); // x=2 is within threshold of left edge
        let zone = SmartPlacement::snap_zone(dragged, &monitor(), &cfg).unwrap();
        assert_eq!(zone, Rect::new(0, 0, 960, 1080));
    }

    #[test]
    fn snap_top_edge_yields_maximize() {
        let cfg = PlacementConfig::default();
        let dragged = Rect::new(500, 1, 400, 300);
        let zone = SmartPlacement::snap_zone(dragged, &monitor(), &cfg).unwrap();
        assert_eq!(zone, monitor().geometry);
    }

    #[test]
    fn snap_top_left_corner_yields_quarter() {
        let cfg = PlacementConfig::default();
        let dragged = Rect::new(1, 1, 400, 300);
        let zone = SmartPlacement::snap_zone(dragged, &monitor(), &cfg).unwrap();
        assert_eq!(zone, Rect::new(0, 0, 960, 540));
    }

    #[test]
    fn no_snap_away_from_edges() {
        let cfg = PlacementConfig::default();
        let dragged = Rect::new(700, 400, 400, 300);
        assert!(SmartPlacement::snap_zone(dragged, &monitor(), &cfg).is_none());
    }

    #[test]
    fn snap_zone_kind_halves_split_the_area_down_the_middle() {
        let area = monitor().geometry;
        assert_eq!(SnapZoneKind::LeftHalf.rect(area), Rect::new(0, 0, 960, 1080));
        assert_eq!(SnapZoneKind::RightHalf.rect(area), Rect::new(960, 0, 960, 1080));
    }

    #[test]
    fn snap_zone_kind_quarters_tile_the_area_with_no_gap_or_overlap() {
        let area = monitor().geometry;
        let quarters = [
            SnapZoneKind::TopLeftQuarter.rect(area),
            SnapZoneKind::TopRightQuarter.rect(area),
            SnapZoneKind::BottomLeftQuarter.rect(area),
            SnapZoneKind::BottomRightQuarter.rect(area),
        ];
        for (i, a) in quarters.iter().enumerate() {
            for b in &quarters[i + 1..] {
                assert!(!a.overlaps(b), "{a:?} and {b:?} must not overlap");
            }
        }
        let covered: u32 = quarters.iter().map(|r| r.width * r.height).sum();
        assert_eq!(covered, area.width * area.height, "quarters must cover the whole area with no gap");
    }

    #[test]
    fn snap_zone_kind_all_has_no_duplicates() {
        let area = monitor().geometry;
        let rects: Vec<_> = SnapZoneKind::ALL.iter().map(|z| z.rect(area)).collect();
        for (i, a) in rects.iter().enumerate() {
            for b in &rects[i + 1..] {
                assert_ne!(a, b, "two different zones must not resolve to the same rect");
            }
        }
    }
}