Commit ab32758
11
files
+606
added
-192
deleted
@@ -70,11 +70,14 @@ gnieździe nasłuchu i deskryptorze backendu. `libLuna` jest zwykłą zależnoś
70
70
Środowisko graficzne Luna (odpowiednik GNOME Shell) na libLunie, działające na kompozytorze
71
71
Selene.
72
72
73
-- `de/luna/src/panel.rs` — **panel górny** (menu, tytuł okna, zegar/data, przełączniki,
74
- przestrzenie robocze, użytkownik) i **launcher** (siatka aplikacji + wyszukiwanie).
75
-- `luna-panel` (bin) — render headless: `docs/images/luna-shell-{light,dark}.png`.
76
-- `luna-shell` (bin) — powierzchnia „chrome” na żywo: pasek + **OSD** + **powiadomienia**
77
- (`osd.rs`, `notifications.rs`; animacja, auto-zamknięcie).
73
+- `de/luna/src/panel.rs` — **panel górny** (sam przycisk burger, tytuł okna, zegar/data,
74
+ przełączniki, przestrzenie robocze, użytkownik) i **launcher** w trzech widokach:
75
+ **Lista**, **Siatka**, **Karuzela** (segmentowy przełącznik w nagłówku).
76
+- `luna-panel` (bin) — render headless: `docs/images/luna-shell-{light,dark}.png`
77
+ oraz `docs/images/luna-launcher-{list,grid,carousel}.png`.
78
+- `luna-shell` (bin) — powierzchnia „chrome” na żywo: burger otwiera/zamyka launcher,
79
+ przełącznik widoku, klik w aplikację zamyka, `Esc` zamyka; do tego **OSD** i **powiadomienia**
80
+ (`osd.rs`, `notifications.rs`).
78
81
- `libluna-platform` obsługuje teraz oba tryby: okno `xdg-shell` (CSD) i powierzchnię
79
82
warstwową (`AppConfig::layer(LayerConfig::…)`) — `LayerConfig::top_panel`/`overlay`/`top_chrome`
80
83
oraz przezroczyste tło (`AppConfig::transparent`).
@@ -1,7 +1,8 @@
1
-//! Podgląd powłoki Luny (headless): panel, OSD i powiadomienia — jasny i ciemny motyw.
1
+//! Podgląd powłoki Luny (headless): panel bez etykiety, launcher w trzech widokach,
2
+//! OSD i powiadomienia.
2
3
//!
3
4
//! Uruchom: `cargo run -p luna --bin luna-panel`
4
-//! Efekt: `docs/images/luna-shell-light.png`, `docs/images/luna-shell-dark.png`
5
+//! Efekt: `docs/images/luna-shell-{light,dark}.png` oraz `docs/images/luna-launcher-{list,grid,carousel}.png`
5
6
6
7
use std::error::Error;
7
8
@@ -10,7 +11,7 @@ use libluna_render::{Scene, SoftwareRenderer, TextEngine};
10
11
use libluna_widgets::PaintCtx;
11
12
use luna::notifications::{Notification, Notifications};
12
13
use luna::osd::Osd;
13
-use luna::panel::{self, App, PanelState};
14
+use luna::panel::{self, App, Launcher, LauncherView, PanelState};
14
15
15
16
const W: u32 = 1280;
16
17
const H: u32 = 720;
@@ -21,14 +22,16 @@ fn main() -> Result<(), Box<dyn Error>> {
21
22
let pstate = PanelState::sample();
22
23
let apps = App::sample();
23
24
25
+ // 1) Pełna powłoka (siatka + OSD + powiadomienia), jasny i ciemny.
24
26
for (name, theme) in [("light", Theme::light()), ("dark", Theme::dark())] {
25
27
let mut scene = Scene::new();
26
28
let mut paint = PaintCtx::new(&mut scene, &theme, &text);
29
+ let launcher = Launcher {
30
+ view: LauncherView::Grid,
31
+ carousel: 0,
32
+ };
33
+ panel::paint_desktop(&pstate, &apps, &launcher, &mut paint, size);
27
34
28
- // Pulpit + panel + launcher.
29
- panel::paint_desktop(&pstate, &apps, &mut paint, size);
30
-
31
- // OSD wyśrodkowany pod paskiem (po animacji wejścia).
32
35
let mut osd = Osd::new("◆", "Głośność", 0.65);
33
36
osd.advance(0.3);
34
37
osd.paint(
@@ -36,22 +39,48 @@ fn main() -> Result<(), Box<dyn Error>> {
36
39
Rect::from_xywh((W as f64 - 340.0) / 2.0, 64.0, 340.0, 72.0),
37
40
);
38
41
39
- // Powiadomienia w prawym-górnym narożniku.
40
42
let mut notes = Notifications::new();
41
43
notes.push(
42
44
Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
43
- .with_glyph("▦"),
45
+ .with_glyph("◆"),
44
46
);
45
47
notes.push(Notification::new("System", "Bateria", "Pozostało 18%").with_glyph("■"));
46
48
notes.advance(0.3);
47
49
notes.paint(&mut paint, Rect::from_xywh(0.0, 56.0, W as f64, 240.0));
48
50
49
- let mut renderer = SoftwareRenderer::new(W, H);
50
- renderer.draw(&scene, 1.0, theme.colors.window_bg);
51
- let png = renderer.encode_png().ok_or("failed to encode PNG")?;
52
- let path = format!("docs/images/luna-shell-{name}.png");
53
- std::fs::write(&path, png)?;
54
- println!("saved {path} ({W}x{H})");
51
+ save(
52
+ &scene,
53
+ &theme,
54
+ &format!("docs/images/luna-shell-{name}.png"),
55
+ )?;
56
+ }
57
+
58
+ // 2) Launcher w trzech widokach (ciemny motyw).
59
+ for (name, view) in [
60
+ ("list", LauncherView::List),
61
+ ("grid", LauncherView::Grid),
62
+ ("carousel", LauncherView::Carousel),
63
+ ] {
64
+ let theme = Theme::dark();
65
+ let mut scene = Scene::new();
66
+ let mut paint = PaintCtx::new(&mut scene, &theme, &text);
67
+ let launcher = Launcher { view, carousel: 2 };
68
+ panel::paint_desktop(&pstate, &apps, &launcher, &mut paint, size);
69
+ save(
70
+ &scene,
71
+ &theme,
72
+ &format!("docs/images/luna-launcher-{name}.png"),
73
+ )?;
55
74
}
75
+
76
+ Ok(())
77
+}
78
+
79
+fn save(scene: &Scene, theme: &Theme, path: &str) -> Result<(), Box<dyn Error>> {
80
+ let mut renderer = SoftwareRenderer::new(W, H);
81
+ renderer.draw(scene, 1.0, theme.colors.window_bg);
82
+ let png = renderer.encode_png().ok_or("failed to encode PNG")?;
83
+ std::fs::write(path, png)?;
84
+ println!("saved {path} ({W}x{H})");
56
85
Ok(())
57
86
}
@@ -1,22 +1,23 @@
1
-//! Powłoka Luny na żywo — pasek, OSD i powiadomienia jako powierzchnia warstwowa.
1
+//! Powłoka Luny na żywo — pasek, launcher (Lista/Siatka/Karuzela), OSD i powiadomienia.
2
2
//!
3
3
//! `luna-shell` tworzy powierzchnię „chrome" (`wlr-layer-shell`): pasek na górze,
4
-//! a pod nim przezroczysty obszar na OSD i powiadomienia. Bez usługi D-Bus
5
-//! `org.freedesktop.Notifications` (kolejny krok) demo samo cyklicznie pokazuje OSD
6
-//! i powiadomienia, żeby warstwę prezentacji dało się zobaczyć na żywo.
4
+//! a pod nim przezroczysty obszar na launcher, OSD i powiadomienia.
5
+//!
6
+//! Interakcja: klik w **burger** otwiera/zamyka launcher; w launcherze przełącznik
7
+//! widoku (Lista/Siatka/Karuzela), klik w aplikację zamyka launcher; `Esc` zamyka.
7
8
8
9
use std::error::Error;
9
10
use std::time::{SystemTime, UNIX_EPOCH};
10
11
11
-use libluna_core::{Rect, Theme};
12
+use libluna_core::{ButtonState, Event, PointerButton, Rect};
12
13
use libluna_platform::{run, AppConfig, Frame, LayerConfig};
13
14
use libluna_widgets::PaintCtx;
14
15
use luna::notifications::{Notification, Notifications};
15
16
use luna::osd::Osd;
16
-use luna::panel::{self, PanelState, THICKNESS};
17
+use luna::panel::{self, App, Launcher, PanelState, LAUNCHER_H, LAUNCHER_W, THICKNESS};
17
18
18
-/// Wysokość powierzchni „chrome" (pasek + obszar nakładek).
19
-const CHROME_H: u32 = 240;
19
+/// Wysokość powierzchni „chrome" (pasek + launcher + OSD/powiadomienia).
20
+const CHROME_H: u32 = 680;
20
21
21
22
fn main() -> Result<(), Box<dyn Error>> {
22
23
let cfg = AppConfig::new("Luna Shell")
@@ -29,6 +30,9 @@ fn main() -> Result<(), Box<dyn Error>> {
29
30
.transparent(true);
30
31
31
32
let mut state = PanelState::sample();
33
+ let apps = App::sample();
34
+ let mut launcher = Launcher::new();
35
+ let mut open = false;
32
36
let mut osd = Osd::new("◆", "Głośność", 0.0);
33
37
let mut notes = Notifications::new();
34
38
let mut timer = 0.0f64;
@@ -37,6 +41,48 @@ fn main() -> Result<(), Box<dyn Error>> {
37
41
let dt = frame.delta_time();
38
42
timer += dt;
39
43
44
+ let size = frame.size;
45
+ let theme = &frame.theme;
46
+ let text = frame.text;
47
+ let bar = Rect::from_xywh(0.0, 0.0, size.width, THICKNESS);
48
+
49
+ let lw = LAUNCHER_W.min((size.width - 80.0).max(320.0));
50
+ let larea = Rect::from_xywh((size.width - lw) / 2.0, THICKNESS + 24.0, lw, LAUNCHER_H);
51
+
52
+ // ── Obsługa wejścia ──
53
+ for ev in frame.events {
54
+ match ev {
55
+ Event::PointerButton(pe) if pe.state == ButtonState::Pressed => match pe.button {
56
+ PointerButton::Left
57
+ if panel::burger_rect(bar, &frame.theme).contains(pe.position) =>
58
+ {
59
+ open = !open;
60
+ }
61
+ PointerButton::Left if open => {
62
+ if let Some(v) =
63
+ panel::launcher_view_at(larea, &frame.theme, text, pe.position)
64
+ {
65
+ launcher.view = v;
66
+ } else if panel::launcher_app_at(
67
+ &apps,
68
+ &launcher,
69
+ larea,
70
+ &frame.theme,
71
+ text,
72
+ pe.position,
73
+ )
74
+ .is_some()
75
+ {
76
+ open = false;
77
+ }
78
+ }
79
+ _ => {}
80
+ },
81
+ Event::Key(ke) if ke.keysym == 0xff1b => open = false, // Escape
82
+ _ => {}
83
+ }
84
+ }
85
+
40
86
// Zegar (UTC).
41
87
let (clock, date) = now_clock();
42
88
state.clock = clock;
@@ -50,22 +96,18 @@ fn main() -> Result<(), Box<dyn Error>> {
50
96
if timer % 4.0 < dt {
51
97
notes.push(
52
98
Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
53
- .with_glyph("▦"),
99
+ .with_glyph("◆"),
54
100
);
55
101
}
56
102
osd.advance(dt);
57
103
notes.advance(dt);
58
104
59
- let size = frame.size;
60
- let theme: &Theme = &frame.theme;
61
- let mut ctx = PaintCtx::new(frame.scene, theme, frame.text);
62
- // Pasek na górze.
63
- panel::paint_panel(
64
- &state,
65
- &mut ctx,
66
- Rect::from_xywh(0.0, 0.0, size.width, THICKNESS),
67
- );
68
- // OSD wyśrodkowany pod paskiem.
105
+ // ── Rysowanie ──
106
+ let mut ctx = PaintCtx::new(frame.scene, theme, text);
107
+ panel::paint_panel(&state, &mut ctx, bar);
108
+ if open {
109
+ panel::paint_launcher(&apps, &launcher, &mut ctx, larea);
110
+ }
69
111
if osd.visible() {
70
112
let w = 340.0;
71
113
osd.paint(
@@ -73,7 +115,6 @@ fn main() -> Result<(), Box<dyn Error>> {
73
115
Rect::from_xywh((size.width - w) / 2.0, THICKNESS + 16.0, w, 72.0),
74
116
);
75
117
}
76
- // Powiadomienia w prawym-górnym narożniku (pod paskiem).
77
118
notes.paint(
78
119
&mut ctx,
79
120
Rect::from_xywh(0.0, THICKNESS + 8.0, size.width, size.height - THICKNESS),
@@ -2,13 +2,27 @@
2
2
//!
3
3
//! Warstwa rysowania jest oddzielona od źródła danych ([`PanelState`], [`App`]), więc
4
4
//! panel można renderować headless (podglądy) oraz „na żywo” w sesji Selene.
5
+//!
6
+//! Panel ma po lewej **sam przycisk burger** (bez etykiety) — kliknięcie otwiera launcher.
7
+//! Launcher ma trzy widoki: **lista**, **siatka** i **karuzela**.
5
8
6
9
use libluna_core::style::weight;
7
10
use libluna_core::{Color, Point, Rect, Size, Theme};
11
+use libluna_render::TextEngine;
8
12
use libluna_widgets::PaintCtx;
9
13
10
14
/// Wysokość panelu (piksele logiczne).
11
15
pub const THICKNESS: f64 = 40.0;
16
+/// Domyślna szerokość karty launchera.
17
+pub const LAUNCHER_W: f64 = 720.0;
18
+/// Domyślna wysokość karty launchera.
19
+pub const LAUNCHER_H: f64 = 600.0;
20
+
21
+const GRID_COLS: usize = 4;
22
+const GRID_CELL_H: f64 = 76.0;
23
+const LIST_ROW_H: f64 = 52.0;
24
+const CARD_W: f64 = 168.0;
25
+const CARD_H: f64 = 200.0;
12
26
13
27
/// Przełącznik szybkiego dostępu (Wi-Fi, dźwięk, jasność…).
14
28
#[derive(Debug, Clone)]
@@ -22,8 +36,6 @@ pub struct Toggle {
22
36
/// Stan panelu — czyste dane, bez logiki okna (łatwe do testów i podglądów).
23
37
#[derive(Debug, Clone)]
24
38
pub struct PanelState {
25
- /// Etykieta przycisku menu (np. „Aktywności”).
26
- pub menu_label: String,
27
39
/// Tytuł aktywnego okna lub nazwa przestrzeni.
28
40
pub title: String,
29
41
/// Godzina, np. „14:32”.
@@ -44,7 +56,6 @@ impl PanelState {
44
56
/// Przykładowy stan (podglądy, testy).
45
57
pub fn sample() -> Self {
46
58
Self {
47
- menu_label: "Aktywności".to_owned(),
48
59
title: "Pliki — Dokumenty".to_owned(),
49
60
clock: "14:32".to_owned(),
50
61
date: "pon, 10 paź".to_owned(),
@@ -72,42 +83,107 @@ impl PanelState {
72
83
}
73
84
}
74
85
75
-/// Pozycje w launcherze.
86
+/// Widok launchera.
87
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88
+pub enum LauncherView {
89
+ /// Pionowa lista wierszy.
90
+ List,
91
+ /// Siatka kafelków.
92
+ #[default]
93
+ Grid,
94
+ /// Pozioma karuzela kart.
95
+ Carousel,
96
+}
97
+
98
+impl LauncherView {
99
+ /// Wszystkie widoki w kolejności przełącznika.
100
+ pub const ALL: [LauncherView; 3] = [
101
+ LauncherView::List,
102
+ LauncherView::Grid,
103
+ LauncherView::Carousel,
104
+ ];
105
+
106
+ /// Etykieta w przełączniku.
107
+ pub fn label(self) -> &'static str {
108
+ match self {
109
+ LauncherView::List => "Lista",
110
+ LauncherView::Grid => "Siatka",
111
+ LauncherView::Carousel => "Karuzela",
112
+ }
113
+ }
114
+
115
+ /// Następny widok (cyklicznie).
116
+ pub fn next(self) -> Self {
117
+ match self {
118
+ LauncherView::List => LauncherView::Grid,
119
+ LauncherView::Grid => LauncherView::Carousel,
120
+ LauncherView::Carousel => LauncherView::List,
121
+ }
122
+ }
123
+}
124
+
125
+/// Stan launchera (widok + bieżąca karta karuzeli).
126
+#[derive(Debug, Clone, Default)]
127
+pub struct Launcher {
128
+ pub view: LauncherView,
129
+ pub carousel: usize,
130
+}
131
+
132
+impl Launcher {
133
+ pub fn new() -> Self {
134
+ Self::default()
135
+ }
136
+
137
+ /// Przełącza na kolejny widok.
138
+ pub fn next_view(&mut self) {
139
+ self.view = self.view.next();
140
+ }
141
+}
142
+
143
+/// Pozycja w launcherze.
76
144
#[derive(Debug, Clone)]
77
145
pub struct App {
78
146
/// Symbol rysowany fontem interfejsu.
79
147
pub glyph: String,
80
148
pub name: String,
149
+ /// Krótki opis/kategoria (widok listy).
150
+ pub category: String,
81
151
}
82
152
83
153
impl App {
84
154
/// Przykładowy zestaw ikon (podglądy).
85
155
pub fn sample() -> Vec<App> {
86
- const APPS: [(&str, &str); 8] = [
87
- ("❖", "Pliki"),
88
- ("▦", "Ustawienia"),
89
- ("✎", "Edytor"),
90
- ("▶", "Odtwarzacz"),
91
- ("★", "Ulubione"),
92
- ("◔", "Zegar"),
93
- ("⌗", "Terminal"),
94
- ("✦", "Sklep"),
156
+ const APPS: [(&str, &str, &str); 8] = [
157
+ ("◆", "Pliki", "System"),
158
+ ("●", "Ustawienia", "System"),
159
+ ("▲", "Edytor", "Akcesoria"),
160
+ ("▶", "Odtwarzacz", "Multimedia"),
161
+ ("★", "Ulubione", "System"),
162
+ ("☀", "Zegar", "Akcesoria"),
163
+ ("■", "Terminal", "System"),
164
+ ("♥", "Sklep", "System"),
95
165
];
96
166
APPS.iter()
97
- .map(|(g, n)| App {
167
+ .map(|(g, n, c)| App {
98
168
glyph: (*g).to_owned(),
99
169
name: (*n).to_owned(),
170
+ category: (*c).to_owned(),
100
171
})
101
172
.collect()
102
173
}
103
174
}
104
175
176
+/// Prostokąt przycisku burgera na panelu.
177
+pub fn burger_rect(panel: Rect, theme: &Theme) -> Rect {
178
+ let cy = panel.center().y;
179
+ Rect::from_xywh(panel.x() + theme.spacing.md, cy - 14.0, 28.0, 28.0)
180
+}
181
+
105
182
/// Rysuje panel górny w zadanym prostokącie.
106
183
pub fn paint_panel(panel: &PanelState, ctx: &mut PaintCtx, rect: Rect) {
107
184
let t = ctx.theme;
108
185
let cy = rect.center().y;
109
186
let body = t.typography.body.size;
110
- let pad = t.spacing.md;
111
187
112
188
// Tło + dolna krawędź oddzielająca od pulpitu.
113
189
ctx.scene.fill_rect(rect, t.colors.headerbar_bg);
@@ -116,28 +192,19 @@ pub fn paint_panel(panel: &PanelState, ctx: &mut PaintCtx, rect: Rect) {
116
192
t.colors.border,
117
193
);
118
194
119
- // ── Lewa grupa: przycisk menu + tytuł + separator ──
120
- let menu = Rect::from_xywh(rect.x() + pad, cy - 12.0, 24.0, 24.0);
195
+ // ── Lewa grupa: sam burger + separator + tytuł okna ──
196
+ let burger = burger_rect(rect, t);
121
197
ctx.scene
122
- .fill_rounded_rect(menu, t.radius.small, t.colors.shade);
198
+ .fill_rounded_rect(burger, t.radius.small, t.colors.shade);
123
199
ctx.label_center(
124
- menu.center().x,
125
- menu.center().y,
200
+ burger.center().x,
201
+ burger.center().y,
126
202
"☰",
127
203
body,
128
204
weight::BOLD,
129
205
t.colors.text,
130
206
);
131
- let mut x = menu.right() + t.spacing.sm;
132
- ctx.label_left(
133
- x,
134
- cy,
135
- &panel.menu_label,
136
- body,
137
- weight::MEDIUM,
138
- t.colors.text,
139
- );
140
- x += ctx.measure(&panel.menu_label, body, weight::MEDIUM) + t.spacing.md;
207
+ let mut x = burger.right() + t.spacing.md;
141
208
ctx.scene
142
209
.fill_rect(Rect::from_xywh(x, cy - 8.0, 1.0, 16.0), t.colors.border);
143
210
x += t.spacing.md;
@@ -155,6 +222,7 @@ pub fn paint_panel(panel: &PanelState, ctx: &mut PaintCtx, rect: Rect) {
155
222
);
156
223
157
224
// ── Prawa grupa (od prawej): użytkownik, przełączniki, przestrzenie ──
225
+ let pad = t.spacing.md;
158
226
let mut right = rect.right() - pad;
159
227
let uw = ctx.measure(&panel.user, body, weight::NORMAL);
160
228
ctx.label_right(right, cy, &panel.user, body, weight::NORMAL, t.colors.text);
@@ -217,73 +285,215 @@ fn paint_pill(ctx: &mut PaintCtx, rect: Rect, label: &str, active: bool) {
217
285
);
218
286
}
219
287
220
-/// Rysuje launcher (menu aplikacji) w zadanym prostokącie.
221
-pub fn paint_launcher(apps: &[App], ctx: &mut PaintCtx, rect: Rect) {
222
- let t = ctx.theme;
288
+/// Rozmieszczenie elementów launchera.
289
+#[derive(Debug, Clone)]
290
+pub struct LauncherRects {
291
+ pub header: Rect,
292
+ pub search: Rect,
293
+ /// Całe segmentowe przełączniki widoku.
294
+ pub switcher: Rect,
295
+ /// Trzy przyciski widoku (kolejność jak [`LauncherView::ALL`]).
296
+ pub views: [Rect; 3],
297
+ /// Obszar na zawartość (lista/siatka/karuzela).
298
+ pub content: Rect,
299
+}
300
+
301
+/// Liczy rozmieszczenie launchera w obszarze `area`.
302
+pub fn launcher_rects(area: Rect, theme: &Theme, text: &TextEngine) -> LauncherRects {
303
+ let pad = theme.spacing.lg;
304
+ let header_h = 30.0;
305
+ let header = Rect::from_xywh(
306
+ area.x() + pad,
307
+ area.y() + pad,
308
+ area.width() - 2.0 * pad,
309
+ header_h,
310
+ );
311
+
312
+ // Przełącznik widoku po prawej nagłówka.
313
+ let cap = theme.typography.caption.size;
314
+ let gap = theme.spacing.xs;
315
+ let widths: Vec<f64> = LauncherView::ALL
316
+ .iter()
317
+ .map(|v| text.measure(v.label(), cap, weight::MEDIUM).width + 2.0 * theme.spacing.md)
318
+ .collect();
319
+ let total: f64 = widths.iter().sum::<f64>() + gap * 2.0;
320
+ let mut views = [Rect::ZERO; 3];
321
+ let mut x = header.right() - total;
322
+ for (i, w) in widths.iter().enumerate() {
323
+ views[i] = Rect::from_xywh(x, header.y() + (header_h - 26.0) / 2.0, *w, 26.0);
324
+ x += w + gap;
325
+ }
326
+ let switcher = Rect::from_xywh(header.right() - total, header.y(), total, 26.0);
327
+
328
+ let search = Rect::from_xywh(
329
+ area.x() + pad,
330
+ header.bottom() + theme.spacing.sm,
331
+ area.width() - 2.0 * pad,
332
+ 32.0,
333
+ );
334
+ let content_y = search.bottom() + theme.spacing.lg;
335
+ let content = Rect::from_xywh(
336
+ area.x() + pad,
337
+ content_y,
338
+ area.width() - 2.0 * pad,
339
+ (area.bottom() - pad - content_y).max(0.0),
340
+ );
341
+
342
+ LauncherRects {
343
+ header,
344
+ search,
345
+ switcher,
346
+ views,
347
+ content,
348
+ }
349
+}
350
+
351
+/// Widok odpowiadający kliknięciu w przełącznik (jeśli trafiono).
352
+pub fn launcher_view_at(
353
+ area: Rect,
354
+ theme: &Theme,
355
+ text: &TextEngine,
356
+ p: Point,
357
+) -> Option<LauncherView> {
358
+ let r = launcher_rects(area, theme, text);
359
+ r.views
360
+ .iter()
361
+ .position(|v| v.contains(p))
362
+ .map(|i| LauncherView::ALL[i])
363
+}
364
+
365
+/// Indeks aplikacji pod punktem `p` (wg aktualnego widoku).
366
+pub fn launcher_app_at(
367
+ apps: &[App],
368
+ launcher: &Launcher,
369
+ area: Rect,
370
+ theme: &Theme,
371
+ text: &TextEngine,
372
+ p: Point,
373
+) -> Option<usize> {
374
+ let content = launcher_rects(area, theme, text).content;
375
+ match launcher.view {
376
+ LauncherView::Grid => (0..apps.len()).find(|&i| grid_tile(content, i).contains(p)),
377
+ LauncherView::List => (0..apps.len()).find(|&i| list_row(content, i).contains(p)),
378
+ LauncherView::Carousel => {
379
+ (0..apps.len()).find(|&i| carousel_card(content, i, launcher.carousel).contains(p))
380
+ }
381
+ }
382
+}
383
+
384
+fn grid_tile(content: Rect, i: usize) -> Rect {
385
+ let gap = 12.0;
386
+ let cell_w = (content.width() - gap * (GRID_COLS as f64 - 1.0)) / GRID_COLS as f64;
387
+ let c = (i % GRID_COLS) as f64;
388
+ let r = (i / GRID_COLS) as f64;
389
+ Rect::from_xywh(
390
+ content.x() + c * (cell_w + gap),
391
+ content.y() + r * (GRID_CELL_H + gap),
392
+ cell_w,
393
+ GRID_CELL_H,
394
+ )
395
+}
396
+
397
+fn list_row(content: Rect, i: usize) -> Rect {
398
+ let gap = 8.0;
399
+ Rect::from_xywh(
400
+ content.x(),
401
+ content.y() + i as f64 * (LIST_ROW_H + gap),
402
+ content.width(),
403
+ LIST_ROW_H,
404
+ )
405
+}
406
+
407
+fn carousel_card(content: Rect, i: usize, selected: usize) -> Rect {
408
+ let gap = 16.0;
409
+ let x = content.center().x - CARD_W / 2.0 + (i as f64 - selected as f64) * (CARD_W + gap);
410
+ Rect::from_xywh(
411
+ x,
412
+ content.y() + (content.height() - CARD_H) / 2.0,
413
+ CARD_W,
414
+ CARD_H,
415
+ )
416
+}
417
+
418
+/// Rysuje launcher w zadanym prostokącie.
419
+pub fn paint_launcher(apps: &[App], launcher: &Launcher, ctx: &mut PaintCtx, area: Rect) {
420
+ let t = *ctx.theme;
421
+ let r = launcher_rects(area, &t, ctx.text);
422
+
223
423
ctx.scene
224
- .fill_rounded_rect(rect, t.radius.large, t.colors.card_bg);
424
+ .fill_rounded_rect(area, t.radius.large, t.colors.card_bg);
225
425
ctx.scene
226
- .stroke_rounded_rect(rect, t.radius.large, 1.0, t.colors.border);
426
+ .stroke_rounded_rect(area, t.radius.large, 1.0, t.colors.border);
227
427
228
- let pad = t.spacing.lg;
229
428
ctx.label_left(
230
- rect.x() + pad,
231
- rect.y() + pad + 6.0,
429
+ r.header.x(),
430
+ r.header.center().y,
232
431
"Aplikacje",
233
432
t.typography.heading.size,
234
433
weight::BOLD,
235
434
t.colors.text,
236
435
);
237
436
437
+ // Przełącznik widoku.
438
+ for (i, view) in LauncherView::ALL.iter().enumerate() {
439
+ paint_pill(ctx, r.views[i], view.label(), *view == launcher.view);
440
+ }
441
+
238
442
// Pole wyszukiwania (makieta).
239
- let search = Rect::from_xywh(
240
- rect.x() + pad,
241
- rect.y() + pad + 28.0,
242
- rect.width() - 2.0 * pad,
243
- 32.0,
244
- );
245
443
ctx.scene
246
- .fill_rounded_rect(search, t.radius.medium, t.colors.view_bg);
444
+ .fill_rounded_rect(r.search, t.radius.medium, t.colors.view_bg);
247
445
ctx.scene
248
- .stroke_rounded_rect(search, t.radius.medium, 1.0, t.colors.border);
446
+ .stroke_rounded_rect(r.search, t.radius.medium, 1.0, t.colors.border);
249
447
ctx.label_left(
250
- search.x() + t.spacing.md,
251
- search.center().y,
448
+ r.search.x() + t.spacing.md,
449
+ r.search.center().y,
252
450
"Szukaj…",
253
451
t.typography.body.size,
254
452
weight::NORMAL,
255
453
t.colors.dim_text,
256
454
);
257
455
258
- // Siatka ikon (4 kolumny).
259
- let cols = 4usize;
260
- let gap = t.spacing.md;
261
- let gx = rect.x() + pad;
262
- let gy = search.bottom() + t.spacing.lg;
263
- let cell_w = (rect.width() - 2.0 * pad - gap * (cols as f64 - 1.0)) / cols as f64;
264
- let cell_h = 76.0;
265
- for (i, app) in apps.iter().enumerate() {
266
- let c = (i % cols) as f64;
267
- let r = (i / cols) as f64;
268
- let tile = Rect::from_xywh(
269
- gx + c * (cell_w + gap),
270
- gy + r * (cell_h + gap),
271
- cell_w,
272
- cell_h,
273
- );
274
- paint_app_tile(app, ctx, tile);
456
+ // Liczba pozycji mieszczących się w obszarze zawartości (bez przewijania).
457
+ let max_grid = (((r.content.height() + 12.0) / (GRID_CELL_H + 12.0))
458
+ .floor()
459
+ .max(0.0) as usize)
460
+ * GRID_COLS;
461
+ let max_list = ((r.content.height() + 8.0) / (LIST_ROW_H + 8.0))
462
+ .floor()
463
+ .max(0.0) as usize;
464
+
465
+ match launcher.view {
466
+ LauncherView::Grid => {
467
+ for (i, app) in apps.iter().take(max_grid).enumerate() {
468
+ paint_grid_tile(app, ctx, grid_tile(r.content, i));
469
+ }
470
+ }
471
+ LauncherView::List => {
472
+ for (i, app) in apps.iter().take(max_list).enumerate() {
473
+ paint_list_row(app, ctx, list_row(r.content, i));
474
+ }
475
+ }
476
+ LauncherView::Carousel => {
477
+ for (i, app) in apps.iter().enumerate() {
478
+ let card = carousel_card(r.content, i, launcher.carousel);
479
+ // Rysujemy tylko karty mieszczące się całe w obszarze (brak obcinania tekstu).
480
+ if card.x() < r.content.x() || card.right() > r.content.right() {
481
+ continue;
482
+ }
483
+ paint_carousel_card(app, i == launcher.carousel, ctx, card);
484
+ }
485
+ }
275
486
}
276
487
}
277
488
278
-/// Kafelek aplikacji: ikona + nazwa.
279
-fn paint_app_tile(app: &App, ctx: &mut PaintCtx, rect: Rect) {
489
+/// Kafelek siatki: ikona + nazwa.
490
+fn paint_grid_tile(app: &App, ctx: &mut PaintCtx, rect: Rect) {
280
491
let t = ctx.theme;
281
492
ctx.scene
282
493
.fill_rounded_rect(rect, t.radius.medium, t.colors.view_bg);
283
494
ctx.scene
284
495
.stroke_rounded_rect(rect, t.radius.medium, 1.0, t.colors.border);
285
496
286
- // Kwadratowa ikona w akcencie (delikatnym), z symbolem.
287
497
let icon = Rect::from_xywh(rect.center().x - 18.0, rect.y() + 12.0, 36.0, 36.0);
288
498
ctx.scene
289
499
.fill_rounded_rect(icon, t.radius.small, t.colors.accent.scale_alpha(0.16));
@@ -305,11 +515,107 @@ fn paint_app_tile(app: &App, ctx: &mut PaintCtx, rect: Rect) {
305
515
);
306
516
}
307
517
518
+/// Wiersz listy: ikona + nazwa + kategoria.
519
+fn paint_list_row(app: &App, ctx: &mut PaintCtx, rect: Rect) {
520
+ let t = ctx.theme;
521
+ ctx.scene
522
+ .fill_rounded_rect(rect, t.radius.medium, t.colors.view_bg);
523
+ ctx.scene
524
+ .stroke_rounded_rect(rect, t.radius.medium, 1.0, t.colors.border);
525
+
526
+ let icon = Rect::from_xywh(rect.x() + t.spacing.md, rect.center().y - 16.0, 32.0, 32.0);
527
+ ctx.scene
528
+ .fill_rounded_rect(icon, t.radius.small, t.colors.accent.scale_alpha(0.16));
529
+ ctx.label_center(
530
+ icon.center().x,
531
+ icon.center().y,
532
+ &app.glyph,
533
+ 16.0,
534
+ weight::MEDIUM,
535
+ t.colors.accent,
536
+ );
537
+ ctx.label_left(
538
+ icon.right() + t.spacing.md,
539
+ rect.center().y,
540
+ &app.name,
541
+ t.typography.body.size,
542
+ weight::MEDIUM,
543
+ t.colors.text,
544
+ );
545
+ ctx.label_right(
546
+ rect.right() - t.spacing.md,
547
+ rect.center().y,
548
+ &app.category,
549
+ t.typography.caption.size,
550
+ weight::NORMAL,
551
+ t.colors.dim_text,
552
+ );
553
+}
554
+
555
+/// Karta karuzeli: duża ikona + nazwa; wybrana wyróżniona akcentem.
556
+fn paint_carousel_card(app: &App, selected: bool, ctx: &mut PaintCtx, rect: Rect) {
557
+ let t = ctx.theme;
558
+ let bg = if selected {
559
+ t.colors.accent.scale_alpha(0.12)
560
+ } else {
561
+ t.colors.view_bg
562
+ };
563
+ ctx.scene.fill_rounded_rect(rect, t.radius.large, bg);
564
+ let border = if selected {
565
+ t.colors.accent
566
+ } else {
567
+ t.colors.border
568
+ };
569
+ ctx.scene.stroke_rounded_rect(
570
+ rect,
571
+ t.radius.large,
572
+ if selected { 2.0 } else { 1.0 },
573
+ border,
574
+ );
575
+
576
+ let icon = Rect::from_xywh(rect.center().x - 34.0, rect.y() + 36.0, 68.0, 68.0);
577
+ ctx.scene
578
+ .fill_rounded_rect(icon, t.radius.medium, t.colors.accent.scale_alpha(0.18));
579
+ ctx.label_center(
580
+ icon.center().x,
581
+ icon.center().y,
582
+ &app.glyph,
583
+ 32.0,
584
+ weight::MEDIUM,
585
+ t.colors.accent,
586
+ );
587
+ ctx.label_center(
588
+ rect.center().x,
589
+ icon.bottom() + 24.0,
590
+ &app.name,
591
+ t.typography.body.size,
592
+ if selected {
593
+ weight::BOLD
594
+ } else {
595
+ weight::MEDIUM
596
+ },
597
+ t.colors.text,
598
+ );
599
+ ctx.label_center(
600
+ rect.center().x,
601
+ icon.bottom() + 44.0,
602
+ &app.category,
603
+ t.typography.caption.size,
604
+ weight::NORMAL,
605
+ t.colors.dim_text,
606
+ );
607
+}
608
+
308
609
/// Pełny podgląd pulpitu: tapeta, panel górny i launcher.
309
-pub fn paint_desktop(panel: &PanelState, apps: &[App], ctx: &mut PaintCtx, size: Size) {
610
+pub fn paint_desktop(
611
+ panel: &PanelState,
612
+ apps: &[App],
613
+ launcher: &Launcher,
614
+ ctx: &mut PaintCtx,
615
+ size: Size,
616
+) {
310
617
let accent = ctx.theme.colors.accent;
311
618
312
- // Tapeta: tło + delikatny motyw z akcentu.
313
619
ctx.scene
314
620
.fill_rect(Rect::from_size(size), ctx.theme.colors.window_bg);
315
621
let blob = Point::new(size.width * 0.78, size.height * 0.74);
@@ -321,15 +627,15 @@ pub fn paint_desktop(panel: &PanelState, apps: &[App], ctx: &mut PaintCtx, size:
321
627
322
628
paint_panel(panel, ctx, Rect::from_xywh(0.0, 0.0, size.width, THICKNESS));
323
629
324
- let lw = 620.0;
325
- let lh = 360.0;
630
+ let lw = LAUNCHER_W;
631
+ let lh = LAUNCHER_H;
326
632
let lrect = Rect::from_xywh(
327
633
(size.width - lw) / 2.0,
328
634
(size.height - lh) / 2.0 + 16.0,
329
635
lw,
330
636
lh,
331
637
);
332
- paint_launcher(apps, ctx, lrect);
638
+ paint_launcher(apps, launcher, ctx, lrect);
333
639
}
334
640
335
641
/// Wyciszony kolor akcentu (pomocniczo dla tapet/podświetleń).
@@ -342,42 +648,77 @@ mod tests {
342
648
use super::*;
343
649
use libluna_render::{Scene, TextEngine};
344
650
345
- fn panel_commands(apps: usize) -> usize {
651
+ fn area() -> Rect {
652
+ Rect::from_xywh(100.0, 60.0, LAUNCHER_W, LAUNCHER_H)
653
+ }
654
+
655
+ fn panel_commands(view: LauncherView) -> usize {
346
656
let theme = Theme::light();
347
657
let text = TextEngine::none();
348
658
let mut scene = Scene::new();
349
659
let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
350
- let panel = PanelState::sample();
351
660
paint_panel(
352
- &panel,
661
+ &PanelState::sample(),
353
662
&mut ctx,
354
663
Rect::from_xywh(0.0, 0.0, 1280.0, THICKNESS),
355
664
);
356
- if apps > 0 {
357
- paint_launcher(
358
- &App::sample()[..apps],
359
- &mut ctx,
360
- Rect::from_xywh(0.0, 0.0, 620.0, 360.0),
361
- );
362
- }
665
+ let launcher = Launcher { view, carousel: 2 };
666
+ paint_launcher(&App::sample(), &launcher, &mut ctx, area());
363
667
scene.len()
364
668
}
365
669
366
670
#[test]
367
- fn sample_state_is_consistent() {
368
- let s = PanelState::sample();
369
- assert!(s.workspaces > 0);
370
- assert!(s.current_ws < s.workspaces);
371
- assert!(!s.toggles.is_empty());
671
+ fn panel_has_no_activities_label_but_burger_button() {
672
+ // Panel ma burger po lewej; kropki przestrzeni i tytuł po prawej od niego.
673
+ let theme = Theme::light();
674
+ let panel = Rect::from_xywh(0.0, 0.0, 1280.0, THICKNESS);
675
+ let burger = burger_rect(panel, &theme);
676
+ assert!(panel.contains(burger.center()));
677
+ assert_eq!(burger.width(), 28.0);
678
+ }
679
+
680
+ #[test]
681
+ fn view_switcher_cycles_and_hit_tests() {
682
+ let theme = Theme::light();
683
+ let text = TextEngine::none();
684
+ let r = launcher_rects(area(), &theme, &text);
685
+ for (i, view) in LauncherView::ALL.iter().enumerate() {
686
+ assert_eq!(
687
+ launcher_view_at(area(), &theme, &text, r.views[i].center()),
688
+ Some(*view)
689
+ );
690
+ }
691
+ assert_eq!(LauncherView::List.next(), LauncherView::Grid);
692
+ assert_eq!(LauncherView::Carousel.next(), LauncherView::List);
372
693
}
373
694
374
695
#[test]
375
- fn panel_emits_draw_commands() {
376
- assert!(panel_commands(0) > 10);
696
+ fn all_three_views_paint() {
697
+ assert!(panel_commands(LauncherView::List) > 20);
698
+ assert!(panel_commands(LauncherView::Grid) > 20);
699
+ assert!(panel_commands(LauncherView::Carousel) > 20);
377
700
}
378
701
379
702
#[test]
380
- fn launcher_draws_more_with_more_apps() {
381
- assert!(panel_commands(8) > panel_commands(4));
703
+ fn app_hit_test_finds_tiles_in_each_view() {
704
+ let theme = Theme::light();
705
+ let text = TextEngine::none();
706
+ let apps = App::sample();
707
+ for view in LauncherView::ALL {
708
+ let launcher = Launcher { view, carousel: 2 };
709
+ let r = launcher_rects(area(), &theme, &text);
710
+ // Punkt wnętrza znanej pozycji (pierwszy kafel/wiersz, albo środek wybranej karty).
711
+ let (p, expected) = match view {
712
+ LauncherView::Grid | LauncherView::List => {
713
+ (Point::new(r.content.x() + 5.0, r.content.y() + 5.0), 0)
714
+ }
715
+ LauncherView::Carousel => (r.content.center(), 2),
716
+ };
717
+ assert_eq!(
718
+ launcher_app_at(&apps, &launcher, area(), &theme, &text, p),
719
+ Some(expected),
720
+ "widok {view:?}"
721
+ );
722
+ }
382
723
}
383
724
}
@@ -140,57 +140,6 @@ impl BufferData {
140
140
}
141
141
}
142
142
143
-#[cfg(test)]
144
-mod tests {
145
- use super::*;
146
- use std::io::Write;
147
-
148
- fn pool(bytes: &[u8]) -> Arc<ShmPoolData> {
149
- let fd =
150
- rustix::fs::memfd_create("selene-shm-test", rustix::fs::MemfdFlags::CLOEXEC).unwrap();
151
- let mut file = std::fs::File::from(fd);
152
- file.write_all(bytes).unwrap();
153
- Arc::new(ShmPoolData::new(file.into(), bytes.len()))
154
- }
155
-
156
- fn buffer(pool: Arc<ShmPoolData>, w: i32, h: i32, stride: i32) -> BufferData {
157
- BufferData {
158
- pool,
159
- offset: 0,
160
- width: w,
161
- height: h,
162
- stride,
163
- format: wl_shm::Format::Argb8888,
164
- }
165
- }
166
-
167
- #[test]
168
- fn to_rgba_swizzles_bgra_to_rgba() {
169
- // 1x1: pamięć B,G,R,A = (10,20,30,40) → RGBA (30,20,10,40).
170
- let rgba = buffer(pool(&[10, 20, 30, 40]), 1, 1, 4).to_rgba().unwrap();
171
- assert_eq!(rgba, vec![30, 20, 10, 40]);
172
- }
173
-
174
- #[test]
175
- fn xrgb_forces_opaque_alpha() {
176
- let mut b = buffer(pool(&[1, 2, 3, 0]), 1, 1, 4);
177
- b.format = wl_shm::Format::Xrgb8888;
178
- assert_eq!(b.to_rgba().unwrap(), vec![3, 2, 1, 255]);
179
- }
180
-
181
- #[test]
182
- fn rejects_stride_smaller_than_row() {
183
- // stride 2 < width*4 → odrzucone, bez paniki/OOB.
184
- assert!(buffer(pool(&[0u8; 64]), 4, 1, 2).to_rgba().is_none());
185
- }
186
-
187
- #[test]
188
- fn rejects_buffer_outside_pool() {
189
- // 2x2 ARGB = 16 bajtów > pula 8 → odrzucone.
190
- assert!(buffer(pool(&[0u8; 8]), 2, 2, 8).to_rgba().is_none());
191
- }
192
-}
193
-
194
143
impl GlobalDispatch<wl_shm::WlShm, ()> for State {
195
144
fn bind(
196
145
_state: &mut Self,
@@ -270,3 +219,54 @@ impl Dispatch<wl_buffer::WlBuffer, BufferData> for State {
270
219
) {
271
220
}
272
221
}
222
+
223
+#[cfg(test)]
224
+mod tests {
225
+ use super::*;
226
+ use std::io::Write;
227
+
228
+ fn pool(bytes: &[u8]) -> Arc<ShmPoolData> {
229
+ let fd =
230
+ rustix::fs::memfd_create("selene-shm-test", rustix::fs::MemfdFlags::CLOEXEC).unwrap();
231
+ let mut file = std::fs::File::from(fd);
232
+ file.write_all(bytes).unwrap();
233
+ Arc::new(ShmPoolData::new(file.into(), bytes.len()))
234
+ }
235
+
236
+ fn buffer(pool: Arc<ShmPoolData>, w: i32, h: i32, stride: i32) -> BufferData {
237
+ BufferData {
238
+ pool,
239
+ offset: 0,
240
+ width: w,
241
+ height: h,
242
+ stride,
243
+ format: wl_shm::Format::Argb8888,
244
+ }
245
+ }
246
+
247
+ #[test]
248
+ fn to_rgba_swizzles_bgra_to_rgba() {
249
+ // 1x1: pamięć B,G,R,A = (10,20,30,40) → RGBA (30,20,10,40).
250
+ let rgba = buffer(pool(&[10, 20, 30, 40]), 1, 1, 4).to_rgba().unwrap();
251
+ assert_eq!(rgba, vec![30, 20, 10, 40]);
252
+ }
253
+
254
+ #[test]
255
+ fn xrgb_forces_opaque_alpha() {
256
+ let mut b = buffer(pool(&[1, 2, 3, 0]), 1, 1, 4);
257
+ b.format = wl_shm::Format::Xrgb8888;
258
+ assert_eq!(b.to_rgba().unwrap(), vec![3, 2, 1, 255]);
259
+ }
260
+
261
+ #[test]
262
+ fn rejects_stride_smaller_than_row() {
263
+ // stride 2 < width*4 → odrzucone, bez paniki/OOB.
264
+ assert!(buffer(pool(&[0u8; 64]), 4, 1, 2).to_rgba().is_none());
265
+ }
266
+
267
+ #[test]
268
+ fn rejects_buffer_outside_pool() {
269
+ // 2x2 ARGB = 16 bajtów > pula 8 → odrzucone.
270
+ assert!(buffer(pool(&[0u8; 8]), 2, 2, 8).to_rgba().is_none());
271
+ }
272
+}
273
273
new file mode 100644
274
274
Binary files /dev/null and b/docs/images/luna-launcher-carousel.png differ
275
275
new file mode 100644
276
276
Binary files /dev/null and b/docs/images/luna-launcher-grid.png differ
277
277
new file mode 100644
278
278
Binary files /dev/null and b/docs/images/luna-launcher-list.png differ
279
279
Binary files a/docs/images/luna-shell-dark.png and b/docs/images/luna-shell-dark.png differ
280
280
Binary files a/docs/images/luna-shell-light.png and b/docs/images/luna-shell-light.png differ
@@ -167,16 +167,16 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
167
167
- Dalej: GLES/akceleracja, hotplug monitorów (`zwlr_output_manager`), `libinput` (gesty),
168
168
selekcja pierwotna, IME (`text-input`).
169
169
- **Luna** (`de/luna`) — powłoka środowiska na `libluna-widgets`. ✅ start: **panel górny**
170
- (menu, tytuł okna, zegar/data, przełączniki, przestrzenie, użytkownik) i **launcher**
171
- (siatka aplikacji + wyszukiwanie); render headless do `docs/images/luna-shell-{light,dark}.png`.
172
- Dane (`PanelState`, `App`) oddzielone od rysowania (`PaintCtx`) — ten sam kod do podglądów
173
- i sesji na żywo. ✅ **`luna-shell`** (bin) uruchamia panel jako prawdziwą **powierzchnię
170
+ (sam **burger** — bez etykiety, który otwiera launcher; tytuł okna, zegar/data,
171
+ przełączniki, przestrzenie, użytkownik) i **launcher** w trzech widokach
172
+ (**Lista / Siatka / Karuzela** z segmentowym przełącznikiem); render headless do
173
+ `docs/images/luna-shell-{light,dark}.png` i `docs/images/luna-launcher-{list,grid,carousel}.png`.
174
+ Dane (`PanelState`, `App`, `Launcher`) oddzielone od rysowania (`PaintCtx`) — ten sam kod do
175
+ podglądów i sesji na żywo. ✅ **`luna-shell`** (bin) uruchamia panel jako prawdziwą **powierzchnię
174
176
warstwową** (`wlr-layer-shell` po stronie klienta w `libluna-platform`); bez kompozytora
175
- kończy się czytelnym `NoCompositor`. ✅ **OSD** (`osd.rs`: głośność/jasność, animacja
176
- wejścia/wyjścia) i **powiadomienia** (`notifications.rs`: kolejka, wjazd z prawej, pasek
177
- czasu, auto-zamknięcie) — pokazywane pod paskiem na powierzchni „chrome”; podglądy
178
- w `docs/images/luna-shell-{light,dark}.png`. Dalej: usługa D-Bus
179
- `org.freedesktop.Notifications`, menu kontekstowe, centrum powiadomień.
177
+ kończy się czytelnym `NoCompositor`. ✅ **OSD** (`osd.rs`) i **powiadomienia** (`notifications.rs`)
178
+ — pokazywane pod paskiem na powierzchni „chrome”. Dalej: usługa D-Bus
179
+ `org.freedesktop.Notifications`, centrum powiadomień, menu kontekstowe, scroll w liście.
180
180
181
181
## Później (poza MVP)
182
182
- Dostępność AT-SPI, IME, schowek, drag & drop, animacje, backend GPU, mini-CSS dla motywów.