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

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

454 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
//! Zarządzanie wyjściami (monitorami): układ, rozmiary, hotplug, ratowanie okien.
//!
//! PO CO osobny moduł: to logika menedżera okien, a nie protokołów Smithay.
//! Kompozytor musi "ogarniać" monitory tak jak każdy inny:
//!
//! * **liczba** — wiele wyjść jednocześnie (backend udev tworzy jedno per konektor),
//! * **rozmiar** — z trybu DRM (udev) lub rozmiaru okna (winit),
//! * **układ** — wyjścia ustawiane poziomo od (0,0), bez przerw,
//! * **hotplug** — po odłączeniu nie zostawiamy dziury ani okien poza ekranem,
//! * **umieszczanie okien** — nowe okno ląduje na monitorze pod kursorem.
//!
//! To jest dokładnie ta warstwa, którą mają "prawdziwe" kompozytory.

use std::{collections::HashMap, path::PathBuf};

use tracing::{debug, info, warn};

use smithay::{
    desktop::{layer_map_for_output, space::SpaceElement},
    // UWAGA: `Scale` do wyjść to `smithay::output::Scale` (enum Integer/Fractional),
    // a NIE `smithay::utils::Scale` (struktura x/y). To dwa różne typy o tej samej nazwie.
    output::{Output, Scale},
    utils::{Logical, Point, Rectangle, Transform},
};

use crate::{
    state::{MyCompositor, CASCADE_STEP},
    window::{PaganWindow, TITLEBAR_HEIGHT},
};

/// Skala odczytana z `PAGAN_SCALE` (np. `1.5`). `None`, gdy nie ustawiono —
/// wtedy NIE ruszamy skali wyjścia (backend mógł ustawić własną).
fn configured_scale() -> Option<f64> {
    std::env::var("PAGAN_SCALE")
        .ok()
        .and_then(|value| value.trim().parse::<f64>().ok())
        .filter(|scale| *scale > 0.0)
}

/// Transformacja odczytana z `PAGAN_TRANSFORM` (`normal|90|180|270|flipped`).
///
/// PO CO opcjonalna: backend winit wymaga `Flipped180` do poprawnego rysowania,
/// więc domyślnie nie nadpisujemy transformacji, jeśli użytkownik jej nie podał.
fn configured_transform() -> Option<Transform> {
    std::env::var("PAGAN_TRANSFORM")
        .ok()
        .and_then(|value| parse_transform(value.trim()))
}

/// Zamienia zapis tekstowy obrotu na `Transform` (używane przez env i plik).
fn parse_transform(value: &str) -> Option<Transform> {
    match value {
        "normal" => Some(Transform::Normal),
        "90" => Some(Transform::_90),
        "180" => Some(Transform::_180),
        "270" => Some(Transform::_270),
        "flipped" => Some(Transform::Flipped),
        _ => None,
    }
}

/// Konfiguracja jednego monitora: pozycja, skala, obrót.
///
/// PO CO osobny typ: te trzy ustawienia pochodzą z różnych źródeł (plik + env)
/// i muszą się wzajemnie nadpisywać **per monitor**, a nie globalnie.
#[derive(Debug, Clone, Default)]
struct OutputConfig {
    position: Option<Point<i32, Logical>>,
    scale: Option<f64>,
    transform: Option<Transform>,
}

/// Ścieżka do pliku konfiguracji monitorów.
///
/// Kolejność: `PAGAN_CONFIG` (wskazanie wprost) → `$XDG_CONFIG_HOME/pagan/outputs.conf`
/// → `~/.config/pagan/outputs.conf`. Brak pliku nie jest błędem — wtedy działa
/// automatyczny układ poziomy plus ewentualne nadpisania ze zmiennych środowiskowych.
fn config_path() -> Option<PathBuf> {
    if let Ok(path) = std::env::var("PAGAN_CONFIG") {
        return Some(PathBuf::from(path));
    }
    let base = std::env::var("XDG_CONFIG_HOME")
        .ok()
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var("HOME")
                .ok()
                .map(|home| PathBuf::from(home).join(".config"))
        })?;
    Some(base.join("pagan").join("outputs.conf"))
}

/// Parsuje jeden wiersz pliku konfiguracji: `NAZWA X Y [SKALA] [OBROT]`.
///
/// Przykład: `DP-1 0 0 1.5 normal`. Pola `SKALA` i `OBROT` są opcjonalne.
fn parse_output_line(line: &str) -> Option<(String, OutputConfig)> {
    let mut parts = line.split_whitespace();
    let name = parts.next()?.to_string();
    let x = parts.next()?.parse::<i32>().ok()?;
    let y = parts.next()?.parse::<i32>().ok()?;
    let scale = parts.next().and_then(|value| value.parse::<f64>().ok());
    let transform = parts.next().and_then(parse_transform);
    Some((
        name,
        OutputConfig {
            position: Some(Point::from((x, y))),
            scale,
            transform,
        },
    ))
}

