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

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

495 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
//! System motywów kompozytora: kolory tła, paska SSD, menu i kursora zapasowego.
//!
//! PO CO osobny moduł: kompozytor sam maluje tylko kilka rzeczy (tło pulpitu,
//! pasek tytułu i menu okna, ducha doklejania, awaryjny kursor) — resztę rysują
//! klienty. Trzymamy te kolory w jednym miejscu, żeby dały się zmieniać bez
//! rekompilacji.
//!
//! Gdzie jest konfiguracja: `$PAGAN_THEME`, a domyślnie
//! `~/.config/pagan/theme.conf` (albo `$XDG_CONFIG_HOME/pagan/theme.conf`).
//! Przy pierwszym uruchomieniu plik powstaje z komentowanym szablonem.
//!
//! **Przeładowanie jest natychmiastowe**: obserwujemy plik przez `inotify`
//! (katalog, nie sam plik — edytory często zapisują przez rename) i po każdej
//! zmianie wczytujemy motyw od nowa. Dodatkowo `Ctrl+Alt+R` wymusza przeładowanie.
//!
//! PO CO globalny stan: `AsRenderElements` okna nie ma dostępu do stanu
//! kompozytora, a to właśnie tam rysujemy pasek i menu. Globalny `RwLock`
//! z motywem pozwala odczytać kolory z dowolnego miejsca bez plątania typów.

use std::{
    fs, io,
    os::fd::{AsFd, OwnedFd},
    path::{Path, PathBuf},
    sync::{OnceLock, RwLock},
};

use inotify::{Inotify, WatchMask};
use smithay::{
    backend::renderer::Color32F,
    reexports::calloop::{
        generic::{Generic, NoIoDrop},
        Interest, LoopHandle, Mode, PostAction,
    },
};
use tracing::{info, warn};

/// Kolory i parametry, które kompozytor naprawdę używa przy rysowaniu.
///
/// Wszystkie pola są `Copy`, więc odczyt motywu to tania kopia kilku liczb.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Theme {
    /// Kolor tła pulpitu (czyszczenie bufora wyjścia).
    pub background: Color32F,
    /// Kolor wypełnienia awaryjnego kursora (gdy brak motywu XCursor).
    pub cursor_fill: Color32F,
    /// Kolor obwódki awaryjnego kursora.
    pub cursor_border: Color32F,
    /// Kolor „ducha” doklejania okna (z alfą — półprzezroczysty podgląd).
    pub snap_preview: Color32F,
    /// Promień zaokrąglenia „ducha” (px).
    pub snap_preview_radius: f32,

    // --- Dekoracje serwerowe (SSD) — firmowy wygląd PaganDE -----------------
    //
    // Styl: PŁASKI (bez gradientów). Tożsamość PaganDE budują: menu aplikacji
    // w tym samym rzędzie co przyciski okna, typografia oraz chłodna paleta.
    /// Płaski kolor paska tytułu AKTYWNEGO okna.
    pub ssd_bar: Color32F,
    /// Płaski kolor paska okna NIEAKTYWNEGO (przygaszony).
    pub ssd_unfocused: Color32F,
    /// Cienka linia oddzielająca pasek od treści okna (1 px, płaska).
    pub ssd_border: Color32F,
    /// Kolor tekstu (tytuł okna i etykiety menu) na pasku aktywnym.
    pub ssd_text: Color32F,
    /// Kolor tekstu na pasku okna nieaktywnego.
    pub ssd_text_unfocused: Color32F,
    /// Kolor glifu „zamknij” (delikatnie czerwony akcent).
    pub ssd_icon_close: Color32F,
    /// Promień zaokrąglenia górnych rogów ramki SSD (px).
    pub ssd_radius: f32,
    /// Rozmiar czcionki tytułu i etykiet menu (px).
    pub ssd_font_px: f32,
    /// Bok obszaru ikon przycisków okna (px).
    pub ssd_icon_px: i32,
    /// Poniżej tej szerokości okna etykiety menu zwijają się do burgera.
    pub ssd_menu_min_width: i32,
}

