← luna

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