gardesk/garfield / 9ce5971

Browse files

Open With: add garlaunch-style app picker with fuzzy search

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9ce59711c387d48d2957b0fdc13c092b2e7b0f2d
Parents
ba16d6a
Tree
a274bbd

6 changed files

StatusFile+-
M Cargo.lock 100 7
M Cargo.toml 7 0
M garfield/Cargo.toml 7 0
M garfield/src/app.rs 48 3
A garfield/src/ui/app_picker.rs 674 0
M garfield/src/ui/mod.rs 2 0
Cargo.lockmodified
@@ -257,6 +257,16 @@ version = "0.1.8"
257257
 source = "registry+https://github.com/rust-lang/crates.io-index"
258258
 checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
259259
 
260
+[[package]]
261
+name = "freedesktop_entry_parser"
262
+version = "1.3.0"
263
+source = "registry+https://github.com/rust-lang/crates.io-index"
264
+checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4"
265
+dependencies = [
266
+ "nom",
267
+ "thiserror 1.0.69",
268
+]
269
+
260270
 [[package]]
261271
 name = "futures-channel"
262272
 version = "0.3.31"
@@ -327,16 +337,19 @@ dependencies = [
327337
  "anyhow",
328338
  "chrono",
329339
  "dirs",
340
+ "freedesktop_entry_parser",
330341
  "garfield-ipc",
331342
  "gartk-core",
332343
  "gartk-render",
333344
  "gartk-x11",
334345
  "libc",
346
+ "nucleo-matcher",
335347
  "serde",
336348
  "serde_json",
337
- "thiserror",
349
+ "thiserror 2.0.18",
338350
  "tracing",
339351
  "tracing-subscriber",
352
+ "walkdir",
340353
  "x11rb",
341354
 ]
342355
 
@@ -346,7 +359,7 @@ version = "0.1.0"
346359
 dependencies = [
347360
  "serde",
348361
  "serde_json",
349
- "thiserror",
362
+ "thiserror 2.0.18",
350363
 ]
351364
 
352365
 [[package]]
@@ -365,7 +378,7 @@ name = "gartk-core"
365378
 version = "0.3.0"
366379
 dependencies = [
367380
  "serde",
368
- "thiserror",
381
+ "thiserror 2.0.18",
369382
 ]
370383
 
371384
 [[package]]
@@ -377,7 +390,7 @@ dependencies = [
377390
  "gartk-x11",
378391
  "pango",
379392
  "pangocairo",
380
- "thiserror",
393
+ "thiserror 2.0.18",
381394
  "tracing",
382395
  "x11rb",
383396
 ]
@@ -387,7 +400,7 @@ name = "gartk-x11"
387400
 version = "0.3.0"
388401
 dependencies = [
389402
  "gartk-core",
390
- "thiserror",
403
+ "thiserror 2.0.18",
391404
  "tracing",
392405
  "x11rb",
393406
 ]
@@ -615,6 +628,22 @@ version = "2.7.6"
615628
 source = "registry+https://github.com/rust-lang/crates.io-index"
616629
 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
617630
 
631
+[[package]]
632
+name = "minimal-lexical"
633
+version = "0.2.1"
634
+source = "registry+https://github.com/rust-lang/crates.io-index"
635
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
636
+
637
+[[package]]
638
+name = "nom"
639
+version = "7.1.3"
640
+source = "registry+https://github.com/rust-lang/crates.io-index"
641
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
642
+dependencies = [
643
+ "memchr",
644
+ "minimal-lexical",
645
+]
646
+
618647
 [[package]]
619648
 name = "nu-ansi-term"
620649
 version = "0.50.3"
@@ -624,6 +653,16 @@ dependencies = [
624653
  "windows-sys 0.61.2",
625654
 ]
626655
 
656
+[[package]]
657
+name = "nucleo-matcher"
658
+version = "0.3.1"
659
+source = "registry+https://github.com/rust-lang/crates.io-index"
660
+checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
661
+dependencies = [
662
+ "memchr",
663
+ "unicode-segmentation",
664
+]
665
+
627666
 [[package]]
628667
 name = "num-traits"
629668
 version = "0.2.19"
@@ -754,7 +793,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
754793
 dependencies = [
755794
  "getrandom",
756795
  "libredox",
757
- "thiserror",
796
+ "thiserror 2.0.18",
758797
 ]
759798
 
760799
 [[package]]
@@ -793,6 +832,15 @@ version = "1.0.22"
793832
 source = "registry+https://github.com/rust-lang/crates.io-index"
794833
 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
795834
 
835
+[[package]]
836
+name = "same-file"
837
+version = "1.0.6"
838
+source = "registry+https://github.com/rust-lang/crates.io-index"
839
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
840
+dependencies = [
841
+ "winapi-util",
842
+]
843
+
796844
 [[package]]
797845
 name = "serde"
798846
 version = "1.0.228"
@@ -908,13 +956,33 @@ version = "0.13.3"
908956
 source = "registry+https://github.com/rust-lang/crates.io-index"