impl Default for Theme {
    fn default() -> Self {
        // Wartości odpowiadają DOKŁADNIE szablonowi (TEMPLATE) w zapisie hex,
        // żeby odczyt szablonu dawał motyw równy domyślnemu (test regresyjny).
        Self {
            background: hex(0x0e, 0x0f, 0x14),
            cursor_fill: hex(0xf2, 0xf2, 0xf7),
            cursor_border: hex(0x14, 0x14, 0x1a),
            snap_preview: rgba(0x4e, 0xa1, 0xff, 0x66),
            snap_preview_radius: 12.0,
            ssd_bar: hex(0x1c, 0x20, 0x2b),
            ssd_unfocused: hex(0x14, 0x16, 0x1c),
            ssd_border: hex(0x0d, 0x0e, 0x13),
            ssd_text: hex(0xd6, 0xda, 0xe4),
            ssd_text_unfocused: hex(0x86, 0x8b, 0x96),
            ssd_icon_close: hex(0xe0, 0x6c, 0x6c),
            ssd_radius: 12.0,
            ssd_font_px: 13.0,
            ssd_icon_px: 18,
            ssd_menu_min_width: 560,
        }
    }
}

/// Kolor nieprzezroczysty z bajtów RGB — spójny z zapisem `#RRGGBB` w pliku.
const fn hex(r: u8, g: u8, b: u8) -> Color32F {
    Color32F::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
}

/// Kolor z kanałem alfa — spójny z zapisem `#RRGGBBAA` w pliku.
const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color32F {
    Color32F::new(
        r as f32 / 255.0,
        g as f32 / 255.0,
        b as f32 / 255.0,
        a as f32 / 255.0,
    )
}

impl Theme {
    /// Parsuje motyw z tekstu. Zwraca motyw i listę ostrzeżeń (nie panikujemy).
    ///
    /// PO CO czysta funkcja: łatwo ją przetestować bez dotykania systemu plików.
    pub fn from_text(text: &str) -> (Self, Vec<String>) {
        let mut theme = Self::default();
        let mut warnings = Vec::new();

        for (index, raw) in text.lines().enumerate() {
            // Komentarz to CAŁY wiersz zaczynający się od `#`. Nie obcinamy
            // „w locie”, bo wtedy zjedlibyśmy kolory w formacie `#RRGGBB`.
            let line = raw.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let Some((key, value)) = line.split_once('=') else {
                warnings.push(format!("wiersz {}: brak znaku '='", index + 1));
                continue;
            };
            if let Err(message) = theme.apply(key.trim(), value.trim()) {
                warnings.push(format!("wiersz {}: {}", index + 1, message));
            }
        }

        (theme, warnings)
    }

    /// Ustawia jedno pole motywu. `Err` = nieznany klucz albo zła wartość.
    fn apply(&mut self, key: &str, value: &str) -> Result<(), String> {
        match key {
            "background" => self.background = parse_color(value).ok_or_else(bad_color)?,
            "cursor_fill" => self.cursor_fill = parse_color(value).ok_or_else(bad_color)?,
            "cursor_border" => self.cursor_border = parse_color(value).ok_or_else(bad_color)?,
            "snap_preview" => self.snap_preview = parse_color(value).ok_or_else(bad_color)?,
            "snap_preview_radius" => self.snap_preview_radius = parse_float(value)?.max(0.0),
            "ssd_bar" => self.ssd_bar = parse_color(value).ok_or_else(bad_color)?,
            "ssd_unfocused" => self.ssd_unfocused = parse_color(value).ok_or_else(bad_color)?,
            "ssd_border" => self.ssd_border = parse_color(value).ok_or_else(bad_color)?,
            "ssd_text" => self.ssd_text = parse_color(value).ok_or_else(bad_color)?,
            "ssd_text_unfocused" => {
                self.ssd_text_unfocused = parse_color(value).ok_or_else(bad_color)?
            }
            "ssd_icon_close" => self.ssd_icon_close = parse_color(value).ok_or_else(bad_color)?,
            "ssd_radius" => self.ssd_radius = parse_float(value)?.max(0.0),
            "ssd_font_px" => self.ssd_font_px = parse_float(value)?.clamp(6.0, 48.0),
            "ssd_icon_px" => self.ssd_icon_px = parse_float(value)?.clamp(8.0, 64.0) as i32,
            "ssd_menu_min_width" => self.ssd_menu_min_width = parse_float(value)?.max(0.0) as i32,
            other => {
                return Err(format!(
                    "nieznany klucz '{other}' (dostępne: background, cursor_fill, \
                     cursor_border, snap_preview, snap_preview_radius, ssd_bar, \
                     ssd_unfocused, ssd_border, ssd_text, ssd_text_unfocused, \
                     ssd_icon_close, ssd_radius, ssd_font_px, ssd_icon_px, ssd_menu_min_width)"
                ));
            }
        }
        Ok(())
    }
}