/// Wczytuje konfigurację monitorów z pliku, a potem nakłada nadpisania z env.
///
/// PO CO taka kolejność: plik jest trwałym "domem" ustawień, a zmienne
/// środowiskowe wygodnym jednorazowym nadpisaniem (np. do testów).
fn load_output_configs() -> HashMap<String, OutputConfig> {
    let mut configs = HashMap::new();

    if let Some(path) = config_path() {
        match std::fs::read_to_string(&path) {
            Ok(text) => {
                for raw in text.lines() {
                    // Komentarz zaczyna się od `#`; puste wiersze pomijamy.
                    let line = raw.split('#').next().unwrap_or("").trim();
                    if line.is_empty() {
                        continue;
                    }
                    match parse_output_line(line) {
                        Some((name, cfg)) => {
                            configs.insert(name, cfg);
                        }
                        None => warn!(line, "nie umiem sparsować wiersza konfiguracji monitora"),
                    }
                }
                info!(path = %path.display(), count = configs.len(), "wczytano konfigurację monitorów");
            }
            // Brak pliku to normalna sytuacja — nie straszymy użytkownika.
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => {
                warn!(path = %path.display(), %err, "nie udało się wczytać pliku konfiguracji monitorów")
            }
        }
    }

    // `PAGAN_OUTPUTS=DP-1:0,0;HDMI-A-1:2560,0` — pozycje nadpisują plik.
    if let Ok(spec) = std::env::var("PAGAN_OUTPUTS") {
        for entry in spec.split(';') {
            let mut parts = entry.split(':');
            let (Some(name), Some(coords)) = (parts.next(), parts.next()) else {
                continue;
            };
            let mut xy = coords
                .split(',')
                .filter_map(|value| value.trim().parse::<i32>().ok());
            if let (Some(x), Some(y)) = (xy.next(), xy.next()) {
                configs.entry(name.trim().to_string()).or_default().position =
                    Some(Point::from((x, y)));
            }
        }
    }

    configs
}

impl MyCompositor {
    /// Zwraca listę (nazwa, geometria) wszystkich wyjść — do logowania/diagnostyki.
    pub fn list_outputs(&self) -> Vec<(String, Rectangle<i32, Logical>)> {
        self.space
            .outputs()
            .filter_map(|output| {
                self.space
                    .output_geometry(output)
                    .map(|geometry| (output.name(), geometry))
            })
            .collect()
    }

    /// Układa wyjścia poziomo od (0,0) i przywraca okna, które wypadły poza ekran.
    ///
    /// Wywoływane przy zmianie liczby/rozmiaru wyjść (hotplug, resize w winit).
    /// Re-layout jest potrzebny, bo po odłączeniu środkowego monitora zostaje
    /// "dziura" w przestrzeni, a okna z usuniętego ekranu nie mają gdzie być.
    pub fn relayout_outputs(&mut self) {
        let outputs: Vec<_> = self.space.outputs().cloned().collect();
        let configs = load_output_configs();
        // Globalne nadpisania (dotyczą monitorów nieopisanych w pliku).
        let global_scale = configured_scale();
        let global_transform = configured_transform();

        let mut offset = Point::<i32, Logical>::from((0, 0));
        let mut summary = Vec::with_capacity(outputs.len());

        for output in &outputs {
            let config = configs.get(&output.name()).cloned().unwrap_or_default();
            let scale = config.scale.or(global_scale);
            let transform = config.transform.or(global_transform);

            // HiDPI / obrót — tylko gdy użytkownik jawnie o to poprosił.
            if scale.is_some() || transform.is_some() {
                output.change_current_state(None, transform, scale.map(Scale::Fractional), None);
            }

            // Pozycja: ręczna (plik lub PAGAN_OUTPUTS), w przeciwnym razie poziomo od (0,0).
            let position = config.position.unwrap_or(offset);
            self.space.map_output(output, position);

            // Warstwy (panele) przeliczają swoje strefy po zmianie geometrii.
            layer_map_for_output(output).arrange();

            let size = self
                .space
                .output_geometry(output)
                .map(|geometry| geometry.size)
                .unwrap_or_else(|| (0, 0).into());
            summary.push(format!(
                "{}={}x{}+{}+{}",
                output.name(),
                size.w,
                size.h,
                position.x,
                position.y
            ));
            offset.x = position.x + size.w;
        }

        // Okna, które wypadły poza ekran, wracają na widok.
        self.ensure_windows_on_screen();

        info!(count = summary.len(), outputs = %summary.join(", "), "układ wyjść zaktualizowany");
    }

