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
+}