fn bad_color() -> String {
    "oczekuję koloru w formacie #RRGGBB albo #RRGGBBAA".to_string()
}

fn parse_float(value: &str) -> Result<f32, String> {
    value
        .parse::<f32>()
        .map_err(|_| format!("oczekuję liczby, dostałem '{value}'"))
}

/// Parsuje kolor `#RRGGBB` / `#RRGGBBAA` (znak `#` opcjonalny).
fn parse_color(value: &str) -> Option<Color32F> {
    let hex = value.trim().trim_start_matches('#');
    let pair = |offset: usize| u8::from_str_radix(&hex[offset..offset + 2], 16).ok();
    let (r, g, b, a) = match hex.len() {
        6 => (pair(0)?, pair(2)?, pair(4)?, 255),
        8 => (pair(0)?, pair(2)?, pair(4)?, pair(6)?),
        _ => return None,
    };
    Some(Color32F::new(
        r as f32 / 255.0,
        g as f32 / 255.0,
        b as f32 / 255.0,
        a as f32 / 255.0,
    ))
}

/// Ścieżka pliku motywu (`$PAGAN_THEME` albo `~/.config/pagan/theme.conf`).
pub fn path() -> PathBuf {
    if let Ok(custom) = std::env::var("PAGAN_THEME") {
        return PathBuf::from(custom);
    }
    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"))
        })
        .unwrap_or_else(|| PathBuf::from("."));
    base.join("pagan").join("theme.conf")
}

/// Szablon zapisywany przy pierwszym uruchomieniu — użytkownik od razu widzi,
/// co da się ustawić, zamiast zgadywać nazwy kluczy.
const TEMPLATE: &str = "\
# Motyw PaganDE (kompozytor).
# Każda zmiana w tym pliku jest przeładowywana NATYCHMIAST (bez restartu).
# Wymuszenie ręczne: Ctrl+Alt+R.
#
# Kolory: #RRGGBB albo #RRGGBBAA (np. #00000080 = półprzezroczysta czerń).

# Tło pulpitu (obszar bez okien).
background = #0e0f14

# Awaryjny kursor (używany tylko, gdy brak motywu XCursor).
cursor_fill = #f2f2f7
cursor_border = #14141a

# „Duch” doklejania okna (podgląd geometrii, gdy przeciągasz okno do krawędzi/rogu).
# Ostatnia para cyfr to przezroczystość (tu ~40%).
snap_preview = #4ea1ff66
snap_preview_radius = 12

# --- Dekoracje serwerowe (SSD) — firmowy wygląd PaganDE ---
# Pasek tytułu rysuje kompozytor dla KAŻDEGO okna (wymuszony tryb serwerowy).
# Styl jest PŁASKI (bez gradientów). Na pasku, w JEDNYM rzędzie, leżą:
#   * z lewej: menu aplikacji (Plik/Edycja/Widok/Pomoc) albo burger przy wąskim oknie,
#   * z prawej: przyciski minimalizuj / maksymalizuj / zamknij.
ssd_bar = #1c202b
ssd_unfocused = #14161c
ssd_border = #0d0e13
ssd_text = #d6dae4
ssd_text_unfocused = #868b96
ssd_icon_close = #e06c6c
ssd_radius = 12
ssd_font_px = 13
ssd_icon_px = 18
# Poniżej tej szerokości okna etykiety menu zwijają się do pojedynczego burgera.
ssd_menu_min_width = 560
";

/// Globalny, współdzielony motyw (ładowany leniwie).
fn store() -> &'static RwLock<Theme> {
    static CURRENT: OnceLock<RwLock<Theme>> = OnceLock::new();
    CURRENT.get_or_init(|| RwLock::new(Theme::default()))
}

/// Zwraca aktualny motyw (kopia — tania, bo wszystkie pola są `Copy`).
pub fn current() -> Theme {
    *store().read().expect("RwLock motywu zatruty")
}

