🔒 Repository is read-only – file editing is disabled.
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);
}
}