← luna

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.