← luna

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 ![Ustawienia — ciemny, DE, fioletowy](docs/images/settings-de-dark.png)
354 378 ![Ustawienia — francuski z pliku](docs/images/settings-fr.png)
355 379
380 +Style instalowane z plików (`examples/luna/themes/nord.theme`, `paper.theme`) — kolor **i geometria**:
381 +
382 +![Ustawienia — styl Nord](docs/images/settings-nord.png)
383 +![Ustawienia — styl Paper](docs/images/settings-paper.png)
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