← luna

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