🔒 Repository is read-only – file editing is disabled.

PaganDE/pagan-compositor/src/snap.rs main

331 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
//! 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::<f64>().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<f64, Logical>,
    output: Rectangle<i32, Logical>,
    config: SnapConfig,
) -> Option<SnapZone> {
    // 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<i32, Logical>) -> Rectangle<i32, Logical> {
    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<i32, Logical> {
        // Typowe wyjście Full HD w punkcie (0,0).
        Rectangle::new((0, 0).into(), (1920, 1080).into())
    }

    fn p(x: f64, y: f64) -> Point<f64, Logical> {
        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())
        );
    }
}