/// Wczytuje motyw z pliku i podmienia globalny stan.
///
/// PO CO osobno od `install`: to samo woła skrót `Ctrl+Alt+R`, więc użytkownik
/// może wymusić przeładowanie nie czekając na zdarzenie z `inotify`.
pub fn reload() -> Theme {
    let path = path();
    let (theme, warnings) = match fs::read_to_string(&path) {
        Ok(text) => Theme::from_text(&text),
        Err(err) if err.kind() == io::ErrorKind::NotFound => {
            // Brak pliku to nie błąd — zostają wartości domyślne.
            (Theme::default(), Vec::new())
        }
        Err(err) => {
            warn!(path = %path.display(), %err, "nie udało się wczytać motywu");
            (Theme::default(), Vec::new())
        }
    };

    for warning in &warnings {
        warn!(path = %path.display(), "{warning}");
    }

    *store().write().expect("RwLock motywu zatruty") = theme;
    info!(
        path = %path.display(),
        background = ?theme.background,
        "motyw przeładowany"
    );
    theme
}

/// Przygotowuje plik motywu (szablon przy pierwszym starcie) i uruchamia
/// obserwację zmian, a także wczytuje motyw początkowy.
///
/// Wywoływane raz, z `MyCompositor::init` — wspólne dla obu backendów.
pub fn install<D: 'static>(handle: &LoopHandle<'static, D>) {
    let path = path();
    ensure_config_file(&path);
    reload();
    watch(handle, path);
}

/// Tworzy katalog i zapisuje szablon, jeśli pliku jeszcze nie ma.
///
/// Działa tylko dla ścieżki domyślnej — gdy użytkownik wskazał `PAGAN_THEME`,
/// nie tworzymy katalogów „na ślepo” (mogłaby to być literówka).
fn ensure_config_file(path: &Path) {
    if path.exists() || std::env::var("PAGAN_THEME").is_ok() {
        return;
    }
    let Some(dir) = path.parent() else {
        return;
    };
    if let Err(err) = fs::create_dir_all(dir) {
        warn!(dir = %dir.display(), %err, "nie udało się utworzyć katalogu konfiguracji");
        return;
    }
    match fs::write(path, TEMPLATE) {
        Ok(()) => info!(path = %path.display(), "zapisano domyślny motyw (szablon)"),
        Err(err) => warn!(path = %path.display(), %err, "nie udało się zapisać szablonu motywu"),
    }
}

