← PaganDE

Commit 4baf6cb

12
plików
+590
dodanych
-37
usuniętych
@@ -98,7 +98,29 @@ Konfiguracja przez zmienne środowiskowe (czytane raz przy starcie):
98 98 | `PAGAN_SNAP_BOTTOM` | `0` | `1` włącza doklejanie do dolnej krawędzi (dolna połowa) |
99 99
100 100 Logika geometrii to czysta funkcja w `pagan-compositor/src/snap.rs` z testami:
101 -`cargo test --bin pagan-compositor` (10 testów).
101 +`cargo test --bin pagan-compositor` (23 testy).
102 +
103 +## Motyw kompozytora (kolory) — natychmiastowe przeładowanie
104 +
105 +Kompozytor sam rysuje tylko tło pulpitu, cień okien i awaryjny kursor — dlatego
106 +motyw steruje nimi. Konfiguracja: `~/.config/pagan/theme.conf` (lub `PAGAN_THEME`),
107 +tworzona z komentowanym szablonem przy pierwszym starcie:
108 +
109 +```conf
110 +background = #0e0f14 # tło pulpitu
111 +shadow_color = #000000 # kolor cienia
112 +shadow_opacity = 0.42
113 +shadow_blur = 24
114 +shadow_offset_y = 6
115 +shadow_radius = 12 # promień rogów sylwetki cienia
116 +cursor_fill = #f2f2f7 # awaryjny kursor (gdy brak motywu XCursor)
117 +cursor_border = #14141a
118 +```
119 +
120 +**Zmiany działają od razu** — kompozytor obserwuje plik przez `inotify` i wczytuje
121 +go od nowa po każdym zapisie (bez restartu). `Ctrl+Alt+R` wymusza przeładowanie
122 +ręcznie. Kolory to `#RRGGBB` / `#RRGGBBAA`. Implementacja: `src/theme.rs`
123 +(7 testów jednostkowych).
102 124
103 125 ### Testy interakcji bez machania myszką (dev)
104 126
@@ -133,7 +155,8 @@ DISPLAY=:1 python3 tools/x11_probe.py find 1280 800 # origin okna klienta
133 155 `PAGAN_SCALE`/`PAGAN_TRANSFORM` oraz per monitor w `~/.config/pagan/outputs.conf`),
134 156 ręczny układ przez `PAGAN_OUTPUTS`.
135 157 - **Skróty**: `Ctrl+Alt+Backspace` (wyjście), `Ctrl+Alt+←/→` (fokus),
136 - `Ctrl+Alt+Delete` (zamknij), `Ctrl+Alt+M` (minimalizuj), `Ctrl+Alt+U` (przywróć).
158 + `Ctrl+Alt+Delete` (zamknij), `Ctrl+Alt+M` (minimalizuj), `Ctrl+Alt+U` (przywróć),
159 + `Ctrl+Alt+R` (przeładuj motyw).
137 160 - **Backend tty-udev**: kompiluje się (`--features udev`, `--tty-udev`), ale **nie był
138 161 uruchamiany na sprzęcie** (komentarze `// WERYFIKUJ:`).
139 162 - **Cień** działa na GPU własnym shaderem (`shadow.frag`). `rounded_corners.frag` jest
@@ -150,13 +173,15 @@ Całość **skompilowana i uruchomiona** na prawdziwym sprzęcie (NVIDIA + Mesa,
150 173 |---|---|---|
151 174 | Build kompozytora (winit) | `cargo build` | ✅ 0 błędów |
152 175 | Build kompozytora (udev) | `cargo build --features udev` | ✅ 0 błędów |
153 -| Testy jednostkowe | `cargo test --bin pagan-compositor` | ✅ 15/15 (snap + konfiguracja monitorów) |
176 +| Testy jednostkowe | `cargo test --bin pagan-compositor` | ✅ 23/23 (snap + monitory + motyw) |
154 177 | Build toolkitu | `cargo build --examples` | ✅ 0 błędów |
155 178 | Kompozytor | `./target/debug/pagan-compositor --winit` | ✅ socket `wayland-1`, output 1280×800 |
156 179 | Klient + CSD | `WAYLAND_DISPLAY=wayland-1 demo` | ✅ okno, klik/hover/maximize/move |
157 180 | Cień (shader) | analiza pikseli klatki | ✅ miękki cień POD oknem |
158 181 | Zrzut ekranu | `WAYLAND_DISPLAY=wayland-1 grim shot.png` | ✅ (nasz `wlr-screencopy` od zera) |
159 182 | Kursor z motywu | `grim -c shot.png` | ✅ widoczny kursor XCursor |
183 +| Motyw — tło | zmiana `background` w `theme.conf` | ✅ nowy kolor w ~1 s (inotify) |
184 +| Motyw — cień | `shadow_color = #00ff00` | ✅ cień zielony (bbox wokół okna) |
160 185 | XWayland | log startowy bez binarki `Xwayland` | ⚠️ miękka degradacja (WARN) — brak binarki w środowisku testowym |
161 186
162 187 Zmierzona geometria z `grim`: menu i trzy przyciski mają środek na **tej samej
@@ -618,6 +618,26 @@ dependencies = [
618 618 "hashbrown",
619 619 ]
620 620
621 +[[package]]
622 +name = "inotify"
623 +version = "0.11.5"
624 +source = "registry+https://github.com/rust-lang/crates.io-index"
625 +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
626 +dependencies = [
627 + "bitflags 2.13.2",
628 + "inotify-sys",
629 + "libc",
630 +]
631 +
632 +[[package]]
633 +name = "inotify-sys"
634 +version = "0.1.8"
635 +source = "registry+https://github.com/rust-lang/crates.io-index"
636 +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
637 +dependencies = [
638 + "libc",
639 +]
640 +
621 641 [[package]]
622 642 name = "input"
623 643 version = "0.9.1"
@@ -1165,6 +1185,7 @@ name = "pagan-compositor"
1165 1185 version = "0.1.0"
1166 1186 dependencies = [
1167 1187 "anyhow",
1188 + "inotify",
1168 1189 "rustix 1.1.4",
1169 1190 "smithay",
1170 1191 "smithay-drm-extras",
@@ -41,6 +41,7 @@ rustix = { version = "1.0", optional = true, features = ["fs"] }
41 41 # przeniósł to do osobnego crate'a. `default-features = false`, bo nie używamy
42 42 # `display-info` (a to pociągnęłoby zależność od libdisplay-info).
43 43 smithay-drm-extras = { path = "../smithay-0.7.0/smithay-drm-extras", default-features = false, optional = true }
44 +inotify = { version = "0.11.5", default-features = false }
44 45
45 46 [features]
46 47 default = []
@@ -70,6 +70,7 @@ src/
70 70 ├── input.rs # mysz, klawiatura, Z-order, surface_under, skróty
71 71 ├── snap.rs # strefy doklejania okien (krawędzie/rogi) + testy
72 72 ├── cursor.rs # kursor z motywu XCursor (cache ikon, animacja klatek)
73 +├── theme.rs # motyw: kolory tła/cienia/kursora + natychmiastowe przeładowanie
73 74 ├── xwayland.rs # XWayland: X11Wm, schowek X11, obsługa okien X11
74 75 ├── screencopy.rs# wlr-screencopy napisany od zera
75 76 ├── render.rs # elementy renderowania, kursor, DnD, OutputDamageTracker
@@ -122,6 +123,7 @@ innego niż o pozycji (0,0) wymaga jeszcze korekty przesunięcia — patrz `// A
122 123 | `PAGAN_TRANSFORM=90\|180\|270\|flipped\|normal` | obrót wyjścia |
123 124 | `PAGAN_OUTPUTS="DP-1:0,0;HDMI-A-1:1920,0"` | ręczny układ monitorów (pozycje logiczne) |
124 125 | `PAGAN_CONFIG=/ścieżka/outputs.conf` | wskazanie pliku konfiguracji monitorów |
126 +| `PAGAN_THEME=/ścieżka/theme.conf` | wskazanie pliku motywu (kolorów) |
125 127 | `PAGAN_DRM_DEVICE=/dev/dri/card1` | wybór GPU dla backendu udev |
126 128 | `PAGAN_SNAP_THRESHOLD=12` | próg doklejania okna do krawędzi (px) |
127 129 | `PAGAN_SNAP_BOTTOM=1` | włącz doklejanie także do dolnej krawędzi |
@@ -144,10 +146,41 @@ HDMI-A-1 2560 0 1.5 90
144 146 ```
145 147
146 148 Pola `SKALA` i `OBROT` są opcjonalne; `#` zaczyna komentarz. Nazwy monitorów
147 -zobaczysz w logu startowym (`układ wyjść zaktualizowany ... DP-1=...`).
149 +Nazwy monitorów zobaczysz w logu startowym (`układ wyjść zaktualizowany ... DP-1=...`).
148 150 Kolejność nadpisywania: **plik → `PAGAN_OUTPUTS`/`PAGAN_SCALE`/`PAGAN_TRANSFORM`**
149 151 (zmienne wygrywają, więc łatwo je przetestować bez ruszania pliku).
150 152
153 +### Motyw kompozytora (kolory) — `theme.conf`
154 +
155 +Kompozytor sam maluje tylko kilka rzeczy (tło pulpitu, cień pod oknami, awaryjny
156 +kursor), więc motyw steruje właśnie nimi. Plik: `~/.config/pagan/theme.conf`
157 +(albo `$PAGAN_THEME`). Przy pierwszym uruchomieniu powstaje z komentowanym
158 +szablonem, więc nie trzeba zgadywać nazw kluczy.
159 +
160 +```conf
161 +background = #0e0f14 # tło pulpitu
162 +shadow_color = #000000 # kolor cienia (RGB)
163 +shadow_opacity = 0.42 # maksymalna nieprzezroczystość (0..1)
164 +shadow_blur = 24 # rozmycie w px (większe = bardziej rozlane)
165 +shadow_offset_y = 6 # przesunięcie cienia w dół
166 +shadow_radius = 12 # promień rogów sylwetki cienia
167 +cursor_fill = #f2f2f7 # awaryjny kursor (gdy brak motywu XCursor)
168 +cursor_border = #14141a
169 +```
170 +
171 +Kolory zapisujesz jako `#RRGGBB` lub `#RRGGBBAA`; komentarz to cały wiersz
172 +zaczynający się od `#`.
173 +
174 +**Przeładowanie jest natychmiastowe**: kompozytor obserwuje plik przez `inotify`
175 +(katalog, nie sam plik — edytory często zapisują przez `rename`) i po każdej
176 +zmianie wczytuje motyw od nowa. Nie trzeba restartu ani żadnego klawisza.
177 +Dodatkowo `Ctrl+Alt+R` wymusza przeładowanie ręcznie (przydatne, gdy edytor
178 +nadpisuje plik w nietypowy sposób albo gdy oglądasz zmiany „na żądanie”).
179 +
180 +Zmiana motywu jest widoczna od następnej klatki: kolor tła idzie do czyszczenia
181 +bufora, a parametry cienia są uniformami shadera. Zrzuty ekranu (`wlr-screencopy`)
182 + też od razu używają nowych kolorów.
183 +
151 184 ## Instalacja zależności (Manjaro/Arch)
152 185
153 186 ```bash
@@ -186,6 +219,7 @@ Przechwytywane zanim trafią do klienta (patrz `input.rs::compositor_shortcut`):
186 219 | `Ctrl+Alt+Delete` | zamknij okno z fokusem (`xdg_toplevel.close`) |
187 220 | `Ctrl+Alt+M` | zminimalizuj okno z fokusem |
188 221 | `Ctrl+Alt+U` | przywróć ostatnio zminimalizowane okno |
222 +| `Ctrl+Alt+R` | przeładuj motyw z pliku |
189 223
190 224 ## Hybrydowe CSD — jak to działa
191 225
@@ -17,10 +17,7 @@ use std::{
17 17 };
18 18
19 19 use smithay::{
20 - backend::{
21 - allocator::Fourcc,
22 - renderer::element::memory::MemoryRenderBuffer,
23 - },
20 + backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer},
24 21 input::pointer::CursorIcon,
25 22 utils::Transform,
26 23 };
@@ -77,7 +74,10 @@ impl Theme {
77 74 fn load_icon(&self, name: &str) -> Option<Vec<Image>> {
78 75 let path = self.theme.load_icon(name)?;
79 76 let mut data = Vec::new();
80 - std::fs::File::open(path).ok()?.read_to_end(&mut data).ok()?;
77 + std::fs::File::open(path)
78 + .ok()?
79 + .read_to_end(&mut data)
80 + .ok()?;
81 81 parse_xcursor(&data)
82 82 }
83 83
@@ -354,6 +354,8 @@ enum CompositorShortcut {
354 354 Minimize,
355 355 /// Przywróć ostatnio schowane okno.
356 356 Restore,
357 + /// Przeładuj motyw z pliku (gdy `inotify` nie zadziała albo dla pewności).
358 + ReloadTheme,
357 359 }
358 360
359 361 /// Rozpoznaje skrót globalny na podstawie modyfikatorów i klawisza.
@@ -377,6 +379,7 @@ fn compositor_shortcut(
377 379 // CapsLock może zamienić literę na wielką — akceptujemy obie.
378 380 Keysym::m | Keysym::M => Some(CompositorShortcut::Minimize),
379 381 Keysym::u | Keysym::U => Some(CompositorShortcut::Restore),
382 + Keysym::r | Keysym::R => Some(CompositorShortcut::ReloadTheme),
380 383 _ => None,
381 384 }
382 385 }
@@ -422,6 +425,11 @@ impl MyCompositor {
422 425 CompositorShortcut::CloseFocused => self.close_focused(),
423 426 CompositorShortcut::Minimize => self.minimize_focused(),
424 427 CompositorShortcut::Restore => self.restore_last_minimized(),
428 + CompositorShortcut::ReloadTheme => {
429 + // Motyw wczytuje się sam po zmianie pliku (inotify); ten skrót to
430 + // droga awaryjna i wygodne „odśwież na żądanie”.
431 + crate::theme::reload();
432 + }
425 433 }
426 434 }
427 435
@@ -9,6 +9,7 @@
9 9 //! - `input` – polityka myszy/klawiatury i Z-order,
10 10 //! - `grabs` – przeciąganie i zmiana rozmiaru,
11 11 //! - `render` – budowa elementów i rysowanie klatki,
12 +//! - `theme` – motyw (kolory tła/cienia/kursora) z natychmiastowym przeładowaniem,
12 13 //! - `focus` – cele fokusa (mysz/klawiatura),
13 14 //! - `shaders` – wbudowane shadery GLSL (zaokrąglenia + cień).
14 15
@@ -22,6 +23,7 @@ mod screencopy;
22 23 mod shaders;
23 24 mod snap;
24 25 mod state;
26 +mod theme;
25 27 mod window;
26 28 mod winit;
27 29 mod xwayland;
@@ -32,11 +32,13 @@ use smithay::{
32 32
33 33 use crate::{
34 34 state::DndIcon,
35 + theme,
35 36 window::{PaganWindow, WindowRenderElement},
36 37 };
37 38
38 -/// Kolor tła pulpitu (ciemny motyw PaganDE).
39 -pub const CLEAR_COLOR: Color32F = Color32F::new(0.055, 0.06, 0.08, 1.0);
39 +// Kolor tła pulpitu i kolory awaryjnego kursora pochodzą z motywu (`theme`).
40 +// Domyślny motyw odpowiada ciemnemu wyglądowi PaganDE; użytkownik zmienia go
41 +// w `~/.config/pagan/theme.conf` (przeładowanie natychmiastowe).
40 42
41 43 // Elementy "nakładkowe" rysowane nad przestrzenią okien (u nas: kursor).
42 44 //
@@ -186,19 +188,20 @@ impl PointerElement {
186 188 /// czytelny na każdym tle. Używany tylko, gdy nie ma kursora z motywu.
187 189 fn fallback_rectangle(&self) -> Vec<CustomRenderElements> {
188 190 let loc = self.location;
191 + let theme = theme::current();
189 192 vec![
190 193 CustomRenderElements::Pointer(SolidColorRenderElement::new(
191 194 Id::new(),
192 195 Rectangle::new(loc, Size::from((12, 18))),
193 196 CommitCounter::default(),
194 - Color32F::new(0.08, 0.08, 0.10, 1.0),
197 + theme.cursor_border,
195 198 Kind::Cursor,
196 199 )),
197 200 CustomRenderElements::Pointer(SolidColorRenderElement::new(
198 201 Id::new(),
199 202 Rectangle::new(Point::from((loc.x + 2, loc.y + 2)), Size::from((8, 13))),
200 203 CommitCounter::default(),
201 - Color32F::new(0.95, 0.95, 0.97, 1.0),
204 + theme.cursor_fill,
202 205 Kind::Cursor,
203 206 )),
204 207 ]
@@ -264,7 +267,7 @@ pub fn output_elements(
264 267 // 3) Zawartość przestrzeni (okna razem z ich cieniami) — na spodzie.
265 268 elements.extend(space_elements.into_iter().map(OutputRenderElements::Space));
266 269
267 - (elements, CLEAR_COLOR)
270 + (elements, theme::current().background)
268 271 }
269 272
270 273 /// Renderuje jedną klatkę wyjścia i zwraca wynik z listą uszkodzonych regionów.
@@ -18,6 +18,7 @@
18 18 // uniform float pad – margines cienia wokół okna
19 19 // uniform float offset – przesunięcie cienia w dół
20 20 // uniform float strength – maksymalna nieprzezroczystość cienia
21 +// uniform vec3 shadow_color – kolor cienia (z motywu, domyślnie czarny)
21 22 //
22 23 // PO CO cień w kompozytorze, a nie w kliencie: klient nie musi rezerwować
23 24 // marginesu wokół bufora, a cień jest spójny dla WSZYSTKICH okien.
@@ -40,6 +41,7 @@ uniform float radius;
40 41 uniform float pad;
41 42 uniform float offset;
42 43 uniform float strength;
44 +uniform vec3 shadow_color;
43 45
44 46 // Odległość ze znakiem do zaokrąglonego prostokąta. Ujemna = wewnątrz.
45 47 float sd_rounded_rect(vec2 p, vec2 half_size, float r) {
@@ -58,5 +60,7 @@ void main() {
58 60 // exp(-d/blur) to tanie, naturalne przybliżenie rozmycia Gaussa.
59 61 float falloff = exp(-max(dist, 0.0) / max(pad * 0.5, 1.0));
60 62
61 - gl_FragColor = vec4(0.0, 0.0, 0.0, falloff * strength * alpha);
63 + // Kolor cienia pochodzi z motywu (`~/.config/pagan/theme.conf`), dzięki czemu
64 + // można dostać np. ciepły/czerwony cień albo całkiem go wyłączyć (strength=0).
65 + gl_FragColor = vec4(shadow_color, falloff * strength * alpha);
62 66 }
@@ -335,6 +335,10 @@ impl MyCompositor {
335 335 // na monitor pod kursorem przy mapowaniu (`map_window`).
336 336 let next_cascade = Point::from((CASCADE_STEP, CASCADE_STEP));
337 337
338 + // Motyw kompozytora: wczytaj plik (utwórz szablon przy pierwszym starcie)
339 + // i obserwuj zmiany. Wspólne dla obu backendów, bo `init` jest wspólny.
340 + crate::theme::install(&handle);
341 +
338 342 MyCompositor {
339 343 space: Space::default(),
340 344 pointer_location: Point::from((0.0, 0.0)),
341 345 new file mode 100644
@@ -0,0 +1,444 @@
1 +//! System motywów kompozytora: kolory tła, cienia i kursora zapasowego.
2 +//!
3 +//! PO CO osobny moduł: kompozytor sam maluje tylko kilka rzeczy (tło pulpitu,
4 +//! cień pod oknami, awaryjny kursor) — resztę rysują klienty (hybrydowe CSD).
5 +//! Trzymamy te kolory w jednym miejscu, żeby dały się zmieniać bez rekompilacji.
6 +//!
7 +//! Gdzie jest konfiguracja: `$PAGAN_THEME`, a domyślnie
8 +//! `~/.config/pagan/theme.conf` (albo `$XDG_CONFIG_HOME/pagan/theme.conf`).
9 +//! Przy pierwszym uruchomieniu plik powstaje z komentowanym szablonem.
10 +//!
11 +//! **Przeładowanie jest natychmiastowe**: obserwujemy plik przez `inotify`
12 +//! (katalog, nie sam plik — edytory często zapisują przez rename) i po każdej
13 +//! zmianie wczytujemy motyw od nowa. Dodatkowo `Ctrl+Alt+R` wymusza przeładowanie.
14 +//!
15 +//! PO CO globalny stan: `AsRenderElements` okna nie ma dostępu do stanu
16 +//! kompozytora, a to właśnie tam rysujemy cień. Globalny `RwLock` z motywem
17 +//! pozwala odczytać kolory z dowolnego miejsca bez plątania typów.
18 +
19 +use std::{
20 + fs, io,
21 + os::fd::{AsFd, OwnedFd},
22 + path::{Path, PathBuf},
23 + sync::{OnceLock, RwLock},
24 +};
25 +
26 +use inotify::{Inotify, WatchMask};
27 +use smithay::{
28 + backend::renderer::Color32F,
29 + reexports::calloop::{
30 + generic::{Generic, NoIoDrop},
31 + Interest, LoopHandle, Mode, PostAction,
32 + },
33 +};
34 +use tracing::{info, warn};
35 +
36 +/// Kolory i parametry, które kompozytor naprawdę używa przy rysowaniu.
37 +///
38 +/// Wszystkie pola są `Copy`, więc odczyt motywu to tania kopia kilku liczb.
39 +#[derive(Debug, Clone, Copy, PartialEq)]
40 +pub struct Theme {
41 + /// Kolor tła pulpitu (czyszczenie bufora wyjścia).
42 + pub background: Color32F,
43 + /// Kolor cienia okien (RGB, składowe 0..1).
44 + pub shadow_color: [f32; 3],
45 + /// Maksymalna nieprzezroczystość cienia (0..1).
46 + pub shadow_opacity: f32,
47 + /// Rozmycie cienia w pikselach (większe = bardziej rozlane).
48 + pub shadow_blur: f32,
49 + /// Przesunięcie cienia w dół (światło „z góry”).
50 + pub shadow_offset_y: f32,
51 + /// Promień zaokrąglenia sylwetki cienia — dopasuj do rogów okien klienta.
52 + pub shadow_radius: f32,
53 + /// Kolor wypełnienia awaryjnego kursora (gdy brak motywu XCursor).
54 + pub cursor_fill: Color32F,
55 + /// Kolor obwódki awaryjnego kursora.
56 + pub cursor_border: Color32F,
57 +}
58 +
59 +impl Default for Theme {
60 + fn default() -> Self {
61 + // Wartości odpowiadają DOKŁADNIE szablonowi (TEMPLATE) w zapisie hex,
62 + // żeby odczyt szablonu dawał motyw równy domyślnemu (test regresyjny).
63 + Self {
64 + background: hex(0x0e, 0x0f, 0x14),
65 + shadow_color: [0.0, 0.0, 0.0],
66 + shadow_opacity: 0.42,
67 + shadow_blur: 24.0,
68 + shadow_offset_y: 6.0,
69 + shadow_radius: 12.0,
70 + cursor_fill: hex(0xf2, 0xf2, 0xf7),
71 + cursor_border: hex(0x14, 0x14, 0x1a),
72 + }
73 + }
74 +}
75 +
76 +/// Kolor nieprzezroczysty z bajtów RGB — spójny z zapisem `#RRGGBB` w pliku.
77 +const fn hex(r: u8, g: u8, b: u8) -> Color32F {
78 + Color32F::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
79 +}
80 +
81 +impl Theme {
82 + /// Parsuje motyw z tekstu. Zwraca motyw i listę ostrzeżeń (nie panikujemy).
83 + ///
84 + /// PO CO czysta funkcja: łatwo ją przetestować bez dotykania systemu plików.
85 + pub fn from_text(text: &str) -> (Self, Vec<String>) {
86 + let mut theme = Self::default();
87 + let mut warnings = Vec::new();
88 +
89 + for (index, raw) in text.lines().enumerate() {
90 + // Komentarz to CAŁY wiersz zaczynający się od `#`. Nie obcinamy
91 + // „w locie”, bo wtedy zjedlibyśmy kolory w formacie `#RRGGBB`.
92 + let line = raw.trim();
93 + if line.is_empty() || line.starts_with('#') {
94 + continue;
95 + }
96 + let Some((key, value)) = line.split_once('=') else {
97 + warnings.push(format!("wiersz {}: brak znaku '='", index + 1));
98 + continue;
99 + };
100 + if let Err(message) = theme.apply(key.trim(), value.trim()) {
101 + warnings.push(format!("wiersz {}: {}", index + 1, message));
102 + }
103 + }
104 +
105 + (theme, warnings)
106 + }
107 +
108 + /// Ustawia jedno pole motywu. `Err` = nieznany klucz albo zła wartość.
109 + fn apply(&mut self, key: &str, value: &str) -> Result<(), String> {
110 + match key {
111 + "background" => self.background = parse_color(value).ok_or_else(bad_color)?,
112 + "shadow_color" => {
113 + let color = parse_color(value).ok_or_else(bad_color)?;
114 + // Cień to składowa RGB; alpha (nieprzezroczystość) jest osobno.
115 + self.shadow_color = [color.r(), color.g(), color.b()];
116 + }
117 + "shadow_opacity" => self.shadow_opacity = parse_float(value)?,
118 + "shadow_blur" => self.shadow_blur = parse_float(value)?.max(1.0),
119 + "shadow_offset_y" => self.shadow_offset_y = parse_float(value)?,
120 + "shadow_radius" => self.shadow_radius = parse_float(value)?.max(0.0),
121 + "cursor_fill" => self.cursor_fill = parse_color(value).ok_or_else(bad_color)?,
122 + "cursor_border" => self.cursor_border = parse_color(value).ok_or_else(bad_color)?,
123 + other => {
124 + return Err(format!(
125 + "nieznany klucz '{other}' (dostępne: background, shadow_color, \
126 + shadow_opacity, shadow_blur, shadow_offset_y, shadow_radius, \
127 + cursor_fill, cursor_border)"
128 + ));
129 + }
130 + }
131 + Ok(())
132 + }
133 +}
134 +
135 +fn bad_color() -> String {
136 + "oczekuję koloru w formacie #RRGGBB albo #RRGGBBAA".to_string()
137 +}
138 +
139 +fn parse_float(value: &str) -> Result<f32, String> {
140 + value
141 + .parse::<f32>()
142 + .map_err(|_| format!("oczekuję liczby, dostałem '{value}'"))
143 +}
144 +
145 +/// Parsuje kolor `#RRGGBB` / `#RRGGBBAA` (znak `#` opcjonalny).
146 +fn parse_color(value: &str) -> Option<Color32F> {
147 + let hex = value.trim().trim_start_matches('#');
148 + let pair = |offset: usize| u8::from_str_radix(&hex[offset..offset + 2], 16).ok();
149 + let (r, g, b, a) = match hex.len() {
150 + 6 => (pair(0)?, pair(2)?, pair(4)?, 255),
151 + 8 => (pair(0)?, pair(2)?, pair(4)?, pair(6)?),
152 + _ => return None,
153 + };
154 + Some(Color32F::new(
155 + r as f32 / 255.0,
156 + g as f32 / 255.0,
157 + b as f32 / 255.0,
158 + a as f32 / 255.0,
159 + ))
160 +}
161 +
162 +/// Ścieżka pliku motywu (`$PAGAN_THEME` albo `~/.config/pagan/theme.conf`).
163 +pub fn path() -> PathBuf {
164 + if let Ok(custom) = std::env::var("PAGAN_THEME") {
165 + return PathBuf::from(custom);
166 + }
167 + let base = std::env::var("XDG_CONFIG_HOME")
168 + .ok()
169 + .map(PathBuf::from)
170 + .or_else(|| {
171 + std::env::var("HOME")
172 + .ok()
173 + .map(|home| PathBuf::from(home).join(".config"))
174 + })
175 + .unwrap_or_else(|| PathBuf::from("."));
176 + base.join("pagan").join("theme.conf")
177 +}
178 +
179 +/// Szablon zapisywany przy pierwszym uruchomieniu — użytkownik od razu widzi,
180 +/// co da się ustawić, zamiast zgadywać nazwy kluczy.
181 +const TEMPLATE: &str = "\
182 +# Motyw PaganDE (kompozytor).
183 +# Każda zmiana w tym pliku jest przeładowywana NATYCHMIAST (bez restartu).
184 +# Wymuszenie ręczne: Ctrl+Alt+R.
185 +#
186 +# Kolory: #RRGGBB albo #RRGGBBAA (np. #00000080 = półprzezroczysta czerń).
187 +
188 +# Tło pulpitu (obszar bez okien).
189 +background = #0e0f14
190 +
191 +# Cień pod oknami.
192 +shadow_color = #000000
193 +shadow_opacity = 0.42
194 +shadow_blur = 24
195 +shadow_offset_y = 6
196 +shadow_radius = 12
197 +
198 +# Awaryjny kursor (używany tylko, gdy brak motywu XCursor).
199 +cursor_fill = #f2f2f7
200 +cursor_border = #14141a
201 +";
202 +
203 +/// Globalny, współdzielony motyw (ładowany leniwie).
204 +fn store() -> &'static RwLock<Theme> {
205 + static CURRENT: OnceLock<RwLock<Theme>> = OnceLock::new();
206 + CURRENT.get_or_init(|| RwLock::new(Theme::default()))
207 +}
208 +
209 +/// Zwraca aktualny motyw (kopia — tania, bo wszystkie pola są `Copy`).
210 +pub fn current() -> Theme {
211 + *store().read().expect("RwLock motywu zatruty")
212 +}
213 +
214 +/// Wczytuje motyw z pliku i podmienia globalny stan.
215 +///
216 +/// PO CO osobno od `install`: to samo woła skrót `Ctrl+Alt+R`, więc użytkownik
217 +/// może wymusić przeładowanie nie czekając na zdarzenie z `inotify`.
218 +pub fn reload() -> Theme {
219 + let path = path();
220 + let (theme, warnings) = match fs::read_to_string(&path) {
221 + Ok(text) => Theme::from_text(&text),
222 + Err(err) if err.kind() == io::ErrorKind::NotFound => {
223 + // Brak pliku to nie błąd — zostają wartości domyślne.
224 + (Theme::default(), Vec::new())
225 + }
226 + Err(err) => {
227 + warn!(path = %path.display(), %err, "nie udało się wczytać motywu");
228 + (Theme::default(), Vec::new())
229 + }
230 + };
231 +
232 + for warning in &warnings {
233 + warn!(path = %path.display(), "{warning}");
234 + }
235 +
236 + *store().write().expect("RwLock motywu zatruty") = theme;
237 + info!(
238 + path = %path.display(),
239 + background = ?theme.background,
240 + "motyw przeładowany"
241 + );
242 + theme
243 +}
244 +
245 +/// Przygotowuje plik motywu (szablon przy pierwszym starcie) i uruchamia
246 +/// obserwację zmian, a także wczytuje motyw początkowy.
247 +///
248 +/// Wywoływane raz, z `MyCompositor::init` — wspólne dla obu backendów.
249 +pub fn install<D: 'static>(handle: &LoopHandle<'static, D>) {
250 + let path = path();
251 + ensure_config_file(&path);
252 + reload();
253 + watch(handle, path);
254 +}
255 +
256 +/// Tworzy katalog i zapisuje szablon, jeśli pliku jeszcze nie ma.
257 +///
258 +/// Działa tylko dla ścieżki domyślnej — gdy użytkownik wskazał `PAGAN_THEME`,
259 +/// nie tworzymy katalogów „na ślepo” (mogłaby to być literówka).
260 +fn ensure_config_file(path: &Path) {
261 + if path.exists() || std::env::var("PAGAN_THEME").is_ok() {
262 + return;
263 + }
264 + let Some(dir) = path.parent() else {
265 + return;
266 + };
267 + if let Err(err) = fs::create_dir_all(dir) {
268 + warn!(dir = %dir.display(), %err, "nie udało się utworzyć katalogu konfiguracji");
269 + return;
270 + }
271 + match fs::write(path, TEMPLATE) {
272 + Ok(()) => info!(path = %path.display(), "zapisano domyślny motyw (szablon)"),
273 + Err(err) => warn!(path = %path.display(), %err, "nie udało się zapisać szablonu motywu"),
274 + }
275 +}
276 +
277 +/// Zakłada obserwację katalogu motywu przez `inotify` (pętla zdarzeń calloop).
278 +///
279 +/// PO CO katalog, a nie sam plik: edytory często zapisują przez plik tymczasowy
280 +/// i `rename`, co zerwałoby obserwację samego pliku. Obserwujemy więc katalog,
281 +/// a reagujemy tylko na zdarzenia dotyczące naszej nazwy pliku.
282 +fn watch<D: 'static>(handle: &LoopHandle<'static, D>, path: PathBuf) {
283 + let Some(dir) = path.parent().map(Path::to_path_buf) else {
284 + warn!("motyw bez katalogu nadrzędnego — pomijam obserwację");
285 + return;
286 + };
287 + if !dir.exists() {
288 + warn!(dir = %dir.display(), "katalog motywu nie istnieje — pomijam obserwację");
289 + return;
290 + }
291 +
292 + let mut inotify = match Inotify::init() {
293 + Ok(inotify) => inotify,
294 + Err(err) => {
295 + warn!(%err, "nie udało się zainicjować inotify — motyw nie będzie się przeładowywał automatycznie");
296 + return;
297 + }
298 + };
299 +
300 + // CLOSE_WRITE: zwykły zapis; MOVED_TO/CREATE: zapis przez rename/utworzenie;
301 + // ATTRIB: zmiana metadanych; DELETE: usunięcie pliku (wrócą domyślne).
302 + let mask = WatchMask::CLOSE_WRITE
303 + | WatchMask::MOVED_TO
304 + | WatchMask::CREATE
305 + | WatchMask::ATTRIB
306 + | WatchMask::DELETE;
307 + if let Err(err) = inotify.watches().add(&dir, mask) {
308 + warn!(dir = %dir.display(), %err, "nie udało się obserwować katalogu motywu");
309 + return;
310 + }
311 +
312 + // Osobny deskryptor służy WYŁĄCZNIE do nasłuchu: calloop polluje go i budzi
313 + // nas, gdy pojawią się zdarzenia. Same zdarzenia odczytujemy przez `Inotify`
314 + // (ten sam fd pod spodem, więc bufor się nie dubluje).
315 + //
316 + // PO CO tak: `Generic` przekazuje źródło jako `NoIoDrop<T>`, który daje tylko
317 + // `Deref` (bez `DerefMut`), więc nie da się wołać `read_events(&mut self)`
318 + // bezpośrednio na źródle. `Inotify` trzymany w domknięciu (FnMut) ten problem
319 + // znosi.
320 + let watch_fd = match inotify.as_fd().try_clone_to_owned() {
321 + Ok(fd) => fd,
322 + Err(err) => {
323 + warn!(%err, "nie udało się zduplikować deskryptora inotify — pomijam obserwację");
324 + return;
325 + }
326 + };
327 +
328 + let file_name = path.file_name().map(|name| name.to_os_string());
329 + let callback = move |_, _fd: &mut NoIoDrop<OwnedFd>, _data: &mut D| {
330 + // Bufor wielokrotnego użytku nie jest tu potrzebny — zdarzenia motywu
331 + // są rzadkie, a `read_events` nie blokuje (fd jest nieblokujący).
332 + let mut buffer = [0u8; 4096];
333 + match inotify.read_events(&mut buffer) {
334 + Ok(events) => {
335 + let touched = events
336 + .into_iter()
337 + .any(|event| match (&file_name, event.name) {
338 + // Zdarzenie bez nazwy dotyczy samego katalogu — nas nie interesuje.
339 + (Some(name), Some(event_name)) => event_name == name.as_os_str(),
340 + _ => false,
341 + });
342 + if touched {
343 + reload();
344 + }
345 + }
346 + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
347 + Err(err) => warn!(%err, "błąd odczytu zdarzeń inotify"),
348 + }
349 + Ok(PostAction::Continue)
350 + };
351 +
352 + match handle.insert_source(
353 + Generic::new(watch_fd, Interest::READ, Mode::Level),
354 + callback,
355 + ) {
356 + Ok(_) => info!(dir = %dir.display(), "obserwuję motyw (przeładowanie natychmiastowe)"),
357 + Err(err) => warn!(%err, "nie udało się zarejestrować obserwacji motywu"),
358 + }
359 +}
360 +
361 +#[cfg(test)]
362 +mod tests {
363 + use super::*;
364 +
365 + #[test]
366 + fn domyslne_wartosci_sa_spojne_z_dotychczasowym_wygladem() {
367 + let theme = Theme::default();
368 + assert_eq!(theme.shadow_color, [0.0, 0.0, 0.0]);
369 + assert_eq!(theme.shadow_opacity, 0.42);
370 + assert_eq!(theme.shadow_radius, 12.0);
371 + }
372 +
373 + #[test]
374 + fn parsuje_kolory_hex_z_i_bez_alfy() {
375 + let (theme, warnings) =
376 + Theme::from_text("background = #ff8000\ncursor_border = #00000080\n");
377 + assert!(warnings.is_empty(), "ostrzeżenia: {warnings:?}");
378 + assert_eq!(
379 + theme.background,
380 + Color32F::new(1.0, 128.0 / 255.0, 0.0, 1.0)
381 + );
382 + assert!((theme.cursor_border.a() - 128.0 / 255.0).abs() < 1e-6);
383 + }
384 +
385 + #[test]
386 + fn kolor_cienia_odrzuca_kanal_alfa_ale_przyjmuje_rgb() {
387 + let (theme, warnings) = Theme::from_text("shadow_color = #112233\n");
388 + assert!(warnings.is_empty());
389 + assert_eq!(
390 + theme.shadow_color,
391 + [
392 + 0x11 as f32 / 255.0,
393 + 0x22 as f32 / 255.0,
394 + 0x33 as f32 / 255.0
395 + ]
396 + );
397 + }
398 +
399 + #[test]
400 + fn komentarze_i_puste_wiersze_sa_ignorowane() {
401 + let (theme, warnings) = Theme::from_text("# komentarz\n\n \nbackground = #101010\n");
402 + assert!(warnings.is_empty(), "ostrzeżenia: {warnings:?}");
403 + assert_eq!(
404 + theme.background,
405 + Color32F::new(
406 + 0x10 as f32 / 255.0,
407 + 0x10 as f32 / 255.0,
408 + 0x10 as f32 / 255.0,
409 + 1.0
410 + )
411 + );
412 + }
413 +
414 + #[test]
415 + fn nieznany_klucz_daje_ostrzezenie_a_nie_panike() {
416 + let (_, warnings) = Theme::from_text("bzdurek = 1\n");
417 + assert_eq!(warnings.len(), 1);
418 + assert!(warnings[0].contains("nieznany klucz"));
419 + }
420 +
421 + #[test]
422 + fn zla_wartosc_koloru_daje_ostrzezenie() {
423 + let (theme, warnings) = Theme::from_text("background = niebieski\n");
424 + assert_eq!(warnings.len(), 1);
425 + // Motyw zostaje z wartością domyślną — błędny wpis nie psuje całości.
426 + assert_eq!(theme.background, Theme::default().background);
427 + }
428 +
429 + #[test]
430 + fn rozmycie_nie_moze_zejsc_do_zera() {
431 + // Dzielenie przez `blur` w shaderze — zero dałoby artefakty.
432 + let (theme, _) = Theme::from_text("shadow_blur = 0\n");
433 + assert!(theme.shadow_blur >= 1.0);
434 + }
435 +
436 + #[test]
437 + fn szablon_zapisywany_uzytkownikowi_parsuje_sie_bez_ostrzezen() {
438 + // Regresja: gdyby szablon zawierał literówkę, użytkownik dostawałby
439 + // ostrzeżenia już przy pierwszym uruchomieniu.
440 + let (theme, warnings) = Theme::from_text(TEMPLATE);
441 + assert!(warnings.is_empty(), "szablon ma błędy: {warnings:?}");
442 + assert_eq!(theme, Theme::default());
443 + }
444 +}
@@ -41,17 +41,11 @@ use smithay::{
41 41 },
42 42 };
43 43
44 -use crate::shaders::SHADOW_FRAG;
45 -
46 -// --- Parametry cienia (jednostki logiczne) -----------------------------------
47 -/// Margines cienia wokół okna; zarazem skala rozmycia.
48 -const SHADOW_PAD: i32 = 24;
49 -/// Przesunięcie cienia w dół (światło z góry).
50 -const SHADOW_OFFSET: f32 = 6.0;
51 -/// Maksymalna nieprzezroczystość cienia.
52 -const SHADOW_STRENGTH: f32 = 0.42;
53 -/// Promień zaokrąglenia sylwetki cienia — zgodny z `CORNER_RADIUS` toolkitu.
54 -const SHADOW_RADIUS: f32 = 12.0;
44 +use crate::{shaders::SHADOW_FRAG, theme};
45 +
46 +// Parametry cienia (margines, rozmycie, przesunięcie, siła, promień rogów)
47 +// pochodzą z motywu (`theme::Theme`) — patrz `~/.config/pagan/theme.conf`.
48 +// Zmiana motywu jest widoczna w następnej klatce, bo parametry są uniformami.
55 49
56 50 #[derive(Debug, Clone, PartialEq)]
57 51 pub struct PaganWindow(pub Window);
@@ -262,6 +256,7 @@ fn shadow_program(renderer: &mut GlesRenderer) -> Option<GlesPixelProgram> {
262 256 UniformName::new("pad", UniformType::_1f),
263 257 UniformName::new("offset", UniformType::_1f),
264 258 UniformName::new("strength", UniformType::_1f),
259 + UniformName::new("shadow_color", UniformType::_3f),
265 260 ];
266 261
267 262 match renderer.compile_custom_pixel_shader(SHADOW_FRAG, &uniforms) {
@@ -284,9 +279,10 @@ fn shadow_program(renderer: &mut GlesRenderer) -> Option<GlesPixelProgram> {
284 279
285 280 /// Buduje element cienia dla okna (o ile shader da się skompilować).
286 281 ///
287 -/// Obszar elementu = okno powiększone o `SHADOW_PAD` z każdej strony. Shader
282 +/// Obszar elementu = okno powiększone o margines cienia z każdej strony. Shader
288 283 /// liczy odległość od zaokrąglonego prostokąta okna i zamienia ją na miękką
289 -/// alfę — patrz `shaders/shadow.frag`.
284 +/// alfę — patrz `shaders/shadow.frag`. Wszystkie parametry (kolor, rozmycie,
285 +/// przesunięcie, siła) bierzemy z motywu, więc zmiana motywu działa od razu.
290 286 fn shadow_element(
291 287 renderer: &mut GlesRenderer,
292 288 location: Point<i32, Physical>,
@@ -298,24 +294,35 @@ fn shadow_element(
298 294 }
299 295
300 296 let program = shadow_program(renderer)?;
297 + let theme = theme::current();
298 +
299 + // Margines musi pomieścić rozmycie ORAZ przesunięcie sylwetki, inaczej cień
300 + // zostałby obcięty na krawędzi elementu.
301 + let margin = (theme.shadow_blur + theme.shadow_offset_y.abs()).ceil() as i32;
302 + let margin = margin.max(1);
301 303
302 304 // `PixelShaderElement` operuje w jednostkach logicznych — przeliczamy
303 305 // pozycję fizyczną na logiczną, a rozmiar okna już mamy logiczny.
304 306 let logical_loc = location.to_f64().to_logical(scale).to_i32_round::<i32>();
305 307 let area = Rectangle::new(
306 - Point::from((logical_loc.x - SHADOW_PAD, logical_loc.y - SHADOW_PAD)),
307 - Size::from((
308 - window_size.w + SHADOW_PAD * 2,
309 - window_size.h + SHADOW_PAD * 2,
310 - )),
308 + Point::from((logical_loc.x - margin, logical_loc.y - margin)),
309 + Size::from((window_size.w + margin * 2, window_size.h + margin * 2)),
311 310 );
312 311
313 312 let uniforms = vec![
314 313 Uniform::new("window_size", (window_size.w as f32, window_size.h as f32)),
315 - Uniform::new("radius", SHADOW_RADIUS),
316 - Uniform::new("pad", SHADOW_PAD as f32),
317 - Uniform::new("offset", SHADOW_OFFSET),
318 - Uniform::new("strength", SHADOW_STRENGTH),
314 + Uniform::new("radius", theme.shadow_radius),
315 + Uniform::new("pad", theme.shadow_blur),
316 + Uniform::new("offset", theme.shadow_offset_y),
317 + Uniform::new("strength", theme.shadow_opacity),
318 + Uniform::new(
319 + "shadow_color",
320 + (
321 + theme.shadow_color[0],
322 + theme.shadow_color[1],
323 + theme.shadow_color[2],
324 + ),
325 + ),
319 326 ];
320 327
321 328 Some(WindowRenderElement::Shadow(PixelShaderElement::new(