Commit 02466d3
14
files
+506
added
-29
deleted
@@ -236,18 +236,41 @@ geometrią**, nie tylko kolorami — np. `theme.metrics.switch.width` albo
236
236
przyszły, instalowany styl może zmienić np. wysokość paska tytułu czy rozmiar przełącznika
237
237
bez dotykania kodu.
238
238
239
-### Plan: style instalowane (jak w GNOME/KDE)
239
+### Style instalowane z plików (jak w GNOME/KDE)
240
+
241
+Wygląd można wybrać nie tylko enumatorem w kodzie, lecz **stylem zainstalowanym jako plik**.
242
+Style to pliki `*.theme` w katalogach (kolejność = priorytet):
243
+`~/.config/luna/themes`, `~/.local/share/luna/themes`, `/usr/share/luna/themes`.
244
+
245
+Format jest liniowy (`klucz = wartość`, `#` = komentarz), z **kropkowymi kluczami**:
246
+
247
+```ini
248
+__name__ = Nord # nazwa w UI
249
+appearance = dark # light | dark (wybiera bazową paletę)
250
+colors.card_bg = #3b4252
251
+colors.text = #eceff4
252
+accent = teal
253
+radius.small = 5
254
+metrics.button.height = 36
255
+metrics.headerbar.height = 48
256
+```
257
+
258
+Klucze pokrywają `colors.*`, `spacing.*`, `radius.*`, `typography.*.size` i `metrics.*`
259
+(np. `metrics.switch.width`) — **styl steruje więc nie tylko kolorami, ale i geometrią**.
260
+Nieznane klucze są ignorowane (zgodność wprzód). Wczytuje/nakłada to
261
+`libluna_core::theme_file` (`ThemePatch`), a wybrany styl zapisuje się w `theme.conf`
262
+(`style = <nazwa>`). Przykłady: `examples/luna/themes/nord.theme`, `paper.theme`.
240
263
241
-Docelowo wybór wyglądu nie będzie enumem zaszytym w kodzie, lecz **stylem instalowanym jako
242
-plik** — tak jak robią to GNOME i KDE. Aplikacja ma wtedy:
264
+Podgląd (headless) tego samego okna w stylach:
243
265
244
-- wykrywać style z katalogów (`~/.local/share/luna/themes`, `/usr/share/luna/themes`),
245
-- pokazywać je na liście w Ustawieniach,
246
-- otwierać **okno podglądu** na żywo przy wyborze (miniatura budowana z realnych widgetów),
247
-- wczytywać tokeny z pliku bez rekompilacji.
266
+```sh
267
+cargo run -p luna-demo --bin render -- --theme examples/luna/themes/nord.theme docs/images/settings-nord.png
268
+cargo run -p luna-demo --bin layout -- --theme examples/luna/themes/nord.theme --out docs/images/layout-nord.png
269
+```
248
270
249
-Do tego czasu motyw buduje się wprost z tokenów (`Theme::light()` / `Theme::dark()` +
250
-`accented(...)`), więc podmiana palety czy geometrii to kwestia kilku linii.
271
+Do zrobienia pozostaje **interaktywny wybór stylu w Ustawieniach** oraz **okno podglądu**
272
+na żywo przy wyborze — silnik (skanowanie katalogów: `available_themes()`, wczytywanie,
273
+nakładanie) jest już gotowy.
251
274
252
275
### Co jeszcze decyduje o „looku GTK4”
253
276
@@ -285,7 +308,8 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
285
308
- `libluna-core`: `Point/Size/Rect/Insets`, `Color` (sRGB, hex), `Scale`, zdarzenia, tokeny
286
309
motywu (`Spacing`, `Radius`, `Typography`), **metryki kontrolek** (`Metrics`) oraz
287
310
**ustawienia wyglądu** (`ThemePreference`, `Accent`, `ThemeSettings` z trwałością
288
- i rozwiązywaniem `light/dark`).
311
+ i rozwiązywaniem `light/dark`), a także **style instalowane z plików** (`theme_file`:
312
+ `ThemePatch`, skanowanie katalogów `available_themes()`).
289
313
- `libluna-i18n`: `Locale` (z `LANG`/`LC_*`), `Catalog`, `Translations`, globalne `tr`/`set_locale`,
290
314
makro `t!` z podstawianiem argumentów, **języki z osobnych plików** `i18n/<kod>.lang`
291
315
(wbudowane) oraz **doładowywane w runtime** z katalogów; `languages()` i `language_name()`.
@@ -353,6 +377,11 @@ z pliku doładowanego w runtime:
353
377

