← luna

Commit ab03027

10
files
+508
added
-20
deleted
@@ -12,7 +12,7 @@ use std::error::Error;
12 12 use std::time::{Duration, Instant};
13 13
14 14 use libluna_core::{
15 - Accent, ButtonState, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent,
15 + Accent, ButtonState, Color, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent,
16 16 ScrollEvent, Size, Theme, ThemePreference, ThemeSettings, WindowEvent,
17 17 };
18 18 use libluna_render::{Scene, SoftwareRenderer, TextEngine};
@@ -68,6 +68,8 @@ pub struct AppConfig {
68 68 /// Gdy ustawione — zamiast okna `xdg-shell` tworzymy powierzchnię `wlr-layer-shell`
69 69 /// (panel/dock/tło). To tryb używany przez powłokę Luna.
70 70 pub layer: Option<LayerConfig>,
71 + /// Przezroczyste tło powierzchni (dla paneli/OSD warstwowych).
72 + pub transparent: bool,
71 73 }
72 74
73 75 /// Konfiguracja powierzchni warstwowej (`wlr-layer-shell`).
@@ -115,6 +117,20 @@ impl LayerConfig {
115 117 keyboard: false,
116 118 }
117 119 }
120 +
121 + /// Powierzchnia „chrome”: pasek o grubości `bar` plus przezroczysty obszar pod nim
122 + /// (na OSD i powiadomienia). Strefa wyłączna obejmuje tylko pasek.
123 + pub fn top_chrome(namespace: impl Into<String>, bar: u32, total: u32) -> Self {
124 + Self {
125 + namespace: namespace.into(),
126 + layer: Layer::Top,
127 + anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
128 + size: (0, total),
129 + margin: (0, 0, 0, 0),
130 + exclusive_zone: bar as i32,
131 + keyboard: true,
132 + }
133 + }
118 134 }
119 135
120 136 impl AppConfig {
@@ -126,6 +142,7 @@ impl AppConfig {
126 142 settings: ThemeSettings::default(),
127 143 system_dark: false,
128 144 layer: None,
145 + transparent: false,
129 146 }
130 147 }
131 148
@@ -135,6 +152,12 @@ impl AppConfig {
135 152 self
136 153 }
137 154
155 + /// Przezroczyste tło (panel/OSD warstwowy).
156 + pub fn transparent(mut self, transparent: bool) -> Self {
157 + self.transparent = transparent;
158 + self
159 + }
160 +
138 161 pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
139 162 self.app_id = app_id.into();
140 163 self
@@ -344,6 +367,7 @@ where
344 367 height: ih,
345 368 scale: 1.0,
346 369 system_dark: config.system_dark,
370 + transparent: config.transparent,
347 371 buffer: None,
348 372 target,
349 373 configured: config.layer.is_none(),
@@ -390,6 +414,8 @@ struct App {
390 414 height: u32,
391 415 scale: f64,
392 416 system_dark: bool,
417 + /// Czy tło powierzchni ma być przezroczyste (panele/OSD).
418 + transparent: bool,
393 419 buffer: Option<Buffer>,
394 420 target: Target,
395 421 /// Powierzchnia warstwowa ma sens dopiero po pierwszej konfiguracji.
@@ -528,8 +554,13 @@ impl App {
528 554 render(&mut frame);
529 555 }
530 556
531 - self.renderer
532 - .draw(&self.scene, scale, theme.colors.window_bg);
557 + // Panele/OSD warstwowe mają przezroczyste tło (widać pulpit pod spodem).
558 + let bg = if self.transparent {
559 + Color::rgba(0.0, 0.0, 0.0, 0.0)
560 + } else {
561 + theme.colors.window_bg
562 + };
563 + self.renderer.draw(&self.scene, scale, bg);
533 564 }
534 565
535 566 fn present(&mut self) {
@@ -70,12 +70,14 @@ Selene.
70 70 - `de/luna/src/panel.rs` — **panel górny** (menu, tytuł okna, zegar/data, przełączniki,
71 71 przestrzenie robocze, użytkownik) i **launcher** (siatka aplikacji + wyszukiwanie).
72 72 - `luna-panel` (bin) — render headless: `docs/images/luna-shell-{light,dark}.png`.
73 -- `luna-shell` (bin) — panel na żywo jako **powierzchnia warstwowa** (`wlr-layer-shell`).
73 +- `luna-shell` (bin) — powierzchnia „chrome” na żywo: pasek + **OSD** + **powiadomienia**
74 + (`osd.rs`, `notifications.rs`; animacja, auto-zamknięcie).
74 75 - `libluna-platform` obsługuje teraz oba tryby: okno `xdg-shell` (CSD) i powierzchnię
75 - warstwową (`AppConfig::layer(LayerConfig::…)`) — `LayerConfig::top_panel`/`overlay`.
76 + warstwową (`AppConfig::layer(LayerConfig::…)`) — `LayerConfig::top_panel`/`overlay`/`top_chrome`
77 + oraz przezroczyste tło (`AppConfig::transparent`).
76 78 - Dane (`PanelState`, `App`) są oddzielone od rysowania (`PaintCtx`), więc ten sam kod
77 79 obsługuje podglądy i sesję na żywo.
78 -- Dalej: OSD, powiadomienia, menu kontekstowe.
80 +- Dalej: usługa D-Bus `org.freedesktop.Notifications`, centrum powiadomień, menu kontekstowe.
79 81
80 82 ## Jeszcze nie / Not yet
81 83
@@ -1,13 +1,15 @@
1 -//! Podgląd powłoki Luny (headless): panel + launcher, w motywie jasnym i ciemnym.
1 +//! Podgląd powłoki Luny (headless): panel, OSD i powiadomienia — jasny i ciemny motyw.
2 2 //!
3 3 //! Uruchom: `cargo run -p luna --bin luna-panel`
4 4 //! Efekt: `docs/images/luna-shell-light.png`, `docs/images/luna-shell-dark.png`
5 5
6 6 use std::error::Error;
7 7
8 -use libluna_core::{Size, Theme};
8 +use libluna_core::{Rect, Size, Theme};
9 9 use libluna_render::{Scene, SoftwareRenderer, TextEngine};
10 10 use libluna_widgets::PaintCtx;
11 +use luna::notifications::{Notification, Notifications};
12 +use luna::osd::Osd;
11 13 use luna::panel::{self, App, PanelState};
12 14
13 15 const W: u32 = 1280;
@@ -16,13 +18,33 @@ const H: u32 = 720;
16 18 fn main() -> Result<(), Box<dyn Error>> {
17 19 let text = TextEngine::system();
18 20 let size = Size::new(W as f64, H as f64);
19 - let panel = PanelState::sample();
21 + let pstate = PanelState::sample();
20 22 let apps = App::sample();
21 23
22 24 for (name, theme) in [("light", Theme::light()), ("dark", Theme::dark())] {
23 25 let mut scene = Scene::new();
24 26 let mut paint = PaintCtx::new(&mut scene, &theme, &text);
25 - panel::paint_desktop(&panel, &apps, &mut paint, size);
27 +
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 + let mut osd = Osd::new("◆", "Głośność", 0.65);
33 + osd.advance(0.3);
34 + osd.paint(
35 + &mut paint,
36 + Rect::from_xywh((W as f64 - 340.0) / 2.0, 64.0, 340.0, 72.0),
37 + );
38 +
39 + // Powiadomienia w prawym-górnym narożniku.
40 + let mut notes = Notifications::new();
41 + notes.push(
42 + Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
43 + .with_glyph("▦"),
44 + );
45 + notes.push(Notification::new("System", "Bateria", "Pozostało 18%").with_glyph("■"));
46 + notes.advance(0.3);
47 + notes.paint(&mut paint, Rect::from_xywh(0.0, 56.0, W as f64, 240.0));
26 48
27 49 let mut renderer = SoftwareRenderer::new(W, H);
28 50 renderer.draw(&scene, 1.0, theme.colors.window_bg);
@@ -1,7 +1,9 @@
1 -//! Panel Luny na żywo — powierzchnia warstwowa (`wlr-layer-shell`) na Selene.
1 +//! Powłoka Luny na żywo — pasek, OSD i powiadomienia jako powierzchnia warstwowa.
2 2 //!
3 -//! Uruchom w sesji Luny: `luna-shell` (albo `cargo run -p luna --bin luna-shell`).
4 -//! Wymaga kompozytora obsługującego `zwlr_layer_shell_v1` (Selene tak).
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.
5 7
6 8 use std::error::Error;
7 9 use std::time::{SystemTime, UNIX_EPOCH};
@@ -9,35 +11,79 @@ use std::time::{SystemTime, UNIX_EPOCH};
9 11 use libluna_core::{Rect, Theme};
10 12 use libluna_platform::{run, AppConfig, Frame, LayerConfig};
11 13 use libluna_widgets::PaintCtx;
14 +use luna::notifications::{Notification, Notifications};
15 +use luna::osd::Osd;
12 16 use luna::panel::{self, PanelState, THICKNESS};
13 17
18 +/// Wysokość powierzchni „chrome" (pasek + obszar nakładek).
19 +const CHROME_H: u32 = 240;
20 +
14 21 fn main() -> Result<(), Box<dyn Error>> {
15 - let cfg = AppConfig::new("Luna Panel")
16 - .app_id("eu.paganlinux.Luna.Panel")
17 - .layer(LayerConfig::top_panel("luna-panel", THICKNESS as u32, true));
22 + let cfg = AppConfig::new("Luna Shell")
23 + .app_id("eu.paganlinux.Luna.Shell")
24 + .layer(LayerConfig::top_chrome(
25 + "luna-shell",
26 + THICKNESS as u32,
27 + CHROME_H,
28 + ))
29 + .transparent(true);
18 30
19 31 let mut state = PanelState::sample();
32 + let mut osd = Osd::new("◆", "Głośność", 0.0);
33 + let mut notes = Notifications::new();
34 + let mut timer = 0.0f64;
20 35
21 36 run(cfg, move |frame: &mut Frame| {
22 - // Żywy zegar (UTC) — panel odświeża się w stałym rytmie.
37 + let dt = frame.delta_time();
38 + timer += dt;
39 +
40 + // Zegar (UTC).
23 41 let (clock, date) = now_clock();
24 42 state.clock = clock;
25 43 state.date = date;
26 44
45 + // Demo: co ~4 s OSD, co ~7 s powiadomienie.
46 + if timer % 7.0 < dt {
47 + let value = 0.15 + (timer as i64 % 8) as f64 / 10.0;
48 + osd.show("Głośność", value);
49 + }
50 + if timer % 4.0 < dt {
51 + notes.push(
52 + Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
53 + .with_glyph("▦"),
54 + );
55 + }
56 + osd.advance(dt);
57 + notes.advance(dt);
58 +
27 59 let size = frame.size;
28 60 let theme: &Theme = &frame.theme;
29 61 let mut ctx = PaintCtx::new(frame.scene, theme, frame.text);
62 + // Pasek na górze.
30 63 panel::paint_panel(
31 64 &state,
32 65 &mut ctx,
33 66 Rect::from_xywh(0.0, 0.0, size.width, THICKNESS),
34 67 );
68 + // OSD wyśrodkowany pod paskiem.
69 + if osd.visible() {
70 + let w = 340.0;
71 + osd.paint(
72 + &mut ctx,
73 + Rect::from_xywh((size.width - w) / 2.0, THICKNESS + 16.0, w, 72.0),
74 + );
75 + }
76 + // Powiadomienia w prawym-górnym narożniku (pod paskiem).
77 + notes.paint(
78 + &mut ctx,
79 + Rect::from_xywh(0.0, THICKNESS + 8.0, size.width, size.height - THICKNESS),
80 + );
35 81
36 82 frame.request_frame();
37 83 })
38 84 }
39 85
40 -/// Bieżąca godzina i data w UTC (bez zależności na TZ) — `("HH:MM", "DD.MM")`.
86 +/// Bieżąca godzina i data w UTC — `("HH:MM", "DD.MM")`.
41 87 fn now_clock() -> (String, String) {
42 88 let secs = SystemTime::now()
43 89 .duration_since(UNIX_EPOCH)
@@ -50,7 +96,7 @@ fn now_clock() -> (String, String) {
50 96 (format!("{h:02}:{m:02}"), format!("{d:02}.{mo:02}"))
51 97 }
52 98
53 -/// Data cywilna (rok, miesiąc, dzień) z liczby dni od 1970-01-01 (algorytm Hinnanta).
99 +/// Data cywilna z liczby dni od 1970-01-01 (algorytm Hinnanta).
54 100 fn civil_from_days(z: i64) -> (i64, u32, u32) {
55 101 let z = z + 719_468;
56 102 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
@@ -6,4 +6,6 @@
6 6 //! Moduły rysują się przez `libluna-widgets::PaintCtx`, więc ten sam kod obsługuje
7 7 //! podglądy headless (zrzuty do dokumentacji) i sesję na żywo.
8 8
9 +pub mod notifications;
10 +pub mod osd;
9 11 pub mod panel;
10 12 new file mode 100644
@@ -0,0 +1,224 @@
1 +//! Powiadomienia (toasty) w powłoce Luny — kolejka, animacja i rysowanie.
2 +//!
3 +//! To warstwa prezentacji: przyjmuje gotowe powiadomienie, animuje wejście, po
4 +//! upływie czasu samoistnie je zamyka. Podłączenie usługi D-Bus
5 +//! `org.freedesktop.Notifications` to kolejny krok (patrz roadmapa).
6 +
7 +use libluna_core::style::weight;
8 +use libluna_core::Rect;
9 +use libluna_widgets::PaintCtx;
10 +
11 +/// Czas animacji wejścia/wyjścia (s).
12 +const SLIDE: f64 = 0.22;
13 +/// Szerokość pojedynczego powiadomienia.
14 +pub const WIDTH: f64 = 340.0;
15 +/// Wysokość pojedynczego powiadomienia.
16 +pub const HEIGHT: f64 = 76.0;
17 +/// Odstęp pionowy między powiadomieniami.
18 +const GAP: f64 = 10.0;
19 +
20 +/// Pojedyncze powiadomienie.
21 +#[derive(Debug, Clone)]
22 +pub struct Notification {
23 + /// Nazwa aplikacji (np. „Pliki”).
24 + pub app: String,
25 + pub title: String,
26 + pub body: String,
27 + /// Symbol rysowany fontem interfejsu.
28 + pub glyph: String,
29 + /// Czas życia (s); 0 = nie zamykaj automatycznie.
30 + pub timeout: f64,
31 +}
32 +
33 +impl Notification {
34 + pub fn new(app: impl Into<String>, title: impl Into<String>, body: impl Into<String>) -> Self {
35 + Self {
36 + app: app.into(),
37 + title: title.into(),
38 + body: body.into(),
39 + glyph: "●".to_owned(),
40 + timeout: 5.0,
41 + }
42 + }
43 +
44 + pub fn with_glyph(mut self, glyph: impl Into<String>) -> Self {
45 + self.glyph = glyph.into();
46 + self
47 + }
48 +}
49 +
50 +/// Kolejka powiadomień z animacją.
51 +#[derive(Default)]
52 +pub struct Notifications {
53 + items: Vec<(Notification, f64)>,
54 +}
55 +
56 +impl Notifications {
57 + pub fn new() -> Self {
58 + Self::default()
59 + }
60 +
61 + /// Dodaje powiadomienie na wierzch stosu.
62 + pub fn push(&mut self, n: Notification) {
63 + self.items.push((n, 0.0));
64 + }
65 +
66 + /// Przesuwa czas i usuwa wygasłe powiadomienia.
67 + pub fn advance(&mut self, dt: f64) {
68 + for (_, age) in &mut self.items {
69 + *age += dt;
70 + }
71 + self.items
72 + .retain(|(n, age)| n.timeout <= 0.0 || *age < n.timeout);
73 + }
74 +
75 + pub fn len(&self) -> usize {
76 + self.items.len()
77 + }
78 +
79 + pub fn is_empty(&self) -> bool {
80 + self.items.is_empty()
81 + }
82 +
83 + /// Prostokąt `i`-tego powiadomienia w kolumnie zakotwiczonej w prawym-górnym
84 + /// narożniku obszaru `area`.
85 + pub fn rect_in(area: Rect, i: usize) -> Rect {
86 + Rect::from_xywh(
87 + area.right() - WIDTH,
88 + area.y() + i as f64 * (HEIGHT + GAP),
89 + WIDTH,
90 + HEIGHT,
91 + )
92 + }
93 +
94 + /// Rysuje stos powiadomień w obszarze `area` (od góry, do prawej).
95 + pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
96 + let t = ctx.theme;
97 + for (i, (n, age)) in self.items.iter().enumerate().rev() {
98 + let mut rect = Self::rect_in(area, i);
99 + // Wjazd z prawej + fade.
100 + let p = (*age / SLIDE).clamp(0.0, 1.0);
101 + let slide = (1.0 - p) * 28.0;
102 + rect = Rect::from_xywh(rect.x() + slide, rect.y(), rect.width(), rect.height());
103 + let a = p as f32;
104 +
105 + ctx.scene
106 + .fill_rounded_rect(rect, t.radius.medium, t.colors.card_bg.scale_alpha(a));
107 + ctx.scene.stroke_rounded_rect(
108 + rect,
109 + t.radius.medium,
110 + 1.0,
111 + t.colors.border.scale_alpha(a),
112 + );
113 +
114 + let pad = t.spacing.md;
115 + let icon = Rect::from_xywh(rect.x() + pad, rect.y() + pad, 32.0, 32.0);
116 + ctx.scene.fill_rounded_rect(
117 + icon,
118 + t.radius.small,
119 + t.colors.accent.scale_alpha(0.18 * a),
120 + );
121 + ctx.label_center(
122 + icon.center().x,
123 + icon.center().y,
124 + &n.glyph,
125 + 16.0,
126 + weight::MEDIUM,
127 + t.colors.accent.scale_alpha(a),
128 + );
129 +
130 + let tx = icon.right() + pad;
131 + ctx.label_left(
132 + tx,
133 + rect.y() + 26.0,
134 + &n.title,
135 + t.typography.body.size,
136 + weight::MEDIUM,
137 + t.colors.text.scale_alpha(a),
138 + );
139 + ctx.label_left(
140 + tx,
141 + rect.y() + 48.0,
142 + &n.body,
143 + t.typography.caption.size,
144 + weight::NORMAL,
145 + t.colors.dim_text.scale_alpha(a),
146 + );
147 + ctx.label_right(
148 + rect.right() - pad,
149 + rect.y() + 26.0,
150 + &n.app,
151 + t.typography.caption.size,
152 + weight::NORMAL,
153 + t.colors.dim_text.scale_alpha(a),
154 + );
155 +
156 + // Pasek czasu (kurczy się).
157 + if n.timeout > 0.0 {
158 + let frac = (1.0 - (*age / n.timeout)).clamp(0.0, 1.0);
159 + let track = Rect::from_xywh(
160 + rect.x() + pad,
161 + rect.bottom() - 8.0,
162 + rect.width() - 2.0 * pad,
163 + 3.0,
164 + );
165 + ctx.scene
166 + .fill_rounded_rect(track, 1.5, t.colors.shade.scale_alpha(a));
167 + let fill =
168 + Rect::from_xywh(track.x(), track.y(), track.width() * frac, track.height());
169 + ctx.scene
170 + .fill_rounded_rect(fill, 1.5, t.colors.accent.scale_alpha(a));
171 + }
172 + }
173 + }
174 +}
175 +
176 +#[cfg(test)]
177 +mod tests {
178 + use super::*;
179 + use libluna_core::Theme;
180 + use libluna_render::{Scene, TextEngine};
181 +
182 + #[test]
183 + fn expires_after_timeout() {
184 + let mut n = Notifications::new();
185 + n.push(Notification::new("Pliki", "Kopiowanie", "12 plików"));
186 + assert_eq!(n.len(), 1);
187 + n.advance(3.0);
188 + assert_eq!(n.len(), 1);
189 + n.advance(3.0);
190 + assert!(n.is_empty());
191 + }
192 +
193 + #[test]
194 + fn zero_timeout_is_sticky() {
195 + let mut n = Notifications::new();
196 + let mut item = Notification::new("System", "Aktualizacja", "W toku");
197 + item.timeout = 0.0;
198 + n.push(item);
199 + n.advance(100.0);
200 + assert_eq!(n.len(), 1);
201 + }
202 +
203 + #[test]
204 + fn rects_stack_downward_from_top_right() {
205 + let area = Rect::from_xywh(0.0, 40.0, 1280.0, 200.0);
206 + let a = Notifications::rect_in(area, 0);
207 + let b = Notifications::rect_in(area, 1);
208 + assert_eq!(a.right(), area.right());
209 + assert!(b.y() > a.y());
210 + }
211 +
212 + #[test]
213 + fn paints_without_window() {
214 + let theme = Theme::dark();
215 + let text = TextEngine::none();
216 + let mut scene = Scene::new();
217 + let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
218 + let mut n = Notifications::new();
219 + n.push(Notification::new("Pliki", "Kopiowanie", "12 plików"));
220 + n.advance(0.3);
221 + n.paint(&mut ctx, Rect::from_xywh(0.0, 40.0, 1280.0, 200.0));
222 + assert!(scene.len() > 5);
223 + }
224 +}
0 225 new file mode 100644
@@ -0,0 +1,157 @@
1 +//! OSD — ekranowe wskaźniki (głośność, jasność, …) pojawiające się na chwilę.
2 +//!
3 +//! Model jest czysty (bez okna), więc łatwo go testować i podglądać headless.
4 +
5 +use libluna_core::style::weight;
6 +use libluna_core::Rect;
7 +use libluna_widgets::PaintCtx;
8 +
9 +/// Czas wchodzenia (s).
10 +const FADE_IN: f64 = 0.18;
11 +/// Czas wychodzenia (s).
12 +const FADE_OUT: f64 = 0.45;
13 +
14 +/// Wskaźnik OSD.
15 +#[derive(Debug, Clone)]
16 +pub struct Osd {
17 + /// Symbol rysowany fontem interfejsu.
18 + pub glyph: String,
19 + /// Etykieta (np. „Głośność”).
20 + pub label: String,
21 + /// Wartość 0..1 (poziom).
22 + pub value: f64,
23 + age: f64,
24 + lifetime: f64,
25 +}
26 +
27 +impl Osd {
28 + /// Tworzy wskaźnik i włącza jego wyświetlanie.
29 + pub fn new(glyph: impl Into<String>, label: impl Into<String>, value: f64) -> Self {
30 + Self {
31 + glyph: glyph.into(),
32 + label: label.into(),
33 + value: value.clamp(0.0, 1.0),
34 + age: 0.0,
35 + lifetime: 2.0,
36 + }
37 + }
38 +
39 + /// Pokazuje wskaźnik ponownie z nową wartością (reset licznika).
40 + pub fn show(&mut self, label: impl Into<String>, value: f64) {
41 + self.label = label.into();
42 + self.value = value.clamp(0.0, 1.0);
43 + self.age = 0.0;
44 + }
45 +
46 + /// Przesuwa czas animacji.
47 + pub fn advance(&mut self, dt: f64) {
48 + self.age += dt;
49 + }
50 +
51 + /// Czy wskaźnik jest jeszcze widoczny.
52 + pub fn visible(&self) -> bool {
53 + self.age < self.lifetime
54 + }
55 +
56 + /// Przezroczystość (0..1) wynikająca z animacji wejścia/wyjścia.
57 + pub fn opacity(&self) -> f64 {
58 + let fade_in = (self.age / FADE_IN).clamp(0.0, 1.0);
59 + let fade_out = ((self.lifetime - self.age) / FADE_OUT).clamp(0.0, 1.0);
60 + fade_in.min(fade_out)
61 + }
62 +
63 + /// Rysuje wskaźnik w zadanym prostokącie.
64 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
65 + let a = self.opacity() as f32;
66 + if a <= 0.01 {
67 + return;
68 + }
69 + let t = ctx.theme;
70 + let cy = rect.center().y;
71 + let pad = t.spacing.lg;
72 +
73 + // Tło „pigułki” + obramowanie.
74 + ctx.scene
75 + .fill_rounded_rect(rect, t.radius.medium, t.colors.card_bg.scale_alpha(a));
76 + ctx.scene
77 + .stroke_rounded_rect(rect, t.radius.medium, 1.0, t.colors.border.scale_alpha(a));
78 +
79 + // Ikona w kwadracie akcentu.
80 + let icon = Rect::from_xywh(rect.x() + pad, cy - 20.0, 40.0, 40.0);
81 + ctx.scene
82 + .fill_rounded_rect(icon, t.radius.small, t.colors.accent.scale_alpha(0.18 * a));
83 + ctx.label_center(
84 + icon.center().x,
85 + icon.center().y,
86 + &self.glyph,
87 + 20.0,
88 + weight::MEDIUM,
89 + t.colors.accent.scale_alpha(a),
90 + );
91 +
92 + // Etykieta i wartość procentowa.
93 + let text_x = icon.right() + t.spacing.md;
94 + ctx.label_left(
95 + text_x,
96 + rect.y() + 24.0,
97 + &self.label,
98 + t.typography.body.size,
99 + weight::MEDIUM,
100 + t.colors.text.scale_alpha(a),
101 + );
102 + ctx.label_right(
103 + rect.right() - pad,
104 + rect.y() + 24.0,
105 + &format!("{}%", (self.value * 100.0).round() as i32),
106 + t.typography.body.size,
107 + weight::NORMAL,
108 + t.colors.dim_text.scale_alpha(a),
109 + );
110 +
111 + // Pasek poziomu.
112 + let bar = Rect::from_xywh(
113 + text_x,
114 + rect.bottom() - 22.0,
115 + (rect.right() - pad) - text_x,
116 + 6.0,
117 + );
118 + ctx.scene
119 + .fill_rounded_rect(bar, 3.0, t.colors.shade.scale_alpha(a));
120 + if self.value > 0.0 {
121 + let fill = Rect::from_xywh(bar.x(), bar.y(), bar.width() * self.value, bar.height());
122 + ctx.scene
123 + .fill_rounded_rect(fill, 3.0, t.colors.accent.scale_alpha(a));
124 + }
125 + }
126 +}
127 +
128 +#[cfg(test)]
129 +mod tests {
130 + use super::*;
131 + use libluna_core::Theme;
132 + use libluna_render::{Scene, TextEngine};
133 +
134 + #[test]
135 + fn fades_in_and_out() {
136 + let mut osd = Osd::new("◆", "Głośność", 0.5);
137 + assert!(osd.visible());
138 + assert!(osd.opacity() < 0.05); // dopiero start
139 + osd.advance(0.3);
140 + assert!(osd.opacity() > 0.9);
141 + osd.advance(2.0);
142 + assert!(!osd.visible());
143 + assert!(osd.opacity() < 0.05);
144 + }
145 +
146 + #[test]
147 + fn paints_without_window() {
148 + let theme = Theme::dark();
149 + let text = TextEngine::none();
150 + let mut scene = Scene::new();
151 + let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
152 + let mut osd = Osd::new("◆", "Głośność", 0.65);
153 + osd.advance(0.3);
154 + osd.paint(&mut ctx, Rect::from_xywh(0.0, 0.0, 320.0, 72.0));
155 + assert!(scene.len() > 5);
156 + }
157 +}
0 158 Binary files a/docs/images/luna-shell-dark.png and b/docs/images/luna-shell-dark.png differ
1 159 Binary files a/docs/images/luna-shell-light.png and b/docs/images/luna-shell-light.png differ
@@ -166,7 +166,11 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
166 166 Dane (`PanelState`, `App`) oddzielone od rysowania (`PaintCtx`) — ten sam kod do podglądów
167 167 i sesji na żywo. ✅ **`luna-shell`** (bin) uruchamia panel jako prawdziwą **powierzchnię
168 168 warstwową** (`wlr-layer-shell` po stronie klienta w `libluna-platform`); bez kompozytora
169 - kończy się czytelnym `NoCompositor`. Dalej: OSD, powiadomienia, menu kontekstowe.
169 + kończy się czytelnym `NoCompositor`. ✅ **OSD** (`osd.rs`: głośność/jasność, animacja
170 + wejścia/wyjścia) i **powiadomienia** (`notifications.rs`: kolejka, wjazd z prawej, pasek
171 + czasu, auto-zamknięcie) — pokazywane pod paskiem na powierzchni „chrome”; podglądy
172 + w `docs/images/luna-shell-{light,dark}.png`. Dalej: usługa D-Bus
173 + `org.freedesktop.Notifications`, menu kontekstowe, centrum powiadomień.
170 174
171 175 ## Później (poza MVP)
172 176 - Dostępność AT-SPI, IME, schowek, drag & drop, animacje, backend GPU, mini-CSS dla motywów.