//! 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) { 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 { value .parse::() .map_err(|_| format!("oczekuję liczby, dostałem '{value}'")) } /// Parsuje kolor `#RRGGBB` / `#RRGGBBAA` (znak `#` opcjonalny). fn parse_color(value: &str) -> Option { 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 { static CURRENT: OnceLock> = 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(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(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`, 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, _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()); } }