354
378

355
379
380
+Style instalowane z plików (`examples/luna/themes/nord.theme`, `paper.theme`) — kolor **i geometria**:
381
+
382
+
383
+
384
+
356
385
Przebieg interakcji (headless `simulate`) — m.in. menu `☰`, dolny panel, powiadomienie (`Toast`)
357
386
karta 12 i ekran stanu („Brak wyników”) klatka 14:
358
387
@@ -1,8 +1,8 @@
1
1
//! Przykład: okno (karta ustawień) zbudowane **wyłącznie** z drzewa układu
2
2
//! (`measure`/`allocate`) — bez ręcznego liczenia prostokątów.
3
3
//!
4
-//! Uruchom: `cargo run -p luna-demo --bin layout`
5
-//! Efekt: `docs/images/layout.png`
4
+//! Uruchom: `cargo run -p luna-demo --bin layout [--theme PATH|NAME] [--out ŚCIEŻKA]`
5
+//! Efekt: `docs/images/layout.png` (albo wskazane `--out`).
6
6
7
7
use std::error::Error;
8
8
@@ -31,7 +31,35 @@ fn row(label: &str, style: FontStyle, control: Node) -> Node {
31
31
}
32
32
33
33
fn main() -> Result<(), Box<dyn Error>> {
34
- let theme = Theme::dark();
34
+ let args: Vec<String> = std::env::args().skip(1).collect();
35
+ let mut theme_spec: Option<String> = None;
36
+ let mut out = "docs/images/layout.png".to_owned();
37
+ let mut i = 0;
38
+ while i < args.len() {
39
+ match args[i].as_str() {
40
+ "--theme" => {
41
+ if let Some(v) = args.get(i + 1) {
42
+ theme_spec = Some(v.clone());
43
+ }
44
+ i += 2;
45
+ }
46
+ "--out" => {
47
+ if let Some(v) = args.get(i + 1) {
48
+ out = v.clone();
49
+ }
50
+ i += 2;
51
+ }
52
+ _ => i += 1,
53
+ }
54
+ }
55
+
56
+ let mut theme = Theme::dark();
57
+ if let Some(spec) = &theme_spec {
58
+ match libluna_core::theme_file::load_spec(spec) {
59
+ Some(patch) => theme = patch.build(theme),
60
+ None => eprintln!("theme not found: {spec}"),
61
+ }
62
+ }
35
63
let text = TextEngine::system();
36
64
let size = Size::new(W as f64, H as f64);
37
65
let ctx = LayoutCtx {
@@ -194,10 +222,7 @@ fn main() -> Result<(), Box<dyn Error>> {
194
222
let mut renderer = SoftwareRenderer::new(W, H);
195
223
renderer.draw(&scene, 1.0, colors.window_bg);
196
224
let png = renderer.encode_png().ok_or("failed to encode PNG")?;
197
- std::fs::write("docs/images/layout.png", png)?;
198
- println!(
199
- "saved docs/images/layout.png ({W}x{H}, {} leaves)",
200
- rects.len()
201
- );
225
+ std::fs::write(&out, png)?;
226
+ println!("saved {out} ({W}x{H}, {} leaves)", rects.len());
202
227
Ok(())
203
228
}
@@ -1,11 +1,12 @@
1
1
//! Renderuje scenę demo do pliku PNG — pozwala obejrzeć wynik bez kompozytora.
2
2
//!
3
3
//! Użycie:
4
-//! `cargo run -p luna-demo --bin render -- [--dark] [--accent NAME] [--lang CODE] [wyjście.png]`
4
+//! `cargo run -p luna-demo --bin render -- [--dark] [--accent NAME] [--lang CODE] [--theme NAME|PATH] [wyjście.png]`
5
5
//!
6
6
//! Przykłady:
7
7
//! `render --lang pl docs/images/settings-light.png`
8
8
//! `render --dark --accent purple --lang de docs/images/settings-dark.png`
9
+//! `render --theme examples/luna/themes/nord.theme docs/images/settings-nord.png`
9
10
10
11
use std::error::Error;
11
12
@@ -20,6 +21,7 @@ fn main() -> Result<(), Box<dyn Error>> {
20
21
let mut dark = false;
21
22
let mut lang = "pl".to_owned();
22
23
let mut accent = Accent::Blue;
24
+ let mut theme_spec: Option<String> = None;
23
25
let mut no_bottom_bar = false;
24
26
let mut no_sidebar = false;
25
27
let mut sidebar_width: Option<f64> = None;
@@ -58,6 +60,12 @@ fn main() -> Result<(), Box<dyn Error>> {
58
60
}
59
61
i += 2;
60
62
}
63
+ "--theme" => {
64
+ if let Some(v) = args.get(i + 1) {
65
+ theme_spec = Some(v.clone());
66
+ }
67
+ i += 2;
68
+ }
61
69
other => {
62
70
out = Some(other.to_owned());
63
71
i += 1;
@@ -83,6 +91,16 @@ fn main() -> Result<(), Box<dyn Error>> {
83
91
ThemePreference::Light
84
92
};
85
93
let theme = preference.resolve(false, accent);
94
+ let theme = match theme_spec.as_deref() {
95
+ Some(spec) => match libluna_core::theme_file::load_spec(spec) {
96
+ Some(patch) => patch.build(theme),
97
+ None => {
98
+ eprintln!("theme not found: {spec}");
99
+ theme
100
+ }
101
+ },
102
+ None => theme,
103
+ };
86
104
87
105
let (w, h) = (900u32, 1028u32);
88
106
let size = Size::new(w as f64, h as f64);
@@ -15,6 +15,8 @@ fn main() -> Result<(), Box<dyn Error>> {
15
15
16
16
// Ustawienia wyglądu wczytane z dysku (jeśli są).
17
17
let settings = ThemeSettings::load();
18
+ // Wybrany styl (instalowany z pliku) — wczytany raz, nakładany na każdą klatkę.
19
+ let style_patch = settings.style().and_then(libluna_core::theme_file::load);
18
20
// Tryb systemowy: prosty przełącznik przez zmienną środowiskową (docelowo portal).
19
21
let system_dark = std::env::var("LUNA_SYSTEM_DARK")
20
22
.map(|v| !v.is_empty() && v != "0")
@@ -23,19 +25,25 @@ fn main() -> Result<(), Box<dyn Error>> {
23
25
let config = AppConfig::new("Aplikacja")
24
26
.app_id("eu.paganlinux.Luna.Settings")
25
27
.initial_size(900, 1028)
26
- .settings(settings)
28
+ .settings(settings.clone())
27
29
.system_dark(system_dark);
28
30
29
31
let mut demo = Demo::default();
30
32
31
33
run(config, move |frame: &mut Frame| {
34
+ // Motyw rozwiązujemy z aktualnych ustawień (+ styl z pliku), aby klatka była poprawna.
35
+ let mut theme = settings.resolve(frame.system_dark());
36
+ if let Some(patch) = &style_patch {
37
+ theme = patch.build(theme);
38
+ }
39
+
32
40
// Logika interakcji jest niezależna od Waylanda: dostaje zdarzenia i ustawienia.
33
41
let mut settings = frame.settings();
34
42
ui::handle_events(
35
43
&mut settings,
36
44
frame.events,
37
45
frame.size,
38
- &frame.theme,
46
+ &theme,
39
47
frame.text,
40
48
&mut demo,
41
49
);
@@ -61,7 +69,6 @@ fn main() -> Result<(), Box<dyn Error>> {
61
69
}
62
70
63
71
// Motyw rozwiązujemy z aktualnych ustawień, aby klatka była już poprawna.
64
- let theme = settings.resolve(frame.system_dark());
65
72
ui::build_scene(
66
73
frame.scene,
67
74
&theme,
@@ -132,11 +132,12 @@ impl ThemePreference {
132
132
}
133
133
}
134
134
135
-/// Trwałe ustawienia wyglądu (tryb + akcent) z flagą „zmienione”.
136
-#[derive(Debug, Clone, Copy)]
135
+/// Trwałe ustawienia wyglądu (tryb + akcent + styl) z flagą „zmienione”.
136
+#[derive(Debug, Clone)]
137
137
pub struct ThemeSettings {
138
138
preference: ThemePreference,
139
139
accent: Accent,
140
+ style: Option<String>,
140
141
dirty: bool,
141
142
}
142
143
@@ -145,6 +146,7 @@ impl Default for ThemeSettings {
145
146
Self {
146
147
preference: ThemePreference::System,
147
148
accent: Accent::Blue,
149
+ style: None,
148
150
dirty: false,
149
151
}
150
152
}
@@ -152,7 +154,9 @@ impl Default for ThemeSettings {
152
154
153
155
impl PartialEq for ThemeSettings {
154
156
fn eq(&self, other: &Self) -> bool {
155
- self.preference == other.preference && self.accent == other.accent
157
+ self.preference == other.preference
158
+ && self.accent == other.accent
159
+ && self.style == other.style
156
160
}
157
161
}
158
162
@@ -161,6 +165,7 @@ impl ThemeSettings {
161
165
Self {
162
166
preference,
163
167
accent,
168
+ style: None,
164
169
dirty: false,
165
170
}
166
171
}
@@ -173,6 +178,11 @@ impl ThemeSettings {
173
178
self.accent
174
179
}
175
180
181
+ /// Nazwa wybranego stylu (instalowanego z pliku), jeśli ustawiona.
182
+ pub fn style(&self) -> Option<&str> {
183
+ self.style.as_deref()
184
+ }
185
+
176
186
pub fn set_preference(&mut self, preference: ThemePreference) {
177
187
if self.preference != preference {
178
188
self.preference = preference;
@@ -187,6 +197,14 @@ impl ThemeSettings {
187
197
}
188
198
}
189
199
200
+ /// Ustawia styl (instalowany z pliku); `None` = wbudowany wygląd.
201
+ pub fn set_style(&mut self, style: Option<String>) {
202
+ if self.style != style {
203
+ self.style = style;
204
+ self.dirty = true;
205
+ }
206
+ }
207
+
190
208
/// Czy coś zmieniono od ostatniego [`clear_dirty`](Self::clear_dirty).
191
209
pub const fn is_dirty(&self) -> bool {
192
210
self.dirty
@@ -207,6 +225,16 @@ impl ThemeSettings {
207
225
base.accented(self.accent.color(), self.accent.foreground())
208
226
}
209
227
228
+ /// Jak [`resolve`](Self::resolve), ale nakłada też wybrany **styl** z pliku
229
+ /// (`~/.local/share/luna/themes/<nazwa>.theme` itd.).
230
+ pub fn resolve_themed(&self, system_dark: bool) -> Theme {
231
+ let base = self.resolve(system_dark);
232
+ match self.style.as_deref().and_then(crate::theme_file::load) {
233
+ Some(patch) => patch.build(base),
234
+ None => base,
235
+ }
236
+ }
237
+
210
238
// --- Trwałość (prosty format `klucz = wartość`) ---
211
239
212
240
/// Domyślna ścieżka konfiguracji: `$XDG_CONFIG_HOME/luna/theme.conf`.
@@ -248,6 +276,13 @@ impl ThemeSettings {
248
276
settings.accent = a;
249
277
}
250
278
}
279
+ "style" => {
280
+ settings.style = if value.is_empty() {
281
+ None
282
+ } else {
283
+ Some(value.to_owned())
284
+ };
285
+ }
251
286
_ => {}
252
287
}
253
288
}
@@ -257,11 +292,15 @@ impl ThemeSettings {
257
292
258
293
/// Serializuje ustawienia do tekstu konfiguracji.
259
294
pub fn to_config_string(&self) -> String {
260
- format!(
295
+ let mut out = format!(
261
296
"preference = {}\naccent = {}\n",
262
297
self.preference.name(),
263
298
self.accent.name()
264
- )
299
+ );
300
+ if let Some(style) = &self.style {
301
+ out.push_str(&format!("style = {style}\n"));
302
+ }
303
+ out
265
304
}
266
305
267
306
/// Zapisuje ustawienia do domyślnej ścieżki (tworzy katalogi).
@@ -310,9 +349,11 @@ mod tests {
310
349
let mut s = ThemeSettings::default();
311
350
s.set_preference(ThemePreference::Dark);
312
351
s.set_accent(Accent::Teal);
352
+ s.set_style(Some("nord".to_owned()));
313
353
assert!(s.is_dirty());
314
354
let parsed = ThemeSettings::from_config_str(&s.to_config_string());
315
355
assert_eq!(parsed, s);
356
+ assert_eq!(parsed.style(), Some("nord"));
316
357
assert!(!parsed.is_dirty());
317
358
}
318
359
@@ -17,6 +17,7 @@ pub mod event;
17
17
pub mod geometry;
18
18
pub mod scale;
19
19
pub mod style;
20
+pub mod theme_file;
20
21
21
22
pub use appearance::{Accent, ThemePreference, ThemeSettings};
22
23
pub use color::Color;
@@ -32,3 +33,4 @@ pub use style::{
32
33
SegmentedMetrics, SliderMetrics, Spacing, StatusPageMetrics, StepperMetrics, SwitchMetrics,
33
34
TabsMetrics, Theme, ToastMetrics, Typography,
34
35
};
36
+pub use theme_file::{available as available_themes, StyledTheme, ThemePatch};
35
37
new file mode 100644
@@ -0,0 +1,303 @@
1
+//! Style instalowane z plików (`*.theme`) — tokeny motywu bez rekompilacji.
2
+//!
3
+//! Format jest prosty i liniowy (`klucz = wartość`, `#` to komentarz), w duchu plików
4
+//! językowych `libluna-i18n`. Klucze są **nazwane kropkowo**, np. `colors.text`,
5
+//! `spacing.md`, `radius.small`, `typography.body.size`, `metrics.button.height`,
6
+//! a `__name__` to nazwa wyświetlana stylu. Nieznane klucze są ignorowane (zgodność wprzód).
7
+//!
8
+//! Katalogi wyszukiwania (kolejność = priorytet):
9
+//! `~/.config/luna/themes`, `~/.local/share/luna/themes`, `/usr/share/luna/themes`.
10
+
11
+use std::collections::BTreeMap;
12
+use std::io;
13
+use std::path::{Path, PathBuf};
14
+
15
+use crate::appearance::Accent;
16
+use crate::color::Color;
17
+use crate::style::{ColorScheme, Theme};
18
+
19
+/// Nadpisania tokenów motywu wczytane z pliku.
20
+#[derive(Debug, Clone, Default)]
21
+pub struct ThemePatch {
22
+ name: Option<String>,
23
+ entries: BTreeMap<String, String>,
24
+}
25
+
26
+impl ThemePatch {
27
+ /// Parsuje styl z tekstu (nieznane klucze są zachowane, ale ignorowane przy nakładaniu).
28
+ pub fn parse(src: &str) -> Self {
29
+ let mut patch = ThemePatch::default();
30
+ for line in src.lines() {
31
+ let line = line.trim();
32
+ if line.is_empty() || line.starts_with('#') {
33
+ continue;
34
+ }
35
+ let Some((key, value)) = line.split_once('=') else {
36
+ continue;
37
+ };
38
+ let key = key.trim();
39
+ let value = value.trim();
40
+ if key == "__name__" {
41
+ patch.name = Some(value.to_owned());
42
+ } else {
43
+ patch.entries.insert(key.to_owned(), value.to_owned());
44
+ }
45
+ }
46
+ patch
47
+ }
48
+
49
+ /// Wczytuje styl z pliku.
50
+ pub fn from_file(path: &Path) -> io::Result<ThemePatch> {
51
+ let src = std::fs::read_to_string(path)?;
52
+ Ok(ThemePatch::parse(&src))
53
+ }
54
+
55
+ /// Nazwa wyświetlana stylu (`__name__`), jeśli podana.
56
+ pub fn name(&self) -> Option<&str> {
57
+ self.name.as_deref()
58
+ }
59
+
60
+ /// Pary `klucz → wartość` (kolejność posortowana).
61
+ pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
62
+ self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
63
+ }
64
+
65
+ fn appearance(&self) -> Option<&str> {
66
+ self.entries.get("appearance").map(String::as_str)
67
+ }
68
+
69
+ /// Buduje motyw: bazą jest wariant z `appearance` (jeśli podany), w przeciwnym razie
70
+ /// `fallback`. Akcent z `fallback` jest zachowany, o ile styl go nie nadpisuje.
71
+ pub fn build(&self, fallback: Theme) -> Theme {
72
+ let mut theme = match self.appearance() {
73
+ Some("dark") => Theme::dark(),
74
+ Some("light") => Theme::light(),
75
+ _ => {
76
+ let mut theme = fallback;
77
+ self.apply(&mut theme);
78
+ return theme;
79
+ }
80
+ };
81
+ // Zachowaj wybrany przez użytkownika akcent, chyba że styl go nadpisze.
82
+ theme.colors.accent = fallback.colors.accent;
83
+ theme.colors.accent_fg = fallback.colors.accent_fg;
84
+ self.apply(&mut theme);
85
+ theme
86
+ }
87
+
88
+ /// Nakłada nadpisania na istniejący motyw (nieznane klucze są ignorowane).
89
+ pub fn apply(&self, theme: &mut Theme) {
90
+ for (key, value) in &self.entries {
91
+ apply_entry(theme, key, value);
92
+ }
93
+ }
94
+}
95
+
96
+fn apply_entry(theme: &mut Theme, key: &str, value: &str) {
97
+ if let Some(field) = key.strip_prefix("colors.") {
98
+ if let Some(color) = Color::from_hex(value) {
99
+ set_color(&mut theme.colors, field, color);
100
+ }
101
+ return;
102
+ }
103
+ macro_rules! set_f64 {
104
+ ($target:expr) => {
105
+ if let Ok(n) = value.parse::<f64>() {
106
+ $target = n;
107
+ }
108
+ };
109
+ }
110
+ match key {
111
+ "spacing.xs" => set_f64!(theme.spacing.xs),
112
+ "spacing.sm" => set_f64!(theme.spacing.sm),
113
+ "spacing.md" => set_f64!(theme.spacing.md),
114
+ "spacing.lg" => set_f64!(theme.spacing.lg),
115
+ "spacing.xl" => set_f64!(theme.spacing.xl),
116
+ "radius.small" => set_f64!(theme.radius.small),
117
+ "radius.medium" => set_f64!(theme.radius.medium),
118
+ "radius.large" => set_f64!(theme.radius.large),
119
+ "typography.title.size" => set_f64!(theme.typography.title.size),
120
+ "typography.heading.size" => set_f64!(theme.typography.heading.size),
121
+ "typography.body.size" => set_f64!(theme.typography.body.size),
122
+ "typography.caption.size" => set_f64!(theme.typography.caption.size),
123
+ "metrics.headerbar.height" => set_f64!(theme.metrics.headerbar.height),
124
+ "metrics.headerbar.control" => set_f64!(theme.metrics.headerbar.control),
125
+ "metrics.button.height" => set_f64!(theme.metrics.button.height),
126
+ "metrics.switch.width" => set_f64!(theme.metrics.switch.width),
127
+ "metrics.switch.height" => set_f64!(theme.metrics.switch.height),
128
+ "metrics.slider.track_height" => set_f64!(theme.metrics.slider.track_height),
129
+ "metrics.tabs.height" => set_f64!(theme.metrics.tabs.height),
130
+ "metrics.entry.height" => set_f64!(theme.metrics.entry.height),
131
+ "metrics.menu.item_h" => set_f64!(theme.metrics.menu.item_h),
132
+ "metrics.card.header" => set_f64!(theme.metrics.card.header),
133
+ "metrics.itemview.row_h" => set_f64!(theme.metrics.itemview.row_h),
134
+ "accent" => {
135
+ if let Some(a) = Accent::from_name(value) {
136
+ theme.colors.accent = a.color();
137
+ theme.colors.accent_fg = a.foreground();
138
+ }
139
+ }
140
+ _ => {}
141
+ }
142
+}
143
+
144
+fn set_color(colors: &mut ColorScheme, field: &str, color: Color) {
145
+ match field {
146
+ "window_bg" => colors.window_bg = color,
147
+ "view_bg" => colors.view_bg = color,
148
+ "card_bg" => colors.card_bg = color,
149
+ "headerbar_bg" => colors.headerbar_bg = color,
150
+ "sidebar_bg" => colors.sidebar_bg = color,
151
+ "text" => colors.text = color,
152
+ "dim_text" => colors.dim_text = color,
153
+ "accent" => colors.accent = color,
154
+ "accent_fg" => colors.accent_fg = color,
155
+ "border" => colors.border = color,
156
+ "shade" => colors.shade = color,
157
+ "error" => colors.error = color,
158
+ "warning" => colors.warning = color,
159
+ "success" => colors.success = color,
160
+ _ => {}
161
+ }
162
+}
163
+
164
+/// Wpis dostępnego stylu: identyfikator (nazwa pliku bez rozszerzenia), ścieżka i nazwa.
165
+#[derive(Debug, Clone, PartialEq, Eq)]
166
+pub struct StyledTheme {
167
+ pub id: String,
168
+ pub name: String,
169
+ pub path: PathBuf,
170
+}
171
+
172
+/// Katalogi wyszukiwania stylów (kolejność = priorytet).
173
+pub fn search_dirs() -> Vec<PathBuf> {
174
+ let mut dirs = Vec::new();
175
+ if let Some(home) = std::env::var_os("HOME") {
176
+ let home = PathBuf::from(home);
177
+ dirs.push(home.join(".config").join("luna").join("themes"));
178
+ dirs.push(
179
+ home.join(".local")
180
+ .join("share")
181
+ .join("luna")
182
+ .join("themes"),
183
+ );
184
+ }
185
+ dirs.push(PathBuf::from("/usr/share/luna/themes"));
186
+ dirs
187
+}
188
+
189
+/// Style dostępne w podanych katalogach (pierwszy wygrywa przy powtórzonym `id`).
190
+pub fn available_in(dirs: &[PathBuf]) -> Vec<StyledTheme> {
191
+ let mut out: Vec<StyledTheme> = Vec::new();
192
+ for dir in dirs {
193
+ let Ok(read) = std::fs::read_dir(dir) else {
194
+ continue;
195
+ };
196
+ for entry in read.flatten() {
197
+ let path = entry.path();
198
+ if path.extension().and_then(|e| e.to_str()) != Some("theme") {
199
+ continue;
200
+ }
201
+ let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
202
+ continue;
203
+ };
204
+ if out.iter().any(|t| t.id == id) {
205
+ continue;
206
+ }
207
+ let name = ThemePatch::from_file(&path)
208
+ .ok()
209
+ .and_then(|p| p.name().map(str::to_owned))
210
+ .unwrap_or_else(|| id.clone());
211
+ out.push(StyledTheme { id, name, path });
212
+ }
213
+ }
214
+ out.sort_by(|a, b| a.id.cmp(&b.id));
215
+ out
216
+}
217
+
218
+/// Style dostępne w standardowych katalogach.
219
+pub fn available() -> Vec<StyledTheme> {
220
+ available_in(&search_dirs())
221
+}
222
+
223
+/// Wczytuje styl o danym `id` ze standardowych katalogów.
224
+pub fn load(id: &str) -> Option<ThemePatch> {
225
+ available()
226
+ .into_iter()
227
+ .find(|t| t.id == id)
228
+ .and_then(|t| ThemePatch::from_file(&t.path).ok())
229
+}
230
+
231
+/// Wczytuje styl z podanej nazwy (`id`) albo bezpośredniej ścieżki do pliku.
232
+pub fn load_spec(spec: &str) -> Option<ThemePatch> {
233
+ let path = Path::new(spec);
234
+ if path.extension().and_then(|e| e.to_str()) == Some("theme") {
235
+ return ThemePatch::from_file(path).ok();
236
+ }
237
+ load(spec)
238
+}
239
+
240
+#[cfg(test)]
241
+mod tests {
242
+ use super::*;
243
+
244
+ const NORD: &str = "\
245
+# przykładowy styl
246
+__name__ = Nord
247
+appearance = dark
248
+colors.card_bg = #3b4252
249
+colors.text = #eceff4
250
+accent = teal
251
+radius.small = 4
252
+metrics.button.height = 38
253
+nieznany.klucz = cokolwiek
254
+";
255
+
256
+ #[test]
257
+ fn parse_name_and_entries() {
258
+ let p = ThemePatch::parse(NORD);
259
+ assert_eq!(p.name(), Some("Nord"));
260
+ assert_eq!(p.entries().count(), 7);
261
+ }
262
+
263
+ #[test]
264
+ fn build_overrides_and_keeps_fallback_accent() {
265
+ // Styl bez `accent` → zachowuje akcent z fallbacku.
266
+ let patch = ThemePatch::parse("appearance = dark\ncolors.text = #123456\n");
267
+ let base = Theme::light().accented(Accent::Purple.color(), Accent::Purple.foreground());
268
+ let theme = patch.build(base);
269
+ assert_eq!(theme.colors.text, Color::from_hex("#123456").unwrap());
270
+ assert_eq!(theme.colors.accent, Accent::Purple.color());
271
+ // `appearance = dark` przełącza paletę na ciemną.
272
+ assert_ne!(theme.colors.window_bg, Theme::light().colors.window_bg);
273
+ }
274
+
275
+ #[test]
276
+ fn parse_applies_geometry_and_accent_and_ignores_unknown() {
277
+ let patch = ThemePatch::parse(NORD);
278
+ let theme = patch.build(Theme::light());
279
+ assert_eq!(theme.radius.small, 4.0);
280
+ assert_eq!(theme.metrics.button.height, 38.0);
281
+ assert_eq!(theme.colors.accent, Accent::Teal.color());
282
+ assert_eq!(theme.colors.card_bg, Color::from_hex("#3b4252").unwrap());
283
+ }
284
+
285
+ #[test]
286
+ fn available_and_load_from_dir() {
287
+ let dir = std::env::temp_dir().join("libluna-theme-test");
288
+ std::fs::create_dir_all(&dir).unwrap();
289
+ let path = dir.join("testish.theme");
290
+ std::fs::write(&path, "__name__ = Testish\ncolors.text = #010203\n").unwrap();
291
+
292
+ let list = available_in(std::slice::from_ref(&dir));
293
+ assert!(list
294
+ .iter()
295
+ .any(|t| t.id == "testish" && t.name == "Testish"));
296
+
297
+ let patch = ThemePatch::from_file(&path).unwrap();
298
+ let theme = patch.build(Theme::light());
299
+ assert_eq!(theme.colors.text, Color::from_hex("#010203").unwrap());
300
+
301
+ let _ = std::fs::remove_dir_all(&dir);
302
+ }
303
+}
@@ -139,7 +139,7 @@ impl Frame<'_> {
139
139
}
140
140
/// Bieżące ustawienia wyglądu (kopia).
141
141
pub fn settings(&self) -> ThemeSettings {
142
- *self.settings
142
+ self.settings.clone()
143
143
}
144
144
145
145
/// Mierzy tekst w pikselach logicznych (szerokość i wysokość).
146
146
new file mode 100644
147
147
Binary files /dev/null and b/docs/images/layout-nord.png differ
148
148
new file mode 100644
149
149
Binary files /dev/null and b/docs/images/settings-nord.png differ
150
150
new file mode 100644
151
151
Binary files /dev/null and b/docs/images/settings-paper.png differ
@@ -52,7 +52,10 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
52
52
- Układ: **retencyjne drzewo `measure`/`allocate`** (`libluna-widgets::layout`) — `Node`
53
53
(`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`), `Constraints`,
54
54
`Layout::fit`; przykład karty złożonej z drzewa (`bin/layout.rs` → `docs/images/layout.png`).
55
-- Dalej: **style instalowane jako pliki** (jak w GNOME/KDE) z **oknem podglądu** przy wyborze,
55
+- **Style instalowane z plików**: `libluna-core::theme_file` (`ThemePatch`, klucze kropkowe,
56
+ `available_themes()`, nakładanie na bazę), trwały wybór stylu w `ThemeSettings` (`theme.conf`),
57
+ przykłady `examples/luna/themes/*.theme`, podgląd przez `render/layout --theme`.
58
+- Dalej: **interaktywny wybór stylu** w Ustawieniach + **okno podglądu** przy wyborze,
56
59
a potem scroll/overflow w `TabBar`, migracja `luna-demo` na drzewo układu.
57
60
58
61
## M2 — Układ + widgety bazowe
59
62
new file mode 100644
@@ -0,0 +1,28 @@
1
+# Styl „Nord" — chłodna, ciemna paleta (przykład instalowanego stylu).
2
+# Skopiuj do ~/.local/share/luna/themes/ albo /usr/share/luna/themes/.
3
+# Klucze są kropkowe; nieznane są ignorowane. `__name__` to nazwa w UI.
4
+__name__ = Nord
5
+appearance = dark
6
+
7
+colors.window_bg = #2e3440
8
+colors.view_bg = #2e3440
9
+colors.card_bg = #3b4252
10
+colors.headerbar_bg = #3b4252
11
+colors.sidebar_bg = #323844
12
+colors.text = #eceff4
13
+colors.dim_text = #a7b1c0
14
+colors.accent = #88c0d0
15
+colors.accent_fg = #2e3440
16
+colors.border = #4c566a
17
+colors.shade = #434c5e
18
+colors.error = #bf616a
19
+colors.warning = #ebcb8b
20
+colors.success = #a3be8c
21
+
22
+radius.small = 5
23
+radius.medium = 8
24
+radius.large = 11
25
+
26
+metrics.headerbar.height = 48
27
+metrics.button.height = 36
28
+metrics.card.header = 42
0
29
new file mode 100644
@@ -0,0 +1,21 @@
1
+# Styl „Paper" — ciepły, jasny wariant (przykład instalowanego stylu).
2
+# Pokazuje, że styl steruje też geometrią (promienie, wysokość kontrolek).
3
+__name__ = Paper
4
+appearance = light
5
+
6
+colors.window_bg = #f6f2e7
7
+colors.view_bg = #fbf8f0
8
+colors.card_bg = #ffffff
9
+colors.headerbar_bg = #efe9da
10
+colors.sidebar_bg = #e9e2d0
11
+colors.text = #3a342a
12
+colors.dim_text = #7a7264
13
+colors.border = #d8cfba
14
+colors.shade = #00000014
15
+
16
+radius.small = 3
17
+radius.medium = 6
18
+radius.large = 9
19
+
20
+metrics.button.height = 30
21
+spacing.md = 10