@@ -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 | +} |