/// Zakłada obserwację katalogu motywu przez `inotify` (pętla zdarzeń calloop).
///
/// PO CO katalog, a nie sam plik: edytory często zapisują przez plik tymczasowy
/// i `rename`, co zerwałoby obserwację samego pliku. Obserwujemy więc katalog,
/// a reagujemy tylko na zdarzenia dotyczące naszej nazwy pliku.
fn watch<D: 'static>(handle: &LoopHandle<'static, D>, path: PathBuf) {
    let Some(dir) = path.parent().map(Path::to_path_buf) else {
        warn!("motyw bez katalogu nadrzędnego — pomijam obserwację");
        return;
    };
    if !dir.exists() {
        warn!(dir = %dir.display(), "katalog motywu nie istnieje — pomijam obserwację");
        return;
    }

    let mut inotify = match Inotify::init() {
        Ok(inotify) => inotify,
        Err(err) => {
            warn!(%err, "nie udało się zainicjować inotify — motyw nie będzie się przeładowywał automatycznie");
            return;
        }
    };

    // CLOSE_WRITE: zwykły zapis; MOVED_TO/CREATE: zapis przez rename/utworzenie;
    // ATTRIB: zmiana metadanych; DELETE: usunięcie pliku (wrócą domyślne).
    let mask = WatchMask::CLOSE_WRITE
        | WatchMask::MOVED_TO
        | WatchMask::CREATE
        | WatchMask::ATTRIB
        | WatchMask::DELETE;
    if let Err(err) = inotify.watches().add(&dir, mask) {
        warn!(dir = %dir.display(), %err, "nie udało się obserwować katalogu motywu");
        return;
    }

    // Osobny deskryptor służy WYŁĄCZNIE do nasłuchu: calloop polluje go i budzi
    // nas, gdy pojawią się zdarzenia. Same zdarzenia odczytujemy przez `Inotify`
    // (ten sam fd pod spodem, więc bufor się nie dubluje).
    //
    // PO CO tak: `Generic` przekazuje źródło jako `NoIoDrop<T>`, który daje tylko
    // `Deref` (bez `DerefMut`), więc nie da się wołać `read_events(&mut self)`
    // bezpośrednio na źródle. `Inotify` trzymany w domknięciu (FnMut) ten problem
    // znosi.
    let watch_fd = match inotify.as_fd().try_clone_to_owned() {
        Ok(fd) => fd,
        Err(err) => {
            warn!(%err, "nie udało się zduplikować deskryptora inotify — pomijam obserwację");
            return;
        }
    };

    let file_name = path.file_name().map(|name| name.to_os_string());
    let callback = move |_, _fd: &mut NoIoDrop<OwnedFd>, _data: &mut D| {
        // Bufor wielokrotnego użytku nie jest tu potrzebny — zdarzenia motywu
        // są rzadkie, a `read_events` nie blokuje (fd jest nieblokujący).
        let mut buffer = [0u8; 4096];
        match inotify.read_events(&mut buffer) {
            Ok(events) => {
                let touched = events
                    .into_iter()
                    .any(|event| match (&file_name, event.name) {
                        // Zdarzenie bez nazwy dotyczy samego katalogu — nas nie interesuje.
                        (Some(name), Some(event_name)) => event_name == name.as_os_str(),
                        _ => false,
                    });
                if touched {
                    reload();
                }
            }
            Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
            Err(err) => warn!(%err, "błąd odczytu zdarzeń inotify"),
        }
        Ok(PostAction::Continue)
    };

    match handle.insert_source(
        Generic::new(watch_fd, Interest::READ, Mode::Level),
        callback,
    ) {
        Ok(_) => info!(dir = %dir.display(), "obserwuję motyw (przeładowanie natychmiastowe)"),
        Err(err) => warn!(%err, "nie udało się zarejestrować obserwacji motywu"),
    }
}

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

    #[test]
    fn domyslne_wartosci_sa_spojne_z_dotychczasowym_wygladem() {
        let theme = Theme::default();
        assert_eq!(theme.background, hex(0x0e, 0x0f, 0x14));
        assert_eq!(theme.ssd_bar, hex(0x1c, 0x20, 0x2b));
        assert_eq!(theme.ssd_radius, 12.0);
    }

    #[test]
    fn parsuje_kolory_hex_z_i_bez_alfy() {
        let (theme, warnings) =
            Theme::from_text("background = #ff8000\ncursor_border = #00000080\n");
        assert!(warnings.is_empty(), "ostrzeżenia: {warnings:?}");
        assert_eq!(
            theme.background,
            Color32F::new(1.0, 128.0 / 255.0, 0.0, 1.0)
        );
        assert!((theme.cursor_border.a() - 128.0 / 255.0).abs() < 1e-6);
    }

    #[test]
    fn prog_burgera_jest_konfigurowalny() {
        let (theme, warnings) = Theme::from_text("ssd_menu_min_width = 700\n");
        assert!(warnings.is_empty());
        assert_eq!(theme.ssd_menu_min_width, 700);
    }

    #[test]
    fn komentarze_i_puste_wiersze_sa_ignorowane() {
        let (theme, warnings) = Theme::from_text("# komentarz\n\n  \nbackground = #101010\n");
        assert!(warnings.is_empty(), "ostrzeżenia: {warnings:?}");
        assert_eq!(
            theme.background,
            Color32F::new(
                0x10 as f32 / 255.0,
                0x10 as f32 / 255.0,
                0x10 as f32 / 255.0,
                1.0
            )
        );
    }

    #[test]
    fn nieznany_klucz_daje_ostrzezenie_a_nie_panike() {
        let (_, warnings) = Theme::from_text("bzdurek = 1\n");
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("nieznany klucz"));
    }

    #[test]
    fn zla_wartosc_koloru_daje_ostrzezenie() {
        let (theme, warnings) = Theme::from_text("background = niebieski\n");
        assert_eq!(warnings.len(), 1);
        // Motyw zostaje z wartością domyślną — błędny wpis nie psuje całości.
        assert_eq!(theme.background, Theme::default().background);
    }

    #[test]
    fn ujemny_promien_ducha_ucina_sie_do_zera() {
        // Promień nie może być ujemny — inaczej shader narysowałby odwrócone rogi.
        let (theme, _) = Theme::from_text("snap_preview_radius = -5\n");
        assert!(theme.snap_preview_radius >= 0.0);
    }

    #[test]
    fn szablon_zapisywany_uzytkownikowi_parsuje_sie_bez_ostrzezen() {
        // Regresja: gdyby szablon zawierał literówkę, użytkownik dostawałby
        // ostrzeżenia już przy pierwszym uruchomieniu.
        let (theme, warnings) = Theme::from_text(TEMPLATE);
        assert!(warnings.is_empty(), "szablon ma błędy: {warnings:?}");
        assert_eq!(theme, Theme::default());
    }
}