Commit f1c2102
4
files
+440
added
-5
deleted
@@ -60,6 +60,9 @@ gnieździe nasłuchu i deskryptorze backendu. `libLuna` jest zwykłą zależnoś
60
60
„dumb”, framebuffer (`ADDFB2`), `SETCRTC` i **page-flip** na wykrytych monitorach; render
61
61
sceny wprost do buforów (podwójne buforowanie). Gdy master trzyma inna sesja — bezpieczny
62
62
fallback do nested (nie odbiera ekranu).
63
+- **Wejście evdev** (`SELENE_INPUT=evdev`, opcjonalne): własne czytanie `/dev/input/event*`
64
+ (wskaźnik względny z wirtualnym kursorem, przyciski, kółko, klawiatura) — rodzime wejście
65
+ dla trybu DRM, bez nadrzędnego kompozytora. Wymaga dostępu do grupy `input`/root.
63
66
- Wielojęzyczność (`libluna-i18n`) i optymalizacja (`[profile.release]` LTO, cache `shm`).
64
67
65
68
## Powłoka Luna / Luna shell (`de/luna`)
@@ -81,8 +84,11 @@ Selene.
81
84
82
85
## Jeszcze nie / Not yet
83
86
84
-- **Wejście w trybie DRM** (evdev/`libinput`) — skanowanie działa, ale bez rodzimego wejścia
85
- okna są tylko wyświetlane; GLES/akceleracja i hotplug (`zwlr_output_manager`) — do zrobienia.
87
+- **Wejście evdev** (`evdev.rs`): zaimplementowane i sterowane `SELENE_INPUT=evdev`, ale
88
+ wymaga dostępu do `/dev/input` (grupa `input`/root); bez tego Selene zgłasza
89
+ `evdev input unavailable`.
90
+- GLES/akceleracja, hotplug monitorów (`zwlr_output_manager`), `libinput` (gesty touchpada,
91
+ akceleracja) zamiast surowych zdarzeń.
86
92
- **Selekcja pierwotna** (`primary selection`); IME / `text-input`.
87
93
- `wl_output` dynamiczne (zmiana trybu), fokus klawiatury po kliknięciu poza oknem.
88
94
- Render tylko przy zmianie modelu (częściowe odświeżanie/damage).
@@ -102,6 +108,7 @@ Tryb nested (domyślny) uruchamia okno na nadrzędnym kompozytorze. **Realne wyj
102
108
```sh
103
109
SELENE_BACKEND=drm XDG_RUNTIME_DIR="$PWD/.selene-runtime" cargo run -p selene
104
110
# diagnostyka bez zmiany trybu: SELENE_DRM_PROBE=1 cargo run -p selene
111
+# rodzime wejście (gdy brak nadrzędnego kompozytora): SELENE_INPUT=evdev cargo run -p selene
105
112
```
106
113
107
114
Uwaga: tryb DRM przejmuje ekran (page-flip); gdy master trzyma Twoja sesja, Selene
@@ -125,6 +132,7 @@ de/selene/src/
125
132
monitors.rs # wl_output (model wielu monitorów, układ, trafienia)
126
133
drm.rs # wykrywanie + scanout DRM/KMS (ioctl, dumb buffer, page-flip), fallback nested
127
134
seat.rs # wl_seat: wskaźnik + klawiatura + trasowanie wejścia i operacje okien
135
+ evdev.rs # wejście z /dev/input/event* (tryb DRM, wirtualny kursor)
128
136
input.rs # magistrala wejścia (kolejka + wybudzanie)
129
137
output.rs # wyjście nested (libluna-platform + libluna-render)
130
138
```
131
139
new file mode 100644
@@ -0,0 +1,377 @@
1
+//! Wejście z urządzeń `evdev` (`/dev/input/event*`) — rodzime wejście dla trybu DRM.
2
+//!
3
+//! W trybie nested wejście przychodzi od nadrzędnego kompozytora. Gdy Selene działa
4
+//! bezpośrednio na DRM/KMS, nie ma rodzica — wtedy czytamy zdarzenia wprost z urządzeń
5
+//! wejściowych i tłumaczymy je na [`libluna_core::Event`] (ten sam strumień, który
6
+//! trafia do `seat::handle_input`).
7
+//!
8
+//! Zakres: wskaźnik (względny), przyciski myszy, kółko i klawiatura. Śledzimy wirtualny
9
+//! kursor w kompozytorze (evdev daje przemieszczenia, nie pozycję bezwzględną).
10
+
11
+use std::os::fd::{AsRawFd, OwnedFd};
12
+
13
+use libluna_core::{
14
+ ButtonState, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent, ScrollEvent,
15
+};
16
+use rustix::fs::{Mode, OFlags};
17
+
18
+// Typy zdarzeń evdev.
19
+const EV_SYN: u16 = 0x00;
20
+const EV_KEY: u16 = 0x01;
21
+const EV_REL: u16 = 0x02;
22
+const SYN_REPORT: u16 = 0;
23
+const REL_HWHEEL: u16 = 0x06;
24
+const REL_WHEEL: u16 = 0x08;
25
+const REL_X: u16 = 0x00;
26
+const REL_Y: u16 = 0x01;
27
+
28
+/// `struct input_event` (64-bit): `timeval` (2×i64) + typ + kod + wartość.
29
+#[repr(C)]
30
+#[derive(Clone, Copy, Default)]
31
+struct RawEvent {
32
+ time_sec: i64,
33
+ time_usec: i64,
34
+ type_: u16,
35
+ code: u16,
36
+ value: i32,
37
+}
38
+
39
+/// Jedno urządzenie wejściowe.
40
+struct Device {
41
+ fd: OwnedFd,
42
+ #[allow(dead_code)]
43
+ path: String,
44
+}
45
+
46
+/// Wejście evdev: zestaw urządzeń + wirtualny kursor.
47
+pub struct EvdevInput {
48
+ devices: Vec<Device>,
49
+ cursor: Point,
50
+ acc_dx: f64,
51
+ acc_dy: f64,
52
+ acc_scroll_x: f64,
53
+ acc_scroll_y: f64,
54
+}
55
+
56
+impl EvdevInput {
57
+ /// Otwiera wszystkie czytelne urządzenia `/dev/input/event*`.
58
+ /// Zwraca `None`, gdy nie ma dostępnych urządzeń (np. brak uprawnień).
59
+ pub fn open() -> Option<Self> {
60
+ let dir = std::fs::read_dir("/dev/input").ok()?;
61
+ let mut paths: Vec<String> = dir
62
+ .flatten()
63
+ .map(|e| e.path())
64
+ .filter(|p| {
65
+ p.file_name()
66
+ .map(|n| n.to_string_lossy().starts_with("event"))
67
+ .unwrap_or(false)
68
+ })
69
+ .map(|p| p.to_string_lossy().into_owned())
70
+ .collect();
71
+ paths.sort();
72
+
73
+ let mut devices = Vec::new();
74
+ for path in paths {
75
+ let Ok(fd) = rustix::fs::open(
76
+ path.as_str(),
77
+ OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
78
+ Mode::empty(),
79
+ ) else {
80
+ continue;
81
+ };
82
+ devices.push(Device { fd, path });
83
+ }
84
+ if devices.is_empty() {
85
+ return None;
86
+ }
87
+ Some(Self {
88
+ devices,
89
+ cursor: Point::new(0.0, 0.0),
90
+ acc_dx: 0.0,
91
+ acc_dy: 0.0,
92
+ acc_scroll_x: 0.0,
93
+ acc_scroll_y: 0.0,
94
+ })
95
+ }
96
+
97
+ /// Surowe deskryptory do `poll` (do wykrywania gotowości).
98
+ pub fn fds(&self) -> Vec<std::os::fd::RawFd> {
99
+ self.devices.iter().map(|d| d.fd.as_raw_fd()).collect()
100
+ }
101
+
102
+ /// Czyta i tłumaczy wszystkie oczekujące zdarzenia.
103
+ ///
104
+ /// `bounds` to rozmiar monitora — kursor jest do niego ograniczany.
105
+ pub fn read_events(&mut self, bounds: (f64, f64)) -> Vec<Event> {
106
+ // Najpierw odczyt (pożyczka współdzielona urządzeń), potem tłumaczenie (mutacja).
107
+ let mut decoded = Vec::new();
108
+ for dev in &self.devices {
109
+ let mut buf = [0u8; std::mem::size_of::<RawEvent>() * 64];
110
+ loop {
111
+ match rustix::io::read(&dev.fd, &mut buf) {
112
+ Ok(0) => break,
113
+ Ok(n) => {
114
+ for chunk in buf[..n].chunks_exact(std::mem::size_of::<RawEvent>()) {
115
+ if let Some(ev) = decode(chunk) {
116
+ decoded.push(ev);
117
+ }
118
+ }
119
+ }
120
+ Err(_) => break, // EAGAIN lub błąd — kończymy na tym urządzeniu
121
+ }
122
+ }
123
+ }
124
+ let mut out = Vec::new();
125
+ for ev in decoded {
126
+ self.apply(ev, bounds, &mut out);
127
+ }
128
+ out
129
+ }
130
+
131
+ fn apply(&mut self, ev: Decoded, bounds: (f64, f64), out: &mut Vec<Event>) {
132
+ match ev {
133
+ Decoded::Motion(dx, dy) => {
134
+ self.acc_dx += dx;
135
+ self.acc_dy += dy;
136
+ }
137
+ Decoded::Scroll(dx, dy) => {
138
+ self.acc_scroll_x += dx;
139
+ self.acc_scroll_y += dy;
140
+ }
141
+ Decoded::Button(button, pressed) => {
142
+ let state = if pressed {
143
+ ButtonState::Pressed
144
+ } else {
145
+ ButtonState::Released
146
+ };
147
+ out.push(Event::PointerButton(PointerEvent {
148
+ position: self.cursor,
149
+ button,
150
+ state,
151
+ modifiers: Modifiers::NONE,
152
+ }));
153
+ }
154
+ Decoded::Key(keysym, pressed) => {
155
+ let state = if pressed {
156
+ ButtonState::Pressed
157
+ } else {
158
+ ButtonState::Released
159
+ };
160
+ out.push(Event::Key(KeyEvent {
161
+ keysym,
162
+ text: None,
163
+ state,
164
+ modifiers: Modifiers::NONE,
165
+ repeat: false,
166
+ }));
167
+ }
168
+ Decoded::Syn => {
169
+ if self.acc_dx != 0.0 || self.acc_dy != 0.0 {
170
+ self.cursor = Point::new(
171
+ (self.cursor.x + self.acc_dx).clamp(0.0, (bounds.0 - 1.0).max(0.0)),
172
+ (self.cursor.y + self.acc_dy).clamp(0.0, (bounds.1 - 1.0).max(0.0)),
173
+ );
174
+ self.acc_dx = 0.0;
175
+ self.acc_dy = 0.0;
176
+ out.push(Event::PointerMotion {
177
+ position: self.cursor,
178
+ modifiers: Modifiers::NONE,
179
+ });
180
+ }
181
+ if self.acc_scroll_x != 0.0 || self.acc_scroll_y != 0.0 {
182
+ out.push(Event::Scroll(ScrollEvent {
183
+ position: self.cursor,
184
+ dx: self.acc_scroll_x,
185
+ dy: -self.acc_scroll_y,
186
+ }));
187
+ self.acc_scroll_x = 0.0;
188
+ self.acc_scroll_y = 0.0;
189
+ }
190
+ }
191
+ }
192
+ }
193
+}
194
+
195
+/// Zdarzenie po przetłumaczeniu z bajtów.
196
+#[derive(Debug, Clone, Copy, PartialEq)]
197
+enum Decoded {
198
+ Motion(f64, f64),
199
+ Scroll(f64, f64),
200
+ Button(PointerButton, bool),
201
+ Key(u32, bool),
202
+ Syn,
203
+}
204
+
205
+/// Dekoduje jeden `input_event`.
206
+fn decode(bytes: &[u8]) -> Option<Decoded> {
207
+ if bytes.len() < std::mem::size_of::<RawEvent>() {
208
+ return None;
209
+ }
210
+ let ev = RawEvent {
211
+ time_sec: i64::from_ne_bytes(bytes[0..8].try_into().ok()?),
212
+ time_usec: i64::from_ne_bytes(bytes[8..16].try_into().ok()?),
213
+ type_: u16::from_ne_bytes(bytes[16..18].try_into().ok()?),
214
+ code: u16::from_ne_bytes(bytes[18..20].try_into().ok()?),
215
+ value: i32::from_ne_bytes(bytes[20..24].try_into().ok()?),
216
+ };
217
+ match ev.type_ {
218
+ EV_SYN if ev.code == SYN_REPORT => Some(Decoded::Syn),
219
+ EV_REL => match ev.code {
220
+ REL_X => Some(Decoded::Motion(ev.value as f64, 0.0)),
221
+ REL_Y => Some(Decoded::Motion(0.0, ev.value as f64)),
222
+ REL_HWHEEL => Some(Decoded::Scroll(ev.value as f64, 0.0)),
223
+ REL_WHEEL => Some(Decoded::Scroll(0.0, ev.value as f64)),
224
+ _ => None,
225
+ },
226
+ EV_KEY => {
227
+ let pressed = ev.value != 0;
228
+ match ev.code {
229
+ 0x110 => Some(Decoded::Button(PointerButton::Left, pressed)),
230
+ 0x111 => Some(Decoded::Button(PointerButton::Right, pressed)),
231
+ 0x112 => Some(Decoded::Button(PointerButton::Middle, pressed)),
232
+ 0x115 => Some(Decoded::Button(PointerButton::Forward, pressed)),
233
+ 0x116 => Some(Decoded::Button(PointerButton::Back, pressed)),
234
+ code => evdev_to_keysym(code).map(|ks| Decoded::Key(ks, pressed)),
235
+ }
236
+ }
237
+ _ => None,
238
+ }
239
+}
240
+
241
+/// Kod evdev → keysym (podzbiór wspólny z mapą klawiatury Selene).
242
+fn evdev_to_keysym(code: u16) -> Option<u32> {
243
+ // Litery (evdev: a=30 … l=38, m=50 …; użyto standardowego układu evdev).
244
+ let keysym = match code {
245
+ 1 => 0xff1b, // Escape
246
+ 14 => 0xff08, // BackSpace
247
+ 15 => 0xff09, // Tab
248
+ 28 => 0xff0d, // Return
249
+ 57 => 0x0020, // space
250
+ 103 => 0xff52, // Up
251
+ 105 => 0xff51, // Left
252
+ 106 => 0xff53, // Right
253
+ 108 => 0xff54, // Down
254
+ 110 => 0xff50, // Home
255
+ 111 => 0xffff, // Delete
256
+ 115 => 0xff57, // End
257
+ // cyfry 1..0
258
+ 2 => 0x0031,
259
+ 3 => 0x0032,
260
+ 4 => 0x0033,
261
+ 5 => 0x0034,
262
+ 6 => 0x0035,
263
+ 7 => 0x0036,
264
+ 8 => 0x0037,
265
+ 9 => 0x0038,
266
+ 10 => 0x0039,
267
+ 11 => 0x0030,
268
+ // litery
269
+ 30 => 0x0061, // a
270
+ 48 => 0x0062, // b
271
+ 46 => 0x0063, // c
272
+ 32 => 0x0064, // d
273
+ 18 => 0x0065, // e
274
+ 33 => 0x0066, // f
275
+ 34 => 0x0067, // g
276
+ 35 => 0x0068, // h
277
+ 23 => 0x0069, // i
278
+ 36 => 0x006a, // j
279
+ 37 => 0x006b, // k
280
+ 38 => 0x006c, // l
281
+ 50 => 0x006d, // m
282
+ 49 => 0x006e, // n
283
+ 24 => 0x006f, // o
284
+ 25 => 0x0070, // p
285
+ 16 => 0x0071, // q
286
+ 19 => 0x0072, // r
287
+ 31 => 0x0073, // s
288
+ 20 => 0x0074, // t
289
+ 22 => 0x0075, // u
290
+ 47 => 0x0076, // v
291
+ 17 => 0x0077, // w
292
+ 45 => 0x0078, // x
293
+ 21 => 0x0079, // y
294
+ 44 => 0x007a, // z
295
+ // modyfikatory
296
+ 42 | 54 => 0xffe1, // Shift
297
+ 29 | 97 => 0xffe3, // Control
298
+ 56 | 100 => 0xffe9, // Alt
299
+ 125 | 126 => 0xffeb, // Super
300
+ _ => return None,
301
+ };
302
+ Some(keysym)
303
+}
304
+
305
+#[cfg(test)]
306
+mod tests {
307
+ use super::*;
308
+
309
+ fn raw(type_: u16, code: u16, value: i32) -> [u8; 24] {
310
+ let mut b = [0u8; 24];
311
+ b[16..18].copy_from_slice(&type_.to_ne_bytes());
312
+ b[18..20].copy_from_slice(&code.to_ne_bytes());
313
+ b[20..24].copy_from_slice(&value.to_ne_bytes());
314
+ b
315
+ }
316
+
317
+ #[test]
318
+ fn raw_event_layout_matches_kernel() {
319
+ assert_eq!(std::mem::size_of::<RawEvent>(), 24);
320
+ }
321
+
322
+ #[test]
323
+ fn decodes_button_and_key_and_rel() {
324
+ assert_eq!(
325
+ decode(&raw(EV_KEY, 0x110, 1)),
326
+ Some(Decoded::Button(PointerButton::Left, true))
327
+ );
328
+ assert_eq!(
329
+ decode(&raw(EV_KEY, 30, 0)),
330
+ Some(Decoded::Key(0x0061, false))
331
+ );
332
+ assert_eq!(
333
+ decode(&raw(EV_REL, REL_X, 5)),
334
+ Some(Decoded::Motion(5.0, 0.0))
335
+ );
336
+ assert_eq!(decode(&raw(EV_SYN, SYN_REPORT, 0)), Some(Decoded::Syn));
337
+ assert_eq!(decode(&raw(EV_KEY, 0x2ff, 1)), None); // nieznany klawisz
338
+ }
339
+
340
+ #[test]
341
+ fn accumulates_motion_and_emits_on_syn() {
342
+ let mut input = EvdevInput {
343
+ devices: Vec::new(),
344
+ cursor: Point::new(100.0, 100.0),
345
+ acc_dx: 0.0,
346
+ acc_dy: 0.0,
347
+ acc_scroll_x: 0.0,
348
+ acc_scroll_y: 0.0,
349
+ };
350
+ let mut out = Vec::new();
351
+ input.apply(Decoded::Motion(10.0, 5.0), (1280.0, 800.0), &mut out);
352
+ assert!(out.is_empty()); // jeszcze bez SYN
353
+ input.apply(Decoded::Syn, (1280.0, 800.0), &mut out);
354
+ assert_eq!(out.len(), 1);
355
+ assert!(
356
+ matches!(out[0], Event::PointerMotion { position, .. } if position.x == 110.0 && position.y == 105.0)
357
+ );
358
+ }
359
+
360
+ #[test]
361
+ fn cursor_is_clamped_to_bounds() {
362
+ let mut input = EvdevInput {
363
+ devices: Vec::new(),
364
+ cursor: Point::new(1270.0, 0.0),
365
+ acc_dx: 0.0,
366
+ acc_dy: 0.0,
367
+ acc_scroll_x: 0.0,
368
+ acc_scroll_y: 0.0,
369
+ };
370
+ let mut out = Vec::new();
371
+ input.apply(Decoded::Motion(1000.0, -50.0), (1280.0, 800.0), &mut out);
372
+ input.apply(Decoded::Syn, (1280.0, 800.0), &mut out);
373
+ assert!(
374
+ matches!(out[0], Event::PointerMotion { position, .. } if position.x == 1279.0 && position.y == 0.0)
375
+ );
376
+ }
377
+}
@@ -9,6 +9,7 @@ mod compositor;
9
9
mod data;
10
10
mod decoration;
11
11
mod drm;
12
+mod evdev;
12
13
mod input;
13
14
mod layer;
14
15
mod monitors;
@@ -110,6 +111,22 @@ fn main() -> Result<(), Box<dyn Error>> {
110
111
111
112
let listener_fd = socket.as_raw_fd();
112
113
114
+ // Wejście evdev (dla trybu DRM, gdzie nie ma nadrzędnego kompozytora).
115
+ let mut evdev_input = if std::env::var("SELENE_INPUT").as_deref() == Ok("evdev") {
116
+ match evdev::EvdevInput::open() {
117
+ Some(input) => {
118
+ println!("{}", libluna_i18n::tr("evdev input enabled"));
119
+ Some(input)
120
+ }
121
+ None => {
122
+ eprintln!("{}", libluna_i18n::tr("evdev input unavailable"));
123
+ None
124
+ }
125
+ }
126
+ } else {
127
+ None
128
+ };
129
+
113
130
loop {
114
131
let backend_fd = display.backend().poll_fd().as_raw_fd();
115
132
let wake_fd = wake_read.as_raw_fd();
@@ -117,13 +134,43 @@ fn main() -> Result<(), Box<dyn Error>> {
117
134
let listener = unsafe { BorrowedFd::borrow_raw(listener_fd) };
118
135
let backend = unsafe { BorrowedFd::borrow_raw(backend_fd) };
119
136
let wake = unsafe { BorrowedFd::borrow_raw(wake_fd) };
120
- let mut fds = [
137
+ let input_raw = evdev_input.as_ref().map(|e| e.fds()).unwrap_or_default();
138
+ // SAFETY: deskryptory należą do `evdev_input`, który żyje przez całą iterację.
139
+ let input_fds: Vec<BorrowedFd> = input_raw
140
+ .iter()
141
+ .map(|&r| unsafe { BorrowedFd::borrow_raw(r) })
142
+ .collect();
143
+ let mut fds = vec![
121
144
rustix::event::PollFd::new(&listener, rustix::event::PollFlags::IN),
122
145
rustix::event::PollFd::new(&backend, rustix::event::PollFlags::IN),
123
146
rustix::event::PollFd::new(&wake, rustix::event::PollFlags::IN),
124
147
];
148
+ for fd in &input_fds {
149
+ fds.push(rustix::event::PollFd::new(fd, rustix::event::PollFlags::IN));
150
+ }
125
151
rustix::event::poll(&mut fds, -1)?;
126
152
153
+ // Wejście evdev → trasowanie (jak z magistrali).
154
+ if input_fds
155
+ .iter()
156
+ .enumerate()
157
+ .any(|(i, _)| fds[3 + i].revents().contains(rustix::event::PollFlags::IN))
158
+ {
159
+ if let Some(input) = evdev_input.as_mut() {
160
+ let bounds = state
161
+ .outputs
162
+ .lock()
163
+ .unwrap()
164
+ .first()
165
+ .map(|o| (o.width as f64, o.height as f64))
166
+ .unwrap_or((1280.0, 800.0));
167
+ let batch = input.read_events(bounds);
168
+ for event in &batch {
169
+ seat::handle_input(&mut state, &handle, event);
170
+ }
171
+ }
172
+ }
173
+
127
174
// Nowy klient.
128
175
if fds[0].revents().contains(rustix::event::PollFlags::IN) {
129
176
while let Ok(Some(stream)) = socket.accept() {
@@ -156,10 +156,13 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
156
156
oraz **realne wyjście DRM/KMS** (`SELENE_BACKEND=drm`, opcjonalne): master, bufor dumb,
157
157
`ADDFB2`, `SETCRTC`, podwójne buforowanie i **page-flip**; bezpieczny fallback do nested,
158
158
gdy master trzyma inna sesja.
159
+ ✅ M-K8: **wejście evdev** (`SELENE_INPUT=evdev`): własne czytanie `/dev/input/event*`
160
+ (wskaźnik względny z wirtualnym kursorem, przyciski, kółko, klawiatura) — rodzime wejście
161
+ dla trybu DRM. Wymaga grupy `input`/root.
159
162
✅ Optymalizacje: cache `mmap` puli `wl_shm` i zero-copy w rendererze (`PixmapRef::from_bytes`).
160
163
Uruchomienie: `cargo run -p selene` (szczegóły: `de/README.md`).
161
-- Dalej: **wejście w trybie DRM** (evdev/`libinput`), GLES/akceleracja, hotplug monitorów
162
- (`zwlr_output_manager`), selekcja pierwotna, IME (`text-input`).
164
+- Dalej: GLES/akceleracja, hotplug monitorów (`zwlr_output_manager`), `libinput` (gesty),
165
+ selekcja pierwotna, IME (`text-input`).
163
166
- **Luna** (`de/luna`) — powłoka środowiska na `libluna-widgets`. ✅ start: **panel górny**
164
167
(menu, tytuł okna, zegar/data, przełączniki, przestrzenie, użytkownik) i **launcher**
165
168
(siatka aplikacji + wyszukiwanie); render headless do `docs/images/luna-shell-{light,dark}.png`.