Commit 8df0159
6
files
+386
added
-6
deleted
@@ -305,6 +305,8 @@ dependencies = [
305
305
"libluna-platform",
306
306
"libluna-render",
307
307
"libluna-widgets",
308
+ "wayland-client",
309
+ "wayland-protocols-wlr",
308
310
]
309
311
310
312
[[package]]
@@ -29,3 +29,6 @@ libluna-widgets.workspace = true
29
29
libluna-render.workspace = true
30
30
libluna-i18n.workspace = true
31
31
libluna-platform.workspace = true
32
+
33
+wayland-client = "0.31"
34
+wayland-protocols-wlr = { version = "0.3", features = ["client"] }
@@ -42,6 +42,8 @@ fn main() -> Result<(), Box<dyn Error>> {
42
42
let mut osd = Osd::new(IconName::Volume, "Głośność", 0.0);
43
43
let mut notes = Notifications::new();
44
44
let mut timer = 0.0f64;
45
+ // Lista okien z kompozytora (foreign-toplevel) — własne połączenie + wątek.
46
+ let toplevels = luna::toplevels::Toplevels::spawn();
45
47
46
48
run(cfg, move |frame: &mut Frame| {
47
49
let dt = frame.delta_time();
@@ -52,6 +54,17 @@ fn main() -> Result<(), Box<dyn Error>> {
52
54
let text = frame.text;
53
55
let bar = Rect::from_xywh(0.0, 0.0, size.width, THICKNESS);
54
56
57
+ // Zegar (UTC) i lista okien z kompozytora (taskbar) — na bieżąco.
58
+ let (clock, date) = now_clock();
59
+ state.clock = clock;
60
+ state.date = date;
61
+ state.title.clear();
62
+ if let Some(tl) = &toplevels {
63
+ state.tasks = tl.tasks();
64
+ } else {
65
+ state.tasks.clear();
66
+ }
67
+
55
68
let lw = LAUNCHER_W.min((size.width - 80.0).max(320.0));
56
69
let larea = Rect::from_xywh((size.width - lw) / 2.0, THICKNESS + 24.0, lw, LAUNCHER_H);
57
70
@@ -91,6 +104,25 @@ fn main() -> Result<(), Box<dyn Error>> {
91
104
launch(&apps, i);
92
105
open = false;
93
106
}
107
+ } else if let Some(i) =
108
+ panel::task_at(bar, &frame.theme, text, &state.tasks, pe.position)
109
+ {
110
+ // Klik w okno na pasku — aktywuj je.
111
+ if let Some(tl) = &toplevels {
112
+ tl.activate(i);
113
+ }
114
+ }
115
+ }
116
+ Event::PointerButton(pe)
117
+ if pe.state == ButtonState::Pressed && pe.button == PointerButton::Middle =>
118
+ {
119
+ // Środkowy klik na oknie na pasku — zamknij je.
120
+ if let Some(i) =
121
+ panel::task_at(bar, &frame.theme, text, &state.tasks, pe.position)
122
+ {
123
+ if let Some(tl) = &toplevels {
124
+ tl.close(i);
125
+ }
94
126
}
95
127
}
96
128
Event::Scroll(se) if open => {
@@ -142,11 +174,6 @@ fn main() -> Result<(), Box<dyn Error>> {
142
174
}
143
175
}
144
176
145
- // Zegar (UTC).
146
- let (clock, date) = now_clock();
147
- state.clock = clock;
148
- state.date = date;
149
-
150
177
// Demo: co ~4 s OSD, co ~7 s powiadomienie.
151
178
if timer % 7.0 < dt {
152
179
let value = 0.15 + (timer as i64 % 8) as f64 / 10.0;
@@ -10,3 +10,4 @@ pub mod apps;
10
10
pub mod notifications;
11
11
pub mod osd;
12
12
pub mod panel;
13
+pub mod toplevels;
@@ -11,6 +11,8 @@ use libluna_core::{Color, Point, Rect, Size, Theme};
11
11
use libluna_render::TextEngine;
12
12
use libluna_widgets::{paint_icon, IconName, PaintCtx};
13
13
14
+use crate::toplevels::Task;
15
+
14
16
/// Wysokość panelu (piksele logiczne).
15
17
pub const THICKNESS: f64 = 40.0;
16
18
/// Domyślna szerokość karty launchera.
@@ -50,6 +52,8 @@ pub struct PanelState {
50
52
pub toggles: Vec<Toggle>,
51
53
/// Nazwa zalogowanego użytkownika.
52
54
pub user: String,
55
+ /// Okna z kompozytora (`zwlr_foreign_toplevel`) — taskbar na pasku.
56
+ pub tasks: Vec<Task>,
53
57
}
54
58
55
59
impl PanelState {
@@ -79,6 +83,18 @@ impl PanelState {
79
83
},
80
84
],
81
85
user: "patryks".to_owned(),
86
+ tasks: vec![
87
+ Task {
88
+ title: "Pliki — Dokumenty".to_owned(),
89
+ app_id: "files".to_owned(),
90
+ active: true,
91
+ },
92
+ Task {
93
+ title: "Terminal".to_owned(),
94
+ app_id: "terminal".to_owned(),
95
+ active: false,
96
+ },
97
+ ],
82
98
}
83
99
}
84
100
}
@@ -237,6 +253,117 @@ pub fn burger_rect(panel: Rect, theme: &Theme) -> Rect {
237
253
Rect::from_xywh(panel.x() + theme.spacing.md, cy - 14.0, 28.0, 28.0)
238
254
}
239
255
256
+/// Rozmieszczenie przycisków okien (taskbar) w pasku.
257
+pub fn task_rects(bar: Rect, theme: &Theme, text: &TextEngine, tasks: &[Task]) -> Vec<Rect> {
258
+ let t = theme;
259
+ let cy = bar.center().y;
260
+ let cap = t.typography.caption.size;
261
+ // Start: za burgerem + separator + odstęp (spójne z `paint_panel`).
262
+ let mut x = burger_rect(bar, t).right() + t.spacing.md + 1.0 + t.spacing.md;
263
+ let limit = bar.center().x - t.spacing.lg;
264
+ let mut rects = Vec::new();
265
+ for task in tasks {
266
+ let label = task_label(task);
267
+ let w = (text.measure(&label, cap, weight::MEDIUM).width + 2.0 * t.spacing.md).min(200.0);
268
+ if x + w > limit {
269
+ break;
270
+ }
271
+ rects.push(Rect::from_xywh(x, cy - 12.0, w, 24.0));
272
+ x += w + t.spacing.sm;
273
+ }
274
+ rects
275
+}
276
+
277
+/// Indeks okna (przycisku) pod punktem `p`.
278
+pub fn task_at(
279
+ bar: Rect,
280
+ theme: &Theme,
281
+ text: &TextEngine,
282
+ tasks: &[Task],
283
+ p: Point,
284
+) -> Option<usize> {
285
+ task_rects(bar, theme, text, tasks)
286
+ .iter()
287
+ .position(|r| r.contains(p))
288
+}
289
+
290
+#[cfg(test)]
291
+mod taskbar_tests {
292
+ use super::*;
293
+
294
+ #[test]
295
+ fn task_buttons_layout_and_hit_test() {
296
+ let theme = Theme::light();
297
+ let text = TextEngine::system();
298
+ let bar = Rect::from_xywh(0.0, 0.0, 1280.0, THICKNESS);
299
+ let tasks = vec![
300
+ Task {
301
+ title: "Pliki".into(),
302
+ app_id: "files".into(),
303
+ active: true,
304
+ },
305
+ Task {
306
+ title: "Terminal".into(),
307
+ app_id: "term".into(),
308
+ active: false,
309
+ },
310
+ ];
311
+ let rects = task_rects(bar, &theme, &text, &tasks);
312
+ assert_eq!(rects.len(), 2);
313
+ assert!(rects[1].x() > rects[0].right());
314
+ assert_eq!(
315
+ task_at(bar, &theme, &text, &tasks, rects[1].center()),
316
+ Some(1)
317
+ );
318
+ assert_eq!(
319
+ task_at(bar, &theme, &text, &tasks, Point::new(2.0, 2.0)),
320
+ None
321
+ );
322
+ }
323
+}
324
+
325
+/// Krótka etykieta okna (tytuł, a gdy pusty — `app_id`), przycięta do ~28 znaków.
326
+fn task_label(task: &Task) -> String {
327
+ let s = if !task.title.is_empty() {
328
+ task.title.clone()
329
+ } else {
330
+ task.app_id.clone()
331
+ };
332
+ let max = 28;
333
+ if s.chars().count() > max {
334
+ let mut out: String = s.chars().take(max - 1).collect();
335
+ out.push('…');
336
+ out
337
+ } else {
338
+ s
339
+ }
340
+}
341
+
342
+/// Rysuje przycisk okna w taskbarze (aktywny — akcent).
343
+fn paint_task_button(ctx: &mut PaintCtx, rect: Rect, task: &Task) {
344
+ let t = ctx.theme;
345
+ let label = task_label(task);
346
+ let cap = t.typography.caption.size;
347
+ let (bg, fg) = if task.active {
348
+ (t.colors.accent, t.colors.accent_fg)
349
+ } else {
350
+ (t.colors.card_bg, t.colors.text)
351
+ };
352
+ ctx.scene.fill_rounded_rect(rect, rect.height() / 2.0, bg);
353
+ if !task.active {
354
+ ctx.scene
355
+ .stroke_rounded_rect(rect, rect.height() / 2.0, 1.0, t.colors.border);
356
+ }
357
+ ctx.label_center(
358
+ rect.center().x,
359
+ rect.center().y,
360
+ &label,
361
+ cap,
362
+ weight::MEDIUM,
363
+ fg,
364
+ );
365
+}
366
+
240
367
/// Rysuje panel górny w zadanym prostokącie.
241
368
pub fn paint_panel(panel: &PanelState, ctx: &mut PaintCtx, rect: Rect) {
242
369
let t = ctx.theme;
@@ -270,7 +397,15 @@ pub fn paint_panel(panel: &PanelState, ctx: &mut PaintCtx, rect: Rect) {
270
397
ctx.scene
271
398
.fill_rect(Rect::from_xywh(x, cy - 8.0, 1.0, 16.0), t.colors.border);
272
399
x += t.spacing.md;
273
- ctx.label_left(x, cy, &panel.title, body, weight::NORMAL, t.colors.dim_text);
400
+ // Taskbar: przyciski okien; przy braku okien — tytuł (podglądy headless).
401
+ if panel.tasks.is_empty() {
402
+ ctx.label_left(x, cy, &panel.title, body, weight::NORMAL, t.colors.dim_text);
403
+ } else {
404
+ let rects = task_rects(rect, t, ctx.text, &panel.tasks);
405
+ for (r, task) in rects.iter().zip(panel.tasks.iter()) {
406
+ paint_task_button(ctx, *r, task);
407
+ }
408
+ }
274
409
275
410
// ── Środek: zegar + data ──
276
411
let clock = format!("{} · {}", panel.clock, panel.date);
277
412
new file mode 100644
@@ -0,0 +1,212 @@
1
+//! Klient `zwlr_foreign_toplevel_management` — lista okien dla paska (taskbar).
2
+//!
3
+//! Powłoka otwiera **własne** połączenie Wayland (niezależne od pętli `libluna-platform`),
4
+//! binduje menedżera `zwlr_foreign_toplevel` i w osobnym wątku śledzi uchwyty okien.
5
+//! UI (wątek rysowania) czyta gotową listę przez [`Toplevels::tasks`], a kliknięcia
6
+//! tłumaczy na [`Toplevels::activate`] / [`Toplevels::close`].
7
+
8
+use std::collections::HashMap;
9
+use std::sync::{Arc, Mutex};
10
+
11
+use wayland_client::globals::{registry_queue_init, GlobalListContents};
12
+use wayland_client::protocol::wl_registry;
13
+use wayland_client::protocol::wl_seat;
14
+use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
15
+use wayland_protocols_wlr::foreign_toplevel::v1::client::{
16
+ zwlr_foreign_toplevel_handle_v1 as hdl, zwlr_foreign_toplevel_manager_v1 as mgr,
17
+};
18
+
19
+/// Stany okna (`zwlr_foreign_toplevel_handle_v1.state`).
20
+pub const ST_MAXIMIZED: u32 = 0;
21
+pub const ST_MINIMIZED: u32 = 1;
22
+pub const ST_ACTIVATED: u32 = 2;
23
+pub const ST_FULLSCREEN: u32 = 3;
24
+
25
+/// Migawka okna dla UI.
26
+#[derive(Debug, Clone, Default)]
27
+pub struct Task {
28
+ pub title: String,
29
+ pub app_id: String,
30
+ pub active: bool,
31
+}
32
+
33
+#[derive(Default)]
34
+struct Entry {
35
+ title: String,
36
+ app_id: String,
37
+ states: Vec<u32>,
38
+ proxy: Option<hdl::ZwlrForeignToplevelHandleV1>,
39
+ closed: bool,
40
+}
41
+
42
+#[derive(Default)]
43
+struct Shared {
44
+ order: Vec<u64>,
45
+ map: HashMap<u64, Entry>,
46
+ seat: Option<wl_seat::WlSeat>,
47
+}
48
+
49
+struct DispatchState {
50
+ shared: Arc<Mutex<Shared>>,
51
+}
52
+
53
+impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for DispatchState {
54
+ fn event(
55
+ _: &mut Self,
56
+ _: &wl_registry::WlRegistry,
57
+ _: wl_registry::Event,
58
+ _: &GlobalListContents,
59
+ _: &Connection,
60
+ _: &QueueHandle<Self>,
61
+ ) {
62
+ }
63
+}
64
+
65
+impl Dispatch<wl_seat::WlSeat, ()> for DispatchState {
66
+ fn event(
67
+ _: &mut Self,
68
+ _: &wl_seat::WlSeat,
69
+ _: wl_seat::Event,
70
+ _: &(),
71
+ _: &Connection,
72
+ _: &QueueHandle<Self>,
73
+ ) {
74
+ }
75
+}
76
+
77
+impl Dispatch<mgr::ZwlrForeignToplevelManagerV1, ()> for DispatchState {
78
+ fn event(
79
+ state: &mut Self,
80
+ _mgr: &mgr::ZwlrForeignToplevelManagerV1,
81
+ event: mgr::Event,
82
+ _: &(),
83
+ _: &Connection,
84
+ _: &QueueHandle<Self>,
85
+ ) {
86
+ if let mgr::Event::Toplevel { toplevel } = event {
87
+ let id = toplevel.id().protocol_id() as u64;
88
+ let mut s = state.shared.lock().unwrap();
89
+ s.order.push(id);
90
+ s.map.insert(
91
+ id,
92
+ Entry {
93
+ proxy: Some(toplevel),
94
+ ..Default::default()
95
+ },
96
+ );
97
+ }
98
+ }
99
+}
100
+
101
+impl Dispatch<hdl::ZwlrForeignToplevelHandleV1, ()> for DispatchState {
102
+ fn event(
103
+ state: &mut Self,
104
+ handle: &hdl::ZwlrForeignToplevelHandleV1,
105
+ event: hdl::Event,
106
+ _: &(),
107
+ _: &Connection,
108
+ _: &QueueHandle<Self>,
109
+ ) {
110
+ let id = handle.id().protocol_id() as u64;
111
+ let mut s = state.shared.lock().unwrap();
112
+ let Some(e) = s.map.get_mut(&id) else {
113
+ return;
114
+ };
115
+ match event {
116
+ hdl::Event::Title { title } => e.title = title,
117
+ hdl::Event::AppId { app_id } => e.app_id = app_id,
118
+ hdl::Event::State { state } => e.states = decode_states(&state),
119
+ hdl::Event::Closed => e.closed = true,
120
+ _ => {}
121
+ }
122
+ }
123
+}
124
+
125
+/// Powłoka połączona z listą okien kompozytora.
126
+pub struct Toplevels {
127
+ shared: Arc<Mutex<Shared>>,
128
+ conn: Connection,
129
+}
130
+
131
+impl Toplevels {
132
+ /// Łączy się z kompozytorem i uruchamia wątek śledzący okna.
133
+ /// `None`, gdy nie ma kompozytora albo brakuje protokołu.
134
+ pub fn spawn() -> Option<Self> {
135
+ // Klient musi mieć `XDG_RUNTIME_DIR`, żeby znaleźć gniazdo kompozytora
136
+ // (menedżer sesji zwykle je ustawia; gdy brak — wybieramy je jak kompozytor).
137
+ let _ = libluna_core::runtime::ensure_runtime_dir();
138
+ let conn = Connection::connect_to_env().ok()?;
139
+ let (globals, mut queue) = registry_queue_init::<DispatchState>(&conn).ok()?;
140
+ let qh = queue.handle();
141
+
142
+ let shared = Arc::new(Mutex::new(Shared::default()));
143
+ let mut state = DispatchState {
144
+ shared: Arc::clone(&shared),
145
+ };
146
+
147
+ let _manager = globals
148
+ .bind::<mgr::ZwlrForeignToplevelManagerV1, DispatchState, ()>(&qh, 1..=3, ())
149
+ .ok()?;
150
+ if let Ok(seat) = globals.bind::<wl_seat::WlSeat, DispatchState, ()>(&qh, 1..=1, ()) {
151
+ shared.lock().unwrap().seat = Some(seat);
152
+ }
153
+
154
+ std::thread::spawn(move || loop {
155
+ if queue.blocking_dispatch(&mut state).is_err() {
156
+ break;
157
+ }
158
+ });
159
+
160
+ Some(Self { shared, conn })
161
+ }
162
+
163
+ /// Bieżąca lista okien (kolejność jak w kompozytorze).
164
+ pub fn tasks(&self) -> Vec<Task> {
165
+ let s = self.shared.lock().unwrap();
166
+ s.order
167
+ .iter()
168
+ .filter_map(|id| s.map.get(id))
169
+ .filter(|e| !e.closed)
170
+ .map(|e| Task {
171
+ title: e.title.clone(),
172
+ app_id: e.app_id.clone(),
173
+ active: e.states.contains(&ST_ACTIVATED),
174
+ })
175
+ .collect()
176
+ }
177
+
178
+ /// Prosi kompozytor o aktywowanie okna o indeksie `index`.
179
+ pub fn activate(&self, index: usize) {
180
+ {
181
+ let s = self.shared.lock().unwrap();
182
+ if let (Some(id), Some(seat)) = (s.order.get(index), s.seat.as_ref()) {
183
+ if let Some(h) = s.map.get(id).and_then(|e| e.proxy.as_ref()) {
184
+ let _ = h.activate(seat);
185
+ }
186
+ }
187
+ }
188
+ let _ = self.conn.flush();
189
+ }
190
+
191
+ /// Prosi kompozytor o zamknięcie okna o indeksie `index`.
192
+ pub fn close(&self, index: usize) {
193
+ {
194
+ let s = self.shared.lock().unwrap();
195
+ if let Some(id) = s.order.get(index) {
196
+ if let Some(h) = s.map.get(id).and_then(|e| e.proxy.as_ref()) {
197
+ let _ = h.close();
198
+ }
199
+ }
200
+ }
201
+ let _ = self.conn.flush();
202
+ }
203
+}
204
+
205
+/// Dekoduje tablicę `state` (każdy stan to 4-bajtowe `u32`, kolejność natywna).
206
+fn decode_states(bytes: &[u8]) -> Vec<u32> {
207
+ bytes
208
+ .chunks_exact(4)
209
+ .filter_map(|c| c.try_into().ok())
210
+ .map(u32::from_ne_bytes)
211
+ .collect()
212
+}