    /// Przenosi na widoczny ekran okna, które znalazły się poza wszystkimi wyjściami.
    ///
    /// Dotyczy m.in. sytuacji, gdy użytkownik odłączy monitor, na którym stało okno.
    pub fn ensure_windows_on_screen(&mut self) {
        let areas = self.output_areas();
        if areas.is_empty() {
            return;
        }

        let orphaned: Vec<PaganWindow> = self
            .space
            .elements()
            .filter(|window| match self.space.element_location(window) {
                Some(location) => {
                    let top_left = location + window.bbox().loc;
                    !areas.iter().any(|area| area.contains(top_left))
                }
                None => false,
            })
            .cloned()
            .collect();

        for window in orphaned {
            let area = areas
                .iter()
                .find(|area| area.contains(self.pointer_location.to_i32_round()))
                .or_else(|| areas.first())
                .cloned()
                .unwrap_or_else(|| Rectangle::from_size((1280, 720).into()));
            let location = self.next_cascade_in(area);
            self.space.map_element(window, location, false);
            self.advance_cascade(area);
        }
    }

    /// Mapuje nowe okno na wyjściu **pod kursorem** (a jeśli tam go nie ma — na
    /// pierwszym wyjściu). To oczekiwane zachowanie pływających menedżerów okien:
    /// nowa aplikacja pojawia się tam, gdzie patrzy użytkownik.
    pub(crate) fn place_new_window(&mut self, window: PaganWindow) {
        let output = self
            .space
            .output_under(self.pointer_location)
            .next()
            .cloned()
            .or_else(|| self.space.outputs().next().cloned());

        // Używamy OBSZARU ROBOCZEGO (bez paska panelu rezerwującego miejsce).
        // Dzięki temu kaskada startuje POD panelem, a nasz pasek SSD jest widoczny.
        let area = output
            .as_ref()
            .and_then(|output| self.output_work_area(output))
            .unwrap_or_else(|| Rectangle::from_size((1280, 720).into()));

        let location = self.next_cascade_in(area);
        let location = self.adjust_for_ssd(&window, location);
        debug!(
            ?area,
            ?location,
            step = ?self.next_cascade,
            ssd = window.wants_ssd(),
            negotiated = crate::window::decoration_negotiated(&window),
            "nowe okno: pozycja kaskady"
        );
        self.space.map_element(window, location, true);
        self.advance_cascade(area);
    }

    /// Obszar wyjścia dostępny dla okien: pełna geometria MINUS strefy wyłączne
    /// warstw (`wlr-layer-shell`, np. panel rezerwujący 36 px u góry).
    ///
    /// PO CO: bez tego pasek SSD (rysowany NAD treścią) chowałby się pod panelem,
    /// co wygląda jak ucięta/przesunięta dekoracja. Smithay liczy ten prostokąt
    /// sam (`LayerMap::non_exclusive_zone`), więc nie zgadujemy wysokości panelu.
    pub(crate) fn output_work_area(&self, output: &Output) -> Option<Rectangle<i32, Logical>> {
        let zone = layer_map_for_output(output).non_exclusive_zone();
        if zone.size.w > 0 && zone.size.h > 0 {
            Some(zone)
        } else {
            // Brak warstw/rezerwacji — cały ekran jest obszarem roboczym.
            self.space.output_geometry(output)
        }
    }

    /// Obszar roboczy wyjścia zawierającego podany punkt (awaryjnie: pierwszego).
    fn work_area_for_point(&self, point: Point<i32, Logical>) -> Option<Rectangle<i32, Logical>> {
        let outputs: Vec<_> = self.space.outputs().cloned().collect();
        outputs
            .iter()
            .find(|output| {
                self.space
                    .output_geometry(output)
                    .is_some_and(|geometry| geometry.contains(point))
            })
            .and_then(|output| self.output_work_area(output))
            .or_else(|| {
                outputs
                    .first()
                    .and_then(|output| self.output_work_area(output))
            })
    }

    /// Przesuwa pozycję okna tak, aby pasek tytułu (SSD) był widoczny.
    ///
    /// PO CO: w naszym modelu `location` to początek TREŚCI okna, a pasek SSD
    /// rysujemy NAD nią (`location.y - TITLEBAR_HEIGHT`). Przesuwamy treść poniżej
    /// GÓRNEJ KRAWĘDZI OBSZARU ROBOCZEGO, więc pasek ląduje pod panelem, a nie za nim.
    pub(crate) fn adjust_for_ssd(
        &self,
        window: &PaganWindow,
        mut location: Point<i32, Logical>,
    ) -> Point<i32, Logical> {
        if window.wants_ssd() {
            if let Some(area) = self.work_area_for_point(location) {
                location.y = location.y.max(area.loc.y + TITLEBAR_HEIGHT);
            }
        }
        location
    }

