//! Doklejanie okien do krawędzi i rogów ekranu („Aero Snap”). //! //! PO CO osobny moduł: to CZYSTA funkcja geometrii — `punkt kursora + obszar //! wyjścia → strefa doklejania → docelowy prostokąt okna`. Nie zależy od Smithaya //! ani od stanu kompozytora, więc można ją przetestować jednostkowo bez GPU //! (patrz `#[cfg(test)]` na dole). Logika *kiedy* i *jak* przesłać to klientowi //! siedzi w `grabs.rs` — tutaj jest tylko matematyka. //! //! Semantyka (jak w Windows Aero Snap / GNOME / KDE): //! //! ```text //! ┌─────────┬─────────┐ krawędź lewa/prawa → połowa ekranu //! │ TL │ TR │ krawędź górna → pełny ekran (maksymalizacja) //! ├─────────┼─────────┤ róg (2 krawędzie) → ćwiartka ekranu //! │ │ │ krawędź dolna → bez doklejania //! │ BL │ BR │ (albo dolna połowa, gdy PAGAN_SNAP_BOTTOM=1) //! └─────────┴─────────┘ //! ``` //! //! „Stackowanie”: gdy okna doklejane są do przeciwległych połówek lub do rogów, //! same układają się w kafelki (2 kolumny / siatka 2×2) — nie potrzebujemy do tego //! żadnego menedżera kafelkowego, wystarczy, że każdy snap zajmuje inną strefę. //! //! Konfiguracja (zmienne środowiskowe, czytane raz przy starcie): //! * `PAGAN_SNAP_THRESHOLD` — odległość od krawędzi włączająca doklejanie (px), //! * `PAGAN_SNAP_BOTTOM=1` — włącza doklejanie do dolnej krawędzi (dolna połowa). use smithay::utils::{Logical, Point, Rectangle}; /// Domyślna odległość kursora od krawędzi wyjścia, przy której włączamy /// doklejanie (w pikselach logicznych). Wartość kompromisowa: na tyle duża, by /// łatwo „trafić” krawędź myszką, i na tyle mała, by nie snapowało przy zwykłym /// przesuwaniu okna po środku ekranu. pub const SNAP_THRESHOLD: f64 = 12.0; /// Konfiguracja doklejania (czytana ze środowiska przy starcie kompozytora). #[derive(Debug, Clone, Copy)] pub struct SnapConfig { /// Odległość od krawędzi włączająca doklejanie. pub threshold: f64, /// Czy dolna krawędź ma doklejać (dolna połowa). Domyślnie `false`, jak w Windows. pub bottom: bool, } impl Default for SnapConfig { fn default() -> Self { Self { threshold: SNAP_THRESHOLD, bottom: false, } } } impl SnapConfig { /// Buduje konfigurację z `PAGAN_SNAP_THRESHOLD` i `PAGAN_SNAP_BOTTOM`. /// /// PO CO z env, a nie na sztywno: ergonomia myszy jest subiektywna, a to /// pozwala dostroić próg bez rekompilacji (spójne z `PAGAN_SCALE` itd.). pub fn from_env() -> Self { let threshold = std::env::var("PAGAN_SNAP_THRESHOLD") .ok() .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0.0) .unwrap_or(SNAP_THRESHOLD); let bottom = matches!( std::env::var("PAGAN_SNAP_BOTTOM").as_deref(), Ok("1") | Ok("true") | Ok("yes") | Ok("on") ); Self { threshold, bottom } } } /// Strefa doklejania wyznaczona z pozycji kursora. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SnapZone { /// Lewa krawędź → lewa połowa. Left, /// Prawa krawędź → prawa połowa. Right, /// Górna krawędź (bez rogów) → pełny ekran. Top, /// Dolna krawędź (tylko gdy włączona w konfiguracji) → dolna połowa. Bottom, /// Lewy górny róg → lewa górna ćwiartka. TopLeft, /// Prawy górny róg → prawa górna ćwiartka. TopRight, /// Lewy dolny róg → lewa dolna ćwiartka. BottomLeft, /// Prawy dolny róg → prawa dolna ćwiartka. BottomRight, } /// Wyznacza strefę doklejania dla kursora w `pointer`. /// /// Zwraca `None`, gdy kursor jest poza obszarem wyjścia albo w jego wnętrzu /// (dalej niż próg od każdej istotnej krawędzi). Dolna krawędź celowo **nie** /// snapuje domyślnie (jak w Aero Snap) — doklejanie do dołu bywa mylące. pub fn snap_zone( pointer: Point, output: Rectangle, config: SnapConfig, ) -> Option { // Kursor poza wyjściem → brak doklejania (nie „przyciągamy” z drugiego monitora). if !output.to_f64().contains(pointer) { return None; } let x0 = output.loc.x as f64; let y0 = output.loc.y as f64; let x1 = x0 + output.size.w as f64; let y1 = y0 + output.size.h as f64; let t = config.threshold; let at_left = pointer.x - x0 <= t; let at_right = x1 - pointer.x <= t; let at_top = pointer.y - y0 <= t; let at_bottom = y1 - pointer.y <= t; match (at_left, at_right, at_top, at_bottom) { // Rogi mają priorytet nad pojedynczą krawędzią. (true, _, true, _) => Some(SnapZone::TopLeft), (true, _, _, true) => Some(SnapZone::BottomLeft), (_, true, true, _) => Some(SnapZone::TopRight), (_, true, _, true) => Some(SnapZone::BottomRight), (true, _, _, _) => Some(SnapZone::Left), (_, true, _, _) => Some(SnapZone::Right), (_, _, true, _) => Some(SnapZone::Top), // Dolna krawędź: tylko gdy użytkownik ją włączył. (_, _, _, true) if config.bottom => Some(SnapZone::Bottom), // Dolna krawędź bez wsparcia i środek: brak doklejania. _ => None, } } /// Docelowa geometria okna dla danej strefy, w współrzędnych przestrzeni /// logicznej (loc może być różny od zera dla drugiego monitora w układzie). /// /// Dzielenie `size / 2` używamy konsekwentnie dla obu połówek, a drugą połowę /// wyrównujemy do prawej/dolnej krawędzi (`w - half`), żeby przy nieparzystej /// szerokości nie powstała jednopikselowa szczelina między kafelkami. pub fn snap_geometry(zone: SnapZone, output: Rectangle) -> Rectangle { let x = output.loc.x; let y = output.loc.y; let w = output.size.w; let h = output.size.h; let half_w = w / 2; let half_h = h / 2; match zone { SnapZone::Left => Rectangle::new((x, y).into(), (half_w, h).into()), SnapZone::Right => Rectangle::new((x + half_w, y).into(), (w - half_w, h).into()), // „Górna krawędź” = zmaksymalizowane okno na całym wyjściu. SnapZone::Top => Rectangle::new((x, y).into(), (w, h).into()), // Dolna krawędź (gdy włączona) = dolna połowa. SnapZone::Bottom => Rectangle::new((x, y + half_h).into(), (w, h - half_h).into()), SnapZone::TopLeft => Rectangle::new((x, y).into(), (half_w, half_h).into()), SnapZone::TopRight => Rectangle::new((x + half_w, y).into(), (w - half_w, half_h).into()), SnapZone::BottomLeft => Rectangle::new((x, y + half_h).into(), (half_w, h - half_h).into()), SnapZone::BottomRight => Rectangle::new( (x + half_w, y + half_h).into(), (w - half_w, h - half_h).into(), ), } } #[cfg(test)] mod tests { use super::*; fn output() -> Rectangle { // Typowe wyjście Full HD w punkcie (0,0). Rectangle::new((0, 0).into(), (1920, 1080).into()) } fn p(x: f64, y: f64) -> Point { Point::from((x, y)) } fn cfg() -> SnapConfig { SnapConfig::default() } #[test] fn srodek_ekranu_nie_snapuje() { assert_eq!(snap_zone(p(960.0, 540.0), output(), cfg()), None); } #[test] fn dolna_krawedz_nie_snapuje_domyslnie() { // Środek dolnej krawędzi — domyślnie bez doklejania (jak w Windows). assert_eq!(snap_zone(p(960.0, 1079.0), output(), cfg()), None); } #[test] fn dolna_krawedz_snapuje_gdy_wlaczona() { let config = SnapConfig { threshold: SNAP_THRESHOLD, bottom: true, }; assert_eq!( snap_zone(p(960.0, 1079.0), output(), config), Some(SnapZone::Bottom) ); // Geometria: dolna połowa 540..1080. assert_eq!( snap_geometry(SnapZone::Bottom, output()), Rectangle::new((0, 540).into(), (1920, 540).into()) ); } #[test] fn prog_jest_konfigurowalny() { // Przy bardzo małym progu punkt 3 px od krawędzi już nie snapuje. let strict = SnapConfig { threshold: 1.0, bottom: false, }; assert_eq!(snap_zone(p(3.0, 540.0), output(), strict), None); // A przy dużym progu snapuje nawet 20 px od krawędzi. let loose = SnapConfig { threshold: 25.0, bottom: false, }; assert_eq!( snap_zone(p(20.0, 540.0), output(), loose), Some(SnapZone::Left) ); } #[test] fn krawedzie_daja_polowki_i_pelny_ekran() { assert_eq!( snap_zone(p(3.0, 540.0), output(), cfg()), Some(SnapZone::Left) ); assert_eq!( snap_zone(p(1917.0, 540.0), output(), cfg()), Some(SnapZone::Right) ); assert_eq!( snap_zone(p(960.0, 3.0), output(), cfg()), Some(SnapZone::Top) ); } #[test] fn rogi_maja_priorytet() { assert_eq!( snap_zone(p(3.0, 3.0), output(), cfg()), Some(SnapZone::TopLeft) ); assert_eq!( snap_zone(p(1917.0, 3.0), output(), cfg()), Some(SnapZone::TopRight) ); assert_eq!( snap_zone(p(3.0, 1077.0), output(), cfg()), Some(SnapZone::BottomLeft) ); assert_eq!( snap_zone(p(1917.0, 1077.0), output(), cfg()), Some(SnapZone::BottomRight) ); } #[test] fn kursor_poza_wyjściem_nie_snapuje() { assert_eq!(snap_zone(p(1925.0, 540.0), output(), cfg()), None); assert_eq!(snap_zone(p(-5.0, 540.0), output(), cfg()), None); } #[test] fn geometria_polowek_i_cwiartek() { let out = output(); // Lewa połowa: 0..960. assert_eq!( snap_geometry(SnapZone::Left, out), Rectangle::new((0, 0).into(), (960, 1080).into()) ); // Prawa połowa: 960..1920 (szerokość = 1920-960 = 960). assert_eq!( snap_geometry(SnapZone::Right, out), Rectangle::new((960, 0).into(), (960, 1080).into()) ); // Górna krawędź = pełny ekran. assert_eq!( snap_geometry(SnapZone::Top, out), Rectangle::new((0, 0).into(), (1920, 1080).into()) ); // Ćwiartki. assert_eq!( snap_geometry(SnapZone::TopLeft, out), Rectangle::new((0, 0).into(), (960, 540).into()) ); assert_eq!( snap_geometry(SnapZone::BottomRight, out), Rectangle::new((960, 540).into(), (960, 540).into()) ); } #[test] fn nieparzysty_rozmiar_bez_szczeliny() { // Szerokość 1921: połowy 960 i 961, suma = 1921 (brak szczeliny). let out = Rectangle::new((0, 0).into(), (1921, 1080).into()); let left = snap_geometry(SnapZone::Left, out); let right = snap_geometry(SnapZone::Right, out); assert_eq!(left.size.w + right.size.w, 1921); assert_eq!(left.loc.x + left.size.w, right.loc.x); } #[test] fn dziala_na_drugim_monitorze() { // Wyjście przesunięte w prawo — geometrie muszą respektować offset. let out = Rectangle::new((1920, 0).into(), (1920, 1080).into()); assert_eq!( snap_zone(p(1923.0, 540.0), out, cfg()), Some(SnapZone::Left) ); assert_eq!( snap_geometry(SnapZone::Left, out), Rectangle::new((1920, 0).into(), (960, 1080).into()) ); assert_eq!( snap_geometry(SnapZone::TopRight, out), Rectangle::new((2880, 0).into(), (960, 540).into()) ); } }