909957
 checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
910958
 
959
+[[package]]
960
+name = "thiserror"
961
+version = "1.0.69"
962
+source = "registry+https://github.com/rust-lang/crates.io-index"
963
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
964
+dependencies = [
965
+ "thiserror-impl 1.0.69",
966
+]
967
+
911968
 [[package]]
912969
 name = "thiserror"
913970
 version = "2.0.18"
914971
 source = "registry+https://github.com/rust-lang/crates.io-index"
915972
 checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
916973
 dependencies = [
917
- "thiserror-impl",
974
+ "thiserror-impl 2.0.18",
975
+]
976
+
977
+[[package]]
978
+name = "thiserror-impl"
979
+version = "1.0.69"
980
+source = "registry+https://github.com/rust-lang/crates.io-index"
981
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
982
+dependencies = [
983
+ "proc-macro2",
984
+ "quote",
985
+ "syn",
918986
 ]
919987
 
920988
 [[package]]
@@ -1055,6 +1123,12 @@ version = "1.0.22"
10551123
 source = "registry+https://github.com/rust-lang/crates.io-index"
10561124
 checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
10571125
 
1126
+[[package]]
1127
+name = "unicode-segmentation"
1128
+version = "1.12.0"
1129
+source = "registry+https://github.com/rust-lang/crates.io-index"
1130
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
1131
+
10581132
 [[package]]
10591133
 name = "utf8parse"
10601134
 version = "0.2.2"
@@ -1073,6 +1147,16 @@ version = "0.2.1"
10731147
 source = "registry+https://github.com/rust-lang/crates.io-index"
10741148
 checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
10751149
 
1150
+[[package]]
1151
+name = "walkdir"
1152
+version = "2.5.0"
1153
+source = "registry+https://github.com/rust-lang/crates.io-index"
1154
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1155
+dependencies = [
1156
+ "same-file",
1157
+ "winapi-util",
1158
+]
1159
+
10761160
 [[package]]
10771161
 name = "wasi"
10781162
 version = "0.11.1+wasi-snapshot-preview1"
@@ -1124,6 +1208,15 @@ dependencies = [
11241208
  "unicode-ident",
11251209
 ]
11261210
 
1211
+[[package]]
1212
+name = "winapi-util"
1213
+version = "0.1.11"
1214
+source = "registry+https://github.com/rust-lang/crates.io-index"
1215
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1216
+dependencies = [
1217
+ "windows-sys 0.61.2",
1218
+]
1219
+
11271220
 [[package]]
11281221
 name = "windows-core"
11291222
 version = "0.62.2"
Cargo.tomlmodified
@@ -43,6 +43,13 @@ clap = { version = "4.5", features = ["derive"] }
4343
 # File system
4444
 dirs = "6.0"
4545
 chrono = "0.4"
46
+walkdir = "2.5"
47
+
48
+# Desktop entries
49
+freedesktop_entry_parser = "1.3"
50
+
51
+# Fuzzy matching
52
+nucleo-matcher = "0.3"
4653
 
4754
 # IPC types (shared between crates)
4855
 garfield-ipc = { path = "garfield-ipc" }
garfield/Cargo.tomlmodified
@@ -34,6 +34,13 @@ serde_json.workspace = true
3434
 dirs.workspace = true
3535
 chrono.workspace = true
3636
 libc.workspace = true
37
+walkdir.workspace = true
38
+
39
+# Desktop entries
40
+freedesktop_entry_parser.workspace = true
41
+
42
+# Fuzzy matching
43
+nucleo-matcher.workspace = true
3744
 
3845
 # IPC
3946
 garfield-ipc.workspace = true
garfield/src/app.rsmodified
@@ -6,7 +6,7 @@ use garfield::core::{
66
     trash_files, restore_from_trash,
77
 };
88
 use garfield::ui::pane::SplitDirection;
9
-use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, InputDialog, InputResult, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
9
+use garfield::ui::{AddressBar, AppPickerDialog, AppPickerResult, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, InputDialog, InputResult, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
1010
 use anyhow::Result;
1111
 use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme};
1212
 use gartk_render::{Renderer, Surface, TextStyle};