    /// Zwraca prostokąty obszarów roboczych wszystkich wyjść.
    fn output_areas(&self) -> Vec<Rectangle<i32, Logical>> {
        self.space
            .outputs()
            .filter_map(|output| self.output_work_area(output))
            .collect()
    }

    /// Wylicza kolejną pozycję kaskady tak, by trafiła w któreś z wyjść.
    #[allow(dead_code)]
    fn next_cascade_for(&self, areas: &[Rectangle<i32, Logical>]) -> Point<i32, Logical> {
        let target = areas
            .iter()
            .find(|area| area.contains(self.pointer_location.to_i32_round()))
            .or_else(|| areas.first())
            .cloned()
            .unwrap_or_else(|| Rectangle::from_size((1280, 720).into()));
        self.next_cascade_in(target)
    }

    /// Kaskada w obrębie jednego wyjścia: przesuwamy się o krok, a gdy dojdziemy
    /// do krawędzi — wracamy do początku obszaru.
    fn next_cascade_in(&self, area: Rectangle<i32, Logical>) -> Point<i32, Logical> {
        let margin = CASCADE_STEP * 3;
        let start = area.loc + Point::from((CASCADE_STEP, CASCADE_STEP));
        let max_x = (area.loc.x + area.size.w - margin).max(start.x);
        let max_y = (area.loc.y + area.size.h - margin).max(start.y);

        let candidate = self.next_cascade;
        let inside = candidate.x >= area.loc.x
            && candidate.y >= area.loc.y
            && candidate.x <= max_x
            && candidate.y <= max_y;

        if inside {
            candidate
        } else {
            start
        }
    }

    /// Przesuwa globalny licznik kaskady o jeden krok (z zawinięciem w obszarze).
    pub(crate) fn advance_cascade(&mut self, area: Rectangle<i32, Logical>) {
        let margin = CASCADE_STEP * 3;
        let start = area.loc + Point::from((CASCADE_STEP, CASCADE_STEP));
        let max_x = (area.loc.x + area.size.w - margin).max(start.x);
        let max_y = (area.loc.y + area.size.h - margin).max(start.y);

        let next = self.next_cascade + Point::from((CASCADE_STEP, CASCADE_STEP));
        self.next_cascade = if next.x > max_x || next.y > max_y {
            start
        } else {
            next
        };
    }
}

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

    #[test]
    fn pelny_wiersz_z_skala_i_obrotem() {
        let (name, cfg) = parse_output_line("DP-1 2560 0 1.5 90").unwrap();
        assert_eq!(name, "DP-1");
        assert_eq!(cfg.position, Some(Point::from((2560, 0))));
        assert_eq!(cfg.scale, Some(1.5));
        assert_eq!(cfg.transform, Some(Transform::_90));
    }

    #[test]
    fn minimalny_wiersz_tylko_pozycja() {
        let (name, cfg) = parse_output_line("HDMI-A-1 0 1080").unwrap();
        assert_eq!(name, "HDMI-A-1");
        assert_eq!(cfg.position, Some(Point::from((0, 1080))));
        assert_eq!(cfg.scale, None);
        assert_eq!(cfg.transform, None);
    }

    #[test]
    fn wiersz_bez_pozycji_jest_odrzucany() {
        // Sama nazwa bez X/Y nie ma sensu — wolimy zignorować niż zgadywać.
        assert!(parse_output_line("DP-1").is_none());
        assert!(parse_output_line("DP-1 abc 0").is_none());
    }

    #[test]
    fn nieznany_obrot_jest_ignorowany_ale_reszta_zostaje() {
        let (_, cfg) = parse_output_line("DP-1 0 0 1.0 obrotowo").unwrap();
        assert_eq!(cfg.transform, None);
        assert_eq!(cfg.scale, Some(1.0));
    }

    #[test]
    fn rozpoznajemy_wszystkie_obroty() {
        assert_eq!(parse_transform("normal"), Some(Transform::Normal));
        assert_eq!(parse_transform("90"), Some(Transform::_90));
        assert_eq!(parse_transform("180"), Some(Transform::_180));
        assert_eq!(parse_transform("270"), Some(Transform::_270));
        assert_eq!(parse_transform("flipped"), Some(Transform::Flipped));
        assert_eq!(parse_transform("bzdurek"), None);
    }
}