Commit 3716f9c
21
files
+5276
added
-3
deleted
@@ -638,6 +638,19 @@ dependencies = [
638
638
"tiny-skia",
639
639
]
640
640
641
+[[package]]
642
+name = "luna-files"
643
+version = "0.0.1"
644
+dependencies = [
645
+ "libc",
646
+ "libluna-core",
647
+ "libluna-i18n",
648
+ "libluna-platform",
649
+ "libluna-render",
650
+ "libluna-widgets",
651
+ "rustix 0.38.44",
652
+]
653
+
641
654
[[package]]
642
655
name = "memchr"
643
656
version = "2.8.3"
@@ -8,6 +8,7 @@ members = [
8
8
"crates/libluna-platform",
9
9
"crates/libluna-widgets",
10
10
"apps/luna-demo",
11
+ "apps/luna-files",
11
12
"de/luna",
12
13
"de/selene",
13
14
]
14
15
new file mode 100644
@@ -0,0 +1,17 @@
1
+[package]
2
+name = "luna-files"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+rust-version.workspace = true
7
+authors.workspace = true
8
+description = "Luna Files — menedżer plików środowiska Luna (libLuna, bez GTK): nawigacja, kosz XDG, urządzenia, operacje w tle."
9
+
10
+[dependencies]
11
+libluna-core.workspace = true
12
+libluna-i18n.workspace = true
13
+libluna-render.workspace = true
14
+libluna-platform.workspace = true
15
+libluna-widgets.workspace = true
16
+rustix = { version = "0.38", features = ["fs"] }
17
+libc = "0.2"
0
18
new file mode 100644
@@ -0,0 +1,85 @@
1
+# Luna Files (`luna-files`)
2
+
3
+Pierwsza „duża” aplikacja uniwersum Luna: menedżer plików zbudowany **wyłącznie na
4
+libLunie** (`libluna-platform` + `libluna-widgets`), bez GTK, uruchamiany na
5
+kompozytorze Selene.
6
+
7
+Dostępne binarki:
8
+
9
+| Binarka | Opis |
10
+|---|---|
11
+| `luna-files` | podstawowa nazwa aplikacji (okno „Pliki”) |
12
+| `gaminedes` | alias — ta sama aplikacja, inna nazwa okna i App ID |
13
+
14
+## Co już działa
15
+
16
+**Nawigacja i widok**
17
+- breadcrumb z klikalnymi segmentami, `Ctrl+L` — edycja ścieżki, `Backspace` — w górę,
18
+ `Alt+←/→` — historia, przyciski myszy Back/Forward;
19
+- **lista z kolumnami** (Nazwa / Rozmiar / Typ / Modyfikacja) i klikalnymi nagłówkami
20
+ sortowania (strzałka pokazuje kierunek), **siatka** kafelków z ikonami typów plików;
21
+- sortowanie (nazwa/rozmiar/data/typ, rosnąco/malejąco), ukrywanie plików (`Ctrl+H`),
22
+ filtr na żywo (`Ctrl+F`), panel boczny chowany `F9`;
23
+- zakładki (`Ctrl+T`, `Ctrl+W`, `Tab`), widok dzielony (`F3`), podgląd pliku (`Space`:
24
+ tekst/kod oraz PNG, dla reszty metadane);
25
+- wielokrotne zaznaczanie: `Ctrl+klik`, `Shift+klik` (zakres), `Shift+↑/↓`, `Ctrl+A`;
26
+ belka statusu pokazuje liczbę **i łączny rozmiar** zaznaczonych;
27
+- menu kontekstowe (prawy przycisk) i menu główne (☰) — wszystkie napisy przez i18n.
28
+
29
+**Pliki**
30
+- kopiowanie/wycinanie/wklejanie (`Ctrl+C/X/V`) z **dialogiem konfliktów**
31
+ (Zastąp / Pomiń / Zmień nazwę / Zastąp wszystkie);
32
+- przenoszenie do kosza zgodnego z **XDG Trash Spec** (`Delete`) i trwałe usuwanie
33
+ (`Shift+Delete`), **przywracanie z kosza** na pierwotną ścieżkę oraz opróżnianie
34
+ kosza z potwierdzeniem;
35
+- zmiana nazwy (`F2`), nowy folder (`Ctrl+N`), odświeżanie (`F5`/`Ctrl+R`),
36
+ otwieranie plików przez `xdg-open`, terminal w katalogu (`F4`).
37
+
38
+**Urządzenia**
39
+- panel boczny: miejsca XDG (z `~/.config/user-dirs.dirs`), kosz, zamontowane dyski
40
+ z wolnym miejscem (rozmiar z `statvfs`);
41
+- montowanie/odmontowanie przez UDisks2 (`udisksctl` → D-Bus, hasło pyta `luna-polkit`);
42
+- lista odświeża się co 2 s, więc podłączenie pendrive'a pojawia się „samo”.
43
+
44
+**Operacje w tle**
45
+- kolejka zadań w osobnym wątku (`ops.rs`): postęp w belce statusu z **prędkością
46
+ i szacowanym czasem**, anulowanie (`✕` lub `Escape`), wynik zbiorczy w powiadomieniu;
47
+ UI nigdy nie blokuje się na dużym kopiowaniu.
48
+
49
+## Architektura
50
+
51
+| Plik | Rola |
52
+|---|---|
53
+| `model.rs` | wpisy katalogu, sortowanie, filtr, rozmiary i daty (testy bez UI) |
54
+| `list.rs` | lista z kolumnami, nagłówki sortowania, wirtualizacja wierszy |
55
+| `trash.rs` | kosz XDG: `files/` + `info/*.trashinfo`, kolizje nazw, `EXDEV` |
56
+| `places.rs` | miejsca XDG, montowania (`/proc/self/mountinfo`), `statvfs`, UDisks2 |
57
+| `ops.rs` | wielowątkowa kolejka kopiuj/przenieś/usuń/zmień nazwę |
58
+| `app.rs` | stan aplikacji, historia, zakładki, powiadomienia, pętla klatki |
59
+| `ui.rs` | layout, rysowanie, obsługa myszy/klawiatury, menu |
60
+
61
+## Skróty
62
+
63
+| Skrót | Akcja |
64
+|---|---|
65
+| `Ctrl+L` | wpisz ścieżkę |
66
+| `Ctrl+F` | szukaj w katalogu |
67
+| `Ctrl+T` / `Ctrl+W` | nowa karta / zamknij kartę |
68
+| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | kopiuj / wytnij / wklej |
69
+| `Ctrl+A` | zaznacz wszystko |
70
+| `Ctrl+H` | ukryj/pokaż pliki ukryte |
71
+| `F9` | pokaż/ukryj panel boczny |
72
+| `Ctrl+N` | nowy folder |
73
+| `F2` / `F3` / `F4` / `F5` | zmiana nazwy / dwa panele / terminal / odśwież |
74
+| `Space` | podgląd |
75
+| `Delete` / `Shift+Delete` | do kosza / trwale usuń |
76
+| `Escape` | zamknij podgląd/menu, wyczyść zaznaczenie |
77
+
78
+## Następne kroki
79
+
80
+- MTP/PTP (telefony) przez GVFS/libmtp, LUKS i montowanie partycji systemowych;
81
+- miniatury obrazów i wideo, podgląd PDF, kolumny (Miller), masowa zmiana nazw;
82
+- archiwa (zip/tar/7z) bez zewnętrznych aplikacji;
83
+- bezpośrednie sygnały UDisks2 (`InterfacesAdded/Removed`) zamiast odpytywania;
84
+- systemowy schowek dla „Kopiuj ścieżkę” (wymaga API schowka w `libluna-platform`);
85
+- „Otwórz za pomocą…” z wyborem aplikacji i zapamiętywaniem domyślnych.
0
86
new file mode 100644
@@ -0,0 +1,881 @@
1
+//! Stan aplikacji i pętla klatki (platforma libLuny).
2
+
3
+use std::error::Error;
4
+use std::path::{Path, PathBuf};
5
+use std::sync::Arc;
6
+use std::time::{Duration, Instant};
7
+
8
+use libluna_core::ThemeSettings;
9
+use libluna_i18n::{set_locale, tr, tr_args, Locale};
10
+use libluna_platform::{run as run_app, AppConfig, Frame};
11
+use libluna_render::ImageData;
12
+use libluna_widgets::{
13
+ AlertDialog, Entry as TextEntry, ItemView, Menu, NavEntry, NavList, PaintCtx, ScrollView,
14
+ SearchEntry, Toast,
15
+};
16
+
17
+use crate::model::{human_size, now_secs, DirView, Entry, Kind, SortKey};
18
+use crate::ops::{Job, Ops, OpsEvent};
19
+use crate::places::{self, Mount, Place};
20
+use crate::{trash, ui};
21
+
22
+/// Odstęp między panelami w trybie dzielonym.
23
+pub const SPLIT_GAP: f64 = 4.0;
24
+
25
+/// Edycja ścieżki albo nazwy (pole tekstowe w pasku adresu).
26
+pub struct EditState {
27
+ pub entry: TextEntry,
28
+ pub rename: Option<PathBuf>,
29
+ pub new_folder: bool,
30
+}
31
+
32
+impl EditState {
33
+ pub fn path(path: &Path) -> Self {
34
+ let mut entry = TextEntry::new(tr("Path"));
35
+ entry.set_text(path.display().to_string());
36
+ entry.set_focused(true);
37
+ entry.select_all();
38
+ Self {
39
+ entry,
40
+ rename: None,
41
+ new_folder: false,
42
+ }
43
+ }
44
+
45
+ pub fn rename(path: &Path) -> Self {
46
+ let mut entry = TextEntry::new(tr("Name"));
47
+ entry.set_text(
48
+ path.file_name()
49
+ .map(|n| n.to_string_lossy().into_owned())
50
+ .unwrap_or_default(),
51
+ );
52
+ entry.set_focused(true);
53
+ entry.select_all();
54
+ Self {
55
+ entry,
56
+ rename: Some(path.to_path_buf()),
57
+ new_folder: false,
58
+ }
59
+ }
60
+}
61
+
62
+/// Podgląd pliku (Space).
63
+pub enum Preview {
64
+ Text(String),
65
+ Image(Arc<ImageData>),
66
+ /// Brak podglądu (np. JPG bez dekodera) — pokazujemy metadane.
67
+ None,
68
+}
69
+
70
+/// Jedna zakładka/panel z własną ścieżką i stanem widoku.
71
+pub struct Pane {
72
+ pub dir: DirView,
73
+ pub view: ItemView,
74
+ pub scroll: ScrollView,
75
+ pub tabs: Vec<PathBuf>,
76
+ pub tab: usize,
77
+ pub back: Vec<PathBuf>,
78
+ pub forward: Vec<PathBuf>,
79
+ pub edit: Option<EditState>,
80
+ pub search: SearchEntry,
81
+ pub search_active: bool,
82
+ pub preview: bool,
83
+ pub preview_cache: Option<(PathBuf, Preview)>,
84
+}
85
+
86
+impl Pane {
87
+ fn new(path: PathBuf) -> Self {
88
+ let dir = DirView::load(&path).unwrap_or_else(|_| {
89
+ DirView::load(places::home()).unwrap_or_else(|_| DirView::load("/").expect("/"))
90
+ });
91
+ let mut view = ItemView::new(dir.items());
92
+ view.set_mode(libluna_widgets::ViewMode::List);
93
+ Self {
94
+ tabs: vec![dir.path.clone()],
95
+ tab: 0,
96
+ back: Vec::new(),
97
+ forward: Vec::new(),
98
+ edit: None,
99
+ search: SearchEntry::new(tr("Search")),
100
+ search_active: false,
101
+ preview: false,
102
+ preview_cache: None,
103
+ scroll: ScrollView::new(),
104
+ dir,
105
+ view,
106
+ }
107
+ }
108
+
109
+ /// Indeksy zaznaczonych wpisów (widoczne).
110
+ pub fn selection(&self) -> Vec<PathBuf> {
111
+ let visible = self.dir.visible_entries();
112
+ self.view
113
+ .selected_indices()
114
+ .iter()
115
+ .filter_map(|i| visible.get(*i))
116
+ .map(|e| e.path.clone())
117
+ .collect()
118
+ }
119
+
120
+ pub fn primary(&self) -> Option<&Entry> {
121
+ self.view.selected().and_then(|i| self.dir.entry(i))
122
+ }
123
+
124
+ /// Po zmianie katalogu/porządku odświeża `ItemView`, zachowując zaznaczenie
125
+ /// po ścieżce (jeśli element nadal istnieje).
126
+ pub fn sync_view(&mut self) {
127
+ let keep = self.primary().map(|e| e.path.clone());
128
+ let items = self.dir.items();
129
+ self.view.set_items(items);
130
+ if let Some(path) = keep {
131
+ if let Some(pos) = (0..self.view.len())
132
+ .find(|i| self.dir.entry(*i).map(|e| e.path == path).unwrap_or(false))
133
+ {
134
+ self.view.set_selected(Some(pos));
135
+ }
136
+ }
137
+ self.scroll = ScrollView::new();
138
+ self.preview_cache = None;
139
+ }
140
+
141
+ pub fn selected_title(&self) -> Option<String> {
142
+ self.primary().map(|e| e.name.clone())
143
+ }
144
+}
145
+
146
+/// Pozycja panelu bocznego.
147
+pub enum SideAction {
148
+ Go(PathBuf),
149
+ Unmount(usize),
150
+ Mount(usize),
151
+}
152
+
153
+/// Co otworzyło menu kontekstowe.
154
+pub enum MenuTarget {
155
+ Item(PathBuf, bool),
156
+ TrashItem(PathBuf),
157
+ Background,
158
+ Device(usize),
159
+ AppMenu,
160
+}
161
+
162
+pub struct ContextMenu {
163
+ pub menu: Menu,
164
+ pub panel: libluna_core::Rect,
165
+ pub target: MenuTarget,
166
+ /// Komenda dla każdego wpisu menu (`None` = sekcja/separator).
167
+ pub cmds: Vec<Option<MenuCmd>>,
168
+}
169
+
170
+/// Akcja wybrana z menu (indeks wpisu → komenda).
171
+#[derive(Debug, Clone, Copy)]
172
+pub enum MenuCmd {
173
+ Open,
174
+ OpenTerminal,
175
+ Copy,
176
+ Cut,
177
+ Paste,
178
+ SelectAll,
179
+ Rename,
180
+ Trash,
181
+ Delete,
182
+ Properties,
183
+ Restore,
184
+ Purge,
185
+ NewFolder,
186
+ NewTab,
187
+ Split,
188
+ ToggleHidden,
189
+ Sort(SortKey),
190
+ ToggleAsc,
191
+ Refresh,
192
+ EmptyTrash,
193
+ Unmount(usize),
194
+ OpenDevice(usize),
195
+}
196
+
197
+pub struct Clipboard {
198
+ pub paths: Vec<PathBuf>,
199
+ pub cut: bool,
200
+}
201
+
202
+impl Clone for Clipboard {
203
+ fn clone(&self) -> Self {
204
+ Self {
205
+ paths: self.paths.clone(),
206
+ cut: self.cut,
207
+ }
208
+ }
209
+}
210
+
211
+/// Dialog konfliktu nazw (blokuje operację do decyzji użytkownika).
212
+pub struct ConflictPrompt {
213
+ pub dialog: AlertDialog,
214
+ pub src: PathBuf,
215
+ pub dst: PathBuf,
216
+}
217
+
218
+impl ConflictPrompt {
219
+ pub fn new(src: PathBuf, dst: PathBuf) -> Self {
220
+ let name = src
221
+ .file_name()
222
+ .map(|n| n.to_string_lossy().into_owned())
223
+ .unwrap_or_default();
224
+ let body = tr_args(
225
+ "{name} already exists in the destination.",
226
+ &[("name", &name)],
227
+ );
228
+ let dialog = AlertDialog::new(tr("File exists"), body)
229
+ .width(520.0)
230
+ .button(tr("Replace"))
231
+ .button(tr("Skip"))
232
+ .button(tr("Rename"))
233
+ .button(tr("Replace All"));
234
+ Self { dialog, src, dst }
235
+ }
236
+}
237
+
238
+/// Cały stan aplikacji.
239
+pub struct App {
240
+ pub panes: [Pane; 2],
241
+ pub active: usize,
242
+ pub split: bool,
243
+ pub sidebar_visible: bool,
244
+ pub places: Vec<Place>,
245
+ pub mounts: Vec<Mount>,
246
+ pub sidebar: NavList,
247
+ pub sidebar_actions: Vec<SideAction>,
248
+ /// Dla każdego wpisu `sidebar.entries()` — indeks akcji (None = sekcja).
249
+ pub sidebar_entry_action: Vec<Option<usize>>,
250
+ pub context: Option<ContextMenu>,
251
+ pub conflict: Option<ConflictPrompt>,
252
+ /// Potwierdzenie opróżnienia kosza (modal).
253
+ pub confirm_empty: Option<AlertDialog>,
254
+ pub ops: Ops,
255
+ pub op_label: Option<String>,
256
+ pub op_progress: Option<(u64, u64)>,
257
+ pub op_current: String,
258
+ pub op_speed: f64,
259
+ op_sample: Option<(Instant, u64)>,
260
+ op_started: Option<Instant>,
261
+ pub toast: Toast,
262
+ pub clipboard: Option<Clipboard>,
263
+ pub last_click: Option<(usize, Instant)>,
264
+ pub last_mount_refresh: Instant,
265
+ pub status: String,
266
+ pub pending_close: bool,
267
+ pub pending_minimize: bool,
268
+ pub pending_maximize: bool,
269
+ pub pending_move: bool,
270
+}
271
+
272
+impl App {
273
+ pub fn new(start: PathBuf) -> Self {
274
+ let home = places::home();
275
+ let start = if start.is_dir() { start } else { home };
276
+ let mut app = Self {
277
+ panes: [Pane::new(start.clone()), Pane::new(start)],
278
+ active: 0,
279
+ split: false,
280
+ sidebar_visible: true,
281
+ places: Vec::new(),
282
+ mounts: Vec::new(),
283
+ sidebar: NavList::new(Vec::new()),
284
+ sidebar_actions: Vec::new(),
285
+ sidebar_entry_action: Vec::new(),
286
+ context: None,
287
+ conflict: None,
288
+ confirm_empty: None,
289
+ ops: Ops::new(),
290
+ op_label: None,
291
+ op_progress: None,
292
+ op_current: String::new(),
293
+ op_speed: 0.0,
294
+ op_sample: None,
295
+ op_started: None,
296
+ toast: Toast::new(""),
297
+ clipboard: None,
298
+ last_click: None,
299
+ last_mount_refresh: Instant::now() - Duration::from_secs(10),
300
+ status: String::new(),
301
+ pending_close: false,
302
+ pending_minimize: false,
303
+ pending_maximize: false,
304
+ pending_move: false,
305
+ };
306
+ app.rebuild_sidebar();
307
+ app
308
+ }
309
+
310
+ pub fn pane(&self) -> &Pane {
311
+ &self.panes[self.active]
312
+ }
313
+
314
+ pub fn pane_mut(&mut self) -> &mut Pane {
315
+ let i = self.active;
316
+ &mut self.panes[i]
317
+ }
318
+
319
+ pub fn other_pane_mut(&mut self) -> &mut Pane {
320
+ let i = 1 - self.active;
321
+ &mut self.panes[i]
322
+ }
323
+
324
+ /// Nawigacja z zapisem historii.
325
+ pub fn navigate(&mut self, pane: usize, path: PathBuf) {
326
+ if !path.is_dir() {
327
+ return;
328
+ }
329
+ if debug() {
330
+ eprintln!("luna-files: navigate {}", path.display());
331
+ }
332
+ let previous = self.panes[pane].dir.path.clone();
333
+ match DirView::load(&path) {
334
+ Ok(dir) => {
335
+ let p = &mut self.panes[pane];
336
+ p.dir = dir;
337
+ p.back.push(previous);
338
+ p.forward.clear();
339
+ p.tabs[p.tab] = p.dir.path.clone();
340
+ p.search.clear();
341
+ p.search_active = false;
342
+ p.dir.set_filter("");
343
+ p.edit = None;
344
+ p.sync_view();
345
+ self.select_sidebar_for(&path);
346
+ }
347
+ Err(e) => self.show_toast(format!("{}: {e}", path.display())),
348
+ }
349
+ }
350
+
351
+ pub fn reload_pane(&mut self, pane: usize) {
352
+ if self.panes[pane].dir.reload().is_ok() {
353
+ self.panes[pane].sync_view();
354
+ }
355
+ }
356
+
357
+ pub fn reload_all(&mut self) {
358
+ for i in 0..2 {
359
+ if self.panes[i].dir.reload().is_ok() {
360
+ self.panes[i].sync_view();
361
+ }
362
+ }
363
+ }
364
+
365
+ pub fn back(&mut self) {
366
+ let p = &mut self.panes[self.active];
367
+ let Some(prev) = p.back.pop() else { return };
368
+ p.forward.push(p.dir.path.clone());
369
+ if let Ok(dir) = DirView::load(&prev) {
370
+ p.dir = dir;
371
+ p.tabs[p.tab] = p.dir.path.clone();
372
+ p.sync_view();
373
+ let path = p.dir.path.clone();
374
+ self.select_sidebar_for(&path);
375
+ }
376
+ }
377
+
378
+ pub fn forward(&mut self) {
379
+ let p = &mut self.panes[self.active];
380
+ let Some(next) = p.forward.pop() else {
381
+ return;
382
+ };
383
+ p.back.push(p.dir.path.clone());
384
+ if let Ok(dir) = DirView::load(&next) {
385
+ p.dir = dir;
386
+ p.tabs[p.tab] = p.dir.path.clone();
387
+ p.sync_view();
388
+ let path = p.dir.path.clone();
389
+ self.select_sidebar_for(&path);
390
+ }
391
+ }
392
+
393
+ pub fn up(&mut self) {
394
+ let path = self.panes[self.active].dir.parent();
395
+ if let Some(path) = path {
396
+ self.navigate(self.active, path);
397
+ }
398
+ }
399
+
400
+ /// Otwiera zaznaczony wpis (katalog → nawigacja, plik → `xdg-open`).
401
+ pub fn open_selected(&mut self) {
402
+ let Some(entry) = self.pane().primary().cloned() else {
403
+ return;
404
+ };
405
+ if entry.is_dir {
406
+ self.navigate(self.active, entry.path);
407
+ } else {
408
+ let _ = std::process::Command::new("xdg-open")
409
+ .arg(&entry.path)
410
+ .spawn();
411
+ }
412
+ }
413
+
414
+ /// Otwiera terminal w bieżącym katalogu.
415
+ pub fn open_terminal(&mut self) {
416
+ let dir = self.pane().dir.path.clone();
417
+ self.open_terminal_at(&dir);
418
+ }
419
+
420
+ /// Otwiera terminal w podanym katalogu.
421
+ pub fn open_terminal_at(&mut self, dir: &Path) {
422
+ let dir_arg = dir.display().to_string();
423
+ let terminal = std::env::var("TERMINAL").ok();
424
+ let spawned = match terminal.as_deref() {
425
+ Some(t) if !t.is_empty() => std::process::Command::new(t)
426
+ .current_dir(dir)
427
+ .spawn()
428
+ .is_ok(),
429
+ _ => ["xfce4-terminal", "foot", "alacritty", "kitty", "wezterm"]
430
+ .iter()
431
+ .any(|t| spawn_terminal(t, &dir_arg)),
432
+ };
433
+ if !spawned {
434
+ self.show_toast(tr("No terminal emulator found"));
435
+ }
436
+ }
437
+
438
+ pub fn new_tab(&mut self) {
439
+ let p = self.pane_mut();
440
+ p.tabs.push(p.dir.path.clone());
441
+ p.tab = p.tabs.len() - 1;
442
+ }
443
+
444
+ pub fn close_tab(&mut self) {
445
+ let p = self.pane_mut();
446
+ if p.tabs.len() <= 1 {
447
+ return;
448
+ }
449
+ p.tabs.remove(p.tab);
450
+ p.tab = p.tab.min(p.tabs.len() - 1);
451
+ let path = p.tabs[p.tab].clone();
452
+ if let Ok(dir) = DirView::load(&path) {
453
+ p.dir = dir;
454
+ p.sync_view();
455
+ }
456
+ }
457
+
458
+ pub fn select_tab(&mut self, index: usize) {
459
+ let p = self.pane_mut();
460
+ if index < p.tabs.len() {
461
+ p.tab = index;
462
+ let path = p.tabs[index].clone();
463
+ if let Ok(dir) = DirView::load(&path) {
464
+ p.dir = dir;
465
+ p.sync_view();
466
+ }
467
+ }
468
+ }
469
+
470
+ pub fn submit(&mut self, job: Job) {
471
+ if !self.ops.submit(job) {
472
+ self.show_toast(tr("Another operation is still running"));
473
+ }
474
+ }
475
+
476
+ pub fn drain_ops(&mut self) {
477
+ for ev in self.ops.poll() {
478
+ match ev {
479
+ OpsEvent::Started { label } => {
480
+ self.op_label = Some(label);
481
+ self.op_progress = Some((0, 0));
482
+ self.op_current.clear();
483
+ self.op_speed = 0.0;
484
+ self.op_sample = None;
485
+ self.op_started = Some(Instant::now());
486
+ }
487
+ OpsEvent::Progress {
488
+ done,
489
+ total,
490
+ current,
491
+ } => {
492
+ let now = Instant::now();
493
+ if let Some((t0, d0)) = self.op_sample {
494
+ let dt = now.duration_since(t0).as_secs_f64();
495
+ if dt >= 0.3 {
496
+ let inst = done.saturating_sub(d0) as f64 / dt;
497
+ self.op_speed = if self.op_speed <= 0.0 {
498
+ inst
499
+ } else {
500
+ self.op_speed * 0.7 + inst * 0.3
501
+ };
502
+ self.op_sample = Some((now, done));
503
+ }
504
+ } else {
505
+ self.op_sample = Some((now, done));
506
+ }
507
+ self.op_progress = Some((done, total));
508
+ self.op_current = current;
509
+ }
510
+ OpsEvent::Conflict { src, dst, .. } => {
511
+ self.conflict = Some(ConflictPrompt::new(src, dst));
512
+ }
513
+ OpsEvent::Finished {
514
+ label,
515
+ ok,
516
+ skipped,
517
+ failed,
518
+ created,
519
+ } => {
520
+ if debug() {
521
+ eprintln!(
522
+ "luna-files: ops '{label}' ok={ok} skipped={skipped} failed={failed:?} created={created:?}"
523
+ );
524
+ }
525
+ self.op_label = None;
526
+ self.op_progress = None;
527
+ self.op_current.clear();
528
+ self.op_speed = 0.0;
529
+ self.op_sample = None;
530
+ self.op_started = None;
531
+ self.conflict = None;
532
+ self.reload_all();
533
+ self.refresh_devices();
534
+ if let Some(path) = created {
535
+ self.select_path(&path);
536
+ }
537
+ let msg = if skipped > 0 {
538
+ tr_args(
539
+ "{label}: {n} done, {skipped} skipped",
540
+ &[
541
+ ("label", &label),
542
+ ("n", &ok.to_string()),
543
+ ("skipped", &skipped.to_string()),
544
+ ],
545
+ )
546
+ } else {
547
+ tr_args(
548
+ "{label}: {n} done",
549
+ &[("label", &label), ("n", &ok.to_string())],
550
+ )
551
+ };
552
+ if let Some(first) = failed.first() {
553
+ self.show_toast(format!("{msg} — {first}"));
554
+ } else {
555
+ self.show_toast(msg);
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ /// Odpowiedź na dialog konfliktu (indeks przycisku).
563
+ pub fn answer_conflict(&mut self, index: usize) {
564
+ use crate::ops::Decision;
565
+ let decision = match index {
566
+ 0 => Decision::Overwrite,
567
+ 1 => Decision::Skip,
568
+ 2 => Decision::Rename,
569
+ 3 => Decision::OverwriteAll,
570
+ _ => Decision::Skip,
571
+ };
572
+ if debug() {
573
+ eprintln!("luna-files: conflict -> {decision:?}");
574
+ }
575
+ self.ops.decide(decision);
576
+ self.conflict = None;
577
+ }
578
+
579
+ pub fn cancel_op(&mut self) {
580
+ self.ops.cancel();
581
+ }
582
+
583
+ pub fn refresh_devices(&mut self) {
584
+ let mounts = places::mounts();
585
+ let changed = mounts.len() != self.mounts.len()
586
+ || mounts
587
+ .iter()
588
+ .zip(self.mounts.iter())
589
+ .any(|(a, b)| a.dev != b.dev || a.mount != b.mount);
590
+ self.mounts = mounts;
591
+ if changed {
592
+ self.rebuild_sidebar();
593
+ let path = self.pane().dir.path.clone();
594
+ self.select_sidebar_for(&path);
595
+ }
596
+ self.last_mount_refresh = Instant::now();
597
+ }
598
+
599
+ pub fn refresh_devices_if_needed(&mut self) {
600
+ if self.last_mount_refresh.elapsed() >= Duration::from_secs(2) {
601
+ self.refresh_devices();
602
+ }
603
+ }
604
+
605
+ pub fn rebuild_sidebar(&mut self) {
606
+ self.places = places::places();
607
+ let mut entries = Vec::new();
608
+ let mut actions = Vec::new();
609
+ let mut mapping = Vec::new();
610
+
611
+ entries.push(NavEntry::Section(tr("Places")));
612
+ mapping.push(None);
613
+ for place in &self.places {
614
+ entries.push(NavEntry::Item(place.label.clone()));
615
+ actions.push(SideAction::Go(place.path.clone()));
616
+ mapping.push(Some(actions.len() - 1));
617
+ }
618
+ entries.push(NavEntry::Item(tr("Trash")));
619
+ actions.push(SideAction::Go(trash::trash_root().join("files")));
620
+ mapping.push(Some(actions.len() - 1));
621
+
622
+ if !self.mounts.is_empty() {
623
+ entries.push(NavEntry::Section(tr("Devices")));
624
+ mapping.push(None);
625
+ for (i, m) in self.mounts.iter().enumerate() {
626
+ let free = human_size(m.free);
627
+ let label = tr_args(
628
+ "{name} — {free} free",
629
+ &[("name", &m.label), ("free", &free)],
630
+ );
631
+ entries.push(NavEntry::Item(label));
632
+ actions.push(SideAction::Go(m.mount.clone()));
633
+ mapping.push(Some(actions.len() - 1));
634
+ // Lewy klik nawiguje; odmontowanie jest w menu kontekstowym.
635
+ let _ = (i, SideAction::Unmount(i));
636
+ }
637
+ }
638
+
639
+ self.sidebar = NavList::new(entries);
640
+ self.sidebar_actions = actions;
641
+ self.sidebar_entry_action = mapping;
642
+ }
643
+
644
+ fn select_sidebar_for(&mut self, path: &Path) {
645
+ let mut best: Option<(usize, usize)> = None;
646
+ for (ai, act) in self.sidebar_actions.iter().enumerate() {
647
+ if let SideAction::Go(p) = act {
648
+ if path.starts_with(p) {
649
+ let depth = p.components().count();
650
+ if best.map(|(_, d)| depth > d).unwrap_or(true) {
651
+ best = Some((ai, depth));
652
+ }
653
+ }
654
+ }
655
+ }
656
+ if let Some((ai, _)) = best {
657
+ if let Some(entry) = self
658
+ .sidebar_entry_action
659
+ .iter()
660
+ .position(|a| *a == Some(ai))
661
+ {
662
+ self.sidebar.set_selected(Some(entry));
663
+ }
664
+ }
665
+ }
666
+
667
+ /// Zaznacza plik w bieżącym panelu (np. po utworzeniu folderu).
668
+ pub fn select_path(&mut self, path: &Path) {
669
+ let p = self.pane_mut();
670
+ let visible = p.dir.visible_entries();
671
+ if let Some(i) = visible.iter().position(|e| e.path == *path) {
672
+ p.view.set_selected(Some(i));
673
+ }
674
+ }
675
+
676
+ pub fn show_toast(&mut self, message: impl Into<String>) {
677
+ self.toast = Toast::new(message);
678
+ self.toast.show_animated();
679
+ }
680
+
681
+ /// Status w dolnej belce.
682
+ pub fn status_text(&self) -> String {
683
+ let p = self.pane();
684
+ let visible = p.view.len();
685
+ let selected = p.view.selected_indices().len();
686
+ let mut text = tr_args("{n} items", &[("n", &visible.to_string())]);
687
+ if selected > 0 {
688
+ let n = selected.to_string();
689
+ let visible_entries = p.dir.visible_entries();
690
+ let size: u64 = p
691
+ .view
692
+ .selected_indices()
693
+ .iter()
694
+ .filter_map(|i| visible_entries.get(*i))
695
+ .map(|e| if e.is_dir { 0 } else { e.size })
696
+ .sum();
697
+ text = format!(
698
+ "{text} · {}",
699
+ tr_args(
700
+ "{n} selected ({size})",
701
+ &[("n", &n), ("size", &human_size(size))]
702
+ )
703
+ );
704
+ }
705
+ if let Some((total, free)) = places::statvfs(&p.dir.path) {
706
+ let pct = if total > 0 {
707
+ (total.saturating_sub(free)) as f64 / total as f64 * 100.0
708
+ } else {
709
+ 0.0
710
+ };
711
+ text = format!(
712
+ "{text} · {} {} ({}%)",
713
+ human_size(free),
714
+ tr("free"),
715
+ pct.round() as i64
716
+ );
717
+ }
718
+ text
719
+ }
720
+
721
+ /// Odświeża podgląd zaznaczonego pliku.
722
+ pub fn update_preview(&mut self) {
723
+ let p = self.pane_mut();
724
+ if !p.preview {
725
+ p.preview_cache = None;
726
+ return;
727
+ }
728
+ let Some(entry) = p.primary().cloned() else {
729
+ p.preview_cache = None;
730
+ return;
731
+ };
732
+ if p.preview_cache
733
+ .as_ref()
734
+ .map(|(path, _)| *path == entry.path)
735
+ .unwrap_or(false)
736
+ {
737
+ return;
738
+ }
739
+ let preview = preview_for(&entry);
740
+ p.preview_cache = Some((entry.path, preview));
741
+ }
742
+}
743
+
744
+/// Diagnostyka `LUNA_FILES_DEBUG=1`.
745
+pub fn debug() -> bool {
746
+ std::env::var("LUNA_FILES_DEBUG").is_ok()
747
+}
748
+
749
+fn preview_for(entry: &Entry) -> Preview {
750
+ if entry.is_dir {
751
+ return Preview::None;
752
+ }
753
+ match entry.kind {
754
+ Kind::Image => {
755
+ // Dekoder PNG jest w libluna-render; inne formaty → metadane.
756
+ if entry
757
+ .path
758
+ .extension()
759
+ .and_then(|e| e.to_str())
760
+ .map(|e| e.eq_ignore_ascii_case("png"))
761
+ .unwrap_or(false)
762
+ {
763
+ if let Ok(bytes) = std::fs::read(&entry.path) {
764
+ if let Some(img) = libluna_render::wallpaper::decode_png(&bytes) {
765
+ return Preview::Image(Arc::new(img));
766
+ }
767
+ }
768
+ }
769
+ Preview::None
770
+ }
771
+ Kind::Text | Kind::Code => {
772
+ use std::io::Read;
773
+ let mut file = match std::fs::File::open(&entry.path) {
774
+ Ok(f) => f,
775
+ Err(_) => return Preview::None,
776
+ };
777
+ let mut buf = vec![0u8; 64 * 1024];
778
+ let n = file.read(&mut buf).unwrap_or(0);
779
+ buf.truncate(n);
780
+ let text = String::from_utf8_lossy(&buf);
781
+ let lines: Vec<&str> = text.lines().take(200).collect();
782
+ Preview::Text(lines.join("\n"))
783
+ }
784
+ _ => Preview::None,
785
+ }
786
+}
787
+
788
+fn spawn_terminal(program: &str, dir: &str) -> bool {
789
+ let args: &[&str] = match program {
790
+ "xfce4-terminal" => &["--working-directory", dir],
791
+ "gnome-terminal" => &["--working-directory", dir],
792
+ "kitty" => &["--directory", dir],
793
+ "alacritty" => &["--working-directory", dir],
794
+ _ => &[],
795
+ };
796
+ std::process::Command::new(program)
797
+ .args(args)
798
+ .current_dir(dir)
799
+ .spawn()
800
+ .is_ok()
801
+}
802
+
803
+/// Uruchamia aplikację (binarki `luna-files` i `gaminedes`).
804
+pub fn run(title_key: &str, app_id: &str) -> Result<(), Box<dyn Error>> {
805
+ set_locale(Locale::from_env());
806
+ libluna_i18n::load_catalogs_from_dirs();
807
+ let title = tr(title_key);
808
+
809
+ let settings = ThemeSettings::load();
810
+ let system_dark = std::env::var("LUNA_SYSTEM_DARK")
811
+ .map(|v| !v.is_empty() && v != "0")
812
+ .unwrap_or(false);
813
+
814
+ let start = std::env::args()
815
+ .nth(1)
816
+ .map(PathBuf::from)
817
+ .filter(|p| p.is_dir())
818
+ .or_else(|| std::env::current_dir().ok())
819
+ .unwrap_or_else(places::home);
820
+
821
+ let config = AppConfig::new(title)
822
+ .app_id(app_id)
823
+ .initial_size(1120, 720)
824
+ .settings(settings)
825
+ .system_dark(system_dark);
826
+
827
+ let mut app = App::new(start);
828
+ app.status = app.status_text();
829
+
830
+ run_app(config, move |frame: &mut Frame| {
831
+ let theme = frame.settings().resolve_themed(frame.system_dark());
832
+ app.drain_ops();
833
+ app.refresh_devices_if_needed();
834
+
835
+ ui::handle_events(&mut app, frame.events, frame.size, &theme, frame.text);
836
+
837
+ if app.pending_close {
838
+ frame.close();
839
+ app.pending_close = false;
840
+ }
841
+ if app.pending_minimize {
842
+ frame.minimize();
843
+ app.pending_minimize = false;
844
+ }
845
+ if app.pending_maximize {
846
+ frame.toggle_maximize();
847
+ app.pending_maximize = false;
848
+ }
849
+ if app.pending_move {
850
+ frame.begin_move();
851
+ app.pending_move = false;
852
+ }
853
+
854
+ app.update_preview();
855
+ app.status = app.status_text();
856
+
857
+ ui::paint(&app, frame.scene, frame.size, &theme, frame.text);
858
+
859
+ if app.toast.is_animating() {
860
+ app.toast.advance(frame.delta_time(), 0.3);
861
+ frame.request_frame();
862
+ }
863
+ if app.ops.busy() {
864
+ frame.request_frame();
865
+ }
866
+ })
867
+}
868
+
869
+/// Pomocnik dla UI: `PaintCtx` z klatki.
870
+pub fn paint_ctx<'a>(
871
+ scene: &'a mut libluna_render::Scene,
872
+ theme: &'a libluna_core::Theme,
873
+ text: &'a libluna_render::TextEngine,
874
+) -> PaintCtx<'a> {
875
+ PaintCtx::new(scene, theme, text)
876
+}
877
+
878
+/// Na potrzeby UI: bieżący czas do animacji/migań.
879
+pub fn tick() -> i64 {
880
+ now_secs()
881
+}
0
882
new file mode 100644
@@ -0,0 +1,7 @@
1
+//! `gaminedes` — alias menedżera plików Luny (ta sama aplikacja, inna nazwa okna).
2
+
3
+use std::error::Error;
4
+
5
+fn main() -> Result<(), Box<dyn Error>> {
6
+ luna_files::run_gaminedes()
7
+}
0
8
new file mode 100644
@@ -0,0 +1,21 @@
1
+//! Luna Files — menedżer plików środowiska Luna na libLunie.
2
+
3
+pub mod app;
4
+pub mod list;
5
+pub mod model;
6
+pub mod ops;
7
+pub mod places;
8
+pub mod trash;
9
+pub mod ui;
10
+
11
+use std::error::Error;
12
+
13
+/// Binarka `luna-files`.
14
+pub fn run_files() -> Result<(), Box<dyn Error>> {
15
+ app::run("Files", "eu.luna.Files")
16
+}
17
+
18
+/// Binarka `gaminedes` — ta sama aplikacja pod inną nazwą (alias).
19
+pub fn run_gaminedes() -> Result<(), Box<dyn Error>> {
20
+ app::run("Gaminedes", "eu.luna.Gaminedes")
21
+}
0
22
new file mode 100644
@@ -0,0 +1,277 @@
1
+//! Widok listy z kolumnami (Nazwa / Rozmiar / Typ / Modyfikacja).
2
+//!
3
+//! `ItemView` z libLuny rysuje tylko etykietę z dopiskiem — do menedżera plików
4
+//! potrzebne są prawdziwe kolumny, klikalne nagłówki i wirtualizacja. Ten moduł
5
+//! rysuje listę bezpośrednio z `DirView`, więc przewijanie i zaznaczanie zostają
6
+//! spójne z resztą aplikacji.
7
+
8
+use libluna_core::style::weight;
9
+use libluna_core::{Point, Rect, Theme};
10
+use libluna_widgets::{icon, IconName, PaintCtx};
11
+
12
+use crate::model::{format_mtime, human_size, DirView, Kind, SortKey};
13
+
14
+/// Wysokość paska nagłówków kolumn.
15
+pub const HEADER_H: f64 = 26.0;
16
+
17
+#[derive(Debug, Clone, Copy)]
18
+pub struct Columns {
19
+ pub name: Rect,
20
+ pub size: Rect,
21
+ pub kind: Rect,
22
+ pub modified: Rect,
23
+}
24
+
25
+/// Szerokości kolumn: nazwa bierze resztę, reszta ma stałe minima.
26
+pub fn columns(rect: Rect, _theme: &Theme) -> Columns {
27
+ let size_w = 90.0;
28
+ let kind_w = 120.0;
29
+ let date_w = 140.0;
30
+ let name_w = (rect.width() - size_w - kind_w - date_w).max(100.0);
31
+ Columns {
32
+ name: Rect::from_xywh(rect.x(), rect.y(), name_w, rect.height()),
33
+ size: Rect::from_xywh(rect.x() + name_w, rect.y(), size_w, rect.height()),
34
+ kind: Rect::from_xywh(rect.x() + name_w + size_w, rect.y(), kind_w, rect.height()),
35
+ modified: Rect::from_xywh(
36
+ rect.x() + name_w + size_w + kind_w,
37
+ rect.y(),
38
+ date_w,
39
+ rect.height(),
40
+ ),
41
+ }
42
+}
43
+
44
+/// Obszar wierszy (poniżej nagłówka).
45
+pub fn rows_rect(rect: Rect) -> Rect {
46
+ Rect::from_xywh(
47
+ rect.x(),
48
+ rect.y() + HEADER_H,
49
+ rect.width(),
50
+ (rect.height() - HEADER_H).max(1.0),
51
+ )
52
+}
53
+
54
+/// Trafienie w nagłówek kolumny (sortowanie).
55
+pub fn header_at(rect: Rect, p: Point, theme: &Theme) -> Option<SortKey> {
56
+ if p.y < rect.y() || p.y >= rect.y() + HEADER_H {
57
+ return None;
58
+ }
59
+ let c = columns(rect, theme);
60
+ if c.name.contains(p) {
61
+ Some(SortKey::Name)
62
+ } else if c.size.contains(p) {
63
+ Some(SortKey::Size)
64
+ } else if c.kind.contains(p) {
65
+ Some(SortKey::Kind)
66
+ } else if c.modified.contains(p) {
67
+ Some(SortKey::Modified)
68
+ } else {
69
+ None
70
+ }
71
+}
72
+
73
+/// Prostokąt wiersza (z przewinięciem).
74
+pub fn row_rect(rows: Rect, i: usize, scroll: f64, theme: &Theme) -> Rect {
75
+ Rect::from_xywh(
76
+ rows.x(),
77
+ rows.y() + i as f64 * theme.metrics.itemview.row_h - scroll,
78
+ rows.width(),
79
+ theme.metrics.itemview.row_h,
80
+ )
81
+}
82
+
83
+/// Indeks wiersza pod punktem.
84
+pub fn index_at(rows: Rect, p: Point, scroll: f64, len: usize, theme: &Theme) -> Option<usize> {
85
+ if !rows.contains(p) {
86
+ return None;
87
+ }
88
+ let row_h = theme.metrics.itemview.row_h;
89
+ let index = ((p.y - rows.y() + scroll) / row_h).floor();
90
+ if index < 0.0 {
91
+ return None;
92
+ }
93
+ let index = index as usize;
94
+ (index < len).then_some(index)
95
+}
96
+
97
+/// Rysuje nagłówek + widoczne wiersze.
98
+pub fn paint(
99
+ ctx: &mut PaintCtx,
100
+ rect: Rect,
101
+ dir: &DirView,
102
+ selection: &[usize],
103
+ hovered: Option<usize>,
104
+ scroll: f64,
105
+ theme: &Theme,
106
+) {
107
+ let colors = theme.colors;
108
+ let c = columns(rect, theme);
109
+ let rows = rows_rect(rect);
110
+
111
+ // Nagłówek
112
+ let header = Rect::from_xywh(rect.x(), rect.y(), rect.width(), HEADER_H);
113
+ ctx.scene.fill_rect(header, colors.headerbar_bg);
114
+ ctx.scene.fill_rect(
115
+ Rect::from_xywh(header.x(), header.bottom() - 1.0, header.width(), 1.0),
116
+ colors.border,
117
+ );
118
+ let caption = theme.typography.caption.size;
119
+ let arrow = if dir.ascending() { "▲" } else { "▼" };
120
+ let labels = [
121
+ (c.name, SortKey::Name, "Name", false),
122
+ (c.size, SortKey::Size, "Size", true),
123
+ (c.kind, SortKey::Kind, "Type", false),
124
+ (c.modified, SortKey::Modified, "Modified", false),
125
+ ];
126
+ for (area, key, label, right) in labels {
127
+ let text = if dir.sort_key() == key {
128
+ format!("{} {arrow}", libluna_i18n::tr(label))
129
+ } else {
130
+ libluna_i18n::tr(label)
131
+ };
132
+ let color = if dir.sort_key() == key {
133
+ colors.text
134
+ } else {
135
+ colors.dim_text
136
+ };
137
+ let cy = header.center().y;
138
+ if right {
139
+ ctx.label_right(
140
+ area.right() - 10.0,
141
+ cy,
142
+ &text,
143
+ caption,
144
+ weight::MEDIUM,
145
+ color,
146
+ );
147
+ } else {
148
+ ctx.label_left(area.x() + 10.0, cy, &text, caption, weight::MEDIUM, color);
149
+ }
150
+ }
151
+
152
+ // Wiersze (wirtualizacja)
153
+ ctx.scene.push_clip(rows);
154
+ let row_h = theme.metrics.itemview.row_h;
155
+ let first = ((0.0f64).max(scroll / row_h)).floor() as usize;
156
+ let visible = (rows.height() / row_h).ceil() as usize + 2;
157
+ let visible_indices = dir.visible();
158
+ for i in first..(first + visible).min(visible_indices.len()) {
159
+ let entry = &dir.entries[visible_indices[i]];
160
+ let r = row_rect(rows, i, scroll, theme);
161
+ if selection.binary_search(&i).is_ok() {
162
+ ctx.scene
163
+ .fill_rounded_rect(r, theme.radius.small, colors.shade.scale_alpha(1.8));
164
+ } else if hovered == Some(i) {
165
+ ctx.scene
166
+ .fill_rounded_rect(r, theme.radius.small, colors.shade);
167
+ }
168
+ let icon_box = Rect::from_xywh(r.x() + 10.0, r.center().y - 9.0, 18.0, 18.0);
169
+ draw_kind(ctx, entry.kind, icon_box, colors.accent, colors.dim_text);
170
+ let name_color = if entry.hidden {
171
+ colors.dim_text
172
+ } else {
173
+ colors.text
174
+ };
175
+ ctx.label_left(
176
+ c.name.x() + 38.0,
177
+ r.center().y,
178
+ &entry.name,
179
+ theme.typography.body.size,
180
+ weight::NORMAL,
181
+ name_color,
182
+ );
183
+ if !entry.is_dir {
184
+ ctx.label_right(
185
+ c.size.right() - 10.0,
186
+ r.center().y,
187
+ &human_size(entry.size),
188
+ theme.typography.caption.size,
189
+ weight::NORMAL,
190
+ colors.dim_text,
191
+ );
192
+ }
193
+ ctx.label_left(
194
+ c.kind.x() + 10.0,
195
+ r.center().y,
196
+ &entry.kind.label(),
197
+ theme.typography.caption.size,
198
+ weight::NORMAL,
199
+ colors.dim_text,
200
+ );
201
+ ctx.label_left(
202
+ c.modified.x() + 10.0,
203
+ r.center().y,
204
+ &format_mtime(entry.modified),
205
+ theme.typography.caption.size,
206
+ weight::NORMAL,
207
+ colors.dim_text,
208
+ );
209
+ }
210
+ ctx.scene.pop_clip();
211
+}
212
+
213
+fn draw_kind(
214
+ ctx: &mut PaintCtx,
215
+ kind: Kind,
216
+ rect: Rect,
217
+ folder: libluna_core::Color,
218
+ file: libluna_core::Color,
219
+) {
220
+ match kind {
221
+ Kind::Folder => icon::folder(ctx, folder, rect),
222
+ Kind::Image => icon::paint_icon(ctx, IconName::Image, rect, file),
223
+ Kind::Audio => icon::paint_icon(ctx, IconName::Music, rect, file),
224
+ Kind::Video => icon::paint_icon(ctx, IconName::Video, rect, file),
225
+ Kind::Archive => icon::paint_icon(ctx, IconName::Archive, rect, file),
226
+ Kind::Document => icon::paint_icon(ctx, IconName::Document, rect, file),
227
+ Kind::Code => icon::paint_icon(ctx, IconName::Code, rect, file),
228
+ Kind::Text => icon::paint_icon(ctx, IconName::Document, rect, file),
229
+ Kind::Other => icon::file(ctx, file, rect),
230
+ }
231
+}
232
+
233
+#[cfg(test)]
234
+mod tests {
235
+ use super::*;
236
+
237
+ fn rect() -> Rect {
238
+ Rect::from_xywh(0.0, 0.0, 700.0, 300.0)
239
+ }
240
+
241
+ #[test]
242
+ fn columns_tile_the_width() {
243
+ let theme = Theme::light();
244
+ let c = columns(rect(), &theme);
245
+ assert!(
246
+ (c.name.width() + c.size.width() + c.kind.width() + c.modified.width() - 700.0).abs()
247
+ < 0.01
248
+ );
249
+ assert!(c.modified.right() <= 700.0 + 0.01);
250
+ }
251
+
252
+ #[test]
253
+ fn header_hits_sort_columns() {
254
+ let theme = Theme::light();
255
+ let c = columns(rect(), &theme);
256
+ let p = Point::new(c.size.center().x, 12.0);
257
+ assert_eq!(header_at(rect(), p, &theme), Some(SortKey::Size));
258
+ assert_eq!(header_at(rect(), Point::new(5.0, 40.0), &theme), None);
259
+ }
260
+
261
+ #[test]
262
+ fn row_index_respects_scroll() {
263
+ let theme = Theme::light();
264
+ let rows = rows_rect(rect());
265
+ let row_h = theme.metrics.itemview.row_h;
266
+ // Drugi wiersz przy przewinięciu 0.
267
+ let p = Point::new(20.0, rows.y() + row_h * 1.5);
268
+ assert_eq!(index_at(rows, p, 0.0, 10, &theme), Some(1));
269
+ // Przy przewinięciu o jeden wiersz ten sam punkt to trzeci wiersz.
270
+ assert_eq!(index_at(rows, p, row_h, 10, &theme), Some(2));
271
+ // Poza listą.
272
+ assert_eq!(
273
+ index_at(rows, Point::new(20.0, rows.bottom() + 5.0), 0.0, 10, &theme),
274
+ None
275
+ );
276
+ }
277
+}
0
278
new file mode 100644
@@ -0,0 +1,7 @@
1
+//! `luna-files` — menedżer plików Luny.
2
+
3
+use std::error::Error;
4
+
5
+fn main() -> Result<(), Box<dyn Error>> {
6
+ luna_files::run_files()
7
+}
0
8
new file mode 100644
@@ -0,0 +1,472 @@
1
+//! Model katalogu: wpisy, sortowanie, filtrowanie i formatowanie.
2
+//!
3
+//! Warstwa niezależna od UI — da się ją testować bez okna i bez Waylanda.
4
+
5
+use std::fs;
6
+use std::io;
7
+use std::path::{Path, PathBuf};
8
+use std::time::UNIX_EPOCH;
9
+
10
+use libluna_i18n::tr;
11
+use libluna_widgets::{Item, ItemKind};
12
+
13
+/// Klucz sortowania listy.
14
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15
+pub enum SortKey {
16
+ Name,
17
+ Size,
18
+ Modified,
19
+ Kind,
20
+}
21
+
22
+impl SortKey {
23
+ pub fn label(self) -> String {
24
+ tr(match self {
25
+ SortKey::Name => "Name",
26
+ SortKey::Size => "Size",
27
+ SortKey::Modified => "Modified",
28
+ SortKey::Kind => "Type",
29
+ })
30
+ }
31
+
32
+ pub fn next(self) -> Self {
33
+ match self {
34
+ SortKey::Name => SortKey::Size,
35
+ SortKey::Size => SortKey::Modified,
36
+ SortKey::Modified => SortKey::Kind,
37
+ SortKey::Kind => SortKey::Name,
38
+ }
39
+ }
40
+}
41
+
42
+/// Rodzaj pliku (na podstawie rozszerzenia) — do ikon, podpisów i kolumny „Typ”.
43
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44
+pub enum Kind {
45
+ Folder,
46
+ Image,
47
+ Audio,
48
+ Video,
49
+ Archive,
50
+ Document,
51
+ Code,
52
+ Text,
53
+ Other,
54
+}
55
+
56
+impl Kind {
57
+ pub fn label(self) -> String {
58
+ tr(match self {
59
+ Kind::Folder => "Folder",
60
+ Kind::Image => "Image",
61
+ Kind::Audio => "Audio",
62
+ Kind::Video => "Video",
63
+ Kind::Archive => "Archive",
64
+ Kind::Document => "Document",
65
+ Kind::Code => "Code",
66
+ Kind::Text => "Text file",
67
+ Kind::Other => "File",
68
+ })
69
+ }
70
+
71
+ pub fn widget_kind(self) -> ItemKind {
72
+ match self {
73
+ Kind::Folder => ItemKind::Folder,
74
+ Kind::Image => ItemKind::Image,
75
+ Kind::Audio => ItemKind::Audio,
76
+ Kind::Video => ItemKind::Video,
77
+ Kind::Archive => ItemKind::Archive,
78
+ Kind::Document => ItemKind::Document,
79
+ Kind::Code => ItemKind::Code,
80
+ Kind::Text => ItemKind::Text,
81
+ Kind::Other => ItemKind::File,
82
+ }
83
+ }
84
+
85
+ fn from_name(name: &str, is_dir: bool) -> Self {
86
+ if is_dir {
87
+ return Kind::Folder;
88
+ }
89
+ let ext = Path::new(name)
90
+ .extension()
91
+ .and_then(|e| e.to_str())
92
+ .unwrap_or("")
93
+ .to_ascii_lowercase();
94
+ match ext.as_str() {
95
+ "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "svg" | "ico" | "tiff" | "avif"
96
+ | "heic" => Kind::Image,
97
+ "mp3" | "flac" | "ogg" | "oga" | "wav" | "opus" | "m4a" | "aac" | "wma" => Kind::Audio,
98
+ "mp4" | "mkv" | "webm" | "avi" | "mov" | "m4v" | "wmv" | "flv" => Kind::Video,
99
+ "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "deb" | "rpm"
100
+ | "iso" => Kind::Archive,
101
+ "pdf" | "odt" | "ods" | "odp" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
102
+ | "epub" | "rtf" => Kind::Document,
103
+ "rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
104
+ | "java" | "sh" | "toml" | "yaml" | "yml" | "json" | "xml" | "css" | "html" | "lua"
105
+ | "rb" | "php" | "cs" | "kt" | "swift" => Kind::Code,
106
+ "txt" | "md" | "log" | "conf" | "ini" | "cfg" | "csv" | "lang" => Kind::Text,
107
+ _ => Kind::Other,
108
+ }
109
+ }
110
+}
111
+
112
+/// Pojedynczy wpis w katalogu.
113
+#[derive(Debug, Clone)]
114
+pub struct Entry {
115
+ pub name: String,
116
+ pub path: PathBuf,
117
+ pub is_dir: bool,
118
+ pub size: u64,
119
+ /// Czas modyfikacji (sekundy od epoki).
120
+ pub modified: Option<i64>,
121
+ pub hidden: bool,
122
+ pub symlink: bool,
123
+ pub kind: Kind,
124
+}
125
+
126
+impl Entry {
127
+ pub fn detail(&self) -> String {
128
+ if self.is_dir {
129
+ Kind::Folder.label()
130
+ } else {
131
+ human_size(self.size)
132
+ }
133
+ }
134
+
135
+ pub fn item(&self) -> Item {
136
+ Item::new(self.name.clone())
137
+ .detail(self.detail())
138
+ .kind(self.kind.widget_kind())
139
+ }
140
+}
141
+
142
+/// Zawartość katalogu + ustawienia prezentacji.
143
+#[derive(Debug, Clone)]
144
+pub struct DirView {
145
+ pub path: PathBuf,
146
+ pub entries: Vec<Entry>,
147
+ sort: SortKey,
148
+ ascending: bool,
149
+ show_hidden: bool,
150
+ filter: String,
151
+}
152
+
153
+impl DirView {
154
+ /// Wczytuje katalog (bez podążania za `..`).
155
+ pub fn load(path: impl Into<PathBuf>) -> io::Result<Self> {
156
+ let path = path.into();
157
+ let entries = read_entries(&path)?;
158
+ let mut view = Self {
159
+ path,
160
+ entries,
161
+ sort: SortKey::Name,
162
+ ascending: true,
163
+ show_hidden: false,
164
+ filter: String::new(),
165
+ };
166
+ view.resort();
167
+ Ok(view)
168
+ }
169
+
170
+ pub fn reload(&mut self) -> io::Result<()> {
171
+ self.entries = read_entries(&self.path)?;
172
+ self.resort();
173
+ Ok(())
174
+ }
175
+
176
+ pub fn sort_key(&self) -> SortKey {
177
+ self.sort
178
+ }
179
+
180
+ pub fn set_sort_key(&mut self, key: SortKey) {
181
+ self.sort = key;
182
+ self.resort();
183
+ }
184
+
185
+ pub fn ascending(&self) -> bool {
186
+ self.ascending
187
+ }
188
+
189
+ pub fn set_ascending(&mut self, ascending: bool) {
190
+ self.ascending = ascending;
191
+ self.resort();
192
+ }
193
+
194
+ pub fn show_hidden(&self) -> bool {
195
+ self.show_hidden
196
+ }
197
+
198
+ pub fn set_show_hidden(&mut self, show: bool) {
199
+ self.show_hidden = show;
200
+ }
201
+
202
+ pub fn filter(&self) -> &str {
203
+ &self.filter
204
+ }
205
+
206
+ pub fn set_filter(&mut self, filter: impl Into<String>) {
207
+ self.filter = filter.into().to_lowercase();
208
+ }
209
+
210
+ pub fn title(&self) -> String {
211
+ self.path
212
+ .file_name()
213
+ .map(|n| n.to_string_lossy().into_owned())
214
+ .unwrap_or_else(|| self.path.display().to_string())
215
+ }
216
+
217
+ pub fn parent(&self) -> Option<PathBuf> {
218
+ self.path.parent().map(Path::to_path_buf)
219
+ }
220
+
221
+ /// Indeksy wpisów widocznych po filtrze i ukrywaniu.
222
+ pub fn visible(&self) -> Vec<usize> {
223
+ self.entries
224
+ .iter()
225
+ .enumerate()
226
+ .filter(|(_, e)| (self.show_hidden || !e.hidden) && self.matches(e))
227
+ .map(|(i, _)| i)
228
+ .collect()
229
+ }
230
+
231
+ fn matches(&self, e: &Entry) -> bool {
232
+ self.filter.is_empty() || e.name.to_lowercase().contains(&self.filter)
233
+ }
234
+
235
+ /// Lista do `ItemView` (kolejność = `visible()`).
236
+ pub fn items(&self) -> Vec<Item> {
237
+ self.visible()
238
+ .into_iter()
239
+ .map(|i| self.entries[i].item())
240
+ .collect()
241
+ }
242
+
243
+ /// Widoczne wpisy w kolejności prezentacji.
244
+ pub fn visible_entries(&self) -> Vec<&Entry> {
245
+ self.visible()
246
+ .into_iter()
247
+ .filter_map(|i| self.entries.get(i))
248
+ .collect()
249
+ }
250
+
251
+ pub fn entry(&self, visible_index: usize) -> Option<&Entry> {
252
+ let i = *self.visible().get(visible_index)?;
253
+ self.entries.get(i)
254
+ }
255
+
256
+ fn resort(&mut self) {
257
+ let key = self.sort;
258
+ let asc = self.ascending;
259
+ self.entries.sort_by(|a, b| {
260
+ // Katalogi zawsze przed plikami (też przy sortowaniu malejąco).
261
+ b.is_dir.cmp(&a.is_dir).then_with(|| {
262
+ let ord = match key {
263
+ SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
264
+ SortKey::Size => a.size.cmp(&b.size),
265
+ SortKey::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)),
266
+ SortKey::Kind => a
267
+ .kind
268
+ .label()
269
+ .cmp(&b.kind.label())
270
+ .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
271
+ };
272
+ if asc {
273
+ ord
274
+ } else {
275
+ ord.reverse()
276
+ }
277
+ })
278
+ });
279
+ }
280
+}
281
+
282
+fn read_entries(path: &Path) -> io::Result<Vec<Entry>> {
283
+ let mut out = Vec::new();
284
+ for entry in fs::read_dir(path)? {
285
+ let Ok(entry) = entry else { continue };
286
+ let name = entry.file_name().to_string_lossy().into_owned();
287
+ let full = entry.path();
288
+ let file_type = entry.file_type().ok();
289
+ let symlink = file_type.map(|t| t.is_symlink()).unwrap_or(false);
290
+ // Dla symlinków idziemy za celem (katalog-link ma być katalogiem).
291
+ let meta = if symlink {
292
+ fs::metadata(&full).ok()
293
+ } else {
294
+ entry.metadata().ok()
295
+ };
296
+ let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
297
+ let size = meta
298
+ .as_ref()
299
+ .map(|m| if m.is_dir() { 0 } else { m.len() })
300
+ .unwrap_or(0);
301
+ let modified = meta
302
+ .as_ref()
303
+ .and_then(|m| m.modified().ok())
304
+ .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
305
+ .map(|d| d.as_secs() as i64);
306
+ out.push(Entry {
307
+ hidden: name.starts_with('.'),
308
+ kind: Kind::from_name(&name, is_dir),
309
+ name,
310
+ path: full,
311
+ is_dir,
312
+ size,
313
+ modified,
314
+ symlink,
315
+ });
316
+ }
317
+ Ok(out)
318
+}
319
+
320
+/// Rozmiar w jednostkach binarnych (np. `1,4 MiB`).
321
+pub fn human_size(bytes: u64) -> String {
322
+ const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
323
+ let mut value = bytes as f64;
324
+ let mut unit = 0;
325
+ while value >= 1024.0 && unit + 1 < UNITS.len() {
326
+ value /= 1024.0;
327
+ unit += 1;
328
+ }
329
+ if unit == 0 {
330
+ format!("{bytes} B")
331
+ } else if value < 10.0 {
332
+ format!("{value:.1} {}", UNITS[unit])
333
+ } else {
334
+ format!("{value:.0} {}", UNITS[unit])
335
+ }
336
+}
337
+
338
+/// Data i godzina według czasu lokalnego (`YYYY-MM-DD HH:MM`).
339
+pub fn format_mtime(secs: Option<i64>) -> String {
340
+ let Some(secs) = secs else {
341
+ return String::new();
342
+ };
343
+ let (y, m, d, h, mi, _) = local_time(secs);
344
+ format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}")
345
+}
346
+
347
+/// Format `DeletionDate` z XDG Trash Spec (`YYYY-MM-DDThh:mm:ss`), czas lokalny.
348
+pub fn trash_date(secs: i64) -> String {
349
+ let (y, m, d, h, mi, s) = local_time(secs);
350
+ format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}")
351
+}
352
+
353
+/// Bieżący czas w sekundach od epoki.
354
+pub fn now_secs() -> i64 {
355
+ std::time::SystemTime::now()
356
+ .duration_since(UNIX_EPOCH)
357
+ .map(|d| d.as_secs() as i64)
358
+ .unwrap_or(0)
359
+}
360
+
361
+/// Rozbicie czasu na pola lokalne (rok, miesiąc, dzień, godzina, minuta, sekunda).
362
+fn local_time(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
363
+ // SAFETY: `localtime_r` wypełnia strukturę `tm` i nie alokuje; przekazujemy
364
+ // poprawny wskaźnik na `time_t`.
365
+ let mut tm: libc::tm = unsafe { std::mem::zeroed() };
366
+ let t = secs as libc::time_t;
367
+ let ok = unsafe { !libc::localtime_r(&t, &mut tm).is_null() };
368
+ if !ok {
369
+ return (1970, 1, 1, 0, 0, 0);
370
+ }
371
+ (
372
+ tm.tm_year + 1900,
373
+ (tm.tm_mon + 1) as u32,
374
+ tm.tm_mday as u32,
375
+ tm.tm_hour as u32,
376
+ tm.tm_min as u32,
377
+ tm.tm_sec as u32,
378
+ )
379
+}
380
+
381
+#[cfg(test)]
382
+mod tests {
383
+ use super::*;
384
+
385
+ #[test]
386
+ fn human_size_scales_binary_units() {
387
+ assert_eq!(human_size(0), "0 B");
388
+ assert_eq!(human_size(999), "999 B");
389
+ assert_eq!(human_size(1024), "1.0 KiB");
390
+ assert_eq!(human_size(1536), "1.5 KiB");
391
+ assert_eq!(human_size(10 * 1024), "10 KiB");
392
+ assert_eq!(human_size(5 * 1024 * 1024), "5.0 MiB");
393
+ }
394
+
395
+ #[test]
396
+ fn kind_is_derived_from_extension() {
397
+ assert_eq!(Kind::from_name("a.png", false), Kind::Image);
398
+ assert_eq!(Kind::from_name("a.MP4", false), Kind::Video);
399
+ assert_eq!(Kind::from_name("a.tar.gz", false), Kind::Archive);
400
+ assert_eq!(Kind::from_name("a.rs", false), Kind::Code);
401
+ assert_eq!(Kind::from_name("a.unknownext", false), Kind::Other);
402
+ assert_eq!(Kind::from_name("katalog", true), Kind::Folder);
403
+ }
404
+
405
+ fn view_with(names: &[(&str, bool)]) -> DirView {
406
+ let mut entries = Vec::new();
407
+ for (name, is_dir) in names {
408
+ entries.push(Entry {
409
+ name: (*name).to_owned(),
410
+ path: PathBuf::from("/tmp").join(name),
411
+ is_dir: *is_dir,
412
+ size: name.len() as u64,
413
+ modified: None,
414
+ hidden: name.starts_with('.'),
415
+ symlink: false,
416
+ kind: Kind::from_name(name, *is_dir),
417
+ });
418
+ }
419
+ DirView {
420
+ path: PathBuf::from("/tmp"),
421
+ entries,
422
+ sort: SortKey::Name,
423
+ ascending: true,
424
+ show_hidden: false,
425
+ filter: String::new(),
426
+ }
427
+ }
428
+
429
+ #[test]
430
+ fn directories_sort_first_and_hidden_are_filtered() {
431
+ let mut v = view_with(&[
432
+ ("b.txt", false),
433
+ ("a", true),
434
+ (".hidden", false),
435
+ ("c", true),
436
+ ]);
437
+ v.resort();
438
+ let names: Vec<&str> = v
439
+ .visible()
440
+ .into_iter()
441
+ .map(|i| v.entries[i].name.as_str())
442
+ .collect();
443
+ assert_eq!(names, vec!["a", "c", "b.txt"]);
444
+ v.set_show_hidden(true);
445
+ assert_eq!(v.visible().len(), 4);
446
+ }
447
+
448
+ #[test]
449
+ fn filter_matches_case_insensitively() {
450
+ let mut v = view_with(&[("Raport.pdf", false), ("notatki.txt", false)]);
451
+ v.set_filter("RAP");
452
+ let names: Vec<String> = v
453
+ .visible()
454
+ .into_iter()
455
+ .map(|i| v.entries[i].name.clone())
456
+ .collect();
457
+ assert_eq!(names, vec!["Raport.pdf"]);
458
+ }
459
+
460
+ #[test]
461
+ fn descending_keeps_directories_first() {
462
+ let mut v = view_with(&[("b.txt", false), ("a.txt", false), ("dir", true)]);
463
+ v.resort();
464
+ v.set_ascending(false);
465
+ let names: Vec<&str> = v
466
+ .visible()
467
+ .into_iter()
468
+ .map(|i| v.entries[i].name.as_str())
469
+ .collect();
470
+ assert_eq!(names, vec!["dir", "b.txt", "a.txt"]);
471
+ }
472
+}
0
473
new file mode 100644
@@ -0,0 +1,615 @@
1
+//! Operacje na plikach w tle: kolejka, postęp, anulowanie.
2
+//!
3
+//! UI nigdy nie kopiuje ani nie usuwa synchronicznie — wysyła [`Job`] do wątku
4
+//! roboczego i dostaje [`OpsEvent`] (postęp, wynik). Dzięki temu duże pliki nie
5
+//! zamrażają okna.
6
+
7
+use std::fs;
8
+use std::io::{self, Read, Write};
9
+use std::path::{Path, PathBuf};
10
+use std::sync::atomic::{AtomicBool, Ordering};
11
+use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
12
+use std::sync::Arc;
13
+use std::time::{Duration, Instant};
14
+
15
+use libluna_i18n::tr;
16
+
17
+use crate::trash;
18
+
19
+/// Zadanie do wykonania w tle.
20
+#[derive(Debug, Clone)]
21
+pub enum Job {
22
+ Copy { srcs: Vec<PathBuf>, dst: PathBuf },
23
+ Move { srcs: Vec<PathBuf>, dst: PathBuf },
24
+ Trash { paths: Vec<PathBuf> },
25
+ Delete { paths: Vec<PathBuf> },
26
+ Rename { from: PathBuf, to: PathBuf },
27
+ NewFolder { parent: PathBuf },
28
+ Mkdir { path: PathBuf },
29
+ Restore { path: PathBuf },
30
+ Purge { path: PathBuf },
31
+}
32
+
33
+/// Decyzja przy konflikcie nazw (dialog w UI).
34
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
+pub enum Decision {
36
+ Overwrite,
37
+ Skip,
38
+ Rename,
39
+ OverwriteAll,
40
+ SkipAll,
41
+ RenameAll,
42
+}
43
+
44
+/// Zdarzenia z wątku operacji.
45
+#[derive(Debug, Clone)]
46
+pub enum OpsEvent {
47
+ Started {
48
+ label: String,
49
+ },
50
+ Progress {
51
+ done: u64,
52
+ total: u64,
53
+ current: String,
54
+ },
55
+ /// Cel już istnieje — UI pokazuje dialog i wysyła [`Decision`].
56
+ Conflict {
57
+ src: PathBuf,
58
+ dst: PathBuf,
59
+ is_dir: bool,
60
+ },
61
+ Finished {
62
+ label: String,
63
+ ok: usize,
64
+ skipped: usize,
65
+ failed: Vec<String>,
66
+ created: Option<PathBuf>,
67
+ },
68
+}
69
+
70
+/// Uchwyt do kolejki operacji (wątek roboczy żyje do końca procesu).
71
+pub struct Ops {
72
+ tx: Sender<Job>,
73
+ rx: Receiver<OpsEvent>,
74
+ decisions: Sender<Decision>,
75
+ cancel: Arc<AtomicBool>,
76
+ busy: bool,
77
+}
78
+
79
+impl Ops {
80
+ pub fn new() -> Self {
81
+ let (job_tx, job_rx) = mpsc::channel::<Job>();
82
+ let (ev_tx, ev_rx) = mpsc::channel::<OpsEvent>();
83
+ let (decision_tx, decision_rx) = mpsc::channel::<Decision>();
84
+ let cancel = Arc::new(AtomicBool::new(false));
85
+ let flag = Arc::clone(&cancel);
86
+ std::thread::Builder::new()
87
+ .name("luna-files-ops".into())
88
+ .spawn(move || {
89
+ while let Ok(job) = job_rx.recv() {
90
+ flag.store(false, Ordering::SeqCst);
91
+ run(job, &ev_tx, &flag, &decision_rx);
92
+ }
93
+ })
94
+ .expect("wątek operacji");
95
+ Self {
96
+ tx: job_tx,
97
+ rx: ev_rx,
98
+ decisions: decision_tx,
99
+ cancel,
100
+ busy: false,
101
+ }
102
+ }
103
+
104
+ /// Dodaje zadanie; `false`, gdy poprzednie jeszcze trwa.
105
+ pub fn submit(&mut self, job: Job) -> bool {
106
+ if self.busy {
107
+ return false;
108
+ }
109
+ if self.tx.send(job).is_ok() {
110
+ self.busy = true;
111
+ true
112
+ } else {
113
+ false
114
+ }
115
+ }
116
+
117
+ /// Odbiera zdarzenia (do wywołania raz na klatkę).
118
+ pub fn poll(&mut self) -> Vec<OpsEvent> {
119
+ let mut out = Vec::new();
120
+ while let Ok(ev) = self.rx.try_recv() {
121
+ if matches!(ev, OpsEvent::Finished { .. }) {
122
+ self.busy = false;
123
+ }
124
+ out.push(ev);
125
+ }
126
+ out
127
+ }
128
+
129
+ pub fn cancel(&self) {
130
+ self.cancel.store(true, Ordering::SeqCst);
131
+ }
132
+
133
+ /// Odpowiedź na dialog konfliktu.
134
+ pub fn decide(&self, decision: Decision) {
135
+ let _ = self.decisions.send(decision);
136
+ }
137
+
138
+ pub fn busy(&self) -> bool {
139
+ self.busy
140
+ }
141
+}
142
+
143
+impl Default for Ops {
144
+ fn default() -> Self {
145
+ Self::new()
146
+ }
147
+}
148
+
149
+struct Ctx<'a> {
150
+ tx: &'a Sender<OpsEvent>,
151
+ cancel: &'a AtomicBool,
152
+ done: u64,
153
+ total: u64,
154
+ last: Instant,
155
+}
156
+
157
+impl Ctx<'_> {
158
+ fn cancelled(&self) -> bool {
159
+ self.cancel.load(Ordering::SeqCst)
160
+ }
161
+
162
+ fn progress(&mut self, current: &Path, force: bool) {
163
+ if force || self.last.elapsed() >= Duration::from_millis(100) {
164
+ self.last = Instant::now();
165
+ let _ = self.tx.send(OpsEvent::Progress {
166
+ done: self.done,
167
+ total: self.total,
168
+ current: current
169
+ .file_name()
170
+ .map(|n| n.to_string_lossy().into_owned())
171
+ .unwrap_or_default(),
172
+ });
173
+ }
174
+ }
175
+}
176
+
177
+fn run(job: Job, tx: &Sender<OpsEvent>, cancel: &AtomicBool, decisions: &Receiver<Decision>) {
178
+ let label = tr(match &job {
179
+ Job::Copy { .. } => "Copying",
180
+ Job::Move { .. } => "Moving",
181
+ Job::Trash { .. } => "Moving to Trash",
182
+ Job::Delete { .. } => "Deleting",
183
+ Job::Rename { .. } => "Renaming",
184
+ Job::NewFolder { .. } => "Creating folder",
185
+ Job::Mkdir { .. } => "Creating folder",
186
+ Job::Restore { .. } => "Restoring",
187
+ Job::Purge { .. } => "Deleting",
188
+ });
189
+ let _ = tx.send(OpsEvent::Started {
190
+ label: label.clone(),
191
+ });
192
+
193
+ let mut ok = 0usize;
194
+ let mut skipped = 0usize;
195
+ let mut failed = Vec::new();
196
+ let mut created = None;
197
+
198
+ match job {
199
+ Job::Copy { srcs, dst } => {
200
+ let total = srcs.iter().map(|p| path_size(p)).sum();
201
+ let mut ctx = Ctx {
202
+ tx,
203
+ cancel,
204
+ done: 0,
205
+ total,
206
+ last: Instant::now(),
207
+ };
208
+ let mut policy = Policy::Ask;
209
+ for src in &srcs {
210
+ if ctx.cancelled() {
211
+ failed.push(tr("Cancelled"));
212
+ break;
213
+ }
214
+ let Some(name) = src.file_name() else {
215
+ continue;
216
+ };
217
+ let Some(dest) = resolve_dest(name, &dst, &mut policy, tx, decisions, cancel)
218
+ else {
219
+ skipped += 1;
220
+ continue;
221
+ };
222
+ match copy_path(src, &dest, &mut ctx) {
223
+ Ok(()) => ok += 1,
224
+ Err(e) => failed.push(format!("{}: {e}", src.display())),
225
+ }
226
+ }
227
+ }
228
+ Job::Move { srcs, dst } => {
229
+ let mut policy = Policy::Ask;
230
+ for src in &srcs {
231
+ if cancel.load(Ordering::SeqCst) {
232
+ failed.push(tr("Cancelled"));
233
+ break;
234
+ }
235
+ let Some(name) = src.file_name() else {
236
+ continue;
237
+ };
238
+ let Some(dest) = resolve_dest(name, &dst, &mut policy, tx, decisions, cancel)
239
+ else {
240
+ skipped += 1;
241
+ continue;
242
+ };
243
+ // Przy „nadpisz” stary cel musi zniknąć (rename nie nadpisze katalogu).
244
+ if dest.symlink_metadata().is_ok() {
245
+ let _ = remove_path(&dest);
246
+ }
247
+ match move_path(src, &dest) {
248
+ Ok(()) => ok += 1,
249
+ Err(e) => failed.push(format!("{}: {e}", src.display())),
250
+ }
251
+ }
252
+ }
253
+ Job::Trash { paths } => {
254
+ for path in paths {
255
+ match trash::move_to_trash(&path) {
256
+ Ok(_) => ok += 1,
257
+ Err(e) => failed.push(format!("{}: {e}", path.display())),
258
+ }
259
+ }
260
+ }
261
+ Job::Delete { paths } => {
262
+ for path in paths {
263
+ match remove_path(&path) {
264
+ Ok(()) => ok += 1,
265
+ Err(e) => failed.push(format!("{}: {e}", path.display())),
266
+ }
267
+ }
268
+ }
269
+ Job::Rename { from, to } => {
270
+ if to.exists() {
271
+ failed.push(tr("A file with that name already exists"));
272
+ } else {
273
+ match fs::rename(&from, &to) {
274
+ Ok(()) => ok = 1,
275
+ Err(e) => failed.push(format!("{}: {e}", from.display())),
276
+ }
277
+ }
278
+ }
279
+ Job::NewFolder { parent } => {
280
+ let dest = unique_dest(&parent, std::ffi::OsStr::new(&tr("New Folder")));
281
+ match fs::create_dir(&dest) {
282
+ Ok(()) => {
283
+ ok = 1;
284
+ created = Some(dest);
285
+ }
286
+ Err(e) => failed.push(format!("{}: {e}", dest.display())),
287
+ }
288
+ }
289
+ Job::Mkdir { path } => match fs::create_dir(&path) {
290
+ Ok(()) => {
291
+ ok = 1;
292
+ created = Some(path);
293
+ }
294
+ Err(e) => failed.push(format!("{}: {e}", path.display())),
295
+ },
296
+ Job::Restore { path } => match trash::restore(&path) {
297
+ Ok(dest) => {
298
+ ok = 1;
299
+ created = Some(dest);
300
+ }
301
+ Err(e) => failed.push(format!("{}: {e}", path.display())),
302
+ },
303
+ Job::Purge { path } => match trash::purge(&path) {
304
+ Ok(()) => ok = 1,
305
+ Err(e) => failed.push(format!("{}: {e}", path.display())),
306
+ },
307
+ }
308
+
309
+ let _ = tx.send(OpsEvent::Finished {
310
+ label,
311
+ ok,
312
+ skipped,
313
+ failed,
314
+ created,
315
+ });
316
+}
317
+
318
+/// Polityka rozwiązywania konfliktów w obrębie jednego zadania.
319
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320
+enum Policy {
321
+ Ask,
322
+ Overwrite,
323
+ Skip,
324
+ Rename,
325
+}
326
+
327
+/// Ustala nazwę docelową; przy istniejącym celu pyta UI ([`OpsEvent::Conflict`]).
328
+fn resolve_dest(
329
+ name: &std::ffi::OsStr,
330
+ dst_dir: &Path,
331
+ policy: &mut Policy,
332
+ tx: &Sender<OpsEvent>,
333
+ decisions: &Receiver<Decision>,
334
+ cancel: &AtomicBool,
335
+) -> Option<PathBuf> {
336
+ let dest = dst_dir.join(name);
337
+ if dest.symlink_metadata().is_err() {
338
+ return Some(dest);
339
+ }
340
+ loop {
341
+ match *policy {
342
+ Policy::Overwrite => return Some(dest),
343
+ Policy::Skip => return None,
344
+ Policy::Rename => return Some(unique_dest(dst_dir, name)),
345
+ Policy::Ask => {
346
+ let is_dir = fs::symlink_metadata(&dest)
347
+ .map(|m| m.is_dir())
348
+ .unwrap_or(false);
349
+ let _ = tx.send(OpsEvent::Conflict {
350
+ src: dst_dir.join(name),
351
+ dst: dest.clone(),
352
+ is_dir,
353
+ });
354
+ let decision = loop {
355
+ if cancel.load(Ordering::SeqCst) {
356
+ return None;
357
+ }
358
+ match decisions.recv_timeout(Duration::from_millis(200)) {
359
+ Ok(d) => break d,
360
+ Err(RecvTimeoutError::Timeout) => continue,
361
+ Err(RecvTimeoutError::Disconnected) => return None,
362
+ }
363
+ };
364
+ match decision {
365
+ Decision::Overwrite => return Some(dest),
366
+ Decision::Skip => return None,
367
+ Decision::Rename => return Some(unique_dest(dst_dir, name)),
368
+ Decision::OverwriteAll => {
369
+ *policy = Policy::Overwrite;
370
+ return Some(dest);
371
+ }
372
+ Decision::SkipAll => {
373
+ *policy = Policy::Skip;
374
+ return None;
375
+ }
376
+ Decision::RenameAll => {
377
+ *policy = Policy::Rename;
378
+ return Some(unique_dest(dst_dir, name));
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+}
385
+
386
+/// Kopiuje plik/katalog, raportując postęp (256 KiB na chunk).
387
+fn copy_path(src: &Path, dst: &Path, ctx: &mut Ctx<'_>) -> io::Result<()> {
388
+ if ctx.cancelled() {
389
+ return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"));
390
+ }
391
+ let meta = fs::symlink_metadata(src)?;
392
+ if meta.is_dir() {
393
+ fs::create_dir_all(dst)?;
394
+ for entry in fs::read_dir(src)? {
395
+ let entry = entry?;
396
+ copy_path(&entry.path(), &dst.join(entry.file_name()), ctx)?;
397
+ }
398
+ Ok(())
399
+ } else if meta.file_type().is_symlink() {
400
+ let target = fs::read_link(src)?;
401
+ std::os::unix::fs::symlink(target, dst)
402
+ } else {
403
+ let mut input = fs::File::open(src)?;
404
+ let mut output = fs::File::create(dst)?;
405
+ let mut buf = vec![0u8; 256 * 1024];
406
+ loop {
407
+ if ctx.cancelled() {
408
+ return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"));
409
+ }
410
+ let n = input.read(&mut buf)?;
411
+ if n == 0 {
412
+ break;
413
+ }
414
+ output.write_all(&buf[..n])?;
415
+ ctx.done += n as u64;
416
+ ctx.progress(dst, false);
417
+ }
418
+ Ok(())
419
+ }
420
+}
421
+
422
+/// Przenosi plik/katalog (rename, a przy `EXDEV` kopiuje i usuwa oryginał).
423
+fn move_path(src: &Path, dst: &Path) -> io::Result<()> {
424
+ match fs::rename(src, dst) {
425
+ Ok(()) => Ok(()),
426
+ Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
427
+ copy_tree(src, dst)?;
428
+ remove_path(src)
429
+ }
430
+ Err(e) => Err(e),
431
+ }
432
+}
433
+
434
+fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
435
+ let meta = fs::symlink_metadata(src)?;
436
+ if meta.is_dir() {
437
+ fs::create_dir_all(dst)?;
438
+ for entry in fs::read_dir(src)? {
439
+ let entry = entry?;
440
+ copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
441
+ }
442
+ Ok(())
443
+ } else {
444
+ fs::copy(src, dst).map(|_| ())
445
+ }
446
+}
447
+
448
+pub(crate) fn remove_path(path: &Path) -> io::Result<()> {
449
+ let meta = fs::symlink_metadata(path)?;
450
+ if meta.is_dir() {
451
+ fs::remove_dir_all(path)
452
+ } else {
453
+ fs::remove_file(path)
454
+ }
455
+}
456
+
457
+fn path_size(path: &Path) -> u64 {
458
+ let Ok(meta) = fs::symlink_metadata(path) else {
459
+ return 0;
460
+ };
461
+ if meta.is_dir() {
462
+ let mut sum = 0;
463
+ if let Ok(entries) = fs::read_dir(path) {
464
+ for entry in entries.flatten() {
465
+ sum += path_size(&entry.path());
466
+ }
467
+ }
468
+ sum
469
+ } else {
470
+ meta.len()
471
+ }
472
+}
473
+
474
+/// Wolna nazwa w katalogu docelowym: `raport.pdf`, `raport (1).pdf`, …
475
+pub(crate) fn unique_dest(dir: &Path, name: &std::ffi::OsStr) -> PathBuf {
476
+ let candidate = dir.join(name);
477
+ if candidate.symlink_metadata().is_err() {
478
+ return candidate;
479
+ }
480
+ let name = name.to_string_lossy();
481
+ let (stem, ext) = match name.rsplit_once('.') {
482
+ Some((s, e)) if !s.is_empty() => (s.to_owned(), format!(".{e}")),
483
+ _ => (name.into_owned(), String::new()),
484
+ };
485
+ let mut n = 1u32;
486
+ loop {
487
+ let candidate = dir.join(format!("{stem} ({n}){ext}"));
488
+ if candidate.symlink_metadata().is_err() {
489
+ return candidate;
490
+ }
491
+ n += 1;
492
+ }
493
+}
494
+
495
+#[cfg(test)]
496
+mod tests {
497
+ use super::*;
498
+
499
+ fn temp_dir(tag: &str) -> PathBuf {
500
+ let dir = std::env::temp_dir().join(format!(
501
+ "luna-files-ops-{tag}-{}-{:?}",
502
+ std::process::id(),
503
+ std::thread::current().id()
504
+ ));
505
+ let _ = fs::remove_dir_all(&dir);
506
+ fs::create_dir_all(&dir).unwrap();
507
+ dir
508
+ }
509
+
510
+ fn wait(ops: &mut Ops) -> OpsEvent {
511
+ let deadline = Instant::now() + Duration::from_secs(10);
512
+ loop {
513
+ for ev in ops.poll() {
514
+ if matches!(ev, OpsEvent::Finished { .. }) {
515
+ return ev;
516
+ }
517
+ }
518
+ assert!(
519
+ Instant::now() < deadline,
520
+ "operacja nie skończyła się w czasie"
521
+ );
522
+ std::thread::sleep(Duration::from_millis(10));
523
+ }
524
+ }
525
+
526
+ #[test]
527
+ fn unique_dest_adds_number() {
528
+ let dir = temp_dir("unique");
529
+ fs::write(dir.join("a.txt"), "x").unwrap();
530
+ let dest = unique_dest(&dir, std::ffi::OsStr::new("a.txt"));
531
+ assert_eq!(dest.file_name().unwrap(), "a (1).txt");
532
+ }
533
+
534
+ #[test]
535
+ fn copy_job_copies_file_content() {
536
+ let dir = temp_dir("copy");
537
+ fs::write(dir.join("src.txt"), "abc").unwrap();
538
+ fs::create_dir(dir.join("out")).unwrap();
539
+ let mut ops = Ops::new();
540
+ assert!(ops.submit(Job::Copy {
541
+ srcs: vec![dir.join("src.txt")],
542
+ dst: dir.join("out"),
543
+ }));
544
+ match wait(&mut ops) {
545
+ OpsEvent::Finished { ok, failed, .. } => {
546
+ assert_eq!(ok, 1, "{failed:?}");
547
+ assert!(failed.is_empty());
548
+ }
549
+ _ => unreachable!(),
550
+ }
551
+ assert_eq!(fs::read_to_string(dir.join("out/src.txt")).unwrap(), "abc");
552
+ }
553
+
554
+ #[test]
555
+ fn conflict_policy_can_skip_rename_or_overwrite() {
556
+ let dir = temp_dir("conflict-policy");
557
+ let dst = dir.join("out");
558
+ fs::create_dir(&dst).unwrap();
559
+ fs::write(dst.join("a.txt"), "stary").unwrap();
560
+ let (tx, _rx) = mpsc::channel();
561
+ let (_dtx, drx) = mpsc::channel();
562
+ let cancel = AtomicBool::new(false);
563
+
564
+ let mut policy = Policy::Skip;
565
+ assert!(resolve_dest(
566
+ std::ffi::OsStr::new("a.txt"),
567
+ &dst,
568
+ &mut policy,
569
+ &tx,
570
+ &drx,
571
+ &cancel
572
+ )
573
+ .is_none());
574
+
575
+ policy = Policy::Rename;
576
+ let renamed = resolve_dest(
577
+ std::ffi::OsStr::new("a.txt"),
578
+ &dst,
579
+ &mut policy,
580
+ &tx,
581
+ &drx,
582
+ &cancel,
583
+ )
584
+ .unwrap();
585
+ assert_eq!(renamed.file_name().unwrap(), "a (1).txt");
586
+
587
+ policy = Policy::Overwrite;
588
+ let replaced = resolve_dest(
589
+ std::ffi::OsStr::new("a.txt"),
590
+ &dst,
591
+ &mut policy,
592
+ &tx,
593
+ &drx,
594
+ &cancel,
595
+ )
596
+ .unwrap();
597
+ assert_eq!(replaced, dst.join("a.txt"));
598
+ }
599
+
600
+ #[test]
601
+ fn new_folder_job_reports_created_path() {
602
+ let dir = temp_dir("mkdir");
603
+ let mut ops = Ops::new();
604
+ assert!(ops.submit(Job::NewFolder {
605
+ parent: dir.clone(),
606
+ }));
607
+ match wait(&mut ops) {
608
+ OpsEvent::Finished { created, .. } => {
609
+ let created = created.expect("ścieżka nowego katalogu");
610
+ assert!(created.is_dir());
611
+ }
612
+ _ => unreachable!(),
613
+ }
614
+ }
615
+}
0
616
new file mode 100644
@@ -0,0 +1,280 @@
1
+//! Miejsca (XDG) i urządzenia (montowania) do panelu bocznego.
2
+//!
3
+//! Lista montowań czytana jest z `/proc/self/mountinfo`, a rozmiary przez `statvfs`.
4
+//! Montowanie/odmontowanie idzie przez `udisksctl` (UDisks2 po D-Busie, z polkit —
5
+//! hasło pyta `luna-polkit`). Docelowo podmienimy to na bezpośrednie sygnały D-Bus;
6
+//! interfejs funkcji zostaje ten sam.
7
+
8
+use std::collections::BTreeMap;
9
+use std::env;
10
+use std::fs;
11
+use std::path::{Path, PathBuf};
12
+use std::process::Command;
13
+
14
+use libluna_i18n::tr;
15
+
16
+#[derive(Debug, Clone)]
17
+pub struct Place {
18
+ pub label: String,
19
+ pub path: PathBuf,
20
+}
21
+
22
+impl Place {
23
+ fn new(label: &str, path: PathBuf) -> Self {
24
+ Self {
25
+ label: tr(label),
26
+ path,
27
+ }
28
+ }
29
+}
30
+
31
+/// Katalogi użytkownika z `~/.config/user-dirs.dirs` (z wartościami domyślnymi).
32
+pub fn user_dirs() -> BTreeMap<String, PathBuf> {
33
+ let home = home();
34
+ let mut map = BTreeMap::new();
35
+ for key in [
36
+ "DESKTOP",
37
+ "DOCUMENTS",
38
+ "DOWNLOAD",
39
+ "PICTURES",
40
+ "MUSIC",
41
+ "VIDEOS",
42
+ "TEMPLATES",
43
+ "PUBLICSHARE",
44
+ ] {
45
+ map.insert(key.to_owned(), home.clone());
46
+ }
47
+ let config = env::var_os("XDG_CONFIG_HOME")
48
+ .map(PathBuf::from)
49
+ .unwrap_or_else(|| home.join(".config"))
50
+ .join("user-dirs.dirs");
51
+ if let Ok(text) = fs::read_to_string(config) {
52
+ for line in text.lines() {
53
+ let line = line.trim();
54
+ if line.starts_with('#') {
55
+ continue;
56
+ }
57
+ let Some((key, value)) = line.split_once('=') else {
58
+ continue;
59
+ };
60
+ let Some(key) = key.trim().strip_prefix("XDG_") else {
61
+ continue;
62
+ };
63
+ let Some(key) = key.strip_suffix("_DIR") else {
64
+ continue;
65
+ };
66
+ let value = value.trim().trim_matches('"');
67
+ let path = value.replace("$HOME", &home.display().to_string());
68
+ if !path.is_empty() {
69
+ map.insert(key.to_owned(), PathBuf::from(path));
70
+ }
71
+ }
72
+ }
73
+ map
74
+}
75
+
76
+pub fn home() -> PathBuf {
77
+ env::var_os("HOME")
78
+ .map(PathBuf::from)
79
+ .unwrap_or_else(|| PathBuf::from("/"))
80
+}
81
+
82
+/// Miejsca w panelu bocznym (tylko istniejące katalogi).
83
+pub fn places() -> Vec<Place> {
84
+ let dirs = user_dirs();
85
+ let mut out = vec![Place::new("Home", home())];
86
+ for (key, label) in [
87
+ ("DESKTOP", "Desktop"),
88
+ ("DOCUMENTS", "Documents"),
89
+ ("DOWNLOAD", "Downloads"),
90
+ ("PICTURES", "Pictures"),
91
+ ("MUSIC", "Music"),
92
+ ("VIDEOS", "Videos"),
93
+ ] {
94
+ if let Some(path) = dirs.get(key) {
95
+ if path.is_dir() && path != &home() {
96
+ out.push(Place::new(label, path.clone()));
97
+ }
98
+ }
99
+ }
100
+ out.push(Place::new("File System", PathBuf::from("/")));
101
+ out
102
+}
103
+
104
+/// Zamontowane urządzenie widoczne w panelu bocznym.
105
+#[derive(Debug, Clone)]
106
+pub struct Mount {
107
+ pub dev: String,
108
+ pub label: String,
109
+ pub mount: PathBuf,
110
+ pub fs: String,
111
+ pub total: u64,
112
+ pub free: u64,
113
+}
114
+
115
+/// Lista montowań „użytkowych” (pomija pseudosystemy i montowania systemowe).
116
+pub fn mounts() -> Vec<Mount> {
117
+ let Ok(text) = fs::read_to_string("/proc/self/mountinfo") else {
118
+ return Vec::new();
119
+ };
120
+ let mut out = Vec::new();
121
+ let mut seen = std::collections::HashSet::new();
122
+ for line in text.lines() {
123
+ let Some(m) = parse_mountinfo(line) else {
124
+ continue;
125
+ };
126
+ if !user_visible_mount(&m.mount, &m.fs) {
127
+ continue;
128
+ }
129
+ if !seen.insert(m.mount.clone()) {
130
+ continue;
131
+ }
132
+ let (total, free) = statvfs(&m.mount).unwrap_or((0, 0));
133
+ out.push(Mount {
134
+ label: mount_label(&m.dev).unwrap_or_else(|| {
135
+ m.mount
136
+ .file_name()
137
+ .map(|n| n.to_string_lossy().into_owned())
138
+ .unwrap_or_else(|| m.dev.clone())
139
+ }),
140
+ dev: m.dev,
141
+ mount: m.mount,
142
+ fs: m.fs,
143
+ total,
144
+ free,
145
+ });
146
+ }
147
+ out.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
148
+ out
149
+}
150
+
151
+struct MountInfo {
152
+ dev: String,
153
+ mount: PathBuf,
154
+ fs: String,
155
+}
156
+
157
+/// Parsuje jedną linię `/proc/self/mountinfo`:
158
+/// `36 35 98:0 /root /mnt rw - ext4 /dev/sda1 rw`.
159
+fn parse_mountinfo(line: &str) -> Option<MountInfo> {
160
+ let parts: Vec<&str> = line.split_whitespace().collect();
161
+ if parts.len() < 7 {
162
+ return None;
163
+ }
164
+ let mount = PathBuf::from(unescape(parts[4]));
165
+ let sep = parts.iter().position(|p| *p == "-")?;
166
+ let fs = parts.get(sep + 1)?.to_string();
167
+ let dev = parts.get(sep + 2).unwrap_or(&"").to_string();
168
+ Some(MountInfo { dev, mount, fs })
169
+}
170
+
171
+fn user_visible_mount(mount: &Path, fs: &str) -> bool {
172
+ const FS: [&str; 18] = [
173
+ "ext2", "ext3", "ext4", "btrfs", "xfs", "f2fs", "vfat", "exfat", "ntfs", "ntfs3",
174
+ "fuseblk", "iso9660", "udf", "hfsplus", "hfs", "msdos", "jfs", "reiserfs",
175
+ ];
176
+ if !FS.contains(&fs) {
177
+ return false;
178
+ }
179
+ const SYSTEM: [&str; 9] = [
180
+ "/",
181
+ "/boot",
182
+ "/boot/efi",
183
+ "/home",
184
+ "/tmp",
185
+ "/var",
186
+ "/usr",
187
+ "/opt",
188
+ "/srv",
189
+ ];
190
+ if SYSTEM.iter().any(|s| mount == Path::new(s)) {
191
+ return false;
192
+ }
193
+ true
194
+}
195
+
196
+/// Odkodowuje oktalne sekwencje z mountinfo (`\040` = spacja).
197
+fn unescape(text: &str) -> String {
198
+ let bytes = text.as_bytes();
199
+ let mut out = Vec::with_capacity(bytes.len());
200
+ let mut i = 0;
201
+ while i < bytes.len() {
202
+ if bytes[i] == b'\\' && i + 3 < bytes.len() {
203
+ if let Ok(v) = u8::from_str_radix(&text[i + 1..i + 4], 8) {
204
+ out.push(v);
205
+ i += 4;
206
+ continue;
207
+ }
208
+ }
209
+ out.push(bytes[i]);
210
+ i += 1;
211
+ }
212
+ String::from_utf8_lossy(&out).into_owned()
213
+}
214
+
215
+/// Etykieta wolumenu z `/dev/disk/by-label` (np. „PENDRIVE”).
216
+pub fn mount_label(dev: &str) -> Option<String> {
217
+ let dir = Path::new("/dev/disk/by-label");
218
+ let target = fs::canonicalize(dev).ok()?;
219
+ for entry in fs::read_dir(dir).ok()?.flatten() {
220
+ if fs::canonicalize(entry.path()).ok().as_ref() == Some(&target) {
221
+ return Some(entry.file_name().to_string_lossy().replace("\\x20", " "));
222
+ }
223
+ }
224
+ None
225
+}
226
+
227
+/// `(rozmiar, wolne)` w bajtach.
228
+pub fn statvfs(path: &Path) -> Option<(u64, u64)> {
229
+ let st = rustix::fs::statvfs(path).ok()?;
230
+ let unit = if st.f_frsize != 0 {
231
+ st.f_frsize
232
+ } else {
233
+ st.f_bsize
234
+ };
235
+ Some((st.f_blocks * unit, st.f_bavail * unit))
236
+}
237
+
238
+/// Montuje urządzenie przez UDisks2 (polkit pyta o hasło, gdy trzeba).
239
+pub fn mount_device(dev: &str) {
240
+ spawn_udisks(&["mount", "-b", dev]);
241
+}
242
+
243
+/// Odmontowuje urządzenie przez UDisks2.
244
+pub fn unmount_device(dev: &str) {
245
+ spawn_udisks(&["unmount", "-b", dev]);
246
+}
247
+
248
+fn spawn_udisks(args: &[&str]) {
249
+ if let Err(e) = Command::new("udisksctl").args(args).spawn() {
250
+ eprintln!("luna-files: udisksctl {:?}: {e}", args);
251
+ }
252
+}
253
+
254
+#[cfg(test)]
255
+mod tests {
256
+ use super::*;
257
+
258
+ #[test]
259
+ fn mountinfo_line_is_parsed() {
260
+ let line =
261
+ "36 35 98:0 /mnt1 /run/media/patryk/PEN\\040DRIVE rw,noatime - vfat /dev/sdb1 rw";
262
+ let m = parse_mountinfo(line).unwrap();
263
+ assert_eq!(m.mount, PathBuf::from("/run/media/patryk/PEN DRIVE"));
264
+ assert_eq!(m.fs, "vfat");
265
+ assert_eq!(m.dev, "/dev/sdb1");
266
+ }
267
+
268
+ #[test]
269
+ fn system_mounts_are_hidden() {
270
+ assert!(user_visible_mount(Path::new("/run/media/x/USB"), "vfat"));
271
+ assert!(!user_visible_mount(Path::new("/"), "ext4"));
272
+ assert!(!user_visible_mount(Path::new("/home"), "ext4"));
273
+ assert!(!user_visible_mount(Path::new("/mnt/x"), "proc"));
274
+ }
275
+
276
+ #[test]
277
+ fn octal_sequences_are_unescaped() {
278
+ assert_eq!(unescape("a\\040b\\134c"), "a b\\c");
279
+ }
280
+}
0
281
new file mode 100644
@@ -0,0 +1,415 @@
1
+//! Kosz zgodny ze specyfikacją XDG Trash (`.trashinfo`).
2
+//!
3
+//! MVP używa kosza domowego (`$XDG_DATA_HOME/Trash`). Dla plików z innego systemu
4
+//! plików `rename` zwróci `EXDEV` — wtedy kopiujemy i usuwamy oryginał (specyfikacja
5
+//! dopuszcza też kosz na szczycie montowania; to kolejny krok).
6
+
7
+use std::env;
8
+use std::fs;
9
+use std::io::{self, Write};
10
+use std::path::{Path, PathBuf};
11
+
12
+use crate::model::{now_secs, trash_date};
13
+
14
+pub fn home() -> PathBuf {
15
+ env::var_os("HOME")
16
+ .map(PathBuf::from)
17
+ .unwrap_or_else(|| PathBuf::from("/"))
18
+}
19
+
20
+/// Katalog kosza użytkownika.
21
+pub fn trash_root() -> PathBuf {
22
+ env::var_os("XDG_DATA_HOME")
23
+ .map(PathBuf::from)
24
+ .unwrap_or_else(|| home().join(".local").join("share"))
25
+ .join("Trash")
26
+}
27
+
28
+/// Przenosi plik/katalog do kosza; zwraca ścieżkę w `Trash/files`.
29
+pub fn move_to_trash(path: &Path) -> io::Result<PathBuf> {
30
+ move_to_trash_in(path, &trash_root())
31
+}
32
+
33
+/// Jak [`move_to_trash`], ale z jawnym katalogiem kosza (testy, inne montowania).
34
+pub fn move_to_trash_in(path: &Path, root: &Path) -> io::Result<PathBuf> {
35
+ let files = root.join("files");
36
+ let info = root.join("info");
37
+ fs::create_dir_all(&files)?;
38
+ fs::create_dir_all(&info)?;
39
+
40
+ let name = path
41
+ .file_name()
42
+ .and_then(|n| n.to_str())
43
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "zła nazwa pliku"))?;
44
+ let original = absolute(path);
45
+
46
+ // Unikalna nazwa w koszu: `raport.pdf`, `raport.2.pdf`, …
47
+ let (stem, ext) = split_name(name);
48
+ let mut candidate = name.to_owned();
49
+ let mut n = 1u32;
50
+ while files.join(&candidate).symlink_metadata().is_ok()
51
+ || info.join(format!("{candidate}.trashinfo")).exists()
52
+ {
53
+ n += 1;
54
+ candidate = if ext.is_empty() {
55
+ format!("{stem}.{n}")
56
+ } else {
57
+ format!("{stem}.{n}.{ext}")
58
+ };
59
+ }
60
+
61
+ let dest = files.join(&candidate);
62
+ move_path(path, &dest)?;
63
+
64
+ let mut f = fs::File::create(info.join(format!("{candidate}.trashinfo")))?;
65
+ writeln!(f, "[Trash Info]")?;
66
+ writeln!(f, "Path={}", encode_path(&original))?;
67
+ writeln!(f, "DeletionDate={}", trash_date(now_secs()))?;
68
+ Ok(dest)
69
+}
70
+
71
+/// Wpis w koszu (z `.trashinfo`).
72
+#[derive(Debug, Clone)]
73
+pub struct Trashed {
74
+ pub name: String,
75
+ /// Ścieżka w `Trash/files`.
76
+ pub path: PathBuf,
77
+ /// Oryginalna lokalizacja.
78
+ pub original: PathBuf,
79
+ pub deleted: String,
80
+}
81
+
82
+/// Zawartość kosza (bez sortowania — robi to UI).
83
+pub fn list() -> Vec<Trashed> {
84
+ list_in(&trash_root())
85
+}
86
+
87
+pub fn list_in(root: &Path) -> Vec<Trashed> {
88
+ let mut out = Vec::new();
89
+ let info = root.join("info");
90
+ let Ok(entries) = fs::read_dir(&info) else {
91
+ return out;
92
+ };
93
+ for entry in entries.flatten() {
94
+ let path = entry.path();
95
+ if path.extension().and_then(|e| e.to_str()) != Some("trashinfo") {
96
+ continue;
97
+ }
98
+ let Some(name) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
99
+ continue;
100
+ };
101
+ let Ok(text) = fs::read_to_string(&path) else {
102
+ continue;
103
+ };
104
+ let mut original = PathBuf::new();
105
+ let mut deleted = String::new();
106
+ for line in text.lines() {
107
+ if let Some(v) = line.strip_prefix("Path=") {
108
+ original = PathBuf::from(decode_path(v));
109
+ } else if let Some(v) = line.strip_prefix("DeletionDate=") {
110
+ deleted = v.to_owned();
111
+ }
112
+ }
113
+ out.push(Trashed {
114
+ path: root.join("files").join(&name),
115
+ name,
116
+ original,
117
+ deleted,
118
+ });
119
+ }
120
+ out
121
+}
122
+
123
+/// Opróżnia kosz; zwraca `(liczba plików, zwolnione bajty)`.
124
+pub fn empty_trash() -> io::Result<(usize, u64)> {
125
+ empty_trash_in(&trash_root())
126
+}
127
+
128
+pub fn empty_trash_in(root: &Path) -> io::Result<(usize, u64)> {
129
+ let files = root.join("files");
130
+ let info = root.join("info");
131
+ let mut count = 0usize;
132
+ let mut bytes = 0u64;
133
+ if let Ok(entries) = fs::read_dir(&files) {
134
+ for entry in entries.flatten() {
135
+ let path = entry.path();
136
+ bytes += path_size(&path);
137
+ remove_path(&path)?;
138
+ count += 1;
139
+ }
140
+ }
141
+ if let Ok(entries) = fs::read_dir(&info) {
142
+ for entry in entries.flatten() {
143
+ let _ = fs::remove_file(entry.path());
144
+ }
145
+ }
146
+ Ok((count, bytes))
147
+}
148
+
149
+/// Ścieżka `.trashinfo` odpowiadająca plikowi w `Trash/files`.
150
+pub fn info_for(trashed: &Path) -> Option<PathBuf> {
151
+ let name = trashed.file_name()?.to_string_lossy();
152
+ let root = trashed.parent()?.parent()?;
153
+ Some(root.join("info").join(format!("{name}.trashinfo")))
154
+}
155
+
156
+/// Przywraca plik z kosza na jego pierwotną ścieżkę (z unikalną nazwą,
157
+/// jeśli coś już tam jest). Usuwa wpis `.trashinfo`.
158
+pub fn restore(trashed: &Path) -> io::Result<PathBuf> {
159
+ let info = info_for(trashed).ok_or_else(|| {
160
+ io::Error::new(io::ErrorKind::InvalidInput, "nieprawidłowa ścieżka kosza")
161
+ })?;
162
+ let text = fs::read_to_string(&info)?;
163
+ let original = text
164
+ .lines()
165
+ .find_map(|line| line.strip_prefix("Path="))
166
+ .map(|v| PathBuf::from(decode_path(v)))
167
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "brak Path w .trashinfo"))?;
168
+ if let Some(parent) = original.parent() {
169
+ fs::create_dir_all(parent)?;
170
+ }
171
+ let dest = if original.symlink_metadata().is_ok() {
172
+ let dir = original.parent().unwrap_or_else(|| Path::new("/"));
173
+ let name = original
174
+ .file_name()
175
+ .map(|n| n.to_os_string())
176
+ .unwrap_or_else(|| std::ffi::OsString::from("plik"));
177
+ let name_str = name.to_string_lossy().into_owned();
178
+ let (stem, ext) = split_name(&name_str);
179
+ let mut n = 1u32;
180
+ loop {
181
+ let candidate = if ext.is_empty() {
182
+ dir.join(format!("{stem} ({n})"))
183
+ } else {
184
+ dir.join(format!("{stem} ({n}).{ext}"))
185
+ };
186
+ if candidate.symlink_metadata().is_err() {
187
+ break candidate;
188
+ }
189
+ n += 1;
190
+ }
191
+ } else {
192
+ original
193
+ };
194
+ move_path(trashed, &dest)?;
195
+ let _ = fs::remove_file(&info);
196
+ Ok(dest)
197
+}
198
+
199
+/// Usuwa wpis z kosza trwale (plik + `.trashinfo`).
200
+pub fn purge(trashed: &Path) -> io::Result<()> {
201
+ remove_path(trashed)?;
202
+ if let Some(info) = info_for(trashed) {
203
+ let _ = fs::remove_file(info);
204
+ }
205
+ Ok(())
206
+}
207
+
208
+fn split_name(name: &str) -> (&str, &str) {
209
+ match name.rsplit_once('.') {
210
+ Some((stem, ext)) if !stem.is_empty() => (stem, ext),
211
+ _ => (name, ""),
212
+ }
213
+}
214
+
215
+fn absolute(path: &Path) -> PathBuf {
216
+ if path.is_absolute() {
217
+ return path.to_path_buf();
218
+ }
219
+ env::current_dir()
220
+ .map(|cwd| cwd.join(path))
221
+ .unwrap_or_else(|_| path.to_path_buf())
222
+}
223
+
224
+fn move_path(src: &Path, dst: &Path) -> io::Result<()> {
225
+ match fs::rename(src, dst) {
226
+ Ok(()) => Ok(()),
227
+ // Inny system plików (pendrive, inna partycja): kopiujemy i usuwamy oryginał.
228
+ Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
229
+ copy_path(src, dst)?;
230
+ remove_path(src)
231
+ }
232
+ Err(e) => Err(e),
233
+ }
234
+}
235
+
236
+fn copy_path(src: &Path, dst: &Path) -> io::Result<()> {
237
+ let meta = fs::symlink_metadata(src)?;
238
+ if meta.is_dir() {
239
+ fs::create_dir_all(dst)?;
240
+ for entry in fs::read_dir(src)? {
241
+ let entry = entry?;
242
+ copy_path(&entry.path(), &dst.join(entry.file_name()))?;
243
+ }
244
+ Ok(())
245
+ } else if meta.file_type().is_symlink() {
246
+ let target = fs::read_link(src)?;
247
+ std::os::unix::fs::symlink(target, dst)
248
+ } else {
249
+ fs::copy(src, dst).map(|_| ())
250
+ }
251
+}
252
+
253
+fn remove_path(path: &Path) -> io::Result<()> {
254
+ let meta = fs::symlink_metadata(path)?;
255
+ if meta.is_dir() {
256
+ fs::remove_dir_all(path)
257
+ } else {
258
+ fs::remove_file(path)
259
+ }
260
+}
261
+
262
+fn path_size(path: &Path) -> u64 {
263
+ let Ok(meta) = fs::symlink_metadata(path) else {
264
+ return 0;
265
+ };
266
+ if meta.is_dir() {
267
+ let mut sum = 0;
268
+ if let Ok(entries) = fs::read_dir(path) {
269
+ for entry in entries.flatten() {
270
+ sum += path_size(&entry.path());
271
+ }
272
+ }
273
+ sum
274
+ } else {
275
+ meta.len()
276
+ }
277
+}
278
+
279
+/// Procentowe kodowanie ścieżki zgodnie z XDG Trash Spec (bez kodowania `/`).
280
+pub fn encode_path(path: &Path) -> String {
281
+ let mut out = String::new();
282
+ for &b in path.as_os_str().as_encoded_bytes() {
283
+ match b {
284
+ b'/' | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
285
+ out.push(b as char)
286
+ }
287
+ _ => out.push_str(&format!("%{b:02X}")),
288
+ }
289
+ }
290
+ out
291
+}
292
+
293
+fn decode_path(text: &str) -> String {
294
+ let bytes = text.as_bytes();
295
+ let mut out = Vec::with_capacity(bytes.len());
296
+ let mut i = 0;
297
+ while i < bytes.len() {
298
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
299
+ if let Ok(v) = u8::from_str_radix(&text[i + 1..i + 3], 16) {
300
+ out.push(v);
301
+ i += 3;
302
+ continue;
303
+ }
304
+ }
305
+ out.push(bytes[i]);
306
+ i += 1;
307
+ }
308
+ String::from_utf8_lossy(&out).into_owned()
309
+}
310
+
311
+#[cfg(test)]
312
+mod tests {
313
+ use super::*;
314
+
315
+ fn temp_dir(tag: &str) -> PathBuf {
316
+ let dir = env::temp_dir().join(format!(
317
+ "luna-files-trash-{tag}-{}-{:?}",
318
+ std::process::id(),
319
+ std::thread::current().id()
320
+ ));
321
+ let _ = fs::remove_dir_all(&dir);
322
+ fs::create_dir_all(&dir).unwrap();
323
+ dir
324
+ }
325
+
326
+ #[test]
327
+ fn trashing_keeps_content_and_writes_info() {
328
+ let base = temp_dir("basic");
329
+ let datadir = base.join("Trash");
330
+ let file = base.join("notatka.txt");
331
+ fs::write(&file, "treść").unwrap();
332
+
333
+ let dest = move_to_trash_in(&file, &datadir).unwrap();
334
+ assert!(!file.exists());
335
+ assert_eq!(fs::read_to_string(&dest).unwrap(), "treść");
336
+
337
+ let info = fs::read_to_string(datadir.join("info/notatka.txt.trashinfo")).unwrap();
338
+ assert!(info.starts_with("[Trash Info]\n"));
339
+ assert!(info.contains("Path="));
340
+ assert!(info.contains("DeletionDate="));
341
+ assert!(!info.contains("Path=%2F")); // slashe nie są kodowane
342
+ }
343
+
344
+ #[test]
345
+ fn name_collisions_get_a_suffix() {
346
+ let base = temp_dir("collision");
347
+ let root = base.join("Trash");
348
+ let a = base.join("dokument.txt");
349
+ let b = base.join("dokument.txt");
350
+ fs::write(&a, "a").unwrap();
351
+ move_to_trash_in(&a, &root).unwrap();
352
+ fs::write(&b, "b").unwrap();
353
+ let dest = move_to_trash_in(&b, &root).unwrap();
354
+ assert_eq!(dest.file_name().unwrap(), "dokument.2.txt");
355
+ assert!(root.join("info/dokument.2.txt.trashinfo").exists());
356
+ }
357
+
358
+ #[test]
359
+ fn listing_reads_original_path() {
360
+ let base = temp_dir("list");
361
+ let root = base.join("Trash");
362
+ let file = base.join("plik.bin");
363
+ fs::write(&file, [1u8, 2, 3]).unwrap();
364
+ move_to_trash_in(&file, &root).unwrap();
365
+ let items = list_in(&root);
366
+ assert_eq!(items.len(), 1);
367
+ assert_eq!(items[0].name, "plik.bin");
368
+ assert_eq!(items[0].original, file);
369
+ }
370
+
371
+ #[test]
372
+ fn emptying_clears_files_and_info() {
373
+ let base = temp_dir("empty");
374
+ let root = base.join("Trash");
375
+ let a = base.join("a.txt");
376
+ fs::write(&a, "12345").unwrap();
377
+ move_to_trash_in(&a, &root).unwrap();
378
+ let (count, bytes) = empty_trash_in(&root).unwrap();
379
+ assert_eq!(count, 1);
380
+ assert_eq!(bytes, 5);
381
+ assert!(list_in(&root).is_empty());
382
+ }
383
+
384
+ #[test]
385
+ fn restore_puts_file_back() {
386
+ let base = temp_dir("restore");
387
+ let root = base.join("Trash");
388
+ let original = base.join("dane.txt");
389
+ fs::write(&original, "abc").unwrap();
390
+ move_to_trash_in(&original, &root).unwrap();
391
+ assert!(!original.exists());
392
+ let restored = restore(&root.join("files/dane.txt")).unwrap();
393
+ assert_eq!(restored, original);
394
+ assert_eq!(fs::read_to_string(&restored).unwrap(), "abc");
395
+ assert!(!root.join("info/dane.txt.trashinfo").exists());
396
+ }
397
+
398
+ #[test]
399
+ fn purge_removes_file_and_info() {
400
+ let base = temp_dir("purge");
401
+ let root = base.join("Trash");
402
+ let file = base.join("x.bin");
403
+ fs::write(&file, [0u8; 4]).unwrap();
404
+ let dest = move_to_trash_in(&file, &root).unwrap();
405
+ purge(&dest).unwrap();
406
+ assert!(!dest.exists());
407
+ assert!(!root.join("info/x.bin.trashinfo").exists());
408
+ }
409
+
410
+ #[test]
411
+ fn path_encoding_escapes_spaces() {
412
+ assert_eq!(encode_path(Path::new("/a b/c.txt")), "/a%20b/c.txt");
413
+ assert_eq!(decode_path("/a%20b/c.txt"), "/a b/c.txt");
414
+ }
415
+}
0
416
new file mode 100644
@@ -0,0 +1,1820 @@
1
+//! Warstwa UI: układ, rysowanie i obsługa wejścia.
2
+//!
3
+//! Wszystkie napisy idą przez `libluna_i18n::tr` (klucz = angielski tekst).
4
+
5
+use std::path::{Component, Path, PathBuf};
6
+use std::time::{Duration, Instant};
7
+
8
+use libluna_core::style::weight;
9
+use libluna_core::{
10
+ ButtonState, Color, Event, Insets, KeyEvent, Modifiers, Point, PointerButton, Rect, Size, Theme,
11
+};
12
+use libluna_render::{Scene, TextEngine};
13
+use libluna_widgets::{
14
+ icon, AlertDialog, ControlsSide, Entry as TextEntry, HeaderBar, IconName, Menu, MenuEntry,
15
+ PaintCtx, PathBar, Tab, TabBar, TextInput, ViewMode, WindowControl,
16
+};
17
+
18
+use crate::app::{App, ContextMenu, EditState, MenuCmd, MenuTarget, Pane, SideAction, SPLIT_GAP};
19
+use crate::list;
20
+use crate::model::{format_mtime, human_size, Kind, SortKey};
21
+use crate::ops::Job;
22
+use crate::places;
23
+use crate::trash;
24
+
25
+const SIDEBAR_W: f64 = 216.0;
26
+const TABS_H: f64 = 34.0;
27
+const NAV_H: f64 = 40.0;
28
+const STATUS_H: f64 = 28.0;
29
+const PREVIEW_W: f64 = 300.0;
30
+const PAD: f64 = 8.0;
31
+const BTN: f64 = 28.0;
32
+const PANE_PATH_H: f64 = 26.0;
33
+
34
+struct Layout {
35
+ header: Rect,
36
+ tabs: Rect,
37
+ nav: Rect,
38
+ sidebar: Rect,
39
+ status: Rect,
40
+ panes: [Rect; 2],
41
+}
42
+
43
+struct Chrome {
44
+ back: Rect,
45
+ forward: Rect,
46
+ up: Rect,
47
+ search: Rect,
48
+ view: Rect,
49
+ menu: Rect,
50
+ plus: Rect,
51
+ path: Rect,
52
+}
53
+
54
+#[derive(Clone, Copy)]
55
+struct PaneRects {
56
+ path: Option<Rect>,
57
+ items: Rect,
58
+ preview: Option<Rect>,
59
+}
60
+
61
+/// Obszar interaktywnych wierszy (w trybie listy bez paska nagłówków).
62
+fn file_rows(pane: &Pane, items: Rect) -> Rect {
63
+ if pane.view.mode() == ViewMode::List {
64
+ list::rows_rect(items)
65
+ } else {
66
+ items
67
+ }
68
+}
69
+
70
+/// Trafienie w wiersz zależnie od trybu widoku.
71
+fn hit_index(pane: &Pane, rows: Rect, p: Point, theme: &Theme) -> Option<usize> {
72
+ match pane.view.mode() {
73
+ ViewMode::List => list::index_at(rows, p, pane.view.scroll(), pane.view.len(), theme),
74
+ ViewMode::Grid => pane.view.index_at(rows, p, theme),
75
+ }
76
+}
77
+
78
+fn layout(app: &App, size: Size, theme: &Theme) -> Layout {
79
+ let hh = theme.metrics.headerbar.height;
80
+ let header = Rect::from_xywh(0.0, 0.0, size.width, hh);
81
+ let tabs = Rect::from_xywh(0.0, hh, size.width, TABS_H);
82
+ let nav = Rect::from_xywh(0.0, tabs.bottom(), size.width, NAV_H);
83
+ let status = Rect::from_xywh(
84
+ 0.0,
85
+ (size.height - STATUS_H).max(nav.bottom()),
86
+ size.width,
87
+ STATUS_H,
88
+ );
89
+ let content_h = (status.y() - nav.bottom()).max(0.0);
90
+ let sidebar_w = if app.sidebar_visible && size.width > 720.0 {
91
+ SIDEBAR_W.min(size.width * 0.35)
92
+ } else {
93
+ 0.0
94
+ };
95
+ let sidebar = Rect::from_xywh(0.0, nav.bottom(), sidebar_w, content_h);
96
+ let area = Rect::from_xywh(
97
+ sidebar.right(),
98
+ nav.bottom(),
99
+ (size.width - sidebar.right()).max(1.0),
100
+ content_h,
101
+ );
102
+ let mut panes = [Rect::ZERO; 2];
103
+ if app.split {
104
+ let w = ((area.width() - SPLIT_GAP) / 2.0).max(1.0);
105
+ panes[0] = Rect::from_xywh(area.x(), area.y(), w, area.height());
106
+ panes[1] = Rect::from_xywh(area.x() + w + SPLIT_GAP, area.y(), w, area.height());
107
+ } else {
108
+ panes[0] = area;
109
+ }
110
+ Layout {
111
+ header,
112
+ tabs,
113
+ nav,
114
+ sidebar,
115
+ status,
116
+ panes,
117
+ }
118
+}
119
+
120
+fn chrome(lay: &Layout, size: Size) -> Chrome {
121
+ let cy = lay.nav.center().y;
122
+ let mut x = lay.nav.x() + PAD;
123
+ let btn = |x: &mut f64| {
124
+ let r = Rect::from_xywh(*x, cy - BTN / 2.0, BTN, BTN);
125
+ *x += BTN + 2.0;
126
+ r
127
+ };
128
+ let back = btn(&mut x);
129
+ let forward = btn(&mut x);
130
+ let up = btn(&mut x);
131
+
132
+ let right = lay.nav.right() - PAD;
133
+ let menu = Rect::from_xywh(right - BTN, cy - BTN / 2.0, BTN, BTN);
134
+ let view = Rect::from_xywh(menu.x() - BTN - 4.0, cy - BTN / 2.0, BTN, BTN);
135
+ let search = Rect::from_xywh(view.x() - BTN - 4.0, cy - BTN / 2.0, BTN, BTN);
136
+ let path = Rect::from_xywh(
137
+ up.right() + PAD,
138
+ lay.nav.y() + 5.0,
139
+ (search.x() - PAD - (up.right() + PAD)).max(40.0),
140
+ NAV_H - 10.0,
141
+ );
142
+ let plus = Rect::from_xywh(
143
+ (size.width - PAD - BTN).max(0.0),
144
+ lay.tabs.center().y - BTN / 2.0,
145
+ BTN,
146
+ BTN,
147
+ );
148
+ Chrome {
149
+ back,
150
+ forward,
151
+ up,
152
+ search,
153
+ view,
154
+ menu,
155
+ plus,
156
+ path,
157
+ }
158
+}
159
+
160
+fn pane_rects(app: &App, lay: &Layout, i: usize, theme: &Theme) -> PaneRects {
161
+ let pane = &app.panes[i];
162
+ let rect = lay.panes[i];
163
+ let (path, body) = if app.split {
164
+ (
165
+ Some(Rect::from_xywh(
166
+ rect.x(),
167
+ rect.y(),
168
+ rect.width(),
169
+ PANE_PATH_H,
170
+ )),
171
+ Rect::from_xywh(
172
+ rect.x(),
173
+ rect.y() + PANE_PATH_H,
174
+ rect.width(),
175
+ (rect.height() - PANE_PATH_H).max(0.0),
176
+ ),
177
+ )
178
+ } else {
179
+ (None, rect)
180
+ };
181
+ let preview = if pane.preview && body.width() > 560.0 {
182
+ Some(Rect::from_xywh(
183
+ body.right() - PREVIEW_W,
184
+ body.y(),
185
+ PREVIEW_W,
186
+ body.height(),
187
+ ))
188
+ } else {
189
+ None
190
+ };
191
+ let items = match preview {
192
+ Some(p) => Rect::from_xywh(
193
+ body.x() + 6.0,
194
+ body.y() + 4.0,
195
+ (p.x() - body.x() - 12.0).max(1.0),
196
+ (body.height() - 8.0).max(1.0),
197
+ ),
198
+ None => Rect::from_xywh(
199
+ body.x() + 6.0,
200
+ body.y() + 4.0,
201
+ (body.width() - 12.0).max(1.0),
202
+ (body.height() - 8.0).max(1.0),
203
+ ),
204
+ };
205
+ let _ = theme;
206
+ PaneRects {
207
+ path,
208
+ items,
209
+ preview,
210
+ }
211
+}
212
+
213
+// ---------------------------------------------------------------------------
214
+// Rysowanie
215
+// ---------------------------------------------------------------------------
216
+
217
+pub fn paint(app: &App, scene: &mut Scene, size: Size, theme: &Theme, text: &TextEngine) {
218
+ let lay = layout(app, size, theme);
219
+ let ch = chrome(&lay, size);
220
+ let mut ctx = PaintCtx::new(scene, theme, text);
221
+
222
+ ctx.scene
223
+ .fill_rect(Rect::from_size(size), theme.colors.window_bg);
224
+
225
+ paint_header(&mut ctx, app, &lay);
226
+ paint_tabs(&mut ctx, app, &lay, &ch);
227
+ paint_nav(&mut ctx, app, &lay, &ch);
228
+ paint_sidebar(&mut ctx, app, &lay);
229
+ paint_panes(&mut ctx, app, &lay);
230
+ paint_status(&mut ctx, app, &lay);
231
+
232
+ if let Some(context) = &app.context {
233
+ context.menu.paint(&mut ctx, context.panel);
234
+ }
235
+ app.toast.paint(&mut ctx, size);
236
+ if let Some(conflict) = &app.conflict {
237
+ conflict.dialog.paint(&mut ctx, size);
238
+ }
239
+ if let Some(confirm) = &app.confirm_empty {
240
+ confirm.paint(&mut ctx, size);
241
+ }
242
+}
243
+
244
+fn paint_header(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
245
+ let title = if app.pane().dir.path == Path::new("/") {
246
+ libluna_i18n::tr("File System")
247
+ } else {
248
+ app.pane().dir.title()
249
+ };
250
+ let header = HeaderBar::new().title(title).controls(ControlsSide::Right);
251
+ header.paint(ctx, lay.header, None);
252
+}
253
+
254
+fn paint_tabs(ctx: &mut PaintCtx, app: &App, lay: &Layout, ch: &Chrome) {
255
+ let pane = app.pane();
256
+ let tabs: Vec<Tab> = pane
257
+ .tabs
258
+ .iter()
259
+ .map(|p| Tab::new(tab_title(p)).closable(pane.tabs.len() > 1))
260
+ .collect();
261
+ let mut bar = TabBar::with_tabs(tabs).expand(false);
262
+ bar.set_selected(pane.tab);
263
+ let rect = Rect::from_xywh(
264
+ lay.tabs.x(),
265
+ lay.tabs.y(),
266
+ (ch.plus.x() - lay.tabs.x() - PAD).max(1.0),
267
+ lay.tabs.height(),
268
+ );
269
+ bar.paint(ctx, rect);
270
+ icon_button(ctx, ch.plus, IconName::Plus, true, false);
271
+}
272
+
273
+fn paint_nav(ctx: &mut PaintCtx, app: &App, lay: &Layout, ch: &Chrome) {
274
+ let pane = app.pane();
275
+ ctx.scene.fill_rect(lay.nav, ctx.theme.colors.headerbar_bg);
276
+
277
+ icon_button(
278
+ ctx,
279
+ ch.back,
280
+ IconName::ChevronLeft,
281
+ !pane.back.is_empty(),
282
+ false,
283
+ );
284
+ icon_button(
285
+ ctx,
286
+ ch.forward,
287
+ IconName::ChevronRight,
288
+ !pane.forward.is_empty(),
289
+ false,
290
+ );
291
+ icon_up(ctx, ch.up, pane.dir.parent().is_some());
292
+
293
+ if let Some(edit) = &pane.edit {
294
+ edit.entry.paint(ctx, ch.path);
295
+ } else if pane.search_active {
296
+ pane.search.paint(ctx, ch.path);
297
+ } else {
298
+ let bar = PathBar::new(path_components(&pane.dir.path));
299
+ bar.paint(ctx, ch.path);
300
+ }
301
+
302
+ icon_button(ctx, ch.search, IconName::Search, true, pane.search_active);
303
+ let view_icon = match pane.view.mode() {
304
+ ViewMode::List => IconName::Grid,
305
+ ViewMode::Grid => IconName::List,
306
+ };
307
+ icon_button(ctx, ch.view, view_icon, true, false);
308
+ icon_button(ctx, ch.menu, IconName::Menu, true, false);
309
+}
310
+
311
+fn paint_sidebar(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
312
+ if lay.sidebar.width() <= 0.0 {
313
+ return;
314
+ }
315
+ ctx.scene
316
+ .fill_rect(lay.sidebar, ctx.theme.colors.sidebar_bg);
317
+ ctx.scene.fill_rect(
318
+ Rect::from_xywh(
319
+ lay.sidebar.right() - 1.0,
320
+ lay.sidebar.y(),
321
+ 1.0,
322
+ lay.sidebar.height(),
323
+ ),
324
+ ctx.theme.colors.border,
325
+ );
326
+ app.sidebar
327
+ .paint(ctx, lay.sidebar.deflate(Insets::splat(6.0)));
328
+}
329
+
330
+fn paint_panes(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
331
+ let count = if app.split { 2 } else { 1 };
332
+ for i in 0..count {
333
+ let pane = &app.panes[i];
334
+ let rect = lay.panes[i];
335
+ let pr = pane_rects(app, lay, i, ctx.theme);
336
+
337
+ if app.split {
338
+ let active = i == app.active;
339
+ ctx.scene.stroke_rounded_rect(
340
+ rect,
341
+ 6.0,
342
+ if active { 1.5 } else { 1.0 },
343
+ if active {
344
+ ctx.theme.colors.accent
345
+ } else {
346
+ ctx.theme.colors.border
347
+ },
348
+ );
349
+ if let Some(path) = pr.path {
350
+ ctx.scene.fill_rect(path, ctx.theme.colors.headerbar_bg);
351
+ let title = path_text(&pane.dir.path);
352
+ ctx.label_left(
353
+ path.x() + 10.0,
354
+ path.center().y,
355
+ &title,
356
+ ctx.theme.typography.caption.size,
357
+ weight::MEDIUM,
358
+ ctx.theme.colors.dim_text,
359
+ );
360
+ }
361
+ }
362
+
363
+ // Pasek przewijania i zawartość (lista ma własne kolumny).
364
+ let rows = file_rows(pane, pr.items);
365
+ let mut sv = pane.scroll.clone();
366
+ sv.set_content_height(pane.view.content_height(rows, ctx.theme));
367
+ sv.set_offset(pane.view.scroll(), rows);
368
+ sv.paint_scrollbar(ctx, rows);
369
+
370
+ if pane.view.mode() == ViewMode::List {
371
+ list::paint(
372
+ ctx,
373
+ pr.items,
374
+ &pane.dir,
375
+ pane.view.selected_indices(),
376
+ pane.view.hovered(),
377
+ pane.view.scroll(),
378
+ ctx.theme,
379
+ );
380
+ } else {
381
+ pane.view.paint(ctx, rows);
382
+ }
383
+
384
+ if pane.view.is_empty() {
385
+ let msg = if !pane.dir.filter().is_empty() {
386
+ libluna_i18n::tr("No matching files")
387
+ } else {
388
+ libluna_i18n::tr("Folder is empty")
389
+ };
390
+ ctx.label_center(
391
+ rows.center().x,
392
+ rows.center().y,
393
+ &msg,
394
+ ctx.theme.typography.body.size,
395
+ weight::NORMAL,
396
+ ctx.theme.colors.dim_text,
397
+ );
398
+ }
399
+
400
+ if let Some(prect) = pr.preview {
401
+ paint_preview(ctx, pane, prect);
402
+ }
403
+ }
404
+}
405
+
406
+fn paint_preview(ctx: &mut PaintCtx, pane: &Pane, rect: Rect) {
407
+ ctx.scene
408
+ .fill_rounded_rect(rect, 8.0, ctx.theme.colors.card_bg);
409
+ ctx.scene
410
+ .stroke_rounded_rect(rect, 8.0, 1.0, ctx.theme.colors.border);
411
+ let inner = rect.deflate(Insets::splat(10.0));
412
+ let Some(entry) = pane.primary() else {
413
+ ctx.label_center(
414
+ inner.center().x,
415
+ inner.center().y,
416
+ &libluna_i18n::tr("No preview"),
417
+ ctx.theme.typography.body.size,
418
+ weight::NORMAL,
419
+ ctx.theme.colors.dim_text,
420
+ );
421
+ return;
422
+ };
423
+ let body = ctx.theme.typography.body.size;
424
+ let caption = ctx.theme.typography.caption.size;
425
+ ctx.label_left(
426
+ inner.x(),
427
+ inner.y() + 8.0,
428
+ &entry.name,
429
+ body,
430
+ weight::BOLD,
431
+ ctx.theme.colors.text,
432
+ );
433
+ let below = Rect::from_xywh(
434
+ inner.x(),
435
+ inner.y() + 22.0,
436
+ inner.width(),
437
+ (inner.height() - 22.0).max(1.0),
438
+ );
439
+ match pane.preview_cache.as_ref().map(|(_, p)| p) {
440
+ Some(crate::app::Preview::Text(text)) => {
441
+ ctx.scene.push_clip(below);
442
+ let line_h = body * 1.25;
443
+ let max_lines = (below.height() / line_h).floor().max(1.0) as usize;
444
+ for (i, line) in text.lines().take(max_lines).enumerate() {
445
+ ctx.label_left(
446
+ below.x(),
447
+ below.y() + i as f64 * line_h + line_h * 0.75,
448
+ line,
449
+ body,
450
+ weight::NORMAL,
451
+ ctx.theme.colors.text,
452
+ );
453
+ }
454
+ ctx.scene.pop_clip();
455
+ }
456
+ Some(crate::app::Preview::Image(img)) => {
457
+ let scale = (below.width() / img.width as f64)
458
+ .min(below.height() / img.height as f64)
459
+ .max(0.0);
460
+ let w = img.width as f64 * scale;
461
+ let h = img.height as f64 * scale;
462
+ let target = Rect::from_xywh(
463
+ below.center().x - w / 2.0,
464
+ below.center().y - h / 2.0,
465
+ w.max(1.0),
466
+ h.max(1.0),
467
+ );
468
+ ctx.scene.image(target, std::sync::Arc::clone(img));
469
+ }
470
+ _ => {
471
+ let mut y = below.y() + 8.0;
472
+ for (label, value) in [
473
+ (libluna_i18n::tr("Type"), entry.kind.label()),
474
+ (libluna_i18n::tr("Size"), human_size(entry.size)),
475
+ (libluna_i18n::tr("Modified"), format_mtime(entry.modified)),
476
+ (libluna_i18n::tr("Path"), entry.path.display().to_string()),
477
+ ] {
478
+ ctx.label_left(
479
+ below.x(),
480
+ y,
481
+ &format!("{label}: {value}"),
482
+ caption,
483
+ weight::NORMAL,
484
+ ctx.theme.colors.dim_text,
485
+ );
486
+ y += caption * 1.8;
487
+ }
488
+ }
489
+ }
490
+}
491
+
492
+fn paint_status(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
493
+ let colors = ctx.theme.colors;
494
+ ctx.scene.fill_rect(lay.status, colors.headerbar_bg);
495
+ ctx.label_left(
496
+ lay.status.x() + PAD,
497
+ lay.status.center().y,
498
+ &app.status,
499
+ ctx.theme.typography.caption.size,
500
+ weight::NORMAL,
501
+ colors.dim_text,
502
+ );
503
+ if let Some((done, total)) = app.op_progress {
504
+ let ratio = if total > 0 {
505
+ (done as f64 / total as f64).clamp(0.0, 1.0)
506
+ } else {
507
+ 0.0
508
+ };
509
+ let bar = Rect::from_xywh(
510
+ lay.status.x(),
511
+ lay.status.y() - 3.0,
512
+ lay.status.width() * ratio,
513
+ 3.0,
514
+ );
515
+ ctx.scene.fill_rect(bar, colors.accent);
516
+ let speed = if app.op_speed > 1.0 {
517
+ format!("{}/s", human_size(app.op_speed as u64))
518
+ } else {
519
+ String::new()
520
+ };
521
+ let eta = if app.op_speed > 1.0 && total > done {
522
+ let secs = ((total - done) as f64 / app.op_speed).round() as u64;
523
+ format!("{:02}:{:02}", secs / 60, secs % 60)
524
+ } else {
525
+ String::new()
526
+ };
527
+ let mut label = format!(
528
+ "{} {}%",
529
+ app.op_label.clone().unwrap_or_default(),
530
+ (ratio * 100.0).round() as i64
531
+ );
532
+ if !speed.is_empty() {
533
+ label.push_str(" · ");
534
+ label.push_str(&speed);
535
+ }
536
+ if !eta.is_empty() {
537
+ label.push_str(" · ");
538
+ label.push_str(&libluna_i18n::tr_args("{time} left", &[("time", &eta)]));
539
+ }
540
+ if !app.op_current.is_empty() {
541
+ label.push_str(" — ");
542
+ label.push_str(&app.op_current);
543
+ }
544
+ let cancel = op_cancel_rect(lay);
545
+ ctx.label_right(
546
+ cancel.x() - PAD,
547
+ lay.status.center().y,
548
+ &label,
549
+ ctx.theme.typography.caption.size,
550
+ weight::NORMAL,
551
+ colors.accent,
552
+ );
553
+ icon::paint_icon(
554
+ ctx,
555
+ IconName::Close,
556
+ cancel.deflate(Insets::splat(4.0)),
557
+ colors.text,
558
+ );
559
+ }
560
+}
561
+
562
+fn icon_button(ctx: &mut PaintCtx, rect: Rect, name: IconName, enabled: bool, active: bool) {
563
+ if active || enabled {
564
+ let bg = if active {
565
+ ctx.theme.colors.shade
566
+ } else {
567
+ Color::TRANSPARENT
568
+ };
569
+ if active {
570
+ ctx.scene.fill_rounded_rect(rect, 6.0, bg);
571
+ }
572
+ }
573
+ let color = if enabled {
574
+ ctx.theme.colors.text
575
+ } else {
576
+ ctx.theme.colors.dim_text.scale_alpha(0.5)
577
+ };
578
+ icon::paint_icon(ctx, name, rect.deflate(Insets::splat(6.0)), color);
579
+}
580
+
581
+fn icon_up(ctx: &mut PaintCtx, rect: Rect, enabled: bool) {
582
+ let color = if enabled {
583
+ ctx.theme.colors.text
584
+ } else {
585
+ ctx.theme.colors.dim_text.scale_alpha(0.5)
586
+ };
587
+ icon::chevron_up(ctx, color, rect.center(), 5.0, 1.6);
588
+}
589
+
590
+/// Przycisk anulowania operacji w belce statusu.
591
+fn op_cancel_rect(lay: &Layout) -> Rect {
592
+ Rect::from_xywh(
593
+ lay.status.right() - PAD - 18.0,
594
+ lay.status.center().y - 9.0,
595
+ 18.0,
596
+ 18.0,
597
+ )
598
+}
599
+
600
+// ---------------------------------------------------------------------------
601
+// Ścieżki i zakładki
602
+// ---------------------------------------------------------------------------
603
+
604
+fn tab_title(path: &Path) -> String {
605
+ if path == Path::new("/") {
606
+ "/".to_owned()
607
+ } else if path == places::home() {
608
+ "~".to_owned()
609
+ } else {
610
+ path.file_name()
611
+ .map(|n| n.to_string_lossy().into_owned())
612
+ .unwrap_or_else(|| path.display().to_string())
613
+ }
614
+}
615
+
616
+fn path_text(path: &Path) -> String {
617
+ if path == places::home() {
618
+ return "~".to_owned();
619
+ }
620
+ if let Ok(rest) = path.strip_prefix(places::home()) {
621
+ return format!("~/{}", rest.display());
622
+ }
623
+ path.display().to_string()
624
+}
625
+
626
+fn path_components(path: &Path) -> Vec<String> {
627
+ let home = places::home();
628
+ if path == home {
629
+ return vec!["~".to_owned()];
630
+ }
631
+ if let Ok(rest) = path.strip_prefix(&home) {
632
+ let mut out = vec!["~".to_owned()];
633
+ for c in rest.components() {
634
+ if let Component::Normal(s) = c {
635
+ out.push(s.to_string_lossy().into_owned());
636
+ }
637
+ }
638
+ return out;
639
+ }
640
+ let mut out = vec!["/".to_owned()];
641
+ for c in path.components() {
642
+ if let Component::Normal(s) = c {
643
+ out.push(s.to_string_lossy().into_owned());
644
+ }
645
+ }
646
+ if out.len() == 1 {
647
+ return vec!["/".to_owned()];
648
+ }
649
+ out
650
+}
651
+
652
+fn segment_path(path: &Path, index: usize) -> PathBuf {
653
+ let comps = path_components(path);
654
+ if index == 0 {
655
+ return if comps[0] == "~" {
656
+ places::home()
657
+ } else {
658
+ PathBuf::from("/")
659
+ };
660
+ }
661
+ let base = if comps[0] == "~" {
662
+ places::home()
663
+ } else {
664
+ PathBuf::from("/")
665
+ };
666
+ let mut out = base;
667
+ for c in comps.iter().skip(1).take(index) {
668
+ out.push(c);
669
+ }
670
+ out
671
+}
672
+
673
+// ---------------------------------------------------------------------------
674
+// Zdarzenia
675
+// ---------------------------------------------------------------------------
676
+
677
+pub fn handle_events(
678
+ app: &mut App,
679
+ events: &[Event],
680
+ size: Size,
681
+ theme: &Theme,
682
+ text: &TextEngine,
683
+) {
684
+ let lay = layout(app, size, theme);
685
+ let ch = chrome(&lay, size);
686
+
687
+ // Modal konfliktu blokuje resztę interfejsu.
688
+ if app.conflict.is_some() {
689
+ for event in events {
690
+ if let Event::PointerButton(pe) = event {
691
+ if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
692
+ let index = app
693
+ .conflict
694
+ .as_ref()
695
+ .and_then(|c| c.dialog.action_at(size, theme, text, pe.position));
696
+ if let Some(index) = index {
697
+ app.answer_conflict(index);
698
+ }
699
+ }
700
+ }
701
+ }
702
+ return;
703
+ }
704
+
705
+ // Modal potwierdzenia (opróżnienie kosza).
706
+ if app.confirm_empty.is_some() {
707
+ for event in events {
708
+ if let Event::PointerButton(pe) = event {
709
+ if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
710
+ let index = app
711
+ .confirm_empty
712
+ .as_ref()
713
+ .and_then(|d| d.action_at(size, theme, text, pe.position));
714
+ match index {
715
+ Some(0) => {
716
+ if crate::app::debug() {
717
+ eprintln!("luna-files: confirm empty trash");
718
+ }
719
+ let _ = trash::empty_trash();
720
+ app.confirm_empty = None;
721
+ app.reload_all();
722
+ app.refresh_devices();
723
+ app.show_toast(libluna_i18n::tr("Trash emptied"));
724
+ }
725
+ Some(_) => {
726
+ if crate::app::debug() {
727
+ eprintln!("luna-files: confirm cancelled");
728
+ }
729
+ app.confirm_empty = None;
730
+ }
731
+ None => {}
732
+ }
733
+ }
734
+ }
735
+ }
736
+ return;
737
+ }
738
+
739
+ for event in events {
740
+ match event {
741
+ Event::PointerButton(pe) => match (pe.button, pe.state) {
742
+ (PointerButton::Left, ButtonState::Pressed) => {
743
+ on_left_press(app, pe.position, pe.modifiers, &lay, &ch, size, theme, text)
744
+ }
745
+ (PointerButton::Right, ButtonState::Pressed) => {
746
+ on_right_press(app, pe.position, &lay, size, theme, text)
747
+ }
748
+ (PointerButton::Back, ButtonState::Pressed) => app.back(),
749
+ (PointerButton::Forward, ButtonState::Pressed) => app.forward(),
750
+ (_, ButtonState::Released) => {}
751
+ _ => {}
752
+ },
753
+ Event::PointerMotion { position, .. } => {
754
+ let p = *position;
755
+ if let Some(context) = app.context.as_mut() {
756
+ context.menu.hover_at(context.panel, Some(p), theme);
757
+ continue;
758
+ }
759
+ let _hovered =
760
+ app.sidebar
761
+ .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme);
762
+ app.sidebar.set_hovered(_hovered);
763
+ for i in 0..if app.split { 2 } else { 1 } {
764
+ let pr = pane_rects(app, &lay, i, theme);
765
+ let rows = file_rows(&app.panes[i], pr.items);
766
+ let hovered = hit_index(&app.panes[i], rows, p, theme);
767
+ app.panes[i].view.set_hovered(hovered);
768
+ app.panes[i]
769
+ .search
770
+ .set_hover_clear(app.panes[i].search.clear_at(ch.path, theme, p));
771
+ }
772
+ }
773
+ Event::PointerLeave => {
774
+ app.sidebar.set_hovered(None);
775
+ for pane in &mut app.panes {
776
+ pane.view.set_hovered(None);
777
+ }
778
+ }
779
+ Event::Scroll(se) => {
780
+ let count = if app.split { 2 } else { 1 };
781
+ for i in 0..count {
782
+ let pr = pane_rects(app, &lay, i, theme);
783
+ let rows = file_rows(&app.panes[i], pr.items);
784
+ if !rows.contains(se.position) {
785
+ continue;
786
+ }
787
+ app.active = i;
788
+ let step = theme.metrics.itemview.row_h;
789
+ let pane = &mut app.panes[i];
790
+ let max = pane.view.max_scroll(rows, theme);
791
+ let next = (pane.view.scroll() + se.dy * step).clamp(0.0, max);
792
+ pane.view.set_scroll(next);
793
+ pane.scroll.set_offset(next, rows);
794
+ break;
795
+ }
796
+ }
797
+ Event::Key(ke) if ke.state == ButtonState::Pressed => {
798
+ on_key(app, ke, &lay, &ch, size, theme, text)
799
+ }
800
+ _ => {}
801
+ }
802
+ }
803
+}
804
+
805
+fn on_left_press(
806
+ app: &mut App,
807
+ p: Point,
808
+ mods: Modifiers,
809
+ lay: &Layout,
810
+ ch: &Chrome,
811
+ size: Size,
812
+ theme: &Theme,
813
+ text: &TextEngine,
814
+) {
815
+ // Anulowanie trwającej operacji ma pierwszeństwo.
816
+ if app.op_progress.is_some() && op_cancel_rect(lay).contains(p) {
817
+ app.cancel_op();
818
+ return;
819
+ }
820
+ // 1. Menu kontekstowe ma pierwszeństwo.
821
+ if let Some(context) = &mut app.context {
822
+ if let Some(index) = context.menu.click_at(context.panel, p, theme) {
823
+ run_menu(app, index);
824
+ } else if !context.panel.contains(p) {
825
+ app.context = None;
826
+ }
827
+ return;
828
+ }
829
+
830
+ // 2. Kontrolki okna (nasz HeaderBar).
831
+ let header = HeaderBar::new().controls(ControlsSide::Right);
832
+ if lay.header.contains(p) {
833
+ if let Some(control) = header.control_at(lay.header, theme, p) {
834
+ match control {
835
+ WindowControl::Close => app.pending_close = true,
836
+ WindowControl::Minimize => app.pending_minimize = true,
837
+ WindowControl::Maximize => app.pending_maximize = true,
838
+ }
839
+ return;
840
+ }
841
+ // Przeciąganie za wolny obszar paska.
842
+ let content = header.controls_layout(lay.header, theme).content;
843
+ if content.contains(p) {
844
+ app.pending_move = true;
845
+ }
846
+ return;
847
+ }
848
+
849
+ // 3. Zakładki.
850
+ if lay.tabs.contains(p) {
851
+ if ch.plus.contains(p) {
852
+ app.new_tab();
853
+ return;
854
+ }
855
+ let pane = app.pane();
856
+ let tabs: Vec<Tab> = pane
857
+ .tabs
858
+ .iter()
859
+ .map(|tp| Tab::new(tab_title(tp)).closable(pane.tabs.len() > 1))
860
+ .collect();
861
+ let mut bar = TabBar::with_tabs(tabs).expand(false);
862
+ bar.set_selected(pane.tab);
863
+ let rect = Rect::from_xywh(
864
+ lay.tabs.x(),
865
+ lay.tabs.y(),
866
+ (ch.plus.x() - lay.tabs.x() - PAD).max(1.0),
867
+ lay.tabs.height(),
868
+ );
869
+ if let Some(i) = bar.close_at(rect, text, theme, p) {
870
+ app.select_tab(i);
871
+ app.close_tab();
872
+ } else if let Some(i) = bar.index_at(rect, text, theme, p) {
873
+ app.select_tab(i);
874
+ }
875
+ return;
876
+ }
877
+
878
+ // 4. Pasek nawigacji.
879
+ if lay.nav.contains(p) {
880
+ if ch.back.contains(p) {
881
+ app.back();
882
+ return;
883
+ }
884
+ if ch.forward.contains(p) {
885
+ app.forward();
886
+ return;
887
+ }
888
+ if ch.up.contains(p) {
889
+ app.up();
890
+ return;
891
+ }
892
+ if ch.search.contains(p) {
893
+ let pane = app.pane_mut();
894
+ pane.search_active = !pane.search_active;
895
+ pane.search.set_focused(pane.search_active);
896
+ if !pane.search_active {
897
+ pane.search.clear();
898
+ pane.dir.set_filter("");
899
+ pane.sync_view();
900
+ }
901
+ return;
902
+ }
903
+ if ch.view.contains(p) {
904
+ let pane = app.pane_mut();
905
+ pane.view.toggle_mode();
906
+ return;
907
+ }
908
+ if ch.menu.contains(p) {
909
+ open_menu(app, p, MenuTarget::AppMenu, size, theme, text);
910
+ return;
911
+ }
912
+ if ch.path.contains(p) {
913
+ let pane = app.pane_mut();
914
+ if pane.edit.is_some() {
915
+ pane.edit.as_mut().unwrap().entry.set_focused(true);
916
+ return;
917
+ }
918
+ if pane.search_active {
919
+ pane.search.set_focused(true);
920
+ return;
921
+ }
922
+ let path = pane.dir.path.clone();
923
+ let comps = PathBar::new(path_components(&path));
924
+ if let Some(i) = comps.index_at(ch.path, text, theme, p) {
925
+ let target = segment_path(&path, i);
926
+ app.navigate(app.active, target);
927
+ }
928
+ return;
929
+ }
930
+ }
931
+
932
+ // 5. Panel boczny.
933
+ if lay.sidebar.contains(p) {
934
+ let entry = app
935
+ .sidebar
936
+ .click_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme);
937
+ if entry {
938
+ let idx = app.sidebar.selected();
939
+ if let Some(action) = idx
940
+ .and_then(|e| app.sidebar_entry_action.get(e).copied().flatten())
941
+ .and_then(|ai| app.sidebar_actions.get(ai))
942
+ {
943
+ match action {
944
+ SideAction::Go(path) => {
945
+ let path = path.clone();
946
+ app.navigate(app.active, path);
947
+ }
948
+ SideAction::Unmount(i) => {
949
+ if let Some(m) = app.mounts.get(*i) {
950
+ places::unmount_device(&m.dev);
951
+ app.show_toast(libluna_i18n::tr("Unmounting…"));
952
+ }
953
+ }
954
+ SideAction::Mount(i) => {
955
+ if let Some(m) = app.mounts.get(*i) {
956
+ places::mount_device(&m.dev);
957
+ app.show_toast(libluna_i18n::tr("Mounting…"));
958
+ }
959
+ }
960
+ }
961
+ }
962
+ }
963
+ return;
964
+ }
965
+
966
+ // 6. Panele.
967
+ let count = if app.split { 2 } else { 1 };
968
+ for i in 0..count {
969
+ if !lay.panes[i].contains(p) {
970
+ continue;
971
+ }
972
+ app.active = i;
973
+ let pr = pane_rects(app, lay, i, theme);
974
+ // Klik w nagłówek kolumny sortuje (tak jak w Nautilusie).
975
+ if app.panes[i].view.mode() == ViewMode::List
976
+ && p.y < pr.items.y() + list::HEADER_H
977
+ && pr.items.contains(p)
978
+ {
979
+ if let Some(key) = list::header_at(pr.items, p, theme) {
980
+ let pane = &mut app.panes[i];
981
+ if pane.dir.sort_key() == key {
982
+ let asc = pane.dir.ascending();
983
+ pane.dir.set_ascending(!asc);
984
+ } else {
985
+ pane.dir.set_sort_key(key);
986
+ // Rozmiar i data naturalnie malejąco.
987
+ pane.dir
988
+ .set_ascending(!matches!(key, SortKey::Size | SortKey::Modified));
989
+ }
990
+ pane.sync_view();
991
+ if crate::app::debug() {
992
+ eprintln!(
993
+ "luna-files: sort {:?} asc={}",
994
+ pane.dir.sort_key(),
995
+ pane.dir.ascending()
996
+ );
997
+ }
998
+ }
999
+ return;
1000
+ }
1001
+ let rows = file_rows(&app.panes[i], pr.items);
1002
+ if rows.contains(p) {
1003
+ let index = hit_index(&app.panes[i], rows, p, theme);
1004
+ let Some(index) = index else {
1005
+ app.panes[i].view.clear_selection();
1006
+ app.panes[i].preview_cache = None;
1007
+ return;
1008
+ };
1009
+ let now = Instant::now();
1010
+ let double = app
1011
+ .last_click
1012
+ .map(|(last, t)| {
1013
+ last == index && now.duration_since(t) < Duration::from_millis(400)
1014
+ })
1015
+ .unwrap_or(false);
1016
+ if double {
1017
+ app.last_click = None;
1018
+ let entry = app.panes[i].dir.entry(index).cloned();
1019
+ if let Some(entry) = entry {
1020
+ if entry.is_dir {
1021
+ app.navigate(i, entry.path);
1022
+ } else {
1023
+ let _ = std::process::Command::new("xdg-open")
1024
+ .arg(&entry.path)
1025
+ .spawn();
1026
+ }
1027
+ }
1028
+ } else {
1029
+ app.last_click = Some((index, now));
1030
+ if mods.ctrl {
1031
+ app.panes[i].view.toggle_selected(index);
1032
+ } else if mods.shift {
1033
+ app.panes[i].view.select_range(index);
1034
+ } else {
1035
+ app.panes[i].view.set_selected(Some(index));
1036
+ }
1037
+ app.panes[i].preview_cache = None;
1038
+ }
1039
+ return;
1040
+ }
1041
+ // Klik poza listą czyści zaznaczenie.
1042
+ app.panes[i].view.clear_selection();
1043
+ app.panes[i].preview_cache = None;
1044
+ return;
1045
+ }
1046
+}
1047
+
1048
+fn on_right_press(
1049
+ app: &mut App,
1050
+ p: Point,
1051
+ lay: &Layout,
1052
+ size: Size,
1053
+ theme: &Theme,
1054
+ text: &TextEngine,
1055
+) {
1056
+ if let Some(context) = &app.context {
1057
+ if context.panel.contains(p) {
1058
+ return;
1059
+ }
1060
+ app.context = None;
1061
+ }
1062
+
1063
+ // Panel boczny: menu urządzenia.
1064
+ if lay.sidebar.contains(p) {
1065
+ let entry = app
1066
+ .sidebar
1067
+ .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme);
1068
+ if let Some(entry) = entry {
1069
+ app.sidebar.set_selected(Some(entry));
1070
+ if let Some(ai) = app.sidebar_entry_action.get(entry).copied().flatten() {
1071
+ if let Some(SideAction::Go(path)) = app.sidebar_actions.get(ai) {
1072
+ if let Some(mi) = app.mounts.iter().position(|m| &m.mount == path) {
1073
+ open_menu(app, p, MenuTarget::Device(mi), size, theme, text);
1074
+ return;
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ return;
1080
+ }
1081
+
1082
+ let count = if app.split { 2 } else { 1 };
1083
+ for i in 0..count {
1084
+ if !lay.panes[i].contains(p) {
1085
+ continue;
1086
+ }
1087
+ app.active = i;
1088
+ let pr = pane_rects(app, lay, i, theme);
1089
+ let rows = file_rows(&app.panes[i], pr.items);
1090
+ if let Some(index) = hit_index(&app.panes[i], rows, p, theme) {
1091
+ if !app.panes[i].view.is_selected(index) {
1092
+ app.panes[i].view.set_selected(Some(index));
1093
+ }
1094
+ if let Some(entry) = app.panes[i].dir.entry(index).cloned() {
1095
+ let in_trash = app.panes[i].dir.path == trash::trash_root().join("files");
1096
+ let target = if in_trash {
1097
+ MenuTarget::TrashItem(entry.path)
1098
+ } else {
1099
+ MenuTarget::Item(entry.path, entry.is_dir)
1100
+ };
1101
+ open_menu(app, p, target, size, theme, text);
1102
+ }
1103
+ } else {
1104
+ open_menu(app, p, MenuTarget::Background, size, theme, text);
1105
+ }
1106
+ return;
1107
+ }
1108
+}
1109
+
1110
+fn on_key(
1111
+ app: &mut App,
1112
+ ke: &KeyEvent,
1113
+ lay: &Layout,
1114
+ ch: &Chrome,
1115
+ _size: Size,
1116
+ theme: &Theme,
1117
+ text: &TextEngine,
1118
+) {
1119
+ // Menu kontekstowe: Escape zamyka.
1120
+ if app.context.is_some() {
1121
+ if ke.keysym == 0xff1b {
1122
+ app.context = None;
1123
+ }
1124
+ return;
1125
+ }
1126
+
1127
+ // Escape anuluje trwającą operację (gdy nie edytujemy tekstu).
1128
+ if ke.keysym == 0xff1b
1129
+ && app.ops.busy()
1130
+ && app.pane().edit.is_none()
1131
+ && !app.pane().search_active
1132
+ && app.conflict.is_none()
1133
+ {
1134
+ app.cancel_op();
1135
+ return;
1136
+ }
1137
+
1138
+ // Pole edycji (ścieżka / nazwa).
1139
+ if app.pane().edit.is_some() {
1140
+ if ke.keysym == 0xff1b {
1141
+ app.pane_mut().edit = None;
1142
+ return;
1143
+ }
1144
+ if ke.keysym == 0xff0d {
1145
+ apply_edit(app);
1146
+ return;
1147
+ }
1148
+ let handled = {
1149
+ let pane = app.pane_mut();
1150
+ let edit = pane.edit.as_mut().unwrap();
1151
+ handle_text_key(&mut edit.entry, ke)
1152
+ };
1153
+ if handled {
1154
+ return;
1155
+ }
1156
+ }
1157
+
1158
+ // Wyszukiwanie.
1159
+ if app.pane().search_active && app.pane().search.is_focused() {
1160
+ if ke.keysym == 0xff1b || ke.keysym == 0xff0d {
1161
+ let pane = app.pane_mut();
1162
+ pane.search_active = false;
1163
+ pane.search.set_focused(false);
1164
+ pane.search.clear();
1165
+ pane.dir.set_filter("");
1166
+ pane.sync_view();
1167
+ return;
1168
+ }
1169
+ let handled = {
1170
+ let pane = app.pane_mut();
1171
+ handle_text_key(&mut pane.search, ke)
1172
+ };
1173
+ if handled {
1174
+ let query = app.pane().search.text().to_owned();
1175
+ let pane = app.pane_mut();
1176
+ pane.dir.set_filter(query);
1177
+ pane.sync_view();
1178
+ return;
1179
+ }
1180
+ }
1181
+
1182
+ let ctrl = ke.modifiers.ctrl;
1183
+ let shift = ke.modifiers.shift;
1184
+ let alt = ke.modifiers.alt;
1185
+
1186
+ if ctrl {
1187
+ match ke.keysym {
1188
+ 0x6c => {
1189
+ // Ctrl+L — edycja ścieżki
1190
+ let path = app.pane().dir.path.clone();
1191
+ app.pane_mut().edit = Some(EditState::path(&path));
1192
+ }
1193
+ 0x66 => {
1194
+ // Ctrl+F — szukanie
1195
+ let pane = app.pane_mut();
1196
+ pane.search_active = true;
1197
+ pane.search.set_focused(true);
1198
+ }
1199
+ 0x74 => app.new_tab(), // Ctrl+T
1200
+ 0x77 => app.close_tab(), // Ctrl+W
1201
+ 0x63 => copy_selection(app, false), // Ctrl+C
1202
+ 0x78 => copy_selection(app, true), // Ctrl+X
1203
+ 0x76 => paste(app), // Ctrl+V
1204
+ 0x61 => app.pane_mut().view.select_all(), // Ctrl+A
1205
+ 0x68 => toggle_hidden(app), // Ctrl+H
1206
+ 0x6e => new_folder_edit(app), // Ctrl+N
1207
+ 0x72 => app.reload_pane(app.active), // Ctrl+R
1208
+ _ => {}
1209
+ }
1210
+ return;
1211
+ }
1212
+
1213
+ match ke.keysym {
1214
+ 0x72 => app.reload_pane(app.active), // r
1215
+ 0xff0d => app.open_selected(), // Enter
1216
+ 0xff08 => app.up(), // Backspace
1217
+ 0xffff => {
1218
+ // Delete — do kosza (Shift = trwale)
1219
+ if shift {
1220
+ delete_selection(app, true);
1221
+ } else {
1222
+ delete_selection(app, false);
1223
+ }
1224
+ }
1225
+ 0xff1b => {
1226
+ let pane = app.pane_mut();
1227
+ pane.preview = false;
1228
+ pane.preview_cache = None;
1229
+ pane.view.clear_selection();
1230
+ }
1231
+ 0x20 => {
1232
+ // Space — podgląd
1233
+ let pane = app.pane_mut();
1234
+ pane.preview = !pane.preview;
1235
+ pane.preview_cache = None;
1236
+ }
1237
+ 0xffbe => {} // F1
1238
+ 0xffbf => {
1239
+ // F2 — zmiana nazwy
1240
+ if let Some(entry) = app.pane().primary().cloned() {
1241
+ app.pane_mut().edit = Some(EditState::rename(&entry.path));
1242
+ }
1243
+ }
1244
+ 0xffc0 => {
1245
+ // F3 — dwa panele
1246
+ app.split = !app.split;
1247
+ if !app.split {
1248
+ app.active = 0;
1249
+ }
1250
+ }
1251
+ 0xffc1 => app.open_terminal(), // F4
1252
+ 0xffc2 => app.reload_pane(app.active), // F5
1253
+ 0xffc6 => app.sidebar_visible = !app.sidebar_visible, // F9
1254
+ 0xff51 => {
1255
+ if alt {
1256
+ app.back();
1257
+ } else {
1258
+ move_selection(app, -1, shift, lay, theme);
1259
+ }
1260
+ }
1261
+ 0xff53 => {
1262
+ if alt {
1263
+ app.forward();
1264
+ } else {
1265
+ move_selection(app, 1, shift, lay, theme);
1266
+ }
1267
+ }
1268
+ 0xff52 => move_selection(app, -1, shift, lay, theme),
1269
+ 0xff54 => move_selection(app, 1, shift, lay, theme),
1270
+ 0xff55 => move_selection(app, -10, shift, lay, theme),
1271
+ 0xff56 => move_selection(app, 10, shift, lay, theme),
1272
+ 0xff50 => select_boundary(app, false, shift, lay, theme),
1273
+ 0xff57 => select_boundary(app, true, shift, lay, theme),
1274
+ 0xff09 => {
1275
+ // Tab — następna zakładka (bez Ctrl)
1276
+ let next = {
1277
+ let p = app.pane();
1278
+ (p.tab + 1) % p.tabs.len().max(1)
1279
+ };
1280
+ app.select_tab(next);
1281
+ }
1282
+ _ => {
1283
+ // Pisanie bez fokusu pola = szybkie wyszukiwanie.
1284
+ if let Some(t) = &ke.text {
1285
+ if !t.is_empty() && !t.chars().all(char::is_control) {
1286
+ let pane = app.pane_mut();
1287
+ pane.search_active = true;
1288
+ pane.search.set_focused(true);
1289
+ pane.search.insert_str(t);
1290
+ let query = pane.search.text().to_owned();
1291
+ pane.dir.set_filter(query);
1292
+ pane.sync_view();
1293
+ }
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ // Pasek edycji ma priorytet nad resztą — nic więcej nie rób.
1299
+ if app.pane().edit.is_some() {
1300
+ let _ = ch;
1301
+ let _ = text;
1302
+ }
1303
+}
1304
+
1305
+fn handle_text_key<T: TextInput>(entry: &mut T, ke: &KeyEvent) -> bool {
1306
+ let shift = ke.modifiers.shift;
1307
+ let ctrl = ke.modifiers.ctrl;
1308
+ if ctrl && ke.keysym == 0x61 {
1309
+ entry.select_all();
1310
+ return true;
1311
+ }
1312
+ if !ctrl {
1313
+ if let Some(t) = &ke.text {
1314
+ if !t.is_empty() && !t.chars().all(char::is_control) {
1315
+ entry.insert_str(t);
1316
+ return true;
1317
+ }
1318
+ }
1319
+ }
1320
+ match ke.keysym {
1321
+ 0xff08 => entry.backspace(),
1322
+ 0xffff => entry.delete_forward(),
1323
+ 0xff51 => {
1324
+ if shift {
1325
+ entry.move_left_selecting(true)
1326
+ } else {
1327
+ entry.move_left()
1328
+ }
1329
+ }
1330
+ 0xff53 => {
1331
+ if shift {
1332
+ entry.move_right_selecting(true)
1333
+ } else {
1334
+ entry.move_right()
1335
+ }
1336
+ }
1337
+ 0xff50 => {
1338
+ if shift {
1339
+ entry.home_selecting(true)
1340
+ } else {
1341
+ entry.home()
1342
+ }
1343
+ }
1344
+ 0xff57 => {
1345
+ if shift {
1346
+ entry.end_selecting(true)
1347
+ } else {
1348
+ entry.end()
1349
+ }
1350
+ }
1351
+ _ => return false,
1352
+ }
1353
+ true
1354
+}
1355
+
1356
+fn apply_edit(app: &mut App) {
1357
+ let dir = app.pane().dir.path.clone();
1358
+ let edit = app.pane_mut().edit.take();
1359
+ let Some(edit) = edit else { return };
1360
+ let text = edit.entry.text().trim().to_owned();
1361
+ if text.is_empty() {
1362
+ return;
1363
+ }
1364
+ if let Some(from) = edit.rename {
1365
+ let to = from
1366
+ .parent()
1367
+ .map(|p| p.join(&text))
1368
+ .unwrap_or_else(|| dir.join(&text));
1369
+ if to != from {
1370
+ app.submit(Job::Rename { from, to });
1371
+ }
1372
+ } else if edit.new_folder {
1373
+ app.submit(Job::Mkdir {
1374
+ path: dir.join(&text),
1375
+ });
1376
+ } else {
1377
+ let target = if text.starts_with('/') {
1378
+ PathBuf::from(&text)
1379
+ } else if text == "~" {
1380
+ places::home()
1381
+ } else if let Some(rest) = text.strip_prefix("~/") {
1382
+ places::home().join(rest)
1383
+ } else {
1384
+ dir.join(&text)
1385
+ };
1386
+ app.navigate(app.active, target);
1387
+ }
1388
+}
1389
+
1390
+fn move_selection(app: &mut App, delta: i32, extend: bool, lay: &Layout, theme: &Theme) {
1391
+ let pr = pane_rects(app, lay, app.active, theme);
1392
+ let rows = file_rows(&app.panes[app.active], pr.items);
1393
+ let p = &mut app.panes[app.active];
1394
+ let len = p.view.len();
1395
+ if len == 0 {
1396
+ return;
1397
+ }
1398
+ let current = p.view.selected().unwrap_or(0) as i32;
1399
+ let next = (current + delta).clamp(0, len as i32 - 1) as usize;
1400
+ if extend {
1401
+ p.view.select_range(next);
1402
+ } else {
1403
+ p.view.set_selected(Some(next));
1404
+ }
1405
+ let slot = p.view.slot_rect(rows, next, theme);
1406
+ if slot.y() < rows.y() {
1407
+ let d = rows.y() - slot.y();
1408
+ p.view.set_scroll(p.view.scroll() - d);
1409
+ } else if slot.bottom() > rows.bottom() {
1410
+ let d = slot.bottom() - rows.bottom();
1411
+ p.view.set_scroll(p.view.scroll() + d);
1412
+ }
1413
+ p.view.clamp_scroll(rows, theme);
1414
+ p.preview_cache = None;
1415
+}
1416
+
1417
+fn select_boundary(app: &mut App, end: bool, extend: bool, lay: &Layout, theme: &Theme) {
1418
+ let pr = pane_rects(app, lay, app.active, theme);
1419
+ let rows = file_rows(&app.panes[app.active], pr.items);
1420
+ let p = &mut app.panes[app.active];
1421
+ if p.view.len() == 0 {
1422
+ return;
1423
+ }
1424
+ let index = if end { p.view.len() - 1 } else { 0 };
1425
+ if extend {
1426
+ p.view.select_range(index);
1427
+ } else {
1428
+ p.view.set_selected(Some(index));
1429
+ }
1430
+ let slot = p.view.slot_rect(rows, index, theme);
1431
+ if slot.y() < rows.y() {
1432
+ let d = rows.y() - slot.y();
1433
+ p.view.set_scroll(p.view.scroll() - d);
1434
+ } else if slot.bottom() > rows.bottom() {
1435
+ let d = slot.bottom() - rows.bottom();
1436
+ p.view.set_scroll(p.view.scroll() + d);
1437
+ }
1438
+ p.view.clamp_scroll(rows, theme);
1439
+ p.preview_cache = None;
1440
+}
1441
+
1442
+fn toggle_hidden(app: &mut App) {
1443
+ let pane = app.pane_mut();
1444
+ let show = !pane.dir.show_hidden();
1445
+ pane.dir.set_show_hidden(show);
1446
+ pane.sync_view();
1447
+}
1448
+
1449
+fn new_folder_edit(app: &mut App) {
1450
+ let dir = app.pane().dir.path.clone();
1451
+ let mut edit = EditState::path(&dir);
1452
+ let mut entry = TextEntry::new(libluna_i18n::tr("Name"));
1453
+ entry.set_text(libluna_i18n::tr("New Folder"));
1454
+ entry.set_focused(true);
1455
+ entry.select_all();
1456
+ edit.entry = entry;
1457
+ edit.rename = None;
1458
+ edit.new_folder = true;
1459
+ app.pane_mut().edit = Some(edit);
1460
+}
1461
+
1462
+fn copy_selection(app: &mut App, cut: bool) {
1463
+ let paths = app.pane().selection();
1464
+ if paths.is_empty() {
1465
+ return;
1466
+ }
1467
+ let n = paths.len();
1468
+ app.clipboard = Some(crate::app::Clipboard { paths, cut });
1469
+ app.show_toast(if cut {
1470
+ libluna_i18n::tr_args("{n} items cut", &[("n", &n.to_string())])
1471
+ } else {
1472
+ libluna_i18n::tr_args("{n} items copied", &[("n", &n.to_string())])
1473
+ });
1474
+}
1475
+
1476
+fn paste(app: &mut App) {
1477
+ let Some(clip) = app.clipboard.clone() else {
1478
+ return;
1479
+ };
1480
+ let dst = app.pane().dir.path.clone();
1481
+ if clip.cut {
1482
+ app.submit(Job::Move {
1483
+ srcs: clip.paths.clone(),
1484
+ dst,
1485
+ });
1486
+ app.clipboard = None;
1487
+ } else {
1488
+ app.submit(Job::Copy {
1489
+ srcs: clip.paths,
1490
+ dst,
1491
+ });
1492
+ }
1493
+}
1494
+
1495
+fn delete_selection(app: &mut App, permanent: bool) {
1496
+ let paths = app.pane().selection();
1497
+ if paths.is_empty() {
1498
+ return;
1499
+ }
1500
+ if permanent {
1501
+ app.submit(Job::Delete { paths });
1502
+ } else {
1503
+ app.submit(Job::Trash { paths });
1504
+ }
1505
+}
1506
+
1507
+// ---------------------------------------------------------------------------
1508
+// Menu
1509
+// ---------------------------------------------------------------------------
1510
+
1511
+fn open_menu(
1512
+ app: &mut App,
1513
+ at: Point,
1514
+ target: MenuTarget,
1515
+ size: Size,
1516
+ theme: &Theme,
1517
+ text: &TextEngine,
1518
+) {
1519
+ let (entries, cmds) = menu_entries(app, &target);
1520
+ let menu = Menu::new(entries);
1521
+ let width = menu.width(text, theme);
1522
+ let anchor = Rect::from_xywh(at.x, at.y, 1.0, 1.0);
1523
+ let panel = menu.place(anchor, width, Rect::from_size(size), theme);
1524
+ app.context = Some(ContextMenu {
1525
+ menu,
1526
+ panel,
1527
+ target,
1528
+ cmds,
1529
+ });
1530
+}
1531
+
1532
+struct MenuBuild {
1533
+ entries: Vec<MenuEntry>,
1534
+ cmds: Vec<Option<MenuCmd>>,
1535
+}
1536
+
1537
+impl MenuBuild {
1538
+ fn new() -> Self {
1539
+ Self {
1540
+ entries: Vec::new(),
1541
+ cmds: Vec::new(),
1542
+ }
1543
+ }
1544
+
1545
+ fn action(&mut self, label: impl Into<String>, shortcut: Option<&str>, cmd: MenuCmd) {
1546
+ let mut entry = MenuEntry::action(label);
1547
+ if let Some(shortcut) = shortcut {
1548
+ entry = entry.shortcut(shortcut);
1549
+ }
1550
+ self.entries.push(entry);
1551
+ self.cmds.push(Some(cmd));
1552
+ }
1553
+
1554
+ fn disabled(&mut self, label: impl Into<String>) {
1555
+ self.entries.push(MenuEntry::action(label).disabled());
1556
+ self.cmds.push(None);
1557
+ }
1558
+
1559
+ fn sep(&mut self) {
1560
+ self.entries.push(MenuEntry::separator());
1561
+ self.cmds.push(None);
1562
+ }
1563
+}
1564
+
1565
+fn menu_entries(app: &App, target: &MenuTarget) -> (Vec<MenuEntry>, Vec<Option<MenuCmd>>) {
1566
+ let tr = libluna_i18n::tr;
1567
+ let mut m = MenuBuild::new();
1568
+ match target {
1569
+ MenuTarget::Item(..) => {
1570
+ m.action(tr("Open"), Some("Enter"), MenuCmd::Open);
1571
+ m.action(tr("Open in Terminal"), Some("F4"), MenuCmd::OpenTerminal);
1572
+ m.sep();
1573
+ m.action(tr("Copy"), Some("Ctrl+C"), MenuCmd::Copy);
1574
+ m.action(tr("Cut"), Some("Ctrl+X"), MenuCmd::Cut);
1575
+ m.sep();
1576
+ m.action(tr("Rename"), Some("F2"), MenuCmd::Rename);
1577
+ m.action(tr("Move to Trash"), Some("Delete"), MenuCmd::Trash);
1578
+ m.action(
1579
+ tr("Delete Permanently"),
1580
+ Some("Shift+Delete"),
1581
+ MenuCmd::Delete,
1582
+ );
1583
+ m.sep();
1584
+ m.action(tr("Properties"), None, MenuCmd::Properties);
1585
+ }
1586
+ MenuTarget::TrashItem(_) => {
1587
+ m.action(tr("Restore"), None, MenuCmd::Restore);
1588
+ m.action(
1589
+ tr("Delete Permanently"),
1590
+ Some("Shift+Delete"),
1591
+ MenuCmd::Purge,
1592
+ );
1593
+ m.sep();
1594
+ m.action(tr("Properties"), None, MenuCmd::Properties);
1595
+ }
1596
+ MenuTarget::Device(i) => {
1597
+ m.action(tr("Unmount"), None, MenuCmd::Unmount(*i));
1598
+ m.action(tr("Open"), None, MenuCmd::OpenDevice(*i));
1599
+ }
1600
+ MenuTarget::Background | MenuTarget::AppMenu => {
1601
+ let pane = app.pane();
1602
+ if matches!(target, MenuTarget::Background) {
1603
+ if app.clipboard.is_some() {
1604
+ m.action(tr("Paste"), Some("Ctrl+V"), MenuCmd::Paste);
1605
+ } else {
1606
+ m.disabled(tr("Paste"));
1607
+ }
1608
+ m.action(tr("Select All"), Some("Ctrl+A"), MenuCmd::SelectAll);
1609
+ m.sep();
1610
+ }
1611
+ m.action(tr("New Folder"), Some("Ctrl+N"), MenuCmd::NewFolder);
1612
+ m.action(tr("New Tab"), Some("Ctrl+T"), MenuCmd::NewTab);
1613
+ m.action(
1614
+ if app.split {
1615
+ tr("Single Pane")
1616
+ } else {
1617
+ tr("Split View")
1618
+ },
1619
+ None,
1620
+ MenuCmd::Split,
1621
+ );
1622
+ m.sep();
1623
+ m.action(
1624
+ if pane.dir.show_hidden() {
1625
+ tr("Hide Hidden Files")
1626
+ } else {
1627
+ tr("Show Hidden Files")
1628
+ },
1629
+ Some("Ctrl+H"),
1630
+ MenuCmd::ToggleHidden,
1631
+ );
1632
+ m.sep();
1633
+ for key in [
1634
+ SortKey::Name,
1635
+ SortKey::Size,
1636
+ SortKey::Modified,
1637
+ SortKey::Kind,
1638
+ ] {
1639
+ let mark = if pane.dir.sort_key() == key {
1640
+ "• "
1641
+ } else {
1642
+ " "
1643
+ };
1644
+ m.action(format!("{mark}{}", key.label()), None, MenuCmd::Sort(key));
1645
+ }
1646
+ m.action(
1647
+ if pane.dir.ascending() {
1648
+ tr("Descending")
1649
+ } else {
1650
+ tr("Ascending")
1651
+ },
1652
+ None,
1653
+ MenuCmd::ToggleAsc,
1654
+ );
1655
+ m.sep();
1656
+ m.action(tr("Refresh"), Some("F5"), MenuCmd::Refresh);
1657
+ m.action(tr("Empty Trash"), None, MenuCmd::EmptyTrash);
1658
+ }
1659
+ }
1660
+ (m.entries, m.cmds)
1661
+}
1662
+
1663
+fn run_menu(app: &mut App, index: usize) {
1664
+ let Some(context) = app.context.take() else {
1665
+ return;
1666
+ };
1667
+ let Some(cmd) = context.cmds.get(index).copied().flatten() else {
1668
+ return;
1669
+ };
1670
+ if crate::app::debug() {
1671
+ eprintln!("luna-files: menu {cmd:?}");
1672
+ }
1673
+ let target = match &context.target {
1674
+ MenuTarget::Item(path, is_dir) => Some((path.clone(), *is_dir)),
1675
+ MenuTarget::TrashItem(path) => Some((path.clone(), false)),
1676
+ _ => None,
1677
+ };
1678
+ match cmd {
1679
+ MenuCmd::Open => {
1680
+ if let Some((path, is_dir)) = target {
1681
+ if is_dir {
1682
+ app.navigate(app.active, path);
1683
+ } else {
1684
+ let _ = std::process::Command::new("xdg-open").arg(&path).spawn();
1685
+ }
1686
+ }
1687
+ }
1688
+ MenuCmd::OpenTerminal => {
1689
+ if let Some((path, is_dir)) = target {
1690
+ let dir = if is_dir {
1691
+ path
1692
+ } else {
1693
+ path.parent()
1694
+ .map(|p| p.to_path_buf())
1695
+ .unwrap_or_else(|| app.pane().dir.path.clone())
1696
+ };
1697
+ app.open_terminal_at(&dir);
1698
+ }
1699
+ }
1700
+ MenuCmd::Copy | MenuCmd::Cut => {
1701
+ if let Some((path, _)) = target {
1702
+ app.clipboard = Some(crate::app::Clipboard {
1703
+ paths: vec![path],
1704
+ cut: matches!(cmd, MenuCmd::Cut),
1705
+ });
1706
+ }
1707
+ }
1708
+ MenuCmd::Rename => {
1709
+ if let Some((path, _)) = target {
1710
+ app.pane_mut().edit = Some(EditState::rename(&path));
1711
+ }
1712
+ }
1713
+ MenuCmd::Trash => {
1714
+ if let Some((path, _)) = target {
1715
+ app.submit(Job::Trash { paths: vec![path] });
1716
+ }
1717
+ }
1718
+ MenuCmd::Delete => {
1719
+ if let Some((path, _)) = target {
1720
+ app.submit(Job::Delete { paths: vec![path] });
1721
+ }
1722
+ }
1723
+ MenuCmd::Restore => {
1724
+ if let Some((path, _)) = target {
1725
+ app.submit(Job::Restore { path });
1726
+ }
1727
+ }
1728
+ MenuCmd::Purge => {
1729
+ if let Some((path, _)) = target {
1730
+ app.submit(Job::Purge { path });
1731
+ }
1732
+ }
1733
+ MenuCmd::Properties => {
1734
+ if let Some((path, _)) = target {
1735
+ show_properties(app, &path);
1736
+ }
1737
+ }
1738
+ MenuCmd::Paste => paste(app),
1739
+ MenuCmd::SelectAll => app.pane_mut().view.select_all(),
1740
+ MenuCmd::NewFolder => new_folder_edit(app),
1741
+ MenuCmd::NewTab => app.new_tab(),
1742
+ MenuCmd::Split => {
1743
+ app.split = !app.split;
1744
+ if !app.split {
1745
+ app.active = 0;
1746
+ }
1747
+ }
1748
+ MenuCmd::ToggleHidden => toggle_hidden(app),
1749
+ MenuCmd::Sort(key) => set_sort(app, key),
1750
+ MenuCmd::ToggleAsc => {
1751
+ let asc = app.pane().dir.ascending();
1752
+ app.pane_mut().dir.set_ascending(!asc);
1753
+ app.pane_mut().sync_view();
1754
+ }
1755
+ MenuCmd::Refresh => app.reload_pane(app.active),
1756
+ MenuCmd::EmptyTrash => {
1757
+ app.confirm_empty = Some(
1758
+ AlertDialog::new(
1759
+ libluna_i18n::tr("Empty Trash"),
1760
+ libluna_i18n::tr("Permanently delete all items in the Trash?"),
1761
+ )
1762
+ .button(libluna_i18n::tr("Empty Trash"))
1763
+ .button(libluna_i18n::tr("Cancel")),
1764
+ );
1765
+ }
1766
+ MenuCmd::Unmount(i) => {
1767
+ if let Some(m) = app.mounts.get(i) {
1768
+ places::unmount_device(&m.dev);
1769
+ app.show_toast(libluna_i18n::tr("Unmounting…"));
1770
+ }
1771
+ }
1772
+ MenuCmd::OpenDevice(i) => {
1773
+ if let Some(m) = app.mounts.get(i) {
1774
+ let mount = m.mount.clone();
1775
+ app.navigate(app.active, mount);
1776
+ }
1777
+ }
1778
+ }
1779
+}
1780
+
1781
+fn set_sort(app: &mut App, key: SortKey) {
1782
+ let pane = app.pane_mut();
1783
+ pane.dir.set_sort_key(key);
1784
+ pane.sync_view();
1785
+}
1786
+
1787
+fn show_properties(app: &mut App, path: &Path) {
1788
+ let Ok(meta) = std::fs::metadata(path) else {
1789
+ return;
1790
+ };
1791
+ let kind = if meta.is_dir() {
1792
+ Kind::Folder.label()
1793
+ } else {
1794
+ libluna_i18n::tr("File")
1795
+ };
1796
+ let modified = meta
1797
+ .modified()
1798
+ .ok()
1799
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1800
+ .map(|d| d.as_secs() as i64);
1801
+ let msg = libluna_i18n::tr_args(
1802
+ "{name} · {kind} · {size} · {date}",
1803
+ &[
1804
+ (
1805
+ "name",
1806
+ &path
1807
+ .file_name()
1808
+ .map(|n| n.to_string_lossy().into_owned())
1809
+ .unwrap_or_default(),
1810
+ ),
1811
+ ("kind", &kind),
1812
+ (
1813
+ "size",
1814
+ &human_size(if meta.is_dir() { 0 } else { meta.len() }),
1815
+ ),
1816
+ ("date", &format_mtime(modified)),
1817
+ ],
1818
+ );
1819
+ app.show_toast(msg);
1820
+}
@@ -66,3 +66,85 @@ Show = Anzeigen
66
66
Hide = Ausblenden
67
67
Close = Programm schließen
68
68
Move to workspace {n} = In Arbeitsfläche {n} verschieben
69
+
70
+# --- Luna Files (Dateimanager) ---
71
+Folder = Ordner
72
+Image = Bild
73
+Audio = Audio
74
+Video = Video
75
+Archive = Archiv
76
+Document = Dokument
77
+Code = Code
78
+Text file = Textdatei
79
+File = Datei
80
+Modified = Geändert
81
+Type = Typ
82
+Desktop = Schreibtisch
83
+Downloads = Downloads
84
+Pictures = Bilder
85
+Music = Musik
86
+Videos = Videos
87
+File System = Dateisystem
88
+Places = Orte
89
+Devices = Geräte
90
+Trash = Papierkorb
91
+Path = Pfad
92
+free = frei
93
+Name = Name
94
+Size = Größe
95
+{n} items = {n} Elemente
96
+{n} selected = {n} ausgewählt
97
+{n} selected ({size}) = {n} ausgewählt ({size})
98
+{label}: {n} done = {label}: {n} erledigt
99
+Another operation is still running = Eine andere Operation läuft noch
100
+No terminal emulator found = Kein Terminalemulator gefunden
101
+No matching files = Keine passenden Dateien
102
+Folder is empty = Ordner ist leer
103
+No preview = Keine Vorschau
104
+Unmounting… = Wird ausgehängt…
105
+Mounting… = Wird eingehängt…
106
+Open = Öffnen
107
+Open in Terminal = Im Terminal öffnen
108
+Copy = Kopieren
109
+Cut = Ausschneiden
110
+Rename = Umbenennen
111
+Move to Trash = In den Papierkorb
112
+Delete Permanently = Endgültig löschen
113
+Properties = Eigenschaften
114
+Unmount = Aushängen
115
+Paste = Einfügen
116
+Select All = Alles auswählen
117
+New Folder = Neuer Ordner
118
+New Tab = Neuer Tab
119
+Single Pane = Ein Bereich
120
+Split View = Geteilte Ansicht
121
+Hide Hidden Files = Versteckte Dateien ausblenden
122
+Show Hidden Files = Versteckte Dateien anzeigen
123
+Descending = Absteigend
124
+Ascending = Aufsteigend
125
+Refresh = Aktualisieren
126
+Empty Trash = Papierkorb leeren
127
+Trash emptied = Papierkorb geleert
128
+{n} items cut = {n} ausgeschnitten
129
+{n} items copied = {n} kopiert
130
+{name} · {kind} · {size} · {date} = {name} · {kind} · {size} · {date}
131
+{name} — {free} free = {name} — {free} frei
132
+Copying = Kopieren
133
+Moving = Verschieben
134
+Moving to Trash = In den Papierkorb
135
+Deleting = Löschen
136
+Renaming = Umbenennen
137
+Creating folder = Ordner wird erstellt
138
+Cancelled = Abgebrochen
139
+A file with that name already exists = Eine Datei mit diesem Namen existiert bereits
140
+Restore = Wiederherstellen
141
+Restoring = Wiederherstellen
142
+Cancel = Abbrechen
143
+File exists = Datei existiert bereits
144
+{name} already exists in the destination. = {name} existiert bereits am Zielort.
145
+Replace = Ersetzen
146
+Skip = Überspringen
147
+Replace All = Alle ersetzen
148
+{label}: {n} done, {skipped} skipped = {label}: {n} erledigt, {skipped} übersprungen
149
+{time} left = noch {time}
150
+Permanently delete all items in the Trash? = Alle Elemente im Papierkorb endgültig löschen?
@@ -67,3 +67,85 @@ Show = Show
67
67
Hide = Hide
68
68
Close = Close
69
69
Move to workspace {n} = Move to workspace {n}
70
+
71
+# --- Luna Files (file manager) ---
72
+Folder = Folder
73
+Image = Image
74
+Audio = Audio
75
+Video = Video
76
+Archive = Archive
77
+Document = Document
78
+Code = Code
79
+Text file = Text file
80
+File = File
81
+Modified = Modified
82
+Type = Type
83
+Desktop = Desktop
84
+Downloads = Downloads
85
+Pictures = Pictures
86
+Music = Music
87
+Videos = Videos
88
+File System = File System
89
+Places = Places
90
+Devices = Devices
91
+Trash = Trash
92
+Path = Path
93
+free = free
94
+Name = Name
95
+Size = Size
96
+{n} items = {n} items
97
+{n} selected = {n} selected
98
+{n} selected ({size}) = {n} selected ({size})
99
+{label}: {n} done = {label}: {n} done
100
+Another operation is still running = Another operation is still running
101
+No terminal emulator found = No terminal emulator found
102
+No matching files = No matching files
103
+Folder is empty = Folder is empty
104
+No preview = No preview
105
+Unmounting… = Unmounting…
106
+Mounting… = Mounting…
107
+Open = Open
108
+Open in Terminal = Open in Terminal
109
+Copy = Copy
110
+Cut = Cut
111
+Rename = Rename
112
+Move to Trash = Move to Trash
113
+Delete Permanently = Delete Permanently
114
+Properties = Properties
115
+Unmount = Unmount
116
+Paste = Paste
117
+Select All = Select All
118
+New Folder = New Folder
119
+New Tab = New Tab
120
+Single Pane = Single Pane
121
+Split View = Split View
122
+Hide Hidden Files = Hide Hidden Files
123
+Show Hidden Files = Show Hidden Files
124
+Descending = Descending
125
+Ascending = Ascending
126
+Refresh = Refresh
127
+Empty Trash = Empty Trash
128
+Trash emptied = Trash emptied
129
+{n} items cut = {n} items cut
130
+{n} items copied = {n} items copied
131
+{name} · {kind} · {size} · {date} = {name} · {kind} · {size} · {date}
132
+{name} — {free} free = {name} — {free} free
133
+Copying = Copying
134
+Moving = Moving
135
+Moving to Trash = Moving to Trash
136
+Deleting = Deleting
137
+Renaming = Renaming
138
+Creating folder = Creating folder
139
+Cancelled = Cancelled
140
+A file with that name already exists = A file with that name already exists
141
+Restore = Restore
142
+Restoring = Restoring
143
+Cancel = Cancel
144
+File exists = File exists
145
+{name} already exists in the destination. = {name} already exists in the destination.
146
+Replace = Replace
147
+Skip = Skip
148
+Replace All = Replace All
149
+{label}: {n} done, {skipped} skipped = {label}: {n} done, {skipped} skipped
150
+{time} left = {time} left
151
+Permanently delete all items in the Trash? = Permanently delete all items in the Trash?
@@ -66,3 +66,85 @@ Show = Pokaż
66
66
Hide = Schowaj
67
67
Close = Zamknij program
68
68
Move to workspace {n} = Przenieś na przestrzeń {n}
69
+
70
+# --- Luna Files (menedżer plików) ---
71
+Folder = Folder
72
+Image = Obraz
73
+Audio = Dźwięk
74
+Video = Wideo
75
+Archive = Archiwum
76
+Document = Dokument
77
+Code = Kod
78
+Text file = Plik tekstowy
79
+File = Plik
80
+Modified = Modyfikacja
81
+Type = Typ
82
+Desktop = Pulpit
83
+Downloads = Pobrane
84
+Pictures = Obrazy
85
+Music = Muzyka
86
+Videos = Wideo
87
+File System = System plików
88
+Places = Miejsca
89
+Devices = Urządzenia
90
+Trash = Kosz
91
+Path = Ścieżka
92
+free = wolne
93
+Name = Nazwa
94
+Size = Rozmiar
95
+{n} items = {n} elementów
96
+{n} selected = zaznaczono {n}
97
+{n} selected ({size}) = zaznaczono {n} ({size})
98
+{label}: {n} done = {label}: {n} — gotowe
99
+Another operation is still running = Inna operacja jeszcze trwa
100
+No terminal emulator found = Nie znaleziono emulatora terminala
101
+No matching files = Brak pasujących plików
102
+Folder is empty = Folder jest pusty
103
+No preview = Brak podglądu
104
+Unmounting… = Odmontowywanie…
105
+Mounting… = Montowanie…
106
+Open = Otwórz
107
+Open in Terminal = Otwórz w terminalu
108
+Copy = Kopiuj
109
+Cut = Wytnij
110
+Rename = Zmień nazwę
111
+Move to Trash = Przenieś do kosza
112
+Delete Permanently = Usuń trwale
113
+Properties = Właściwości
114
+Unmount = Odmontuj
115
+Paste = Wklej
116
+Select All = Zaznacz wszystko
117
+New Folder = Nowy folder
118
+New Tab = Nowa karta
119
+Single Pane = Jeden panel
120
+Split View = Widok dzielony
121
+Hide Hidden Files = Ukryj pliki ukryte
122
+Show Hidden Files = Pokaż ukryte pliki
123
+Descending = Malejąco
124
+Ascending = Rosnąco
125
+Refresh = Odśwież
126
+Empty Trash = Opróżnij kosz
127
+Trash emptied = Kosz opróżniony
128
+{n} items cut = Wycięto {n}
129
+{n} items copied = Skopiowano {n}
130
+{name} · {kind} · {size} · {date} = {name} · {kind} · {size} · {date}
131
+{name} — {free} free = {name} — {free} wolnego
132
+Copying = Kopiowanie
133
+Moving = Przenoszenie
134
+Moving to Trash = Przenoszenie do kosza
135
+Deleting = Usuwanie
136
+Renaming = Zmiana nazwy
137
+Creating folder = Tworzenie folderu
138
+Cancelled = Anulowano
139
+A file with that name already exists = Plik o tej nazwie już istnieje
140
+Restore = Przywróć
141
+Restoring = Przywracanie
142
+Cancel = Anuluj
143
+File exists = Plik już istnieje
144
+{name} already exists in the destination. = {name} już istnieje w miejscu docelowym.
145
+Replace = Zastąp
146
+Skip = Pomiń
147
+Replace All = Zastąp wszystkie
148
+{label}: {n} done, {skipped} skipped = {label}: {n} — gotowe, pominięto {skipped}
149
+{time} left = pozostało {time}
150
+Permanently delete all items in the Trash? = Trwale usunąć wszystkie elementy z kosza?
@@ -156,6 +156,12 @@ impl AlertDialog {
156
156
self
157
157
}
158
158
159
+ /// Szerokość okna (np. gdy jest więcej przycisków).
160
+ pub fn width(mut self, width: f64) -> Self {
161
+ self.dialog.width = width;
162
+ self
163
+ }
164
+
159
165
fn height(&self, theme: &Theme) -> f64 {
160
166
let pad = theme.spacing.lg;
161
167
let header = theme.metrics.sheet.header;
@@ -9,7 +9,7 @@ use libluna_core::style::weight;
9
9
use libluna_core::{Color, Point, Rect, Theme};
10
10
11
11
use crate::ctx::PaintCtx;
12
-use crate::icon;
12
+use crate::icon::{self, IconName};
13
13
14
14
/// Rodzaj elementu (wpływa na ikonę).
15
15
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -17,6 +17,13 @@ pub enum ItemKind {
17
17
Folder,
18
18
#[default]
19
19
File,
20
+ Image,
21
+ Audio,
22
+ Video,
23
+ Archive,
24
+ Document,
25
+ Code,
26
+ Text,
20
27
}
21
28
22
29
/// Pojedynczy element (plik/katalog).
@@ -69,7 +76,10 @@ pub enum ViewMode {
69
76
pub struct ItemView {
70
77
items: Vec<Item>,
71
78
mode: ViewMode,
79
+ /// Zaznaczenie „główne” (kotwica dla Shift+klik i klawiatury).
72
80
selected: Option<usize>,
81
+ /// Wszystkie zaznaczone elementy (rosnąco, bez duplikatów).
82
+ selected_set: Vec<usize>,
73
83
hovered: Option<usize>,
74
84
scroll: f64,
75
85
}
@@ -89,6 +99,7 @@ impl ItemView {
89
99
pub fn set_items(&mut self, items: Vec<Item>) {
90
100
self.items = items;
91
101
self.selected = None;
102
+ self.selected_set.clear();
92
103
self.scroll = 0.0;
93
104
}
94
105
@@ -119,14 +130,60 @@ impl ItemView {
119
130
self.selected
120
131
}
121
132
133
+ /// Wszystkie zaznaczone indeksy (rosnąco).
134
+ pub fn selected_indices(&self) -> &[usize] {
135
+ &self.selected_set
136
+ }
137
+
138
+ pub fn is_selected(&self, i: usize) -> bool {
139
+ self.selected_set.binary_search(&i).is_ok()
140
+ }
141
+
122
142
pub fn set_selected(&mut self, selected: Option<usize>) {
123
143
self.selected = selected;
144
+ self.selected_set.clear();
145
+ if let Some(i) = selected {
146
+ self.selected_set.push(i);
147
+ }
148
+ }
149
+
150
+ /// Dodaje/usuwa element z zaznaczenia (Ctrl+klik); staje się kotwicą.
151
+ pub fn toggle_selected(&mut self, i: usize) {
152
+ match self.selected_set.binary_search(&i) {
153
+ Ok(pos) => {
154
+ self.selected_set.remove(pos);
155
+ }
156
+ Err(pos) => self.selected_set.insert(pos, i),
157
+ }
158
+ self.selected = Some(i);
159
+ }
160
+
161
+ /// Zaznacza zakres od kotwicy do `to` (Shift+klik).
162
+ pub fn select_range(&mut self, to: usize) {
163
+ let from = self.selected.unwrap_or(to);
164
+ let (a, b) = if from <= to { (from, to) } else { (to, from) };
165
+ self.selected_set = (a..=b).collect();
166
+ self.selected = Some(to);
167
+ }
168
+
169
+ pub fn select_all(&mut self) {
170
+ self.selected_set = (0..self.items.len()).collect();
171
+ self.selected = self.items.len().checked_sub(1);
172
+ }
173
+
174
+ pub fn clear_selection(&mut self) {
175
+ self.selected = None;
176
+ self.selected_set.clear();
124
177
}
125
178
126
179
pub fn set_hovered(&mut self, hovered: Option<usize>) {
127
180
self.hovered = hovered;
128
181
}
129
182
183
+ pub fn hovered(&self) -> Option<usize> {
184
+ self.hovered
185
+ }
186
+
130
187
pub fn scroll(&self) -> f64 {
131
188
self.scroll
132
189
}
@@ -205,7 +262,7 @@ impl ItemView {
205
262
/// Zaznacza element pod punktem `p`; zwraca jego indeks.
206
263
pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
207
264
let i = self.index_at(rect, p, theme)?;
208
- self.selected = Some(i);
265
+ self.set_selected(Some(i));
209
266
Some(i)
210
267
}
211
268
@@ -219,7 +276,12 @@ impl ItemView {
219
276
ctx.scene.push_clip(rect);
220
277
for (i, item) in self.items.iter().enumerate() {
221
278
let s = self.slot_rect(rect, i, &theme);
222
- let selected = self.selected == Some(i);
279
+ // Wirtualizacja: elementy poza widokiem nie generują komend rysowania.
280
+ // Bez tego katalog z tysiącami plików zamulał każdą klatkę.
281
+ if s.bottom() <= rect.y() || s.y() >= rect.bottom() {
282
+ continue;
283
+ }
284
+ let selected = self.is_selected(i);
223
285
if selected {
224
286
ctx.scene
225
287
.fill_rounded_rect(s, theme.radius.small, colors.shade.scale_alpha(1.8));
@@ -286,6 +348,13 @@ fn draw_icon(ctx: &mut PaintCtx, kind: ItemKind, box_: Rect, folder: Color, file
286
348
match kind {
287
349
ItemKind::Folder => icon::folder(ctx, folder, box_),
288
350
ItemKind::File => icon::file(ctx, file, box_),
351
+ ItemKind::Image => icon::paint_icon(ctx, IconName::Image, box_, file),
352
+ ItemKind::Audio => icon::paint_icon(ctx, IconName::Music, box_, file),
353
+ ItemKind::Video => icon::paint_icon(ctx, IconName::Video, box_, file),
354
+ ItemKind::Archive => icon::paint_icon(ctx, IconName::Archive, box_, file),
355
+ ItemKind::Document => icon::paint_icon(ctx, IconName::Document, box_, file),
356
+ ItemKind::Code => icon::paint_icon(ctx, IconName::Code, box_, file),
357
+ ItemKind::Text => icon::paint_icon(ctx, IconName::Document, box_, file),
289
358
}
290
359
}
291
360
@@ -320,6 +389,24 @@ mod tests {
320
389
assert!(d.y() > a.y());
321
390
}
322
391
392
+ #[test]
393
+ fn multi_selection_toggle_range_and_all() {
394
+ let mut v = ItemView::new(items());
395
+ v.set_selected(Some(1));
396
+ assert!(v.is_selected(1));
397
+ v.toggle_selected(3);
398
+ assert_eq!(v.selected_indices(), &[1, 3]);
399
+ v.toggle_selected(1);
400
+ assert_eq!(v.selected_indices(), &[3]);
401
+ v.set_selected(Some(1));
402
+ v.select_range(3);
403
+ assert_eq!(v.selected_indices(), &[1, 2, 3]);
404
+ v.select_all();
405
+ assert_eq!(v.selected_indices().len(), 4);
406
+ v.clear_selection();
407
+ assert!(v.selected_indices().is_empty());
408
+ }
409
+
323
410
#[test]
324
411
fn list_slots_stack_vertically() {
325
412
let theme = Theme::light();
326
413
new file mode 100644
@@ -0,0 +1,15 @@
1
+[Desktop Entry]
2
+Type=Application
3
+Name=Files
4
+Name[pl]=Pliki
5
+Name[de]=Dateien
6
+Comment=Browse and manage files
7
+Comment[pl]=Przeglądaj i zarządzaj plikami
8
+Comment[de]=Dateien durchsuchen und verwalten
9
+Exec=luna-files
10
+Icon=system-file-manager
11
+Terminal=false
12
+Categories=System;FileManager;
13
+Keywords=files;folders;manager;
14
+Keywords[pl]=pliki;foldery;menedżer;
15
+Keywords[de]=Dateien;Ordner;Dateimanager;
@@ -92,6 +92,14 @@ files() {
92
92
printf '%s|%s|%s\n' "$lib/luna-keyring" "$dest/bin/luna-keyring" "755"
93
93
printf '%s|%s|%s\n' "$lib/luna-polkit" "$dest/bin/luna-polkit" "755"
94
94
printf '%s|%s|%s\n' "$lib/luna-demo" "$dest/bin/luna-demo" "755"
95
+ # Menedżer plików Luna Files (+ alias `gaminedes`).
96
+ if [ -f "$lib/luna-files" ]; then
97
+ printf '%s|%s|%s\n' "$lib/luna-files" "$dest/bin/luna-files" "755"
98
+ printf '%s|%s|%s\n' "$root/de/session/eu.luna.Files.desktop" "$dest/share/applications/eu.luna.Files.desktop" "644"
99
+ fi
100
+ if [ -f "$lib/gaminedes" ]; then
101
+ printf '%s|%s|%s\n' "$lib/gaminedes" "$dest/bin/gaminedes" "755"
102
+ fi
95
103
if [ -f "$lib/gallery" ]; then
96
104
printf '%s|%s|%s\n' "$lib/gallery" "$dest/bin/luna-gallery" "755"
97
105
fi