@@ -84,6 +84,8 @@ pub struct App {
8484
     context_menu: ContextMenu,
8585
     /// Input dialog for text entry.
8686
     input_dialog: InputDialog,
87
+    /// Application picker dialog.
88
+    app_picker: AppPickerDialog,
8789
     /// Path pending "Open With" custom application.
8890
     pending_open_with_path: Option<PathBuf>,
8991
     /// Paths pending delete confirmation.
@@ -208,6 +210,9 @@ impl App {
208210
         // Create input dialog (full window bounds)
209211
         let input_dialog = InputDialog::new(Rect::new(0, 0, width, height));
210212
 
213
+        // Create app picker dialog (full window bounds)
214
+        let app_picker = AppPickerDialog::new(Rect::new(0, 0, width, height));
215
+
211216
         // Content area bounds (for panes)
212217
         let content_bounds = Rect::new(
213218
             sidebar_w as i32,
@@ -258,6 +263,7 @@ impl App {
258263
             progress_dialog,
259264
             context_menu,
260265
             input_dialog,
266
+            app_picker,
261267
             pending_open_with_path: None,
262268
             pending_delete_paths: Vec::new(),
263269
             undo_stack: UndoStack::new(),
@@ -376,6 +382,14 @@ impl App {
376382
             return;
377383
         }
378384
 
385
+        // Check app picker
386
+        if self.app_picker.is_visible() {
387
+            if let Some(result) = self.app_picker.on_click(pos) {
388
+                self.handle_app_picker_result(result);
389
+            }
390
+            return;
391
+        }
392
+
379393
         // Check help modal (clicking outside closes it)
380394
         if self.help_modal.on_click(pos) {
381395
             return;
@@ -635,6 +649,12 @@ impl App {
635649
             return;
636650
         }
637651
 
652
+        // Handle app picker hover
653
+        if self.app_picker.is_visible() {
654
+            self.app_picker.on_mouse_move(pos);
655
+            return;
656
+        }
657
+
638658
         // Handle context menu hover
639659
         if self.context_menu.is_visible() {
640660
             self.context_menu.on_mouse_move(pos);
@@ -741,6 +761,14 @@ impl App {
741761
             return;
742762
         }
743763
 
764
+        // Handle app picker when visible
765
+        if self.app_picker.is_visible() {
766
+            if let Some(result) = self.app_picker.handle_key(key) {
767
+                self.handle_app_picker_result(result);
768
+            }
769
+            return;
770
+        }
771
+
744772
         // Handle help modal when visible
745773
         if self.help_modal.is_visible() {
746774
             match key {
@@ -1560,6 +1588,19 @@ impl App {
15601588
         }
15611589
     }
15621590
 
1591
+    /// Handle app picker result.
1592
+    fn handle_app_picker_result(&mut self, result: AppPickerResult) {
1593
+        match result {
1594
+            AppPickerResult::Selected(exec) => {
1595
+                // Open the pending file with the selected application
1596
+                self.open_with_custom(&exec);
1597
+            }
1598
+            AppPickerResult::Cancelled => {
1599
+                self.pending_open_with_path = None;
1600
+            }
1601
+        }
1602
+    }
1603
+
15631604
     /// Handle conflict dialog result.
15641605
     fn handle_conflict_action(&mut self, action: ConflictAction) {
15651606
         let pending = match self.pending_paste.take() {
@@ -1869,10 +1910,10 @@ impl App {
18691910
             return;
18701911
         };
18711912
 
1872
-        // Handle $CUSTOM - show input dialog
1913
+        // Handle $CUSTOM - show app picker
18731914
         if app == "$CUSTOM" {
18741915
             self.pending_open_with_path = Some(path);
1875
-            self.input_dialog.show("Open With", "Enter application name:", "");
1916
+            self.app_picker.show();
18761917
             return;
18771918
         }
18781919
 
@@ -2481,6 +2522,7 @@ impl App {
24812522
         self.progress_dialog.set_bounds(Rect::new(0, 0, width, height));
24822523
         self.context_menu.set_bounds(Rect::new(0, 0, width, height));
24832524
         self.input_dialog.set_bounds(Rect::new(0, 0, width, height));
2525
+        self.app_picker.set_bounds(Rect::new(0, 0, width, height));
24842526
     }
24852527
 
24862528
     /// Render the application.
@@ -2553,6 +2595,9 @@ impl App {
25532595
         // Draw input dialog overlay (on top of everything)
25542596
         self.input_dialog.render(&self.renderer)?;
25552597
 
2598
+        // Draw app picker overlay (on top of everything)
2599
+        self.app_picker.render(&self.renderer)?;
2600
+
25562601
         // Draw context menu overlay
25572602
         self.context_menu.render(&self.renderer)?;
25582603
 
garfield/src/ui/app_picker.rsadded
@@ -0,0 +1,674 @@
1
+//! Application picker dialog for "Open With" functionality.
2
+//!
3
+//! Provides a mini garlaunch-style dialog that scans for installed applications
4
+//! and allows the user to search and select one to open a file with.
5
+
6
+use anyhow::Result;
7
+use freedesktop_entry_parser::Entry;
8
+use gartk_core::{Key, Point, Rect};
9
+use gartk_render::{Renderer, TextStyle};
10
+use nucleo_matcher::{Config, Matcher, Utf32Str};
11
+use nucleo_matcher::pattern::{Pattern, CaseMatching, Normalization};
12
+use std::collections::HashSet;
13
+use std::path::PathBuf;
14
+
15
+/// Maximum number of visible items in the list.
16
+const MAX_VISIBLE_ITEMS: usize = 10;
17
+
18
+/// Item height in pixels.
19
+const ITEM_HEIGHT: u32 = 36;
20
+
21
+/// Input field height.
22
+const INPUT_HEIGHT: u32 = 40;
23
+
24
+/// Padding inside dialog.
25
+const DIALOG_PADDING: u32 = 16;
26
+
27
+/// An installed application entry.
28
+#[derive(Debug, Clone)]
29
+pub struct AppEntry {
30
+    /// Display name from .desktop file.
31
+    pub name: String,
32
+    /// Optional description/comment.
33
+    pub description: Option<String>,
34
+    /// Exec command (cleaned of field codes).
35
+    pub exec: String,
36
+    /// Icon name (not currently rendered).
37
+    pub icon: Option<String>,
38
+    /// Path to the .desktop file.
39
+    pub desktop_path: PathBuf,
40
+}
41
+
42
+impl AppEntry {
43
+    /// Parse an AppEntry from a .desktop file.
44
+    fn from_desktop_file(path: &PathBuf) -> Option<Self> {
45
+        let entry = Entry::parse_file(path).ok()?;
46
+        let section = entry.section("Desktop Entry");
47
+
48
+        // Skip hidden or no-display entries
49
+        if section.attr("NoDisplay") == Some("true") {
50
+            return None;
51
+        }
52
+        if section.attr("Hidden") == Some("true") {
53
+            return None;
54
+        }
55
+
56
+        // Must have Name and Exec
57
+        let name = section.attr("Name")?.to_string();
58
+        let exec_raw = section.attr("Exec")?;
59
+
60
+        // Clean exec command - remove field codes like %f, %F, %u, %U, etc.
61
+        let exec = clean_exec_command(exec_raw);
62
+
63
+        let description = section.attr("Comment").map(String::from);
64
+        let icon = section.attr("Icon").map(String::from);
65
+
66
+        Some(AppEntry {
67
+            name,
68
+            description,
69
+            exec,
70
+            icon,
71
+            desktop_path: path.clone(),
72
+        })
73
+    }
74
+}
75
+
76
+/// Clean field codes from an Exec command.
77
+fn clean_exec_command(exec: &str) -> String {
78
+    let mut result = String::with_capacity(exec.len());
79
+    let mut chars = exec.chars().peekable();
80
+
81
+    while let Some(c) = chars.next() {
82
+        if c == '%' {
83
+            // Skip the field code character
84
+            if let Some(&next) = chars.peek() {
85
+                match next {
86
+                    'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v' | 'm' => {
87
+                        chars.next();
88
+                        continue;
89
+                    }
90
+                    '%' => {
91
+                        // %% becomes %
92
+                        chars.next();
93
+                        result.push('%');
94
+                        continue;
95
+                    }
96
+                    _ => {}
97
+                }
98
+            }
99
+        }
100
+        result.push(c);
101
+    }
102
+
103
+    result.trim().to_string()
104
+}
105
+
106
+/// Get XDG application directories to scan.
107
+fn get_app_directories() -> Vec<PathBuf> {
108
+    let mut dirs = Vec::new();
109
+
110
+    // User applications
111
+    if let Some(data_home) = dirs::data_dir() {
112
+        dirs.push(data_home.join("applications"));
113
+    }
114
+
115
+    // System applications
116
+    dirs.push(PathBuf::from("/usr/share/applications"));
117
+    dirs.push(PathBuf::from("/usr/local/share/applications"));
118
+
119
+    // NixOS
120
+    dirs.push(PathBuf::from("/run/current-system/sw/share/applications"));
121
+
122
+    // Flatpak user
123
+    if let Some(data_home) = dirs::data_dir() {
124
+        dirs.push(data_home.join("flatpak/exports/share/applications"));
125
+    }
126
+
127
+    // Flatpak system
128
+    dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
129
+
130
+    // Snap
131
+    dirs.push(PathBuf::from("/var/lib/snapd/desktop/applications"));
132
+
133
+    dirs
134
+}
135
+
136
+/// Scan for installed applications.
137
+fn scan_applications() -> Vec<AppEntry> {
138
+    let mut apps = Vec::new();
139
+    let mut seen_files: HashSet<String> = HashSet::new();
140
+
141
+    for dir in get_app_directories() {
142
+        if !dir.exists() {
143
+            continue;
144
+        }
145
+
146
+        // Use walkdir to handle nested directories
147
+        for entry in walkdir::WalkDir::new(&dir)
148
+            .follow_links(true)
149
+            .max_depth(2)
150
+            .into_iter()
151
+            .filter_map(|e| e.ok())
152
+        {
153
+            let path = entry.path();
154
+
155
+            // Only process .desktop files
156
+            if path.extension().map(|e| e != "desktop").unwrap_or(true) {
157
+                continue;
158
+            }
159
+
160
+            // Deduplicate by filename
161
+            let filename = path.file_name()
162
+                .and_then(|n| n.to_str())
163
+                .unwrap_or("")
164
+                .to_string();
165
+
166
+            if seen_files.contains(&filename) {
167
+                continue;
168
+            }
169
+            seen_files.insert(filename);
170
+
171
+            // Parse the entry
172
+            let path_buf = path.to_path_buf();
173
+            if let Some(app) = AppEntry::from_desktop_file(&path_buf) {
174
+                apps.push(app);
175
+            }
176
+        }
177
+    }
178
+
179
+    // Sort by name
180
+    apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
181
+    apps
182
+}
183
+
184
+/// Result of app picker interaction.
185
+#[derive(Debug, Clone)]
186
+pub enum AppPickerResult {
187
+    /// User selected an application.
188
+    Selected(String),
189
+    /// User cancelled.
190
+    Cancelled,
191
+}
192
+
193
+/// A modal dialog for picking an application.
194
+pub struct AppPickerDialog {
195
+    /// Window bounds (for centering).
196
+    bounds: Rect,
197
+    /// All discovered applications.
198
+    all_apps: Vec<AppEntry>,
199
+    /// Filtered applications (matching search).
200
+    filtered_apps: Vec<usize>,
201
+    /// Search input text.
202
+    input: String,
203
+    /// Cursor position in input.
204
+    cursor: usize,
205
+    /// Selected item index in filtered list.
206
+    selected: usize,
207
+    /// Scroll offset for list.
208
+    scroll_offset: usize,
209
+    /// Whether the dialog is visible.
210
+    visible: bool,
211
+    /// Fuzzy matcher.
212
+    matcher: Matcher,
213
+    /// Hovered item index.
214
+    hovered_index: Option<usize>,
215
+}
216
+
217
+impl AppPickerDialog {
218
+    /// Create a new app picker dialog.
219
+    pub fn new(bounds: Rect) -> Self {
220
+        Self {
221
+            bounds,
222
+            all_apps: Vec::new(),
223
+            filtered_apps: Vec::new(),
224
+            input: String::new(),
225
+            cursor: 0,
226
+            selected: 0,
227
+            scroll_offset: 0,
228
+            visible: false,
229
+            matcher: Matcher::new(Config::DEFAULT),
230
+            hovered_index: None,
231
+        }
232
+    }
233
+
234
+    /// Set bounds.
235
+    pub fn set_bounds(&mut self, bounds: Rect) {
236
+        self.bounds = bounds;
237
+    }
238
+
239
+    /// Check if visible.
240
+    pub fn is_visible(&self) -> bool {
241
+        self.visible
242
+    }
243
+
244
+    /// Show the dialog.
245
+    pub fn show(&mut self) {
246
+        // Scan for applications if not already loaded
247
+        if self.all_apps.is_empty() {
248
+            self.all_apps = scan_applications();
249
+        }
250
+
251
+        // Reset state
252
+        self.input.clear();
253
+        self.cursor = 0;
254
+        self.selected = 0;
255
+        self.scroll_offset = 0;
256
+        self.hovered_index = None;
257
+
258
+        // Initially show all apps
259
+        self.filtered_apps = (0..self.all_apps.len()).collect();
260
+
261
+        self.visible = true;
262
+    }
263
+
264
+    /// Hide the dialog.
265
+    pub fn hide(&mut self) {
266
+        self.visible = false;
267
+    }
268
+
269
+    /// Reload applications list.
270
+    pub fn reload(&mut self) {
271
+        self.all_apps = scan_applications();
272
+        self.filter_apps();
273
+    }
274
+
275
+    /// Filter applications based on current input.
276
+    fn filter_apps(&mut self) {
277
+        if self.input.is_empty() {
278
+            // Show all apps when no input
279
+            self.filtered_apps = (0..self.all_apps.len()).collect();
280
+        } else {
281
+            // Use nucleo for fuzzy matching
282
+            let pattern = Pattern::new(
283
+                &self.input,
284
+                CaseMatching::Smart,
285
+                Normalization::Smart,
286
+                nucleo_matcher::pattern::AtomKind::Fuzzy,
287
+            );
288
+
289
+            let mut matches: Vec<(usize, u32)> = self.all_apps
290
+                .iter()
291
+                .enumerate()
292
+                .filter_map(|(idx, app)| {
293
+                    let mut buf = Vec::new();
294
+                    let haystack = Utf32Str::new(&app.name, &mut buf);
295
+                    let score = pattern.score(haystack, &mut self.matcher)?;
296
+
297
+                    // Also try matching description
298
+                    let desc_score = app.description.as_ref().and_then(|desc| {
299
+                        let mut desc_buf = Vec::new();
300
+                        let desc_haystack = Utf32Str::new(desc, &mut desc_buf);
301
+                        pattern.score(desc_haystack, &mut self.matcher)
302
+                    }).unwrap_or(0);
303
+
304
+                    Some((idx, score.max(desc_score)))
305
+                })
306
+                .collect();
307
+
308
+            // Sort by score descending
309
+            matches.sort_by(|a, b| b.1.cmp(&a.1));
310
+
311
+            self.filtered_apps = matches.into_iter().map(|(idx, _)| idx).collect();
312
+        }
313
+
314
+        // Reset selection
315
+        self.selected = 0;
316
+        self.scroll_offset = 0;
317
+    }
318
+
319
+    /// Handle key press. Returns Some(result) if dialog should close.
320
+    pub fn handle_key(&mut self, key: &Key) -> Option<AppPickerResult> {
321
+        if !self.visible {
322
+            return None;
323
+        }
324
+
325
+        match key {
326
+            Key::Escape => {
327
+                self.hide();
328
+                Some(AppPickerResult::Cancelled)
329
+            }
330
+            Key::Return => {
331
+                if let Some(&idx) = self.filtered_apps.get(self.selected) {
332
+                    if let Some(app) = self.all_apps.get(idx) {
333
+                        let exec = app.exec.clone();
334
+                        self.hide();
335
+                        return Some(AppPickerResult::Selected(exec));
336
+                    }
337
+                }
338
+                None
339
+            }
340
+            Key::Up => {
341
+                if self.selected > 0 {
342
+                    self.selected -= 1;
343
+                    self.ensure_visible();
344
+                }
345
+                None
346
+            }
347
+            Key::Down => {
348
+                if self.selected + 1 < self.filtered_apps.len() {
349
+                    self.selected += 1;
350
+                    self.ensure_visible();
351
+                }
352
+                None
353
+            }
354
+            Key::PageUp => {
355
+                self.selected = self.selected.saturating_sub(MAX_VISIBLE_ITEMS);
356
+                self.ensure_visible();
357
+                None
358
+            }
359
+            Key::PageDown => {
360
+                self.selected = (self.selected + MAX_VISIBLE_ITEMS)
361
+                    .min(self.filtered_apps.len().saturating_sub(1));
362
+                self.ensure_visible();
363
+                None
364
+            }
365
+            Key::Char(c) => {
366
+                self.input.insert(self.cursor, *c);
367
+                self.cursor += 1;
368
+                self.filter_apps();
369
+                None
370
+            }
371
+            Key::Backspace => {
372
+                if self.cursor > 0 {
373
+                    self.cursor -= 1;
374
+                    self.input.remove(self.cursor);
375
+                    self.filter_apps();
376
+                }
377
+                None
378
+            }
379
+            Key::Delete => {
380
+                if self.cursor < self.input.len() {
381
+                    self.input.remove(self.cursor);
382
+                    self.filter_apps();
383
+                }
384
+                None
385
+            }
386
+            Key::Left => {
387
+                if self.cursor > 0 {
388
+                    self.cursor -= 1;
389
+                }
390
+                None
391
+            }
392
+            Key::Right => {
393
+                if self.cursor < self.input.len() {
394
+                    self.cursor += 1;
395
+                }
396
+                None
397
+            }
398
+            Key::Home => {
399
+                self.cursor = 0;
400
+                None
401
+            }
402
+            Key::End => {
403
+                self.cursor = self.input.len();
404
+                None
405
+            }
406
+            _ => None,
407
+        }
408
+    }
409
+
410
+    /// Ensure the selected item is visible.
411
+    fn ensure_visible(&mut self) {
412
+        if self.selected < self.scroll_offset {
413
+            self.scroll_offset = self.selected;
414
+        } else if self.selected >= self.scroll_offset + MAX_VISIBLE_ITEMS {
415
+            self.scroll_offset = self.selected - MAX_VISIBLE_ITEMS + 1;
416
+        }
417
+    }
418
+
419
+    /// Handle mouse click. Returns Some(result) if dialog should close.
420
+    pub fn on_click(&mut self, pos: Point) -> Option<AppPickerResult> {
421
+        if !self.visible {
422
+            return None;
423
+        }
424
+
425
+        let dialog_rect = self.dialog_rect();
426
+
427
+        // Click outside closes dialog
428
+        if !dialog_rect.contains_point(pos) {
429
+            self.hide();
430
+            return Some(AppPickerResult::Cancelled);
431
+        }
432
+
433
+        // Check if click is in item list area
434
+        let list_y_start = dialog_rect.y + DIALOG_PADDING as i32 + INPUT_HEIGHT as i32 + 8;
435
+        let list_y_end = list_y_start + (MAX_VISIBLE_ITEMS as i32 * ITEM_HEIGHT as i32);
436
+
437
+        if pos.y >= list_y_start && pos.y < list_y_end {
438
+            let relative_y = pos.y - list_y_start;
439
+            let clicked_index = (relative_y / ITEM_HEIGHT as i32) as usize + self.scroll_offset;
440
+
441
+            if clicked_index < self.filtered_apps.len() {
442
+                if let Some(&idx) = self.filtered_apps.get(clicked_index) {
443
+                    if let Some(app) = self.all_apps.get(idx) {
444
+                        let exec = app.exec.clone();
445
+                        self.hide();
446
+                        return Some(AppPickerResult::Selected(exec));
447
+                    }
448
+                }
449
+            }
450
+        }
451
+
452
+        // Check if click is in input area
453
+        let input_rect = self.input_rect();
454
+        if input_rect.contains_point(pos) {
455
+            // Could implement click-to-position cursor here
456
+            return None;
457
+        }
458
+
459
+        None
460
+    }
461
+
462
+    /// Handle mouse move.
463
+    pub fn on_mouse_move(&mut self, pos: Point) {
464
+        if !self.visible {
465
+            return;
466
+        }
467
+
468
+        let dialog_rect = self.dialog_rect();
469
+        let list_y_start = dialog_rect.y + DIALOG_PADDING as i32 + INPUT_HEIGHT as i32 + 8;
470
+        let list_y_end = list_y_start + (MAX_VISIBLE_ITEMS as i32 * ITEM_HEIGHT as i32);
471
+
472
+        if pos.y >= list_y_start && pos.y < list_y_end
473
+            && pos.x >= dialog_rect.x + DIALOG_PADDING as i32
474
+            && pos.x < dialog_rect.x + dialog_rect.width as i32 - DIALOG_PADDING as i32
475
+        {
476
+            let relative_y = pos.y - list_y_start;
477
+            let hovered = (relative_y / ITEM_HEIGHT as i32) as usize + self.scroll_offset;
478
+
479
+            if hovered < self.filtered_apps.len() {
480
+                self.hovered_index = Some(hovered);
481
+            } else {
482
+                self.hovered_index = None;
483
+            }
484
+        } else {
485
+            self.hovered_index = None;
486
+        }
487
+    }
488
+
489
+    /// Get the dialog rectangle (centered).
490
+    fn dialog_rect(&self) -> Rect {
491
+        let dialog_width = 500.min(self.bounds.width.saturating_sub(40));
492
+        let dialog_height = (DIALOG_PADDING * 2 + INPUT_HEIGHT + 8 + (MAX_VISIBLE_ITEMS as u32 * ITEM_HEIGHT) + 24)
493
+            .min(self.bounds.height.saturating_sub(40));
494
+
495
+        let x = self.bounds.x + (self.bounds.width as i32 - dialog_width as i32) / 2;
496
+        let y = self.bounds.y + (self.bounds.height as i32 - dialog_height as i32) / 3; // Upper third
497
+
498
+        Rect::new(x, y, dialog_width, dialog_height)
499
+    }
500
+
501
+    /// Get the input field rectangle.
502
+    fn input_rect(&self) -> Rect {
503
+        let dialog = self.dialog_rect();
504
+        Rect::new(
505
+            dialog.x + DIALOG_PADDING as i32,
506
+            dialog.y + DIALOG_PADDING as i32,
507
+            dialog.width - DIALOG_PADDING * 2,
508
+            INPUT_HEIGHT,
509
+        )
510
+    }
511
+
512
+    /// Render the dialog.
513
+    pub fn render(&self, renderer: &Renderer) -> Result<()> {
514
+        if !self.visible {
515
+            return Ok(());
516
+        }
517
+
518
+        let theme = renderer.theme();
519
+        let dialog_rect = self.dialog_rect();
520
+
521
+        // Dim background overlay
522
+        renderer.fill_rect(self.bounds, gartk_core::Color::from_u8(0, 0, 0, 180))?;
523
+
524
+        // Dialog background
525
+        renderer.fill_rounded_rect(dialog_rect, 8.0, theme.background)?;
526
+        renderer.stroke_rounded_rect(dialog_rect, 8.0, theme.border, 1.0)?;
527
+
528
+        // Title
529
+        let title_style = TextStyle::new()
530
+            .font_family(&theme.font_family)
531
+            .font_size(theme.font_size + 2.0)
532
+            .color(theme.foreground);
533
+
534
+        renderer.text(
535
+            "Open With Application",
536
+            (dialog_rect.x + DIALOG_PADDING as i32) as f64,
537
+            (dialog_rect.y + 8) as f64,
538
+            &title_style,
539
+        )?;
540
+
541
+        // Input field
542
+        let input_rect = self.input_rect();
543
+        renderer.fill_rounded_rect(input_rect, 4.0, theme.item_background)?;
544
+        renderer.stroke_rounded_rect(input_rect, 4.0, theme.selection_background, 2.0)?;
545
+
546
+        let input_style = TextStyle::new()
547
+            .font_family(&theme.font_family)
548
+            .font_size(theme.font_size)
549
+            .color(theme.foreground);
550
+
551
+        // Prompt and input text
552
+        let prompt = "Search: ";
553
+        renderer.text(
554
+            prompt,
555
+            (input_rect.x + 12) as f64,
556
+            (input_rect.y + 10) as f64,
557
+            &input_style,
558
+        )?;
559
+
560
+        let prompt_width = renderer.measure_text(prompt, &input_style)
561
+            .map(|m| m.width as i32)
562
+            .unwrap_or(60);
563
+
564
+        let input_text_x = input_rect.x + 12 + prompt_width;
565
+        renderer.text(
566
+            &self.input,
567
+            input_text_x as f64,
568
+            (input_rect.y + 10) as f64,
569
+            &input_style,
570
+        )?;
571
+
572
+        // Cursor
573
+        let cursor_text = &self.input[..self.cursor];
574
+        let cursor_offset = if cursor_text.is_empty() {
575
+            0
576
+        } else {
577
+            renderer.measure_text(cursor_text, &input_style)
578
+                .map(|m| m.width as i32)
579
+                .unwrap_or(0)
580
+        };
581
+
582
+        let cursor_x = input_text_x + cursor_offset;
583
+        renderer.line(
584
+            cursor_x as f64,
585
+            (input_rect.y + 8) as f64,
586
+            cursor_x as f64,
587
+            (input_rect.y + INPUT_HEIGHT as i32 - 8) as f64,
588
+            theme.foreground,
589
+            1.0,
590
+        )?;
591
+
592
+        // Item list
593
+        let list_y_start = dialog_rect.y + DIALOG_PADDING as i32 + INPUT_HEIGHT as i32 + 8;
594
+        let list_width = dialog_rect.width - DIALOG_PADDING * 2;
595
+
596
+        let name_style = TextStyle::new()
597
+            .font_family(&theme.font_family)
598
+            .font_size(theme.font_size)
599
+            .color(theme.foreground);
600
+
601
+        let desc_style = TextStyle::new()
602
+            .font_family(&theme.font_family)
603
+            .font_size(theme.font_size - 2.0)
604
+            .color(theme.item_description);
605
+
606
+        let visible_end = (self.scroll_offset + MAX_VISIBLE_ITEMS).min(self.filtered_apps.len());
607
+
608
+        for (i, &app_idx) in self.filtered_apps[self.scroll_offset..visible_end].iter().enumerate() {
609
+            let app = &self.all_apps[app_idx];
610
+            let item_y = list_y_start + (i as i32 * ITEM_HEIGHT as i32);
611
+            let display_idx = self.scroll_offset + i;
612
+
613
+            let item_rect = Rect::new(
614
+                dialog_rect.x + DIALOG_PADDING as i32,
615
+                item_y,
616
+                list_width,
617
+                ITEM_HEIGHT,
618
+            );
619
+
620
+            // Highlight selected or hovered item
621
+            let is_selected = display_idx == self.selected;
622
+            let is_hovered = self.hovered_index == Some(display_idx);
623
+
624
+            if is_selected {
625
+                renderer.fill_rounded_rect(item_rect, 4.0, theme.selection_background)?;
626
+            } else if is_hovered {
627
+                renderer.fill_rounded_rect(item_rect, 4.0, theme.item_hover_background)?;
628
+            }
629
+
630
+            // App name
631
+            renderer.text(
632
+                &app.name,
633
+                (item_rect.x + 12) as f64,
634
+                (item_y + 6) as f64,
635
+                &name_style,
636
+            )?;
637
+
638
+            // App description (if any)
639
+            if let Some(desc) = &app.description {
640
+                // Truncate long descriptions
641
+                let max_desc_len = 60;
642
+                let truncated = if desc.len() > max_desc_len {
643
+                    format!("{}...", &desc[..max_desc_len])
644
+                } else {
645
+                    desc.clone()
646
+                };
647
+
648
+                renderer.text(
649
+                    &truncated,
650
+                    (item_rect.x + 12) as f64,
651
+                    (item_y + 6 + theme.font_size as i32) as f64,
652
+                    &desc_style,
653
+                )?;
654
+            }
655
+        }
656
+
657
+        // Item count
658
+        let count_text = format!("{} / {} applications", self.filtered_apps.len(), self.all_apps.len());
659
+        let count_style = TextStyle::new()
660
+            .font_family(&theme.font_family)
661
+            .font_size(theme.font_size - 2.0)
662
+            .color(theme.item_description);
663
+
664
+        let count_y = dialog_rect.y + dialog_rect.height as i32 - 20;
665
+        renderer.text(
666
+            &count_text,
667
+            (dialog_rect.x + DIALOG_PADDING as i32) as f64,
668
+            count_y as f64,
669
+            &count_style,
670
+        )?;
671
+
672
+        Ok(())
673
+    }
674
+}
garfield/src/ui/mod.rsmodified
@@ -1,6 +1,7 @@
11
 //! UI components for garfield.
22
 
33
 pub mod address_bar;
4
+pub mod app_picker;
45
 pub mod breadcrumb;
56
 pub mod column_view;
67
 pub mod context_menu;
@@ -16,6 +17,7 @@ pub mod tab_bar;
1617
 pub mod toolbar;
1718
 
1819
 pub use address_bar::AddressBar;
20
+pub use app_picker::{AppPickerDialog, AppPickerResult};
1921
 pub use context_menu::{ContextMenu, ContextMenuAction, ContextType};
2022
 pub use dialog::{ConfirmDialog, ConflictAction, ConflictDialog, DialogResult, InputDialog, InputResult, ProgressDialog, ProgressInfo};
2123
 pub use breadcrumb::Breadcrumb;