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(