Commit e6c7536
9
files
+371
added
-9
deleted
@@ -251,6 +251,7 @@ pub enum IconName {
251
251
Home,
252
252
User,
253
253
Info,
254
+ App,
254
255
}
255
256
256
257
/// Współrzędne w siatce 24×24 mapowane na prostokąt docelowy.
@@ -552,6 +553,19 @@ pub fn paint_icon(paint: &mut PaintCtx, name: IconName, rect: Rect, color: Color
552
553
scene.fill_circle(g.p(12.0, 7.8), g.d(1.3), color);
553
554
scene.stroke_line(g.p(12.0, 11.0), g.p(12.0, 16.5), w, color);
554
555
}
556
+ IconName::App => {
557
+ scene.stroke_rounded_rect(
558
+ Rect::from_xywh(g.p(4.0, 4.0).x, g.p(4.0, 4.0).y, g.d(16.0), g.d(16.0)),
559
+ g.d(3.0),
560
+ w,
561
+ color,
562
+ );
563
+ scene.fill_rounded_rect(
564
+ Rect::from_xywh(g.p(8.5, 8.5).x, g.p(8.5, 8.5).y, g.d(7.0), g.d(7.0)),
565
+ g.d(1.5),
566
+ color,
567
+ );
568
+ }
555
569
}
556
570
}
557
571
@@ -593,6 +607,7 @@ mod icon_tests {
593
607
IconName::Home,
594
608
IconName::User,
595
609
IconName::Info,
610
+ IconName::App,
596
611
] {
597
612
let mut scene = Scene::new();
598
613
let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
@@ -77,8 +77,11 @@ Selene.
77
77
- `luna-panel` (bin) — render headless: `docs/images/luna-shell-{light,dark}.png`
78
78
oraz `docs/images/luna-launcher-{list,grid,carousel}.png`.
79
79
- `luna-shell` (bin) — powierzchnia „chrome” na żywo: burger otwiera/zamyka launcher,
80
- przełącznik widoku, klik w aplikację zamyka, `Esc` zamyka; do tego **OSD** i **powiadomienia**
81
- (`osd.rs`, `notifications.rs`).
80
+ przełącznik widoku, **przewijanie listy**, klik w aplikację **uruchamia** ją, `Esc` zamyka;
81
+ do tego **OSD** i **powiadomienia** (`osd.rs`, `notifications.rs`).
82
+- **Prawdziwe aplikacje**: `apps.rs` skanuje pliki `.desktop` (`XDG_DATA_HOME`/`XDG_DATA_DIRS`),
83
+ parsuje `[Desktop Entry]` (nazwy lokalizowane po `LANG`), filtruje `NoDisplay`/`Hidden`;
84
+ podgląd: `cargo run -p luna --bin luna-apps`. Gdy brak `.desktop` — zestaw demo.
82
85
- `libluna-platform` obsługuje teraz oba tryby: okno `xdg-shell` (CSD) i powierzchnię
83
86
warstwową (`AppConfig::layer(LayerConfig::…)`) — `LayerConfig::top_panel`/`overlay`/`top_chrome`
84
87
oraz przezroczyste tło (`AppConfig::transparent`).
@@ -19,6 +19,10 @@ path = "src/bin/luna-panel.rs"
19
19
name = "luna-shell"
20
20
path = "src/bin/luna-shell.rs"
21
21
22
+[[bin]]
23
+name = "luna-apps"
24
+path = "src/bin/luna-apps.rs"
25
+
22
26
[dependencies]
23
27
libluna-core.workspace = true
24
28
libluna-widgets.workspace = true
25
29
new file mode 100644
@@ -0,0 +1,295 @@
1
+//! Wykrywanie zainstalowanych aplikacji na podstawie plików `.desktop` (XDG).
2
+//!
3
+//! Skanujemy katalogi `applications/` z `XDG_DATA_HOME` i `XDG_DATA_DIRS`, parsujemy
4
+//! sekcję `[Desktop Entry]` i zwracamy listę aplikacji do pokazania w launcherze.
5
+//! Kolejność katalogów = priorytet (użytkownik nad systemem); duplikaty po `id` wygrywa
6
+//! pierwszy napotkany.
7
+//!
8
+//! Parsowanie jest czyste ([`parse`]) — łatwe do testów bez dostępu do systemu plików.
9
+
10
+use std::collections::HashSet;
11
+use std::path::{Path, PathBuf};
12
+
13
+use libluna_widgets::IconName;
14
+
15
+/// Aplikacja z pliku `.desktop`.
16
+#[derive(Debug, Clone, PartialEq, Eq)]
17
+pub struct DesktopApp {
18
+ pub name: String,
19
+ pub exec: String,
20
+ pub icon: Option<String>,
21
+ pub comment: Option<String>,
22
+ pub terminal: bool,
23
+ pub categories: Vec<String>,
24
+}
25
+
26
+/// Język z otoczenia (`LANG`/`LC_MESSAGES`), np. `pl` z `pl_PL.UTF-8`.
27
+pub fn current_lang() -> Option<String> {
28
+ let raw = std::env::var("LC_MESSAGES")
29
+ .or_else(|_| std::env::var("LANG"))
30
+ .ok()?;
31
+ let lang = raw.split(['_', '.', '@']).next()?.trim();
32
+ if lang.is_empty() || lang == "C" || lang == "POSIX" {
33
+ None
34
+ } else {
35
+ Some(lang.to_ascii_lowercase())
36
+ }
37
+}
38
+
39
+/// Usuwa kody pól (`%U`, `%f`, `%i`…) z polecenia `Exec`.
40
+fn strip_field_codes(exec: &str) -> String {
41
+ exec.split_whitespace()
42
+ .filter(|tok| !(tok.starts_with('%') && tok.len() == 2))
43
+ .collect::<Vec<_>>()
44
+ .join(" ")
45
+}
46
+
47
+/// Parsuje treść pliku `.desktop`. Zwraca `None`, gdy to nie jest wyświetlana aplikacja.
48
+pub fn parse(content: &str, lang: Option<&str>) -> Option<DesktopApp> {
49
+ let mut in_entry = false;
50
+ let mut name = None;
51
+ let mut name_loc = None;
52
+ let mut comment = None;
53
+ let mut exec = None;
54
+ let mut icon = None;
55
+ let mut terminal = false;
56
+ let mut ty = None;
57
+ let mut nodisplay = false;
58
+ let mut hidden = false;
59
+ let mut categories = Vec::new();
60
+
61
+ let name_key = lang.map(|l| format!("Name[{l}]"));
62
+ let comment_key = lang.map(|l| format!("Comment[{l}]"));
63
+
64
+ for raw in content.lines() {
65
+ let line = raw.trim();
66
+ if line.is_empty() || line.starts_with('#') {
67
+ continue;
68
+ }
69
+ if line.starts_with('[') {
70
+ in_entry = line == "[Desktop Entry]";
71
+ continue;
72
+ }
73
+ if !in_entry {
74
+ continue;
75
+ }
76
+ let Some((k, v)) = line.split_once('=') else {
77
+ continue;
78
+ };
79
+ let (k, v) = (k.trim(), v.trim());
80
+ match k {
81
+ "Type" => ty = Some(v.to_owned()),
82
+ "Name" => name = Some(v.to_owned()),
83
+ "Comment" => comment = Some(v.to_owned()),
84
+ "Exec" => exec = Some(v.to_owned()),
85
+ "Icon" => icon = Some(v.to_owned()),
86
+ "Terminal" => terminal = v.eq_ignore_ascii_case("true"),
87
+ "NoDisplay" => nodisplay = v.eq_ignore_ascii_case("true"),
88
+ "Hidden" => hidden = v.eq_ignore_ascii_case("true"),
89
+ "Categories" => {
90
+ categories = v
91
+ .split(';')
92
+ .filter(|s| !s.is_empty())
93
+ .map(|s| s.to_owned())
94
+ .collect();
95
+ }
96
+ _ => {
97
+ if name_key.as_deref() == Some(k) {
98
+ name_loc = Some(v.to_owned());
99
+ } else if comment_key.as_deref() == Some(k) {
100
+ comment = Some(v.to_owned());
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ if ty.as_deref() != Some("Application") || nodisplay || hidden {
107
+ return None;
108
+ }
109
+ Some(DesktopApp {
110
+ name: name_loc.or(name)?,
111
+ exec: strip_field_codes(&exec?),
112
+ icon,
113
+ comment,
114
+ terminal,
115
+ categories,
116
+ })
117
+}
118
+
119
+/// Skanuje system w poszukiwaniu aplikacji (posortowane po nazwie).
120
+pub fn scan() -> Vec<DesktopApp> {
121
+ let lang = current_lang();
122
+ let mut seen: HashSet<String> = HashSet::new();
123
+ let mut apps = Vec::new();
124
+
125
+ for dir in application_dirs() {
126
+ for path in desktop_files(&dir, 0) {
127
+ let id = path
128
+ .strip_prefix(&dir)
129
+ .unwrap_or(&path)
130
+ .to_string_lossy()
131
+ .replace('/', "-");
132
+ if !seen.insert(id) {
133
+ continue;
134
+ }
135
+ if let Ok(content) = std::fs::read_to_string(&path) {
136
+ if let Some(app) = parse(&content, lang.as_deref()) {
137
+ apps.push(app);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ apps.sort_by_key(|a| a.name.to_lowercase());
144
+ apps
145
+}
146
+
147
+/// Katalogi `applications/` w kolejności priorytetu (użytkownik pierwszy).
148
+fn application_dirs() -> Vec<PathBuf> {
149
+ let mut dirs = Vec::new();
150
+ if let Ok(home) = std::env::var("XDG_DATA_HOME") {
151
+ dirs.push(PathBuf::from(home).join("applications"));
152
+ } else if let Ok(home) = std::env::var("HOME") {
153
+ dirs.push(PathBuf::from(home).join(".local/share/applications"));
154
+ }
155
+ let data_dirs =
156
+ std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".to_owned());
157
+ for d in data_dirs.split(':').filter(|d| !d.is_empty()) {
158
+ dirs.push(PathBuf::from(d).join("applications"));
159
+ }
160
+ dirs
161
+}
162
+
163
+/// Zbiera pliki `.desktop` z katalogu (rekurencyjnie do głębokości 2).
164
+fn desktop_files(dir: &Path, depth: usize) -> Vec<PathBuf> {
165
+ let Ok(entries) = std::fs::read_dir(dir) else {
166
+ return Vec::new();
167
+ };
168
+ let mut out = Vec::new();
169
+ for entry in entries.flatten() {
170
+ let path = entry.path();
171
+ if path.is_dir() {
172
+ if depth < 2 {
173
+ out.extend(desktop_files(&path, depth + 1));
174
+ }
175
+ } else if path.extension().map(|e| e == "desktop").unwrap_or(false) {
176
+ out.push(path);
177
+ }
178
+ }
179
+ out
180
+}
181
+
182
+/// Dobiera ikonę z pakietu Luny na podstawie kategorii/nazwy (heurystyka).
183
+pub fn icon_for(app: &DesktopApp) -> IconName {
184
+ let cats = app.categories.join(" ").to_lowercase();
185
+ let name = app.name.to_lowercase();
186
+ let has = |needle: &str| cats.contains(needle) || name.contains(needle);
187
+
188
+ if has("filemanager") || has("files") || name.contains("pliki") {
189
+ IconName::Files
190
+ } else if has("terminal") || has("console") || name.contains("terminal") {
191
+ IconName::Terminal
192
+ } else if has("audio") || has("video") || has("player") || has("music") {
193
+ IconName::Music
194
+ } else if has("browser") || has("network") || has("wifi") {
195
+ IconName::Wifi
196
+ } else if has("settings") || has("preferences") || has("system") {
197
+ IconName::Settings
198
+ } else if has("office") || has("texteditor") || has("editor") {
199
+ IconName::Edit
200
+ } else if has("game") {
201
+ IconName::Star
202
+ } else if has("graphics") || has("photography") {
203
+ IconName::Edit
204
+ } else {
205
+ IconName::App
206
+ }
207
+}
208
+
209
+#[cfg(test)]
210
+mod tests {
211
+ use super::*;
212
+
213
+ const FIREFOX: &str = "\
214
+[Desktop Entry]
215
+Type=Application
216
+Name=Firefox
217
+Name[pl]=Firefox
218
+GenericName=Web Browser
219
+Comment=Browse the Web
220
+Exec=firefox %u
221
+Icon=firefox
222
+Terminal=false
223
+Categories=Network;WebBrowser;
224
+";
225
+
226
+ #[test]
227
+ fn parses_application_entry() {
228
+ let app = parse(FIREFOX, Some("pl")).unwrap();
229
+ assert_eq!(app.name, "Firefox");
230
+ assert_eq!(app.exec, "firefox"); // %u usunięte
231
+ assert!(!app.terminal);
232
+ assert!(app.categories.contains(&"WebBrowser".to_owned()));
233
+ assert_eq!(icon_for(&app), IconName::Wifi);
234
+ }
235
+
236
+ #[test]
237
+ fn prefers_localized_name() {
238
+ let content =
239
+ "[Desktop Entry]\nType=Application\nName=Files\nName[pl]=Pliki\nExec=nautilus\n";
240
+ assert_eq!(parse(content, Some("pl")).unwrap().name, "Pliki");
241
+ // Bez dopasowania języka → nazwa bazowa.
242
+ assert_eq!(parse(content, Some("de")).unwrap().name, "Files");
243
+ }
244
+
245
+ #[test]
246
+ fn skips_non_application_and_hidden() {
247
+ assert!(parse("[Desktop Entry]\nType=Link\nName=x\nExec=x\n", None).is_none());
248
+ assert!(parse(
249
+ "[Desktop Entry]\nType=Application\nName=x\nExec=x\nNoDisplay=true\n",
250
+ None
251
+ )
252
+ .is_none());
253
+ assert!(parse(
254
+ "[Desktop Entry]\nType=Application\nName=x\nExec=x\nHidden=true\n",
255
+ None
256
+ )
257
+ .is_none());
258
+ assert!(parse("[Desktop Entry]\nType=Application\nName=x\n", None).is_none());
259
+ }
260
+
261
+ #[test]
262
+ fn ignores_unrelated_sections() {
263
+ let content = "[Desktop Action New]\nName=Nowy\nExec=touch\n\n[Desktop Entry]\nType=Application\nName=Pliki\nExec=nautilus\n";
264
+ assert_eq!(parse(content, None).unwrap().name, "Pliki");
265
+ }
266
+
267
+ #[test]
268
+ fn icon_heuristics_cover_common_categories() {
269
+ let mk = |cat: &str, name: &str| DesktopApp {
270
+ name: name.into(),
271
+ exec: "x".into(),
272
+ icon: None,
273
+ comment: None,
274
+ terminal: false,
275
+ categories: cat
276
+ .split(';')
277
+ .filter(|s| !s.is_empty())
278
+ .map(String::from)
279
+ .collect(),
280
+ };
281
+ assert_eq!(
282
+ icon_for(&mk("System;FileManager;", "Pliki")),
283
+ IconName::Files
284
+ );
285
+ assert_eq!(
286
+ icon_for(&mk("System;TerminalEmulator;", "Terminal")),
287
+ IconName::Terminal
288
+ );
289
+ assert_eq!(
290
+ icon_for(&mk("AudioVideo;Player;", "Odtwarzacz")),
291
+ IconName::Music
292
+ );
293
+ assert_eq!(icon_for(&mk("Utility;", "Costam")), IconName::App);
294
+ }
295
+}
0
296
new file mode 100644
@@ -0,0 +1,17 @@
1
+//! `luna-apps` — wypisuje aplikacje wykryte z plików `.desktop` (diagnostyka launchera).
2
+//!
3
+//! Uruchom: `cargo run -p luna --bin luna-apps`
4
+
5
+fn main() {
6
+ let apps = luna::apps::scan();
7
+ println!("wykryto {} aplikacji:", apps.len());
8
+ for app in &apps {
9
+ let icon = luna::apps::icon_for(app);
10
+ println!(
11
+ " {:<28} {:<10} {}",
12
+ app.name,
13
+ format!("{icon:?}"),
14
+ app.exec
15
+ );
16
+ }
17
+}
@@ -30,7 +30,13 @@ fn main() -> Result<(), Box<dyn Error>> {
30
30
.transparent(true);
31
31
32
32
let mut state = PanelState::sample();
33
- let apps = App::sample();
33
+ // Prawdziwe aplikacje z plików `.desktop`; gdy brak (np. kontener) — zestaw demo.
34
+ let desktop = luna::apps::scan();
35
+ let apps = if desktop.is_empty() {
36
+ App::sample()
37
+ } else {
38
+ App::from_desktop(&desktop)
39
+ };
34
40
let mut launcher = Launcher::new();
35
41
let mut open = false;
36
42
let mut osd = Osd::new("◆", "Głośność", 0.0);
@@ -73,16 +79,19 @@ fn main() -> Result<(), Box<dyn Error>> {
73
79
{
74
80
launcher.view = v;
75
81
launcher.scroll = 0.0;
76
- } else if panel::launcher_app_at(
82
+ } else if let Some(i) = panel::launcher_app_at(
77
83
&apps,
78
84
&launcher,
79
85
larea,
80
86
&frame.theme,
81
87
text,
82
88
pe.position,
83
- )
84
- .is_some()
85
- {
89
+ ) {
90
+ // Uruchom aplikację (jeśli ma polecenie).
91
+ if let Some(exec) = apps.get(i).and_then(|a| a.exec.clone()) {
92
+ let _ =
93
+ std::process::Command::new("sh").arg("-c").arg(exec).spawn();
94
+ }
86
95
open = false;
87
96
}
88
97
}
@@ -6,6 +6,7 @@
6
6
//! Moduły rysują się przez `libluna-widgets::PaintCtx`, więc ten sam kod obsługuje
7
7
//! podglądy headless (zrzuty do dokumentacji) i sesję na żywo.
8
8
9
+pub mod apps;
9
10
pub mod notifications;
10
11
pub mod osd;
11
12
pub mod panel;
@@ -150,10 +150,12 @@ pub struct App {
150
150
pub name: String,
151
151
/// Krótki opis/kategoria (widok listy).
152
152
pub category: String,
153
+ /// Polecenie uruchamiające (dla prawdziwych aplikacji z plików `.desktop`).
154
+ pub exec: Option<String>,
153
155
}
154
156
155
157
impl App {
156
- /// Przykładowy zestaw ikon (podglądy).
158
+ /// Przykładowy zestaw ikon (podglądy, gdy brak `.desktop`).
157
159
pub fn sample() -> Vec<App> {
158
160
const APPS: [(IconName, &str, &str); 12] = [
159
161
(IconName::Files, "Pliki", "System"),
@@ -174,6 +176,19 @@ impl App {
174
176
icon: *i,
175
177
name: (*n).to_owned(),
176
178
category: (*c).to_owned(),
179
+ exec: None,
180
+ })
181
+ .collect()
182
+ }
183
+
184
+ /// Buduje pozycje launchera z wykrytych aplikacji (plików `.desktop`).
185
+ pub fn from_desktop(list: &[crate::apps::DesktopApp]) -> Vec<App> {
186
+ list.iter()
187
+ .map(|d| App {
188
+ icon: crate::apps::icon_for(d),
189
+ name: d.name.clone(),
190
+ category: d.categories.first().cloned().unwrap_or_default(),
191
+ exec: Some(d.exec.clone()),
177
192
})
178
193
.collect()
179
194
}
@@ -177,7 +177,10 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
177
177
(**Lista / Siatka / Karuzela** z segmentowym przełącznikiem); render headless do
178
178
`docs/images/luna-shell-{light,dark}.png` i `docs/images/luna-launcher-{list,grid,carousel}.png`.
179
179
Dane (`PanelState`, `App`, `Launcher`) oddzielone od rysowania (`PaintCtx`) — ten sam kod do
180
- podglądów i sesji na żywo. ✅ **`luna-shell`** (bin) uruchamia panel jako prawdziwą **powierzchnię
180
+ podglądów i sesji na żywo. ✅ **Prawdziwe aplikacje**: `apps.rs` skanuje pliki `.desktop`
181
+ (XDG), parsuje `[Desktop Entry]` z lokalizacją nazw i filtrowaniem `NoDisplay`/`Hidden`;
182
+ klik w pozycję uruchamia polecenie. Podgląd listy: `cargo run -p luna --bin luna-apps`.
183
+ ✅ **`luna-shell`** (bin) uruchamia panel jako prawdziwą **powierzchnię
181
184
warstwową** (`wlr-layer-shell` po stronie klienta w `libluna-platform`); bez kompozytora
182
185
kończy się czytelnym `NoCompositor`. ✅ **OSD** (`osd.rs`) i **powiadomienia** (`notifications.rs`)
183
186
— pokazywane pod paskiem na powierzchni „chrome”. Dalej: usługa D-Bus