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