← luna

Commit df38c99

4
files
+296
added
-23
deleted
@@ -12,9 +12,11 @@ use std::time::{SystemTime, UNIX_EPOCH};
12 12 use libluna_core::{ButtonState, Event, PointerButton, Rect};
13 13 use libluna_platform::{run, AppConfig, Frame, LayerConfig};
14 14 use libluna_widgets::{IconName, PaintCtx};
15 -use luna::notifications::{Notification, Notifications};
15 +use luna::notifications::Notifications;
16 16 use luna::osd::Osd;
17 -use luna::panel::{self, App, Launcher, PanelState, LAUNCHER_H, LAUNCHER_W, THICKNESS};
17 +use luna::panel::{
18 + self, App, Launcher, PanelState, Toggle, ToggleAction, LAUNCHER_H, LAUNCHER_W, THICKNESS,
19 +};
18 20
19 21 /// Wysokość powierzchni „chrome" (pasek + launcher + OSD/powiadomienia).
20 22 const CHROME_H: u32 = 680;
@@ -41,15 +43,15 @@ fn main() -> Result<(), Box<dyn Error>> {
41 43 let mut open = false;
42 44 let mut osd = Osd::new(IconName::Volume, "Głośność", 0.0);
43 45 let mut notes = Notifications::new();
44 - let mut timer = 0.0f64;
45 46 // Lista okien z kompozytora (foreign-toplevel) — własne połączenie + wątek.
46 47 let toplevels = luna::toplevels::Toplevels::spawn();
48 + // Realny stan systemu (użytkownik, dźwięk, Wi-Fi, bateria) — odpytywany w tle.
49 + let system = luna::system::spawn();
47 50 // Sygnatura stanu — przerysowujemy tylko, gdy coś się zmieniło.
48 51 let mut last_sig: (bool, String, u64) = (false, String::new(), u64::MAX);
49 52
50 53 run(cfg, move |frame: &mut Frame| {
51 54 let dt = frame.delta_time();
52 - timer += dt;
53 55
54 56 let size = frame.size;
55 57 let theme = &frame.theme;
@@ -66,6 +68,11 @@ fn main() -> Result<(), Box<dyn Error>> {
66 68 } else {
67 69 state.tasks.clear();
68 70 }
71 + // Realny stan systemu → użytkownik i przełączniki.
72 + if let Ok(sys) = system.lock() {
73 + state.user = sys.user.clone();
74 + state.toggles = build_toggles(&sys);
75 + }
69 76
70 77 let lw = LAUNCHER_W.min((size.width - 80.0).max(320.0));
71 78 let larea = Rect::from_xywh((size.width - lw) / 2.0, THICKNESS + 24.0, lw, LAUNCHER_H);
@@ -113,6 +120,17 @@ fn main() -> Result<(), Box<dyn Error>> {
113 120 if let Some(tl) = &toplevels {
114 121 tl.activate(i);
115 122 }
123 + } else if let Some(i) =
124 + panel::toggle_at(bar, &frame.theme, text, &state, pe.position)
125 + {
126 + // Klik w przełącznik — realna akcja systemowa.
127 + match state.toggles[i].action {
128 + ToggleAction::Wifi => {
129 + luna::system::set_wifi(!state.toggles[i].active);
130 + }
131 + ToggleAction::Volume => luna::system::toggle_mute(),
132 + ToggleAction::None => {}
133 + }
116 134 }
117 135 }
118 136 Event::PointerButton(pe)
@@ -176,17 +194,7 @@ fn main() -> Result<(), Box<dyn Error>> {
176 194 }
177 195 }
178 196
179 - // Demo: co ~4 s OSD, co ~7 s powiadomienie.
180 - if timer % 7.0 < dt {
181 - let value = 0.15 + (timer as i64 % 8) as f64 / 10.0;
182 - osd.show("Głośność", value);
183 - }
184 - if timer % 4.0 < dt {
185 - notes.push(
186 - Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
187 - .with_icon(IconName::Files),
188 - );
189 - }
197 + // OSD/powiadomienia sterowane zdarzeniami (demo usunięte).
190 198 osd.advance(dt);
191 199 notes.advance(dt);
192 200
@@ -216,6 +224,14 @@ fn main() -> Result<(), Box<dyn Error>> {
216 224 .wrapping_mul(1_099_511_628_211)
217 225 .wrapping_add(u64::from(t.active) ^ t.title.len() as u64);
218 226 }
227 + for tg in &state.toggles {
228 + th = th
229 + .wrapping_mul(1_099_511_628_211)
230 + .wrapping_add(u64::from(tg.active) ^ tg.label.len() as u64);
231 + }
232 + th = th
233 + .wrapping_mul(1_099_511_628_211)
234 + .wrapping_add(state.user.len() as u64);
219 235 let sig = (open, state.clock.clone(), th);
220 236 let animating = osd.visible() || notes.visible();
221 237 if !frame.events.is_empty() || animating || open || sig != last_sig {
@@ -225,6 +241,36 @@ fn main() -> Result<(), Box<dyn Error>> {
225 241 })
226 242 }
227 243
244 +/// Buduje przełączniki paska z realnego stanu systemu.
245 +fn build_toggles(s: &luna::system::SystemStatus) -> Vec<Toggle> {
246 + let mut v = Vec::new();
247 + if let Some((on, ssid)) = &s.wifi {
248 + v.push(Toggle {
249 + label: ssid.clone().unwrap_or_else(|| "Wi-Fi".to_owned()),
250 + icon: IconName::Wifi,
251 + active: *on,
252 + action: ToggleAction::Wifi,
253 + });
254 + }
255 + if let Some((pct, muted)) = s.volume {
256 + v.push(Toggle {
257 + label: format!("{pct}%"),
258 + icon: IconName::Volume,
259 + active: !muted,
260 + action: ToggleAction::Volume,
261 + });
262 + }
263 + if let Some((pct, charging)) = s.battery {
264 + v.push(Toggle {
265 + label: format!("{pct}%"),
266 + icon: IconName::Battery,
267 + active: charging,
268 + action: ToggleAction::None,
269 + });
270 + }
271 + v
272 +}
273 +
228 274 /// Uruchamia aplikację o indeksie `i` (jeśli ma polecenie).
229 275 fn launch(apps: &[App], i: usize) {
230 276 if let Some(exec) = apps.get(i).and_then(|a| a.exec.clone()) {
@@ -10,4 +10,5 @@ pub mod apps;
10 10 pub mod notifications;
11 11 pub mod osd;
12 12 pub mod panel;
13 +pub mod system;
13 14 pub mod toplevels;
@@ -26,6 +26,15 @@ const LIST_ROW_H: f64 = 52.0;
26 26 const CARD_W: f64 = 168.0;
27 27 const CARD_H: f64 = 200.0;
28 28
29 +/// Akcja przełącznika w pasku (kliknięcie).
30 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31 +pub enum ToggleAction {
32 + #[default]
33 + None,
34 + Wifi,
35 + Volume,
36 +}
37 +
29 38 /// Przełącznik szybkiego dostępu (Wi-Fi, dźwięk, bateria…).
30 39 #[derive(Debug, Clone)]
31 40 pub struct Toggle {
@@ -33,6 +42,8 @@ pub struct Toggle {
33 42 /// Ikona z pakietu Luny.
34 43 pub icon: IconName,
35 44 pub active: bool,
45 + /// Co robi klik (jeśli cokolwiek).
46 + pub action: ToggleAction,
36 47 }
37 48
38 49 /// Stan panelu — czyste dane, bez logiki okna (łatwe do testów i podglądów).
@@ -70,16 +81,19 @@ impl PanelState {
70 81 label: "Wi-Fi".to_owned(),
71 82 icon: IconName::Wifi,
72 83 active: true,
84 + action: ToggleAction::Wifi,
73 85 },
74 86 Toggle {
75 87 label: "Dźwięk".to_owned(),
76 88 icon: IconName::Volume,
77 89 active: true,
90 + action: ToggleAction::Volume,
78 91 },
79 92 Toggle {
80 93 label: "82%".to_owned(),
81 94 icon: IconName::Battery,
82 95 active: false,
96 + action: ToggleAction::None,
83 97 },
84 98 ],
85 99 user: "patryks".to_owned(),
@@ -253,6 +267,42 @@ pub fn burger_rect(panel: Rect, theme: &Theme) -> Rect {
253 267 Rect::from_xywh(panel.x() + theme.spacing.md, cy - 14.0, 28.0, 28.0)
254 268 }
255 269
270 +/// Prostokąty pigułek przełączników (spójne z `paint_panel`), w kolejności `state.toggles`.
271 +pub fn toggle_rects(bar: Rect, theme: &Theme, text: &TextEngine, state: &PanelState) -> Vec<Rect> {
272 + let t = theme;
273 + let cy = bar.center().y;
274 + let cap = t.typography.caption.size;
275 + let pad = t.spacing.md;
276 + let uw = text
277 + .measure(&state.user, t.typography.body.size, weight::NORMAL)
278 + .width;
279 + // Za użytkownikiem i separatorem (jak w `paint_panel`).
280 + let mut right = bar.right() - pad - uw - 2.0 * t.spacing.md;
281 + let mut rects = vec![Rect::ZERO; state.toggles.len()];
282 + for (i, toggle) in state.toggles.iter().enumerate().rev() {
283 + let icon = 13.0;
284 + let lw = text.measure(&toggle.label, cap, weight::MEDIUM).width;
285 + let w = t.spacing.sm * 2.0 + icon + t.spacing.xs + lw;
286 + let pill = Rect::from_xywh(right - w, cy - 12.0, w, 24.0);
287 + rects[i] = pill;
288 + right = pill.x() - t.spacing.sm;
289 + }
290 + rects
291 +}
292 +
293 +/// Indeks przełącznika pod punktem `p`.
294 +pub fn toggle_at(
295 + bar: Rect,
296 + theme: &Theme,
297 + text: &TextEngine,
298 + state: &PanelState,
299 + p: Point,
300 +) -> Option<usize> {
301 + toggle_rects(bar, theme, text, state)
302 + .iter()
303 + .position(|r| r.contains(p))
304 +}
305 +
256 306 /// Rozmieszczenie przycisków okien (taskbar) w pasku.
257 307 pub fn task_rects(bar: Rect, theme: &Theme, text: &TextEngine, tasks: &[Task]) -> Vec<Rect> {
258 308 let t = theme;
@@ -428,14 +478,13 @@ pub fn paint_panel(panel: &PanelState, ctx: &mut PaintCtx, rect: Rect) {
428 478 .fill_rect(Rect::from_xywh(right, cy - 8.0, 1.0, 16.0), t.colors.border);
429 479 right -= t.spacing.md;
430 480
431 - for toggle in panel.toggles.iter().rev() {
432 - let cap = t.typography.caption.size;
433 - let lw = ctx.measure(&toggle.label, cap, weight::MEDIUM);
434 - let icon = 13.0;
435 - let w = t.spacing.sm * 2.0 + icon + t.spacing.xs + lw;
436 - let pill = Rect::from_xywh(right - w, cy - 12.0, w, 24.0);
437 - paint_toggle_pill(ctx, pill, toggle);
438 - right = pill.x() - t.spacing.sm;
481 + // Przełączniki szybkiego dostępu (pigułki).
482 + let toggles = toggle_rects(rect, t, ctx.text, panel);
483 + for (r, toggle) in toggles.iter().zip(panel.toggles.iter()) {
484 + paint_toggle_pill(ctx, *r, toggle);
485 + }
486 + if let Some(last) = toggles.last() {
487 + right = last.x() - t.spacing.sm;
439 488 }
440 489
441 490 for i in (0..panel.workspaces).rev() {
442 491 new file mode 100644
@@ -0,0 +1,177 @@
1 +//! Realny stan systemu dla paska: użytkownik, dźwięk (PipeWire/`wpctl`), Wi-Fi (`nmcli`),
2 +//! bateria (`/sys/class/power_supply`). Wartości są odpytywane w tle i podawane UI.
3 +
4 +use std::process::Command;
5 +use std::sync::{Arc, Mutex};
6 +use std::time::Duration;
7 +
8 +/// Migawka stanu systemu (to, co pasek pokazuje naprawdę).
9 +#[derive(Debug, Clone, Default)]
10 +pub struct SystemStatus {
11 + pub user: String,
12 + /// `(procent, wyciszone)`.
13 + pub volume: Option<(u8, bool)>,
14 + /// `(włączone, SSID gdy połączone)`.
15 + pub wifi: Option<(bool, Option<String>)>,
16 + /// `(procent, ładuje)`.
17 + pub battery: Option<(u8, bool)>,
18 +}
19 +
20 +impl SystemStatus {
21 + /// Odczytuje bieżący stan (dzieła polecenia; wołać poza wątkiem UI).
22 + pub fn poll() -> Self {
23 + Self {
24 + user: user_name(),
25 + volume: volume(),
26 + wifi: wifi(),
27 + battery: battery(),
28 + }
29 + }
30 +}
31 +
32 +/// Współdzielony stan, aktualizowany w tle.
33 +pub type Shared = Arc<Mutex<SystemStatus>>;
34 +
35 +/// Startuje wątek odpytywania (co ~2 s) i zwraca współdzielony stan.
36 +pub fn spawn() -> Shared {
37 + let shared: Shared = Arc::new(Mutex::new(SystemStatus::poll()));
38 + let writer = Arc::clone(&shared);
39 + std::thread::spawn(move || loop {
40 + std::thread::sleep(Duration::from_secs(2));
41 + let fresh = SystemStatus::poll();
42 + if let Ok(mut s) = writer.lock() {
43 + *s = fresh;
44 + }
45 + });
46 + shared
47 +}
48 +
49 +/// Włącza/wyłącza Wi-Fi (przełącznik „Wi-Fi” w pasku).
50 +pub fn set_wifi(on: bool) {
51 + spawn_cmd("nmcli", &["radio", "wifi", if on { "on" } else { "off" }]);
52 +}
53 +
54 +/// Przełącza wyciszenie domyślnego wyjścia audio.
55 +pub fn toggle_mute() {
56 + spawn_cmd("wpctl", &["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]);
57 +}
58 +
59 +fn spawn_cmd(program: &str, args: &[&str]) {
60 + let _ = Command::new(program).args(args).spawn();
61 +}
62 +
63 +fn user_name() -> String {
64 + std::env::var("USER")
65 + .or_else(|_| std::env::var("LOGNAME"))
66 + .unwrap_or_else(|_| "user".to_owned())
67 +}
68 +
69 +/// Uruchamia polecenie (bez locale, by pola `nmcli` były angielskie) i zwraca stdout.
70 +fn run(program: &str, args: &[&str]) -> Option<String> {
71 + let out = Command::new(program)
72 + .env("LC_ALL", "C")
73 + .args(args)
74 + .output()
75 + .ok()?;
76 + if out.status.success() {
77 + Some(String::from_utf8_lossy(&out.stdout).into_owned())
78 + } else {
79 + None
80 + }
81 +}
82 +
83 +fn volume() -> Option<(u8, bool)> {
84 + // `wpctl get-volume` → „Volume: 0.40” lub „Volume: 0.40 [MUTED]”.
85 + parse_volume(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"])?)
86 +}
87 +
88 +/// Parsuje wynik `wpctl get-volume` → `(procent, wyciszone)`.
89 +fn parse_volume(out: &str) -> Option<(u8, bool)> {
90 + let muted = out.contains("MUTED");
91 + let value: f64 = out.split_whitespace().nth(1)?.parse().ok()?;
92 + Some(((value * 100.0).round() as u8, muted))
93 +}
94 +
95 +fn wifi() -> Option<(bool, Option<String>)> {
96 + // „enabled:connected” / „disabled:…”.
97 + let general = run("nmcli", &["-t", "-f", "WIFI,STATE", "general"])?;
98 + let (enabled, connected) = parse_wifi_general(&general)?;
99 + let ssid = if connected {
100 + run("nmcli", &["-t", "-f", "ACTIVE,SSID", "dev", "wifi"]).and_then(|o| parse_ssid(&o))
101 + } else {
102 + None
103 + };
104 + Some((enabled, ssid))
105 +}
106 +
107 +/// Parsuje `general` z `nmcli -t -f WIFI,STATE general` → `(włączone, połączone)`.
108 +///
109 +/// Stan bywa dłuższy („connected (local only)”), a „disconnected” zawiera
110 +/// podciąg „connected”, więc porównujemy początek pola.
111 +fn parse_wifi_general(out: &str) -> Option<(bool, bool)> {
112 + let (wifi, state) = out.trim().split_once(':')?;
113 + Some((wifi == "enabled", state.starts_with("connected")))
114 +}
115 +
116 +/// Parsuje `nmcli -t -f ACTIVE,SSID dev wifi` → SSID aktywnej sieci.
117 +fn parse_ssid(out: &str) -> Option<String> {
118 + out.lines()
119 + .find_map(|l| l.strip_prefix("yes:").map(str::to_owned))
120 +}
121 +
122 +fn battery() -> Option<(u8, bool)> {
123 + for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
124 + if !entry.file_name().to_string_lossy().starts_with("BAT") {
125 + continue;
126 + }
127 + let dir = entry.path();
128 + let cap: u8 = std::fs::read_to_string(dir.join("capacity"))
129 + .ok()?
130 + .trim()
131 + .parse()
132 + .ok()?;
133 + let status = std::fs::read_to_string(dir.join("status")).unwrap_or_default();
134 + let charging = matches!(status.trim(), "Charging" | "Full");
135 + return Some((cap, charging));
136 + }
137 + None
138 +}
139 +
140 +#[cfg(test)]
141 +mod tests {
142 + use super::*;
143 +
144 + #[test]
145 + fn parses_volume_and_mute() {
146 + assert_eq!(parse_volume("Volume: 0.40\n"), Some((40, false)));
147 + assert_eq!(parse_volume("Volume: 0.40 [MUTED]\n"), Some((40, true)));
148 + assert_eq!(parse_volume("Volume: 1.00\n"), Some((100, false)));
149 + assert_eq!(parse_volume("Nieoczekiwane\n"), None);
150 + }
151 +
152 + #[test]
153 + fn parses_wifi_general_and_ssid() {
154 + assert_eq!(parse_wifi_general("enabled:connected"), Some((true, true)));
155 + assert_eq!(
156 + parse_wifi_general("enabled:connected (local only)\n"),
157 + Some((true, true))
158 + );
159 + assert_eq!(
160 + parse_wifi_general("enabled:disconnected\n"),
161 + Some((true, false))
162 + );
163 + assert_eq!(
164 + parse_wifi_general("enabled:connecting\n"),
165 + Some((true, false))
166 + );
167 + assert_eq!(
168 + parse_wifi_general("disabled:unavailable"),
169 + Some((false, false))
170 + );
171 + assert_eq!(parse_wifi_general("śmieci"), None);
172 +
173 + let scan = "no:Dom\nyes:PaganNet\nno:Sasiad\n";
174 + assert_eq!(parse_ssid(scan).as_deref(), Some("PaganNet"));
175 + assert_eq!(parse_ssid("no:Dom\nno:Sasiad\n"), None);
176 + }
177 +}