//! 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 { std::env::var("PAGAN_SCALE") .ok() .and_then(|value| value.trim().parse::().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 { 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 { 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>, scale: Option, transform: Option, } /// Ś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 { 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::().ok()?; let y = parts.next()?.parse::().ok()?; let scale = parts.next().and_then(|value| value.parse::().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 { 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::().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)> { 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::::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 = 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> { 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) -> Option> { 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, ) -> Point { 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> { 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]) -> Point { 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) -> Point { 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) { 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); } }