1use std::{
4 collections::HashSet,
5 fs::{self, Metadata},
6 path::{Path, PathBuf},
7 time::SystemTime,
8};
9
10use crate::{
11 backend::{MouseButton, Window, WindowEvent, create_window},
12 error::Error,
13 render::{Canvas, Font, Rgba, rgb},
14 ui::{
15 BASE_BUTTON_HEIGHT, BASE_BUTTON_SPACING, BASE_CORNER_RADIUS, Colors, KEY_BACKSPACE,
16 KEY_DOWN, KEY_ESCAPE, KEY_RETURN, KEY_UP,
17 widgets::{Widget, button::Button, text_input::TextInput},
18 },
19};
20
21const BASE_WINDOW_WIDTH: u32 = 700;
23const BASE_WINDOW_HEIGHT: u32 = 500;
24const BASE_PADDING: u32 = 12;
25const BASE_SIDEBAR_WIDTH: u32 = 160;
26const BASE_TOOLBAR_HEIGHT: u32 = 36;
27const BASE_PATH_BAR_HEIGHT: u32 = 32;
28const BASE_SEARCH_WIDTH: u32 = 200;
29const BASE_ITEM_HEIGHT: u32 = 28;
30const BASE_ICON_SIZE: u32 = 20;
31const BASE_SECTION_HEADER_HEIGHT: u32 = 22;
32
33const BASE_NAME_COL_WIDTH: u32 = 280;
35const BASE_SIZE_COL_WIDTH: u32 = 80;
36const BASE_COLUMN_HEADER_HEIGHT: u32 = 28;
37const BASE_FILENAME_ROW_HEIGHT: u32 = 58;
38const BASE_FOOTER_HEIGHT: u32 = 44;
39const BASE_CONTENT_GAP: u32 = 12;
40const BASE_FILENAME_LABEL_HEIGHT: u32 = 20;
41
42#[derive(Debug, Clone)]
44pub enum FileSelectResult {
45 Selected(PathBuf),
46 SelectedMultiple(Vec<PathBuf>),
47 Cancelled,
48 Closed,
49}
50
51impl FileSelectResult {
52 pub fn exit_code(&self) -> i32 {
53 match self {
54 FileSelectResult::Selected(_) | FileSelectResult::SelectedMultiple(_) => 0,
55 FileSelectResult::Cancelled => 1,
56 FileSelectResult::Closed => 1,
57 }
58 }
59}
60
61#[derive(Clone)]
63struct QuickAccess {
64 name: &'static str,
65 path: PathBuf,
66 icon: QuickAccessIcon,
67}
68
69#[derive(Clone, Copy)]
70enum QuickAccessIcon {
71 Home,
72 Desktop,
73 Documents,
74 Downloads,
75 Pictures,
76 Music,
77 Videos,
78}
79
80#[derive(Clone)]
82struct MountPoint {
83 device: String,
84 mount_point: PathBuf,
85 label: Option<String>,
86}
87
88#[derive(Clone, Copy)]
90enum MountIcon {
91 UsbDrive,
92 ExternalHdd,
93 Optical,
94 Generic,
95}
96
97#[derive(Debug, Clone)]
99pub struct FileFilter {
100 pub name: String,
101 pub patterns: Vec<String>,
102}
103
104pub struct FileSelectBuilder {
106 title: String,
107 directory: bool,
108 save: bool,
109 filename: String,
110 start_path: Option<PathBuf>,
111 width: Option<u32>,
112 height: Option<u32>,
113 colors: Option<&'static Colors>,
114 filters: Vec<FileFilter>,
115 multiple: bool,
116 separator: String,
117}
118
119impl FileSelectBuilder {
120 pub fn new() -> Self {
121 Self {
122 title: String::new(),
123 directory: false,
124 save: false,
125 filename: String::new(),
126 start_path: None,
127 width: None,
128 height: None,
129 colors: None,
130 filters: Vec::new(),
131 multiple: false,
132 separator: String::from(" "),
133 }
134 }
135
136 pub fn title(mut self, title: &str) -> Self {
137 self.title = title.to_string();
138 self
139 }
140
141 pub fn directory(mut self, directory: bool) -> Self {
142 self.directory = directory;
143 self
144 }
145
146 pub fn save(mut self, save: bool) -> Self {
147 self.save = save;
148 self
149 }
150
151 pub fn filename(mut self, filename: &str) -> Self {
152 self.filename = filename.to_string();
153 self
154 }
155
156 pub fn start_path(mut self, path: &Path) -> Self {
157 self.start_path = Some(path.to_path_buf());
158 self
159 }
160
161 pub fn colors(mut self, colors: &'static Colors) -> Self {
162 self.colors = Some(colors);
163 self
164 }
165
166 pub fn width(mut self, width: u32) -> Self {
167 self.width = Some(width);
168 self
169 }
170
171 pub fn height(mut self, height: u32) -> Self {
172 self.height = Some(height);
173 self
174 }
175
176 pub fn add_filter(mut self, filter: FileFilter) -> Self {
177 self.filters.push(filter);
178 self
179 }
180
181 pub fn multiple(mut self, multiple: bool) -> Self {
182 self.multiple = multiple;
183 self
184 }
185
186 pub fn separator(mut self, separator: &str) -> Self {
187 self.separator = separator.to_string();
188 self
189 }
190
191 pub fn show(self) -> Result<FileSelectResult, Error> {
192 let colors = self.colors.unwrap_or_else(|| crate::ui::detect_theme());
193
194 let logical_width = self.width.unwrap_or(BASE_WINDOW_WIDTH);
196 let logical_height = self.height.unwrap_or(BASE_WINDOW_HEIGHT);
197
198 let mut window = create_window(logical_width as u16, logical_height as u16)?;
200 let title = if self.title.is_empty() {
201 if self.directory {
202 "Select Directory"
203 } else if self.save {
204 "Save File"
205 } else {
206 "Open File"
207 }
208 } else {
209 &self.title
210 };
211 window.set_title(title)?;
212
213 let scale = window.scale_factor();
215
216 let font = Font::load(scale);
218
219 let window_width = (logical_width as f32 * scale) as u32;
221 let window_height = (logical_height as f32 * scale) as u32;
222 let padding = (BASE_PADDING as f32 * scale) as u32;
223 let sidebar_width = (BASE_SIDEBAR_WIDTH as f32 * scale) as u32;
224 let toolbar_height = (BASE_TOOLBAR_HEIGHT as f32 * scale) as u32;
225 let path_bar_height = (BASE_PATH_BAR_HEIGHT as f32 * scale) as u32;
226 let search_width = (BASE_SEARCH_WIDTH as f32 * scale) as u32;
227 let item_height = (BASE_ITEM_HEIGHT as f32 * scale) as u32;
228 let name_col_width = (BASE_NAME_COL_WIDTH as f32 * scale) as u32;
229 let size_col_width = (BASE_SIZE_COL_WIDTH as f32 * scale) as u32;
230
231 let quick_access = build_quick_access();
233
234 let mounted_drives = get_mounted_drives();
236
237 let mut ok_button = Button::new(if self.save { "Save" } else { "Open" }, &font, scale);
239 let mut cancel_button = Button::new("Cancel", &font, scale);
240
241 let mut search_input = TextInput::new(search_width).with_placeholder("Search...");
243
244 let save_mode = self.save && !self.directory;
246
247 let mut history: Vec<PathBuf> = Vec::new();
249 let mut history_index: usize = 0;
250
251 let mut current_dir = self
253 .start_path
254 .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
255 history.push(current_dir.clone());
256
257 let mut all_entries: Vec<DirEntry> = Vec::new();
258 let mut filtered_entries: Vec<usize> = Vec::new(); let mut selected_indices: HashSet<usize> = HashSet::new();
260 let mut scroll_offset: usize = 0;
261 let mut show_hidden = false;
262 let mut search_text = String::new();
263 let mut hovered_quick_access: Option<usize> = None;
264 let mut hovered_entry: Option<usize> = None;
265 let mut hovered_drive: Option<usize> = None;
266
267 let mut completion_matches: Vec<String> = Vec::new();
269 let mut completion_popup_index: usize = 0;
270
271 let mut search_matches: Vec<String> = Vec::new();
273 let mut search_popup_index: usize = 0;
274
275 let mut window_dragging = false;
276
277 let mut thumb_drag = false;
279 let mut thumb_drag_offset: Option<i32> = None;
280 let mut scrollbar_hovered = false;
281
282 load_directory(¤t_dir, &mut all_entries, self.directory, show_hidden);
284 update_filtered(
285 &all_entries,
286 &search_text,
287 &mut filtered_entries,
288 &self.filters,
289 );
290
291 let filename_row_height = if save_mode {
293 (BASE_FILENAME_ROW_HEIGHT as f32 * scale) as u32
294 } else {
295 0
296 };
297 let content_gap = (BASE_CONTENT_GAP as f32 * scale) as u32;
298 let footer_height = (BASE_FOOTER_HEIGHT as f32 * scale) as u32;
299 let sidebar_x = padding as i32;
300 let sidebar_y = (padding + toolbar_height + content_gap) as i32;
301 let sidebar_h = window_height
302 - padding * 2
303 - toolbar_height
304 - content_gap
305 - footer_height
306 - filename_row_height;
307
308 let main_x = (padding + sidebar_width + content_gap) as i32;
309 let main_y = sidebar_y;
310 let main_w = window_width - padding * 2 - sidebar_width - content_gap;
311 let main_h = sidebar_h;
312
313 let header_offset = (BASE_COLUMN_HEADER_HEIGHT as f32 * scale) as u32;
314 let list_y = main_y + path_bar_height as i32 + header_offset as i32;
315 let list_h = main_h - path_bar_height - header_offset;
316 let visible_items = (list_h / item_height) as usize;
317
318 let section_header_height = (BASE_SECTION_HEADER_HEIGHT as f32 * scale) as u32;
320 let item_height_scaled = item_height;
321 let gap_between_sections = content_gap;
322
323 let button_y =
325 (window_height - padding - (BASE_BUTTON_HEIGHT as f32 * scale) as u32) as i32;
326 let mut bx = window_width as i32 - padding as i32;
327 bx -= cancel_button.width() as i32;
328 cancel_button.set_position(bx, button_y);
329 bx -= (BASE_BUTTON_SPACING as f32 * scale) as i32 + ok_button.width() as i32;
330 ok_button.set_position(bx, button_y);
331
332 let filename_y = button_y - filename_row_height as i32;
334 let filename_label_h = (BASE_FILENAME_LABEL_HEIGHT as f32 * scale) as i32;
335 let mut filename_input = if save_mode {
336 let mut input = TextInput::new(main_w).with_placeholder("Enter filename...");
337 if !self.filename.is_empty() {
338 input = input.with_default_text(&self.filename);
339 }
340 input.set_focus(true);
341 input.set_position(main_x, filename_y + filename_label_h);
342 Some(input)
343 } else {
344 None
345 };
346
347 let search_x = window_width as i32 - padding as i32 - search_width as i32;
349 let search_y = padding as i32 + (2.0 * scale) as i32;
350 search_input.set_position(search_x, search_y);
351
352 let mut canvas = Canvas::new(window_width, window_height);
354 let mut mouse_x = 0i32;
355 let mut mouse_y = 0i32;
356
357 let draw = |canvas: &mut Canvas,
359 colors: &Colors,
360 font: &Font,
361 current_dir: &Path,
362 quick_access: &[QuickAccess],
363 all_entries: &[DirEntry],
364 filtered_entries: &[usize],
365 selected_indices: &HashSet<usize>,
366 scroll_offset: usize,
367 hovered_quick_access: Option<usize>,
368 hovered_entry: Option<usize>,
369 show_hidden: bool,
370 search_input: &TextInput,
371 ok_button: &Button,
372 cancel_button: &Button,
373 history: &[PathBuf],
374 history_index: usize,
375 mounted_drives: &[MountPoint],
376 hovered_drive: Option<usize>,
377 scale: f32,
378 scrollbar_hovered: bool,
379 filename_input: Option<&TextInput>| {
380 let width = canvas.width() as f32;
381 let height = canvas.height() as f32;
382 let radius = BASE_CORNER_RADIUS * scale;
383
384 canvas.fill_dialog_bg(
385 width,
386 height,
387 colors.window_bg,
388 colors.window_border,
389 colors.window_shadow,
390 radius,
391 );
392
393 let toolbar_bg = darken(colors.window_bg, 0.03);
395 canvas.fill_rect(
396 0.0,
397 0.0,
398 window_width as f32,
399 (toolbar_height + padding) as f32,
400 toolbar_bg,
401 );
402
403 let nav_y = padding as i32 + (4.0 * scale) as i32;
405 let can_back = history_index > 0;
406 let can_forward = history_index + 1 < history.len();
407
408 draw_nav_button(
410 canvas,
411 padding as i32,
412 nav_y,
413 "<",
414 can_back,
415 colors,
416 font,
417 scale,
418 );
419 draw_nav_button(
421 canvas,
422 (padding as f32 + 32.0 * scale) as i32,
423 nav_y,
424 ">",
425 can_forward,
426 colors,
427 font,
428 scale,
429 );
430 let can_up = current_dir.parent().is_some();
432 draw_nav_button(
433 canvas,
434 (padding as f32 + 68.0 * scale) as i32,
435 nav_y,
436 "^",
437 can_up,
438 colors,
439 font,
440 scale,
441 );
442 draw_nav_button(
444 canvas,
445 (padding as f32 + 104.0 * scale) as i32,
446 nav_y,
447 "~",
448 true,
449 colors,
450 font,
451 scale,
452 );
453 let toggle_x = (padding as f32 + 150.0 * scale) as i32;
455 draw_toggle(
456 canvas,
457 toggle_x,
458 nav_y,
459 ".*",
460 show_hidden,
461 colors,
462 font,
463 scale,
464 );
465
466 search_input.draw_to(canvas, colors, font);
468
469 let sidebar_bg = darken(colors.window_bg, 0.02);
471 canvas.fill_rounded_rect(
472 sidebar_x as f32,
473 sidebar_y as f32,
474 sidebar_width as f32,
475 sidebar_h as f32,
476 6.0 * scale,
477 sidebar_bg,
478 );
479
480 draw_section_header(
482 canvas,
483 sidebar_x,
484 sidebar_y + (8.0 * scale) as i32,
485 "PLACES",
486 colors,
487 font,
488 scale,
489 );
490
491 let places_items_start_y =
492 sidebar_y + (8.0 * scale) as i32 + section_header_height as i32;
493 for (i, qa) in quick_access.iter().enumerate() {
494 let y = places_items_start_y + (i as i32 * item_height_scaled as i32);
495 let is_hovered = hovered_quick_access == Some(i);
496 let is_current = qa.path == current_dir;
497
498 if is_current {
499 canvas.fill_rounded_rect(
500 (sidebar_x + (4.0 * scale) as i32) as f32,
501 y as f32,
502 (sidebar_width - (8.0 * scale) as u32) as f32,
503 28.0 * scale,
504 4.0 * scale,
505 colors.input_border_focused,
506 );
507 } else if is_hovered {
508 canvas.fill_rounded_rect(
509 (sidebar_x + (4.0 * scale) as i32) as f32,
510 y as f32,
511 (sidebar_width - (8.0 * scale) as u32) as f32,
512 28.0 * scale,
513 4.0 * scale,
514 darken(colors.window_bg, 0.05),
515 );
516 }
517
518 draw_quick_access_icon(
519 canvas,
520 sidebar_x + (12.0 * scale) as i32,
521 y + (4.0 * scale) as i32,
522 qa.icon,
523 colors,
524 scale,
525 );
526
527 let text_color = if is_current {
528 rgb(255, 255, 255)
529 } else {
530 colors.text
531 };
532 let name_canvas = font.render(qa.name).with_color(text_color).finish();
533 canvas.draw_canvas(
534 &name_canvas,
535 sidebar_x + (36.0 * scale) as i32,
536 y + (6.0 * scale) as i32,
537 );
538 }
539
540 if !mounted_drives.is_empty() {
542 let drives_section_y = places_items_start_y
543 + (quick_access.len() as i32 * item_height_scaled as i32)
544 + gap_between_sections as i32;
545
546 draw_section_header(
547 canvas,
548 sidebar_x,
549 drives_section_y,
550 "DRIVES",
551 colors,
552 font,
553 scale,
554 );
555
556 let drives_items_start_y = drives_section_y + section_header_height as i32;
557 for (i, drive) in mounted_drives.iter().enumerate() {
558 let y = drives_items_start_y + (i as i32 * item_height_scaled as i32);
559 let is_hovered = hovered_drive == Some(i);
560 let is_current = drive.mount_point == current_dir;
561
562 if is_current {
563 canvas.fill_rounded_rect(
564 (sidebar_x + (4.0 * scale) as i32) as f32,
565 y as f32,
566 (sidebar_width - (8.0 * scale) as u32) as f32,
567 28.0 * scale,
568 4.0 * scale,
569 colors.input_border_focused,
570 );
571 } else if is_hovered {
572 canvas.fill_rounded_rect(
573 (sidebar_x + (4.0 * scale) as i32) as f32,
574 y as f32,
575 (sidebar_width - (8.0 * scale) as u32) as f32,
576 28.0 * scale,
577 4.0 * scale,
578 darken(colors.window_bg, 0.05),
579 );
580 }
581
582 let icon = get_mount_icon(&drive.device);
583 draw_mount_icon(
584 canvas,
585 sidebar_x + (12.0 * scale) as i32,
586 y + (6.0 * scale) as i32,
587 icon,
588 colors,
589 scale,
590 );
591
592 let display_name = drive.label.as_deref().unwrap_or_else(|| {
593 drive
594 .mount_point
595 .file_name()
596 .and_then(|n| n.to_str())
597 .unwrap_or(&drive.device)
598 });
599 let truncated_name = truncate_name(display_name, 18);
600
601 let text_color = if is_current {
602 rgb(255, 255, 255)
603 } else {
604 colors.text
605 };
606 let name_canvas = font.render(&truncated_name).with_color(text_color).finish();
607 canvas.draw_canvas(
608 &name_canvas,
609 sidebar_x + (36.0 * scale) as i32,
610 y + (6.0 * scale) as i32,
611 );
612 }
613 }
614
615 canvas.fill_rounded_rect(
617 main_x as f32,
618 main_y as f32,
619 main_w as f32,
620 main_h as f32,
621 6.0 * scale,
622 colors.input_bg,
623 );
624
625 draw_breadcrumbs(
627 canvas,
628 main_x + (8.0 * scale) as i32,
629 main_y + (6.0 * scale) as i32,
630 main_w - (16.0 * scale) as u32,
631 current_dir,
632 colors,
633 font,
634 );
635
636 let header_y = main_y + path_bar_height as i32;
638 let header_bg = darken(colors.input_bg, 0.03);
639 canvas.fill_rect(
640 main_x as f32,
641 header_y as f32,
642 main_w as f32,
643 26.0 * scale,
644 header_bg,
645 );
646
647 let header_text = rgb(150, 150, 150);
648 let name_header = font.render("Name").with_color(header_text).finish();
649 canvas.draw_canvas(
650 &name_header,
651 main_x + (32.0 * scale) as i32,
652 header_y + (5.0 * scale) as i32,
653 );
654 let size_header = font.render("Size").with_color(header_text).finish();
655 canvas.draw_canvas(
656 &size_header,
657 main_x + name_col_width as i32 + (8.0 * scale) as i32,
658 header_y + (5.0 * scale) as i32,
659 );
660 let date_header = font.render("Modified").with_color(header_text).finish();
661 canvas.draw_canvas(
662 &date_header,
663 main_x + name_col_width as i32 + size_col_width as i32 + (16.0 * scale) as i32,
664 header_y + (5.0 * scale) as i32,
665 );
666
667 canvas.fill_rect(
669 main_x as f32,
670 (header_y + (26.0 * scale) as i32) as f32,
671 main_w as f32,
672 1.0,
673 colors.input_border,
674 );
675
676 let list_x = main_x;
678 for (vi, &ei) in filtered_entries
679 .iter()
680 .skip(scroll_offset)
681 .take(visible_items)
682 .enumerate()
683 {
684 let entry = &all_entries[ei];
685 let y = list_y + (vi as u32 * item_height) as i32;
686 let is_selected = selected_indices.contains(&ei);
687 let is_hovered = hovered_entry == Some(ei);
688
689 let row_bg = if vi % 2 == 1 {
691 darken(colors.input_bg, 0.02)
692 } else {
693 colors.input_bg
694 };
695
696 if is_selected {
698 canvas.fill_rect(
699 (list_x + 2) as f32,
700 y as f32,
701 (main_w - 4) as f32,
702 item_height as f32,
703 colors.input_border_focused,
704 );
705 } else if is_hovered {
706 canvas.fill_rect(
707 (list_x + 2) as f32,
708 y as f32,
709 (main_w - 4) as f32,
710 item_height as f32,
711 darken(colors.input_bg, 0.06),
712 );
713 } else {
714 canvas.fill_rect(
715 list_x as f32,
716 y as f32,
717 main_w as f32,
718 item_height as f32,
719 row_bg,
720 );
721 }
722
723 let icon_x = list_x + (8.0 * scale) as i32;
725 let icon_y = y + (4.0 * scale) as i32;
726 if entry.is_dir {
727 draw_folder_icon(canvas, icon_x, icon_y, colors, scale);
728 } else {
729 draw_file_icon(canvas, icon_x, icon_y, &entry.name, colors, scale);
730 }
731
732 let text_color = if is_selected {
734 rgb(255, 255, 255)
735 } else {
736 colors.text
737 };
738 let display_name = truncate_name(&entry.name, 35);
739 let name_canvas = font.render(&display_name).with_color(text_color).finish();
740 canvas.draw_canvas(
741 &name_canvas,
742 list_x + (32.0 * scale) as i32,
743 y + (6.0 * scale) as i32,
744 );
745
746 if !entry.is_dir {
748 let size_str = format_size(entry.size);
749 let size_color = if is_selected {
750 rgb(220, 220, 220)
751 } else {
752 rgb(140, 140, 140)
753 };
754 let size_canvas = font.render(&size_str).with_color(size_color).finish();
755 canvas.draw_canvas(
756 &size_canvas,
757 list_x + name_col_width as i32 + (8.0 * scale) as i32,
758 y + (6.0 * scale) as i32,
759 );
760 }
761
762 let date_str = format_date(entry.modified);
764 let date_color = if is_selected {
765 rgb(220, 220, 220)
766 } else {
767 rgb(140, 140, 140)
768 };
769 let date_canvas = font.render(&date_str).with_color(date_color).finish();
770 canvas.draw_canvas(
771 &date_canvas,
772 list_x + name_col_width as i32 + size_col_width as i32 + (16.0 * scale) as i32,
773 y + (6.0 * scale) as i32,
774 );
775 }
776
777 if filtered_entries.len() > visible_items {
779 let scrollbar_width = if scrollbar_hovered {
780 12.0 * scale
781 } else {
782 8.0 * scale
783 };
784 let scrollbar_x = main_x + main_w as i32 - scrollbar_width as i32;
785 let scrollbar_h = list_h as f32;
786 let thumb_h = (visible_items as f32 / filtered_entries.len() as f32 * scrollbar_h)
787 .max(20.0 * scale);
788 let thumb_y = scroll_offset as f32 / filtered_entries.len() as f32 * scrollbar_h;
789
790 canvas.fill_rounded_rect(
792 scrollbar_x as f32,
793 list_y as f32,
794 scrollbar_width - 2.0 * scale,
795 scrollbar_h,
796 3.0 * scale,
797 darken(colors.input_bg, 0.05),
798 );
799 canvas.fill_rounded_rect(
801 scrollbar_x as f32,
802 list_y as f32 + thumb_y,
803 scrollbar_width - 2.0 * scale,
804 thumb_h,
805 3.0 * scale,
806 if scrollbar_hovered {
807 colors.input_border_focused
808 } else {
809 colors.input_border
810 },
811 );
812 }
813
814 canvas.stroke_rounded_rect(
816 main_x as f32,
817 main_y as f32,
818 main_w as f32,
819 main_h as f32,
820 6.0 * scale,
821 colors.input_border,
822 1.0,
823 );
824
825 if let Some(fi) = filename_input {
827 let label = title;
828 let label_canvas = font.render(label).with_color(colors.text).finish();
829 canvas.draw_canvas(&label_canvas, main_x, filename_y + (2.0 * scale) as i32);
830 fi.draw_to(canvas, colors, font);
831 }
832
833 ok_button.draw_to(canvas, colors, font);
835 cancel_button.draw_to(canvas, colors, font);
836
837 let status = format!("{} items", filtered_entries.len());
839 let status_canvas = font.render(&status).with_color(rgb(120, 120, 120)).finish();
840 canvas.draw_canvas(&status_canvas, main_x, button_y + (8.0 * scale) as i32);
841 };
842
843 draw(
845 &mut canvas,
846 colors,
847 &font,
848 ¤t_dir,
849 &quick_access,
850 &all_entries,
851 &filtered_entries,
852 &selected_indices,
853 scroll_offset,
854 hovered_quick_access,
855 hovered_entry,
856 show_hidden,
857 &search_input,
858 &ok_button,
859 &cancel_button,
860 &history,
861 history_index,
862 &mounted_drives,
863 hovered_drive,
864 scale,
865 scrollbar_hovered,
866 filename_input.as_ref(),
867 );
868 if save_mode && !completion_matches.is_empty() {
869 let visible = completion_matches.len().min(MAX_POPUP_ITEMS);
870 let popup_h = (visible as i32) * POPUP_ITEM_HEIGHT + 2;
871 draw_completion_popup(
872 &mut canvas,
873 &font,
874 colors,
875 &completion_matches,
876 completion_popup_index,
877 main_x,
878 filename_y + filename_label_h - popup_h,
879 main_w,
880 );
881 }
882 if !search_matches.is_empty() && search_input.has_focus() {
883 draw_completion_popup(
884 &mut canvas,
885 &font,
886 colors,
887 &search_matches,
888 search_popup_index,
889 search_x,
890 search_y + 32,
891 search_width,
892 );
893 }
894 window.set_contents(&canvas)?;
895 window.show()?;
896
897 loop {
899 let event = window.wait_for_event()?;
900 let mut needs_redraw = false;
901
902 match &event {
903 WindowEvent::CloseRequested => return Ok(FileSelectResult::Closed),
904 WindowEvent::RedrawRequested => needs_redraw = true,
905 WindowEvent::CursorEnter(pos) | WindowEvent::CursorMove(pos) => {
906 if window_dragging {
907 let _ = window.start_drag();
908 window_dragging = false;
909 }
910
911 mouse_x = pos.x as i32;
912 mouse_y = pos.y as i32;
913
914 if thumb_drag && !filtered_entries.is_empty() {
916 let scrollbar_y = list_y;
917
918 if mouse_x >= main_x
919 && mouse_x < main_x + main_w as i32
920 && mouse_y >= list_y
921 && mouse_y < list_y + list_h as i32
922 {
923 let visible_items = (list_h / item_height) as usize;
924 let total_items = filtered_entries.len();
925 let max_scroll = total_items.saturating_sub(visible_items);
926
927 if max_scroll > 0 {
928 let scrollbar_h_f32 = list_h as f32 - 8.0 * scale;
929 let thumb_h_f32 = (visible_items as f32 / total_items as f32
930 * scrollbar_h_f32)
931 .max(20.0 * scale);
932 let thumb_h = thumb_h_f32 as i32;
933 let max_thumb_y = scrollbar_h_f32 as i32 - thumb_h;
934
935 let offset = thumb_drag_offset.unwrap_or(thumb_h / 2);
936 let thumb_y =
937 (mouse_y - scrollbar_y - offset).clamp(0, max_thumb_y);
938 let scroll_ratio = if max_thumb_y > 0 {
939 thumb_y as f32 / max_thumb_y as f32
940 } else {
941 0.0
942 };
943 scroll_offset = ((scroll_ratio * max_scroll as f32) as usize)
944 .clamp(0, max_scroll);
945 needs_redraw = true;
946 }
947 }
948 }
949
950 if !thumb_drag {
952 let old_qa = hovered_quick_access;
953 let old_entry = hovered_entry;
954 let old_drive = hovered_drive;
955
956 hovered_quick_access = None;
958 hovered_entry = None;
959 hovered_drive = None;
960
961 if mouse_x >= sidebar_x
962 && mouse_x < sidebar_x + sidebar_width as i32
963 && mouse_y >= sidebar_y
964 {
965 let places_items_start_y =
966 sidebar_y + (8.0 * scale) as i32 + section_header_height as i32;
967 let rel_y = mouse_y - places_items_start_y;
968 if rel_y >= 0 {
969 let idx = (rel_y as f32 / item_height_scaled as f32) as usize;
970 if idx < quick_access.len() {
971 hovered_quick_access = Some(idx);
972 }
973 }
974
975 if !mounted_drives.is_empty() {
976 let drives_section_y = places_items_start_y
977 + (quick_access.len() as i32 * item_height_scaled as i32)
978 + gap_between_sections as i32;
979 let drives_items_start_y =
980 drives_section_y + section_header_height as i32;
981 let rel_y = mouse_y - drives_items_start_y;
982 if rel_y >= 0 {
983 let idx = (rel_y as f32 / item_height_scaled as f32) as usize;
984 if idx < mounted_drives.len() {
985 hovered_drive = Some(idx);
986 }
987 }
988 }
989 }
990
991 let scrollbar_width = if scrollbar_hovered {
993 12.0 * scale
994 } else {
995 8.0 * scale
996 };
997 let scrollbar_x = main_x + main_w as i32 - scrollbar_width as i32;
998
999 scrollbar_hovered = mouse_x >= scrollbar_x
1001 && mouse_x < main_x + main_w as i32
1002 && mouse_y >= list_y
1003 && mouse_y < list_y + list_h as i32
1004 && !filtered_entries.is_empty();
1005
1006 if mouse_x >= main_x
1007 && mouse_x < scrollbar_x
1008 && mouse_y >= list_y
1009 && mouse_y < list_y + list_h as i32
1010 {
1011 let rel_y = (mouse_y - list_y) as usize;
1012 let idx = scroll_offset + rel_y / item_height as usize;
1013 if idx < filtered_entries.len() {
1014 hovered_entry = Some(filtered_entries[idx]);
1015 }
1016 }
1017
1018 if old_qa != hovered_quick_access
1019 || old_entry != hovered_entry
1020 || old_drive != hovered_drive
1021 {
1022 needs_redraw = true;
1023 }
1024 }
1025 }
1026 WindowEvent::ButtonPress(MouseButton::Left, _) => {
1027 window_dragging = true;
1028 let mut clicking_scrollbar = false;
1029
1030 if !filtered_entries.is_empty() {
1032 let scrollbar_width = if scrollbar_hovered {
1033 12.0 * scale
1034 } else {
1035 8.0 * scale
1036 };
1037 let scrollbar_x = main_x + main_w as i32 - scrollbar_width as i32;
1038
1039 if mouse_x >= scrollbar_x
1041 && mouse_x < main_x + main_w as i32
1042 && mouse_y >= list_y
1043 && mouse_y < list_y + list_h as i32
1044 {
1045 clicking_scrollbar = true;
1046
1047 let scrollbar_y = list_y;
1049 let visible_items = (list_h / item_height) as usize;
1050 let total_items = filtered_entries.len();
1051
1052 if visible_items < total_items {
1053 let scrollbar_h_f32 = list_h as f32 - 8.0 * scale;
1054 let thumb_h_f32 = (visible_items as f32 / total_items as f32
1055 * scrollbar_h_f32)
1056 .max(20.0 * scale);
1057 let thumb_h = thumb_h_f32 as i32;
1058
1059 let max_scroll = total_items - visible_items;
1060 let max_thumb_y = scrollbar_h_f32 as i32 - thumb_h;
1061 let thumb_y = if max_thumb_y > 0 {
1062 ((scroll_offset as f32 / max_scroll as f32)
1063 * max_thumb_y as f32)
1064 as i32
1065 } else {
1066 0
1067 };
1068
1069 let rel_y = mouse_y - scrollbar_y;
1070 if mouse_x >= scrollbar_x
1071 && mouse_x < scrollbar_x + scrollbar_width as i32
1072 && rel_y >= scrollbar_y as i32 + thumb_y
1073 && rel_y < scrollbar_y as i32 + thumb_y + thumb_h
1074 {
1075 thumb_drag = true;
1076 thumb_drag_offset = Some(mouse_y - (scrollbar_y + thumb_y));
1077 }
1078 }
1079 }
1080 }
1081
1082 let nav_y = padding as i32 + (4.0 * scale) as i32;
1084 let btn_size = (28.0 * scale) as i32;
1085 if mouse_y >= nav_y && mouse_y < nav_y + btn_size {
1086 if mouse_x >= padding as i32 && mouse_x < padding as i32 + btn_size {
1088 if history_index > 0 {
1089 history_index -= 1;
1090 navigate_to_directory(
1091 history[history_index].clone(),
1092 &mut current_dir,
1093 &mut history,
1094 &mut history_index,
1095 &mut all_entries,
1096 self.directory,
1097 show_hidden,
1098 &search_text,
1099 &mut filtered_entries,
1100 &mut selected_indices,
1101 &mut scroll_offset,
1102 &self.filters,
1103 );
1104 needs_redraw = true;
1105 }
1106 }
1107 else if mouse_x >= (padding as f32 + 32.0 * scale) as i32
1109 && mouse_x < (padding as f32 + 60.0 * scale) as i32
1110 {
1111 if history_index + 1 < history.len() {
1112 history_index += 1;
1113 navigate_to_directory(
1114 history[history_index].clone(),
1115 &mut current_dir,
1116 &mut history,
1117 &mut history_index,
1118 &mut all_entries,
1119 self.directory,
1120 show_hidden,
1121 &search_text,
1122 &mut filtered_entries,
1123 &mut selected_indices,
1124 &mut scroll_offset,
1125 &self.filters,
1126 );
1127 needs_redraw = true;
1128 }
1129 }
1130 else if mouse_x >= (padding as f32 + 68.0 * scale) as i32
1132 && mouse_x < (padding as f32 + 96.0 * scale) as i32
1133 {
1134 if let Some(parent) = current_dir.parent() {
1135 navigate_to_directory(
1136 parent.to_path_buf(),
1137 &mut current_dir,
1138 &mut history,
1139 &mut history_index,
1140 &mut all_entries,
1141 self.directory,
1142 show_hidden,
1143 &search_text,
1144 &mut filtered_entries,
1145 &mut selected_indices,
1146 &mut scroll_offset,
1147 &self.filters,
1148 );
1149 needs_redraw = true;
1150 }
1151 }
1152 else if mouse_x >= (padding as f32 + 104.0 * scale) as i32
1154 && mouse_x < (padding as f32 + 132.0 * scale) as i32
1155 {
1156 if let Some(home) = dirs::home_dir() {
1157 navigate_to_directory(
1158 home,
1159 &mut current_dir,
1160 &mut history,
1161 &mut history_index,
1162 &mut all_entries,
1163 self.directory,
1164 show_hidden,
1165 &search_text,
1166 &mut filtered_entries,
1167 &mut selected_indices,
1168 &mut scroll_offset,
1169 &self.filters,
1170 );
1171 needs_redraw = true;
1172 }
1173 }
1174 else if mouse_x >= (padding as f32 + 150.0 * scale) as i32
1176 && mouse_x < (padding as f32 + 178.0 * scale) as i32
1177 {
1178 show_hidden = !show_hidden;
1179 load_directory(
1180 ¤t_dir,
1181 &mut all_entries,
1182 self.directory,
1183 show_hidden,
1184 );
1185 update_filtered(
1186 &all_entries,
1187 &search_text,
1188 &mut filtered_entries,
1189 &self.filters,
1190 );
1191 selected_indices.clear();
1192 scroll_offset = 0;
1193 needs_redraw = true;
1194 }
1195 }
1196
1197 if !clicking_scrollbar {
1199 if let Some(idx) = hovered_quick_access {
1200 let qa = &quick_access[idx];
1201 navigate_to_directory(
1202 qa.path.clone(),
1203 &mut current_dir,
1204 &mut history,
1205 &mut history_index,
1206 &mut all_entries,
1207 self.directory,
1208 show_hidden,
1209 &search_text,
1210 &mut filtered_entries,
1211 &mut selected_indices,
1212 &mut scroll_offset,
1213 &self.filters,
1214 );
1215 needs_redraw = true;
1216 }
1217
1218 if let Some(idx) = hovered_drive {
1220 let drive = &mounted_drives[idx];
1221 navigate_to_directory(
1222 drive.mount_point.clone(),
1223 &mut current_dir,
1224 &mut history,
1225 &mut history_index,
1226 &mut all_entries,
1227 self.directory,
1228 show_hidden,
1229 &search_text,
1230 &mut filtered_entries,
1231 &mut selected_indices,
1232 &mut scroll_offset,
1233 &self.filters,
1234 );
1235 needs_redraw = true;
1236 }
1237
1238 if let Some(ei) = hovered_entry {
1240 if self.multiple {
1241 if selected_indices.contains(&ei) {
1243 selected_indices.remove(&ei);
1244 } else {
1245 selected_indices.insert(ei);
1246 }
1247 } else {
1248 if selected_indices.contains(&ei) {
1250 let entry = &all_entries[ei];
1251 if entry.is_dir {
1252 navigate_to(
1253 entry.path.clone(),
1254 &mut current_dir,
1255 &mut history,
1256 &mut history_index,
1257 );
1258 load_directory(
1259 ¤t_dir,
1260 &mut all_entries,
1261 self.directory,
1262 show_hidden,
1263 );
1264 update_filtered(
1265 &all_entries,
1266 &search_text,
1267 &mut filtered_entries,
1268 &self.filters,
1269 );
1270 selected_indices.clear();
1271 scroll_offset = 0;
1272 } else if save_mode {
1273 if let Some(ref mut fi) = filename_input {
1275 fi.set_text(&entry.name);
1276 completion_matches.clear();
1277 completion_popup_index = 0;
1278 }
1279 } else if !self.directory {
1280 return Ok(FileSelectResult::Selected(entry.path.clone()));
1281 }
1282 } else {
1283 selected_indices.clear();
1284 selected_indices.insert(ei);
1285 if save_mode {
1287 let entry = &all_entries[ei];
1288 if !entry.is_dir {
1289 if let Some(ref mut fi) = filename_input {
1290 fi.set_text(&entry.name);
1291 completion_matches.clear();
1292 completion_popup_index = 0;
1293 }
1294 }
1295 }
1296 }
1297 }
1298 needs_redraw = true;
1299 }
1300 }
1301
1302 let in_search = mouse_x >= search_x
1304 && mouse_x < search_x + search_width as i32
1305 && mouse_y >= search_y
1306 && mouse_y < search_y + (32.0 * scale) as i32;
1307
1308 if save_mode {
1309 if in_search {
1311 search_input.set_focus(true);
1312 if let Some(ref mut fi) = filename_input {
1313 fi.set_focus(false);
1314 }
1315 completion_matches.clear();
1317 completion_popup_index = 0;
1318 } else {
1319 search_input.set_focus(false);
1320 if let Some(ref mut fi) = filename_input {
1321 fi.set_focus(true);
1322 }
1323 search_matches.clear();
1325 search_popup_index = 0;
1326 search_input.set_completion(None);
1327 }
1328 } else {
1329 if !in_search && !search_matches.is_empty() {
1330 search_matches.clear();
1331 search_popup_index = 0;
1332 search_input.set_completion(None);
1333 }
1334 search_input.set_focus(in_search);
1335 }
1336 }
1337 WindowEvent::ButtonRelease(_, _) => {
1338 window_dragging = false;
1339 thumb_drag = false;
1340 thumb_drag_offset = None;
1341 }
1342 WindowEvent::Scroll(direction) => {
1343 match direction {
1344 crate::backend::ScrollDirection::Up => {
1345 if scroll_offset > 0 {
1346 scroll_offset = scroll_offset.saturating_sub(3);
1347 needs_redraw = true;
1348 }
1349 }
1350 crate::backend::ScrollDirection::Down => {
1351 if scroll_offset + visible_items < filtered_entries.len() {
1352 scroll_offset = (scroll_offset + 3)
1353 .min(filtered_entries.len().saturating_sub(visible_items));
1354 needs_redraw = true;
1355 }
1356 }
1357 _ => {}
1358 }
1359 }
1360 WindowEvent::KeyPress(key_event) => {
1361 let filename_has_focus =
1362 filename_input.as_ref().map_or(false, |fi| fi.has_focus());
1363
1364 if key_event.keysym == KEY_ESCAPE {
1365 if search_input.has_focus() {
1366 if !search_matches.is_empty() {
1367 search_matches.clear();
1369 search_popup_index = 0;
1370 search_input.set_completion(None);
1371 } else {
1372 search_input.set_focus(false);
1373 if let Some(ref mut fi) = filename_input {
1375 fi.set_focus(true);
1376 }
1377 }
1378 needs_redraw = true;
1379 } else if filename_has_focus {
1380 if !completion_matches.is_empty() {
1381 completion_matches.clear();
1383 completion_popup_index = 0;
1384 if let Some(ref mut fi) = filename_input {
1385 fi.set_completion(None);
1386 }
1387 } else {
1388 if let Some(ref mut fi) = filename_input {
1389 fi.set_focus(false);
1390 }
1391 }
1392 needs_redraw = true;
1393 } else {
1394 return Ok(FileSelectResult::Cancelled);
1395 }
1396 }
1397 if !search_input.has_focus() && !filename_has_focus {
1398 match key_event.keysym {
1399 KEY_UP => {
1400 if !filtered_entries.is_empty() {
1401 let new_index =
1402 if let Some(&sel) = selected_indices.iter().next() {
1403 if let Some(pos) =
1404 filtered_entries.iter().position(|&e| e == sel)
1405 {
1406 if pos > 0 {
1407 Some(filtered_entries[pos - 1])
1408 } else {
1409 Some(sel)
1410 }
1411 } else {
1412 Some(filtered_entries[0])
1413 }
1414 } else {
1415 Some(filtered_entries[0])
1416 };
1417
1418 if let Some(idx) = new_index {
1419 if self.multiple {
1420 if selected_indices.contains(&idx) {
1421 selected_indices.remove(&idx);
1422 } else {
1423 selected_indices.insert(idx);
1424 }
1425 } else {
1426 selected_indices.clear();
1427 selected_indices.insert(idx);
1428 }
1429
1430 if let Some(pos) =
1431 filtered_entries.iter().position(|&e| e == idx)
1432 {
1433 if pos < scroll_offset {
1434 scroll_offset = pos;
1435 }
1436 }
1437 needs_redraw = true;
1438 }
1439 }
1440 }
1441 KEY_DOWN => {
1442 if !filtered_entries.is_empty() {
1443 let new_index =
1444 if let Some(&sel) = selected_indices.iter().next() {
1445 if let Some(pos) =
1446 filtered_entries.iter().position(|&e| e == sel)
1447 {
1448 if pos + 1 < filtered_entries.len() {
1449 Some(filtered_entries[pos + 1])
1450 } else {
1451 Some(sel)
1452 }
1453 } else {
1454 Some(filtered_entries[0])
1455 }
1456 } else {
1457 Some(filtered_entries[0])
1458 };
1459
1460 if let Some(idx) = new_index {
1461 if self.multiple {
1462 if selected_indices.contains(&idx) {
1463 selected_indices.remove(&idx);
1464 } else {
1465 selected_indices.insert(idx);
1466 }
1467 } else {
1468 selected_indices.clear();
1469 selected_indices.insert(idx);
1470 }
1471
1472 if let Some(pos) =
1473 filtered_entries.iter().position(|&e| e == idx)
1474 {
1475 if pos + 1 >= scroll_offset + visible_items {
1476 scroll_offset = pos + 1 - visible_items + 1;
1477 }
1478 }
1479 needs_redraw = true;
1480 }
1481 }
1482 }
1483 KEY_RETURN => {
1484 if self.multiple && !selected_indices.is_empty() {
1485 let selected_files: Vec<PathBuf> = selected_indices
1486 .iter()
1487 .filter(|&ei| !all_entries[*ei].is_dir)
1488 .map(|&ei| all_entries[ei].path.clone())
1489 .collect();
1490 if !selected_files.is_empty() {
1491 return Ok(FileSelectResult::SelectedMultiple(
1492 selected_files,
1493 ));
1494 }
1495 } else if let Some(&sel) = selected_indices.iter().next() {
1496 let entry = &all_entries[sel];
1497 if entry.is_dir {
1498 navigate_to_directory(
1499 entry.path.clone(),
1500 &mut current_dir,
1501 &mut history,
1502 &mut history_index,
1503 &mut all_entries,
1504 self.directory,
1505 show_hidden,
1506 &search_text,
1507 &mut filtered_entries,
1508 &mut selected_indices,
1509 &mut scroll_offset,
1510 &self.filters,
1511 );
1512 needs_redraw = true;
1513 } else if !self.directory {
1514 return Ok(FileSelectResult::Selected(entry.path.clone()));
1515 }
1516 }
1517 }
1518 KEY_BACKSPACE => {
1519 if let Some(parent) = current_dir.parent() {
1520 navigate_to_directory(
1521 parent.to_path_buf(),
1522 &mut current_dir,
1523 &mut history,
1524 &mut history_index,
1525 &mut all_entries,
1526 self.directory,
1527 show_hidden,
1528 &search_text,
1529 &mut filtered_entries,
1530 &mut selected_indices,
1531 &mut scroll_offset,
1532 &self.filters,
1533 );
1534 needs_redraw = true;
1535 }
1536 }
1537 _ => {}
1538 }
1539 }
1540 }
1541 _ => {}
1542 }
1543
1544 {
1546 let mut search_popup_handled = false;
1547
1548 if !search_matches.is_empty() && search_input.has_focus() {
1550 if let WindowEvent::KeyPress(key_event) = &event {
1551 const POPUP_KEY_UP: u32 = 0xff52;
1552 const POPUP_KEY_DOWN: u32 = 0xff54;
1553 match key_event.keysym {
1554 POPUP_KEY_UP => {
1555 if search_popup_index > 0 {
1556 search_popup_index -= 1;
1557 } else {
1558 search_popup_index = search_matches.len() - 1;
1559 }
1560 let text = search_input.text().to_string();
1561 let name = &search_matches[search_popup_index];
1562 if name.to_lowercase().starts_with(&text.to_lowercase()) {
1563 let pc = text.chars().count();
1564 search_input
1565 .set_completion(Some(name.chars().skip(pc).collect()));
1566 } else {
1567 search_input.set_completion(None);
1568 }
1569 needs_redraw = true;
1570 search_popup_handled = true;
1571 }
1572 POPUP_KEY_DOWN => {
1573 search_popup_index =
1574 (search_popup_index + 1) % search_matches.len();
1575 let text = search_input.text().to_string();
1576 let name = &search_matches[search_popup_index];
1577 if name.to_lowercase().starts_with(&text.to_lowercase()) {
1578 let pc = text.chars().count();
1579 search_input
1580 .set_completion(Some(name.chars().skip(pc).collect()));
1581 } else {
1582 search_input.set_completion(None);
1583 }
1584 needs_redraw = true;
1585 search_popup_handled = true;
1586 }
1587 _ => {}
1588 }
1589 }
1590 }
1591
1592 if !search_matches.is_empty() && search_input.has_focus() {
1594 if let WindowEvent::ButtonPress(MouseButton::Left, _) = &event {
1595 let popup_x = search_x;
1596 let popup_y = search_y + 32;
1597 let popup_w = search_width as i32;
1598 let visible = search_matches.len().min(MAX_POPUP_ITEMS) as i32;
1599 let popup_h = visible * POPUP_ITEM_HEIGHT + 2;
1600 if mouse_x >= popup_x
1601 && mouse_x < popup_x + popup_w
1602 && mouse_y >= popup_y
1603 && mouse_y < popup_y + popup_h
1604 {
1605 let idx = ((mouse_y - popup_y - 1) / POPUP_ITEM_HEIGHT) as usize;
1606 if idx < search_matches.len().min(MAX_POPUP_ITEMS) {
1607 search_input.set_text(&search_matches[idx]);
1608 search_matches.clear();
1609 search_popup_index = 0;
1610 let new_search = search_input.text().to_lowercase();
1611 if new_search != search_text {
1612 search_text = new_search;
1613 update_filtered(
1614 &all_entries,
1615 &search_text,
1616 &mut filtered_entries,
1617 &self.filters,
1618 );
1619 selected_indices.clear();
1620 scroll_offset = 0;
1621 }
1622 needs_redraw = true;
1623 search_popup_handled = true;
1624 }
1625 }
1626 }
1627 }
1628
1629 if !search_popup_handled {
1630 let search_text_before = search_input.text().to_string();
1631 if search_input.process_event(&event) {
1632 needs_redraw = true;
1633 }
1634 let new_search = search_input.text().to_lowercase();
1635 if new_search != search_text {
1636 search_text = new_search;
1637 update_filtered(
1638 &all_entries,
1639 &search_text,
1640 &mut filtered_entries,
1641 &self.filters,
1642 );
1643 selected_indices.clear();
1644 scroll_offset = 0;
1645 }
1646 if search_input.text() != search_text_before {
1648 search_popup_index = 0;
1649 let text = search_input.text().to_string();
1650 search_matches = find_all_completions(
1651 &all_entries,
1652 &text,
1653 MAX_POPUP_ITEMS,
1654 false,
1655 false,
1656 );
1657 if !search_matches.is_empty()
1659 && search_matches[0]
1660 .to_lowercase()
1661 .starts_with(&text.to_lowercase())
1662 {
1663 let pc = text.chars().count();
1664 search_input
1665 .set_completion(Some(search_matches[0].chars().skip(pc).collect()));
1666 } else {
1667 search_input.set_completion(None);
1668 }
1669 }
1670 if search_input.was_tab_pressed() {
1672 let text = search_input.text().to_string();
1673 if !text.is_empty() {
1674 search_matches = find_all_completions(
1675 &all_entries,
1676 &text,
1677 MAX_POPUP_ITEMS,
1678 false,
1679 false,
1680 );
1681 search_popup_index = 0;
1682 if !search_matches.is_empty()
1683 && search_matches[0]
1684 .to_lowercase()
1685 .starts_with(&text.to_lowercase())
1686 {
1687 let pc = text.chars().count();
1688 search_input.set_completion(Some(
1689 search_matches[0].chars().skip(pc).collect(),
1690 ));
1691 } else {
1692 search_input.set_completion(None);
1693 }
1694 }
1695 let new_search = search_input.text().to_lowercase();
1697 if new_search != search_text {
1698 search_text = new_search;
1699 update_filtered(
1700 &all_entries,
1701 &search_text,
1702 &mut filtered_entries,
1703 &self.filters,
1704 );
1705 selected_indices.clear();
1706 scroll_offset = 0;
1707 }
1708 needs_redraw = true;
1709 }
1710 if search_input.was_submitted() && !search_matches.is_empty() {
1712 search_input.set_text(&search_matches[search_popup_index]);
1713 search_matches.clear();
1714 search_popup_index = 0;
1715 let new_search = search_input.text().to_lowercase();
1716 if new_search != search_text {
1717 search_text = new_search;
1718 update_filtered(
1719 &all_entries,
1720 &search_text,
1721 &mut filtered_entries,
1722 &self.filters,
1723 );
1724 selected_indices.clear();
1725 scroll_offset = 0;
1726 }
1727 needs_redraw = true;
1728 }
1729 }
1730 }
1731
1732 if let Some(ref mut fi) = filename_input {
1734 let mut popup_handled = false;
1735
1736 if !completion_matches.is_empty() {
1738 if let WindowEvent::KeyPress(key_event) = &event {
1739 const POPUP_KEY_UP: u32 = 0xff52;
1740 const POPUP_KEY_DOWN: u32 = 0xff54;
1741 match key_event.keysym {
1742 POPUP_KEY_UP => {
1743 if completion_popup_index > 0 {
1744 completion_popup_index -= 1;
1745 } else {
1746 completion_popup_index = completion_matches.len() - 1;
1747 }
1748 let prefix = fi.text().to_string();
1749 let name = &completion_matches[completion_popup_index];
1750 let pc = prefix.chars().count();
1751 fi.set_completion(Some(name.chars().skip(pc).collect()));
1752 needs_redraw = true;
1753 popup_handled = true;
1754 }
1755 POPUP_KEY_DOWN => {
1756 completion_popup_index =
1757 (completion_popup_index + 1) % completion_matches.len();
1758 let prefix = fi.text().to_string();
1759 let name = &completion_matches[completion_popup_index];
1760 let pc = prefix.chars().count();
1761 fi.set_completion(Some(name.chars().skip(pc).collect()));
1762 needs_redraw = true;
1763 popup_handled = true;
1764 }
1765 _ => {}
1766 }
1767 }
1768 }
1769
1770 if !completion_matches.is_empty() {
1772 if let WindowEvent::ButtonPress(MouseButton::Left, _) = &event {
1773 let popup_x = main_x;
1774 let popup_w = main_w as i32;
1775 let visible = completion_matches.len().min(MAX_POPUP_ITEMS) as i32;
1776 let popup_h = visible * POPUP_ITEM_HEIGHT + 2;
1777 let popup_y = filename_y + filename_label_h - popup_h;
1778 if mouse_x >= popup_x
1779 && mouse_x < popup_x + popup_w
1780 && mouse_y >= popup_y
1781 && mouse_y < popup_y + popup_h
1782 {
1783 let idx = ((mouse_y - popup_y - 1) / POPUP_ITEM_HEIGHT) as usize;
1784 if idx < completion_matches.len().min(MAX_POPUP_ITEMS) {
1785 fi.set_text(&completion_matches[idx]);
1786 completion_matches.clear();
1787 completion_popup_index = 0;
1788 needs_redraw = true;
1789 popup_handled = true;
1790 }
1791 }
1792 }
1793 }
1794
1795 if !popup_handled {
1796 let text_before = fi.text().to_string();
1797 if fi.process_event(&event) {
1798 needs_redraw = true;
1799 }
1800 if fi.text() != text_before {
1802 completion_popup_index = 0;
1803 let prefix = fi.text().to_string();
1804 completion_matches = find_all_completions(
1805 &all_entries,
1806 &prefix,
1807 MAX_POPUP_ITEMS,
1808 true,
1809 true,
1810 );
1811 if !completion_matches.is_empty() {
1812 let pc = prefix.chars().count();
1813 fi.set_completion(Some(
1814 completion_matches[0].chars().skip(pc).collect(),
1815 ));
1816 } else {
1817 fi.set_completion(None);
1818 }
1819 }
1820 if fi.was_tab_pressed() {
1822 let prefix = fi.text().to_string();
1823 if !prefix.is_empty() {
1824 completion_matches = find_all_completions(
1826 &all_entries,
1827 &prefix,
1828 MAX_POPUP_ITEMS,
1829 true,
1830 true,
1831 );
1832 completion_popup_index = 0;
1833 if !completion_matches.is_empty() {
1834 let pc = prefix.chars().count();
1835 fi.set_completion(Some(
1836 completion_matches[0].chars().skip(pc).collect(),
1837 ));
1838 } else {
1839 fi.set_completion(None);
1840 }
1841 }
1842 needs_redraw = true;
1843 }
1844 if fi.was_submitted() {
1845 if !completion_matches.is_empty() {
1847 fi.set_text(&completion_matches[completion_popup_index]);
1848 completion_matches.clear();
1849 completion_popup_index = 0;
1850 needs_redraw = true;
1851 } else {
1852 let name = fi.text().trim().to_string();
1853 if !name.is_empty() {
1854 return Ok(FileSelectResult::Selected(current_dir.join(&name)));
1855 }
1856 }
1857 }
1858 }
1859 }
1860
1861 needs_redraw |= ok_button.process_event(&event);
1863 needs_redraw |= cancel_button.process_event(&event);
1864
1865 if ok_button.was_clicked() {
1866 if save_mode {
1868 if let Some(ref fi) = filename_input {
1869 let name = fi.text().trim().to_string();
1870 if !name.is_empty() {
1871 return Ok(FileSelectResult::Selected(current_dir.join(&name)));
1872 }
1873 }
1874 } else if self.multiple && !selected_indices.is_empty() {
1875 let selected_files: Vec<PathBuf> = selected_indices
1876 .iter()
1877 .filter(|&ei| !all_entries[*ei].is_dir)
1878 .map(|&ei| all_entries[ei].path.clone())
1879 .collect();
1880 if !selected_files.is_empty() {
1881 return Ok(FileSelectResult::SelectedMultiple(selected_files));
1882 }
1883 } else if let Some(&sel) = selected_indices.iter().next() {
1884 let entry = &all_entries[sel];
1885 return Ok(FileSelectResult::Selected(entry.path.clone()));
1886 } else if self.directory {
1887 return Ok(FileSelectResult::Selected(current_dir.clone()));
1888 }
1889 }
1890
1891 if cancel_button.was_clicked() {
1892 return Ok(FileSelectResult::Cancelled);
1893 }
1894
1895 while let Some(ev) = window.poll_for_event()? {
1897 match &ev {
1898 WindowEvent::CloseRequested => {
1899 return Ok(FileSelectResult::Closed);
1900 }
1901 WindowEvent::CursorEnter(pos) | WindowEvent::CursorMove(pos) => {
1902 mouse_x = pos.x as i32;
1903 mouse_y = pos.y as i32;
1904 }
1905 WindowEvent::ButtonPress(button, _modifiers)
1906 if *button == MouseButton::Left =>
1907 {
1908 if !filtered_entries.is_empty() {
1909 let scrollbar_x = main_x + main_w as i32 - (8.0 * scale) as i32;
1910 let scrollbar_y = list_y;
1911
1912 if mouse_x >= main_x
1913 && mouse_x < main_x + main_w as i32
1914 && mouse_y >= list_y
1915 && mouse_y < list_y + list_h as i32
1916 {
1917 let visible_items = (list_h / item_height) as usize;
1918 let total_items = filtered_entries.len();
1919
1920 if visible_items < total_items {
1921 let scrollbar_h_f32 = list_h as f32 - 8.0 * scale;
1922 let thumb_h_f32 = (visible_items as f32 / total_items as f32
1923 * scrollbar_h_f32)
1924 .max(20.0 * scale);
1925 let thumb_h = thumb_h_f32 as i32;
1926
1927 let max_scroll = total_items - visible_items;
1928 let max_thumb_y = scrollbar_h_f32 as i32 - thumb_h;
1929 let thumb_y = if max_thumb_y > 0 {
1930 ((scroll_offset as f32 / max_scroll as f32)
1931 * max_thumb_y as f32)
1932 as i32
1933 } else {
1934 0
1935 };
1936
1937 let rel_y = mouse_y - scrollbar_y;
1938 if mouse_x >= scrollbar_x
1939 && mouse_x < scrollbar_x + (6.0 * scale) as i32
1940 && rel_y >= thumb_y
1941 && rel_y < thumb_y + thumb_h
1942 {
1943 thumb_drag = true;
1944 thumb_drag_offset = Some(mouse_y - (scrollbar_y + thumb_y));
1945 }
1946 }
1947 }
1948 }
1949 }
1950 WindowEvent::ButtonRelease(_, _) => {
1951 thumb_drag = false;
1952 thumb_drag_offset = None;
1953 }
1954 _ => {}
1955 }
1956
1957 needs_redraw |= ok_button.process_event(&ev);
1958 needs_redraw |= cancel_button.process_event(&ev);
1959 }
1960
1961 if needs_redraw {
1962 draw(
1963 &mut canvas,
1964 colors,
1965 &font,
1966 ¤t_dir,
1967 &quick_access,
1968 &all_entries,
1969 &filtered_entries,
1970 &selected_indices,
1971 scroll_offset,
1972 hovered_quick_access,
1973 hovered_entry,
1974 show_hidden,
1975 &search_input,
1976 &ok_button,
1977 &cancel_button,
1978 &history,
1979 history_index,
1980 &mounted_drives,
1981 hovered_drive,
1982 scale,
1983 scrollbar_hovered,
1984 filename_input.as_ref(),
1985 );
1986 if save_mode && !completion_matches.is_empty() {
1987 let visible = completion_matches.len().min(MAX_POPUP_ITEMS);
1988 let popup_h = (visible as i32) * POPUP_ITEM_HEIGHT + 2;
1989 draw_completion_popup(
1990 &mut canvas,
1991 &font,
1992 colors,
1993 &completion_matches,
1994 completion_popup_index,
1995 main_x,
1996 filename_y + filename_label_h - popup_h,
1997 main_w,
1998 );
1999 }
2000 if !search_matches.is_empty() && search_input.has_focus() {
2001 draw_completion_popup(
2002 &mut canvas,
2003 &font,
2004 colors,
2005 &search_matches,
2006 search_popup_index,
2007 search_x,
2008 search_y + 32,
2009 search_width,
2010 );
2011 }
2012 window.set_contents(&canvas)?;
2013 }
2014 }
2015 }
2016}
2017
2018impl Default for FileSelectBuilder {
2019 fn default() -> Self {
2020 Self::new()
2021 }
2022}
2023
2024struct DirEntry {
2027 name: String,
2028 path: PathBuf,
2029 is_dir: bool,
2030 size: u64,
2031 modified: Option<SystemTime>,
2032}
2033
2034fn build_quick_access() -> Vec<QuickAccess> {
2035 let mut items = Vec::new();
2036
2037 if let Some(home) = dirs::home_dir() {
2038 items.push(QuickAccess {
2039 name: "Home",
2040 path: home,
2041 icon: QuickAccessIcon::Home,
2042 });
2043 }
2044 if let Some(desktop) = dirs::desktop_dir() {
2045 items.push(QuickAccess {
2046 name: "Desktop",
2047 path: desktop,
2048 icon: QuickAccessIcon::Desktop,
2049 });
2050 }
2051 if let Some(docs) = dirs::document_dir() {
2052 items.push(QuickAccess {
2053 name: "Documents",
2054 path: docs,
2055 icon: QuickAccessIcon::Documents,
2056 });
2057 }
2058 if let Some(dl) = dirs::download_dir() {
2059 items.push(QuickAccess {
2060 name: "Downloads",
2061 path: dl,
2062 icon: QuickAccessIcon::Downloads,
2063 });
2064 }
2065 if let Some(pics) = dirs::picture_dir() {
2066 items.push(QuickAccess {
2067 name: "Pictures",
2068 path: pics,
2069 icon: QuickAccessIcon::Pictures,
2070 });
2071 }
2072 if let Some(music) = dirs::audio_dir() {
2073 items.push(QuickAccess {
2074 name: "Music",
2075 path: music,
2076 icon: QuickAccessIcon::Music,
2077 });
2078 }
2079 if let Some(videos) = dirs::video_dir() {
2080 items.push(QuickAccess {
2081 name: "Videos",
2082 path: videos,
2083 icon: QuickAccessIcon::Videos,
2084 });
2085 }
2086
2087 items
2088}
2089
2090fn get_mounted_drives() -> Vec<MountPoint> {
2091 let mut drives = Vec::new();
2092
2093 if let Ok(content) = std::fs::read_to_string("/run/mount/utab") {
2095 for line in content.lines() {
2096 let mut device: Option<String> = None;
2097 let mut mount_point: Option<PathBuf> = None;
2098
2099 for pair in line.split_whitespace() {
2101 let mut kv = pair.split('=');
2102 if let Some(key) = kv.next() {
2103 let value = kv.next();
2104 match key {
2105 "SRC" => {
2106 device = value.map(|v| v.to_string());
2107 }
2108 "TARGET" => {
2109 mount_point = value.map(PathBuf::from);
2110 }
2111 _ => {}
2112 }
2113 }
2114 }
2115
2116 if let (Some(dev), Some(mp)) = (device, mount_point) {
2118 if mp.as_os_str() == "/" {
2120 continue;
2121 }
2122
2123 let label = get_volume_label(&dev);
2124
2125 drives.push(MountPoint {
2126 device: dev,
2127 mount_point: mp,
2128 label,
2129 });
2130 }
2131 }
2132 }
2133
2134 drives
2135}
2136
2137fn get_volume_label(device: &str) -> Option<String> {
2138 use std::process::Command;
2139
2140 let output = Command::new("lsblk")
2141 .args(["-o", "LABEL", "-n", device])
2142 .output()
2143 .ok()?;
2144
2145 let label = String::from_utf8_lossy(&output.stdout).trim().to_string();
2146
2147 if label.is_empty() { None } else { Some(label) }
2148}
2149
2150fn get_mount_icon(device: &str) -> MountIcon {
2151 let is_usb = device
2153 .strip_prefix("/dev/")
2154 .map(|_dev| {
2155 std::fs::read_dir("/dev/disk/by-id")
2156 .ok()
2157 .map(|entries| {
2158 entries
2159 .filter_map(|e| e.ok())
2160 .filter(|e| e.file_name().to_string_lossy().starts_with("usb-"))
2161 .any(|e| {
2162 e.path()
2163 .canonicalize()
2164 .ok()
2165 .as_ref()
2166 .and_then(|p| p.to_str())
2167 .map(|p| device.contains(p))
2168 .unwrap_or(false)
2169 })
2170 })
2171 .unwrap_or(false)
2172 })
2173 .unwrap_or(false);
2174
2175 if is_usb {
2176 return MountIcon::UsbDrive;
2177 }
2178
2179 if device.starts_with("/dev/sr") || device.starts_with("/dev/scd") {
2180 return MountIcon::Optical;
2181 }
2182
2183 if device.starts_with("/dev/nvme") || device.starts_with("/dev/mmc") {
2184 return MountIcon::ExternalHdd;
2185 }
2186
2187 MountIcon::Generic
2188}
2189
2190fn load_directory(path: &Path, entries: &mut Vec<DirEntry>, dirs_only: bool, show_hidden: bool) {
2191 entries.clear();
2192
2193 if let Some(parent) = path.parent() {
2194 entries.push(DirEntry {
2195 name: "..".to_string(),
2196 path: parent.to_path_buf(),
2197 is_dir: true,
2198 size: 0,
2199 modified: None,
2200 });
2201 }
2202
2203 let mut dirs: Vec<DirEntry> = Vec::new();
2204 let mut files: Vec<DirEntry> = Vec::new();
2205
2206 if let Ok(read_dir) = fs::read_dir(path) {
2207 for entry in read_dir.flatten() {
2208 let name = entry.file_name().to_string_lossy().to_string();
2209
2210 if !show_hidden && name.starts_with('.') {
2211 continue;
2212 }
2213
2214 let metadata = entry.path().metadata().ok();
2215 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
2216
2217 if dirs_only && !is_dir {
2218 continue;
2219 }
2220
2221 let size = metadata.as_ref().map(Metadata::len).unwrap_or(0);
2222 let modified = metadata.as_ref().and_then(|m| m.modified().ok());
2223
2224 let de = DirEntry {
2225 name,
2226 path: entry.path(),
2227 is_dir,
2228 size,
2229 modified,
2230 };
2231
2232 if is_dir {
2233 dirs.push(de);
2234 } else {
2235 files.push(de);
2236 }
2237 }
2238 }
2239
2240 dirs.sort_by_key(|a| a.name.to_lowercase());
2241 files.sort_by_key(|a| a.name.to_lowercase());
2242
2243 entries.extend(dirs);
2244 entries.extend(files);
2245}
2246
2247fn update_filtered(
2248 all: &[DirEntry],
2249 search: &str,
2250 filtered: &mut Vec<usize>,
2251 filters: &[FileFilter],
2252) {
2253 filtered.clear();
2254 for (i, entry) in all.iter().enumerate() {
2255 let matches_search = search.is_empty() || entry.name.to_lowercase().contains(search);
2256 if entry.is_dir {
2257 if matches_search {
2258 filtered.push(i);
2259 }
2260 } else {
2261 let matches_filter = filters.is_empty() || matches_any_filter(&entry.name, filters);
2262 if matches_filter && matches_search {
2263 filtered.push(i);
2264 }
2265 }
2266 }
2267}
2268
2269fn matches_any_filter(name: &str, filters: &[FileFilter]) -> bool {
2270 let name_lower = name.to_lowercase();
2271 for filter in filters {
2272 for pattern in &filter.patterns {
2273 if matches_pattern(&name_lower, pattern) {
2274 return true;
2275 }
2276 }
2277 }
2278 false
2279}
2280
2281fn matches_pattern(name: &str, pattern: &str) -> bool {
2282 let pattern_lower = pattern.to_lowercase();
2283 if pattern_lower == "*" {
2284 return true;
2285 }
2286
2287 if pattern_lower.starts_with("*") && pattern_lower.ends_with("*") {
2288 let inner = &pattern_lower[1..pattern_lower.len() - 1];
2289 name.contains(inner)
2290 } else if let Some(suffix) = pattern_lower.strip_prefix("*") {
2291 name.ends_with(suffix)
2292 } else if pattern_lower.ends_with("*") {
2293 let prefix = &pattern_lower[..pattern_lower.len() - 1];
2294 name.starts_with(prefix)
2295 } else {
2296 name == pattern_lower
2297 }
2298}
2299
2300fn navigate_to(
2301 dest: PathBuf,
2302 current: &mut PathBuf,
2303 history: &mut Vec<PathBuf>,
2304 index: &mut usize,
2305) {
2306 history.truncate(*index + 1);
2308 history.push(dest.clone());
2309 *index = history.len() - 1;
2310 *current = dest;
2311}
2312
2313#[allow(clippy::too_many_arguments)]
2314fn navigate_to_directory(
2315 dest: PathBuf,
2316 current_dir: &mut PathBuf,
2317 history: &mut Vec<PathBuf>,
2318 history_index: &mut usize,
2319 all_entries: &mut Vec<DirEntry>,
2320 directory_mode: bool,
2321 show_hidden: bool,
2322 search_text: &str,
2323 filtered_entries: &mut Vec<usize>,
2324 selected_indices: &mut HashSet<usize>,
2325 scroll_offset: &mut usize,
2326 filters: &[FileFilter],
2327) {
2328 if dest.exists() {
2329 navigate_to(dest, current_dir, history, history_index);
2330 load_directory(current_dir, all_entries, directory_mode, show_hidden);
2331 update_filtered(all_entries, search_text, filtered_entries, filters);
2332 selected_indices.clear();
2333 *scroll_offset = 0;
2334 }
2335}
2336
2337fn find_all_completions(
2339 entries: &[DirEntry],
2340 text: &str,
2341 max: usize,
2342 files_only: bool,
2343 prefix_only: bool,
2344) -> Vec<String> {
2345 if text.is_empty() {
2346 return Vec::new();
2347 }
2348 let text_lower = text.to_lowercase();
2349 entries
2350 .iter()
2351 .filter(|e| {
2352 (!files_only || !e.is_dir) && {
2353 let name_lower = e.name.to_lowercase();
2354 if prefix_only {
2355 name_lower.starts_with(&text_lower)
2356 } else {
2357 name_lower.contains(&text_lower)
2358 }
2359 }
2360 })
2361 .take(max)
2362 .map(|e| e.name.clone())
2363 .collect()
2364}
2365
2366const POPUP_ITEM_HEIGHT: i32 = 26;
2367const MAX_POPUP_ITEMS: usize = 8;
2368
2369fn draw_completion_popup(
2370 canvas: &mut Canvas,
2371 font: &Font,
2372 colors: &Colors,
2373 matches: &[String],
2374 selected: usize,
2375 x: i32,
2376 y: i32,
2377 width: u32,
2378) {
2379 if matches.is_empty() {
2380 return;
2381 }
2382 let visible = matches.len().min(MAX_POPUP_ITEMS);
2383 let popup_h = (visible as i32) * POPUP_ITEM_HEIGHT + 2; canvas.fill_rounded_rect(
2387 x as f32,
2388 y as f32,
2389 width as f32,
2390 popup_h as f32,
2391 4.0,
2392 colors.input_bg,
2393 );
2394 canvas.stroke_rounded_rect(
2396 x as f32,
2397 y as f32,
2398 width as f32,
2399 popup_h as f32,
2400 4.0,
2401 colors.input_border_focused,
2402 1.0,
2403 );
2404
2405 for (i, name) in matches.iter().take(visible).enumerate() {
2406 let item_y = y + 1 + (i as i32) * POPUP_ITEM_HEIGHT;
2407
2408 if i == selected {
2410 canvas.fill_rect(
2411 (x + 1) as f32,
2412 item_y as f32,
2413 (width - 2) as f32,
2414 POPUP_ITEM_HEIGHT as f32,
2415 colors.input_border_focused,
2416 );
2417 }
2418
2419 let text_color = if i == selected {
2420 colors.input_bg
2421 } else {
2422 colors.text
2423 };
2424 let label = font.render(name).with_color(text_color).finish();
2425 let text_y = item_y + (POPUP_ITEM_HEIGHT - label.height() as i32) / 2;
2426 canvas.draw_canvas(&label, x + 6, text_y);
2427 }
2428}
2429
2430fn darken(color: Rgba, amount: f32) -> Rgba {
2431 rgb(
2432 (color.r as f32 * (1.0 - amount)) as u8,
2433 (color.g as f32 * (1.0 - amount)) as u8,
2434 (color.b as f32 * (1.0 - amount)) as u8,
2435 )
2436}
2437
2438fn truncate_name(name: &str, max_len: usize) -> String {
2439 if name.chars().count() > max_len {
2440 format!("{}...", name.chars().take(max_len - 3).collect::<String>())
2441 } else {
2442 name.to_string()
2443 }
2444}
2445
2446fn format_size(bytes: u64) -> String {
2447 if bytes < 1024 {
2448 format!("{} B", bytes)
2449 } else if bytes < 1024 * 1024 {
2450 format!("{:.1} KB", bytes as f64 / 1024.0)
2451 } else if bytes < 1024 * 1024 * 1024 {
2452 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
2453 } else {
2454 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
2455 }
2456}
2457
2458fn format_date(time: Option<SystemTime>) -> String {
2459 match time {
2460 Some(t) => {
2461 let duration = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
2462 let secs = duration.as_secs();
2463 let now = SystemTime::now()
2465 .duration_since(SystemTime::UNIX_EPOCH)
2466 .unwrap_or_default()
2467 .as_secs();
2468 let diff = now.saturating_sub(secs);
2469
2470 if diff < 60 {
2471 "Just now".to_string()
2472 } else if diff < 3600 {
2473 format!("{} min ago", diff / 60)
2474 } else if diff < 86400 {
2475 format!("{} hrs ago", diff / 3600)
2476 } else if diff < 86400 * 7 {
2477 format!("{} days ago", diff / 86400)
2478 } else {
2479 let days_since_epoch = secs / 86400;
2481 let years = 1970 + days_since_epoch / 365;
2482 format!("{}", years)
2483 }
2484 }
2485 None => "-".to_string(),
2486 }
2487}
2488
2489#[allow(clippy::too_many_arguments)]
2490fn draw_nav_button(
2491 canvas: &mut Canvas,
2492 x: i32,
2493 y: i32,
2494 label: &str,
2495 enabled: bool,
2496 colors: &Colors,
2497 font: &Font,
2498 scale: f32,
2499) {
2500 let bg = if enabled {
2501 colors.button
2502 } else {
2503 darken(colors.button, 0.1)
2504 };
2505 let size = 28.0 * scale;
2506 canvas.fill_rounded_rect(x as f32, y as f32, size, size, 4.0 * scale, bg);
2507
2508 let text_color = if enabled {
2509 colors.button_text
2510 } else {
2511 rgb(100, 100, 100)
2512 };
2513 let tc = font.render(label).with_color(text_color).finish();
2514 canvas.draw_canvas(&tc, x + (10.0 * scale) as i32, y + (6.0 * scale) as i32);
2515}
2516
2517#[allow(clippy::too_many_arguments)]
2518fn draw_toggle(
2519 canvas: &mut Canvas,
2520 x: i32,
2521 y: i32,
2522 label: &str,
2523 active: bool,
2524 colors: &Colors,
2525 font: &Font,
2526 scale: f32,
2527) {
2528 let bg = if active {
2529 colors.input_border_focused
2530 } else {
2531 colors.button
2532 };
2533 let size = 28.0 * scale;
2534 canvas.fill_rounded_rect(x as f32, y as f32, size, size, 4.0 * scale, bg);
2535
2536 let text_color = if active {
2537 rgb(255, 255, 255)
2538 } else {
2539 colors.button_text
2540 };
2541 let tc = font.render(label).with_color(text_color).finish();
2542 canvas.draw_canvas(&tc, x + (6.0 * scale) as i32, y + (6.0 * scale) as i32);
2543}
2544
2545fn draw_breadcrumbs(
2546 canvas: &mut Canvas,
2547 x: i32,
2548 y: i32,
2549 max_w: u32,
2550 path: &Path,
2551 colors: &Colors,
2552 font: &Font,
2553) {
2554 let components: Vec<_> = path.components().collect();
2555
2556 let mut total_width = 0i32;
2558 let ellipsis_width = font
2559 .render("...")
2560 .with_color(rgb(120, 120, 120))
2561 .finish()
2562 .width() as i32
2563 + 8;
2564 let sep_width = font
2565 .render(" / ")
2566 .with_color(rgb(100, 100, 100))
2567 .finish()
2568 .width() as i32;
2569
2570 for (i, comp) in components.iter().enumerate() {
2571 let name = comp.as_os_str().to_string_lossy();
2572 let display = if name.is_empty() { "/" } else { &name };
2573 total_width += font
2574 .render(display)
2575 .with_color(colors.text)
2576 .finish()
2577 .width() as i32;
2578
2579 if i < components.len() - 1 && !matches!(comp, std::path::Component::RootDir) {
2580 total_width += sep_width;
2581 }
2582 }
2583
2584 let num_components = components.len();
2586 let components_to_show = if total_width > max_w as i32 {
2587 (1..=num_components.min(4))
2589 .rev()
2590 .find(|n| {
2591 let start = num_components - n;
2592 let mut test_width = if start > 0 { ellipsis_width } else { 0 };
2593
2594 for (i, comp) in components.iter().enumerate().skip(start) {
2595 let name = comp.as_os_str().to_string_lossy();
2596 let display = if name.is_empty() { "/" } else { &name };
2597 test_width += font
2598 .render(display)
2599 .with_color(colors.text)
2600 .finish()
2601 .width() as i32;
2602
2603 if i < num_components - 1 && !matches!(comp, std::path::Component::RootDir) {
2604 test_width += sep_width;
2605 }
2606 }
2607
2608 test_width <= max_w as i32
2609 })
2610 .unwrap_or(1)
2611 } else {
2612 num_components
2613 };
2614
2615 let start = num_components - components_to_show;
2616
2617 let mut cx = x;
2618 let available_width = max_w as i32;
2619
2620 if start > 0 {
2621 let tc = font.render("...").with_color(rgb(120, 120, 120)).finish();
2622 canvas.draw_canvas(&tc, cx, y);
2623 cx += tc.width() as i32 + 8;
2624 }
2625
2626 for (i, comp) in components.iter().enumerate().skip(start) {
2627 let name = comp.as_os_str().to_string_lossy();
2628 let display = if name.is_empty() { "/" } else { &name };
2629
2630 let is_last = i == num_components - 1;
2631 let is_root = matches!(comp, std::path::Component::RootDir);
2632 let text_color = if is_last {
2633 colors.text
2634 } else {
2635 rgb(120, 120, 120)
2636 };
2637
2638 let tc = font.render(display).with_color(text_color).finish();
2639
2640 let remaining_width = available_width - (cx - x);
2642 if tc.width() as i32 > remaining_width && is_last {
2643 let chars: Vec<char> = display.chars().collect();
2645 let ellipsis = font.render("...").with_color(text_color).finish();
2646 let ellipsis_w = ellipsis.width() as i32;
2647 let max_text_w = remaining_width - ellipsis_w;
2648
2649 if max_text_w > 0 {
2650 let mut truncated = String::new();
2651 let mut current_w = 0i32;
2652
2653 for c in chars {
2654 let c_canvas = font
2655 .render(c.to_string().as_str())
2656 .with_color(text_color)
2657 .finish();
2658 if current_w + c_canvas.width() as i32 > max_text_w {
2659 truncated.push('…');
2660 break;
2661 }
2662 truncated.push(c);
2663 current_w += c_canvas.width() as i32;
2664 }
2665
2666 let truncated_tc = font.render(&truncated).with_color(text_color).finish();
2667 canvas.draw_canvas(&truncated_tc, cx, y);
2668 cx += truncated_tc.width() as i32;
2669 }
2670 } else {
2671 canvas.draw_canvas(&tc, cx, y);
2672 cx += tc.width() as i32;
2673 }
2674
2675 if !is_last && !is_root {
2676 let sep = font.render(" / ").with_color(rgb(100, 100, 100)).finish();
2677 canvas.draw_canvas(&sep, cx, y);
2678 cx += sep.width() as i32;
2679 }
2680 }
2681}
2682
2683fn draw_folder_icon(canvas: &mut Canvas, x: i32, y: i32, colors: &Colors, scale: f32) {
2684 let folder_color = rgb(240, 180, 70); let icon_size = BASE_ICON_SIZE as f32 * scale;
2686 canvas.fill_rounded_rect(
2688 x as f32,
2689 (y + (4.0 * scale) as i32) as f32,
2690 icon_size,
2691 14.0 * scale,
2692 2.0 * scale,
2693 folder_color,
2694 );
2695 canvas.fill_rounded_rect(
2697 x as f32,
2698 y as f32,
2699 10.0 * scale,
2700 6.0 * scale,
2701 2.0 * scale,
2702 folder_color,
2703 );
2704 let _ = colors;
2705}
2706
2707fn draw_file_icon(canvas: &mut Canvas, x: i32, y: i32, name: &str, colors: &Colors, scale: f32) {
2708 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
2709 let icon_size = BASE_ICON_SIZE as f32 * scale;
2710
2711 let icon_color = match ext.as_str() {
2712 "rs" => rgb(220, 120, 70), "py" => rgb(70, 130, 180), "js" | "ts" => rgb(240, 220, 80), "html" | "htm" => rgb(220, 80, 50), "css" => rgb(80, 120, 200), "json" | "yaml" | "yml" | "toml" => rgb(150, 150, 150),
2718 "md" | "txt" => rgb(180, 180, 180),
2719 "png" | "jpg" | "jpeg" | "gif" | "svg" => rgb(100, 180, 100), _ => rgb(160, 160, 160),
2721 };
2722
2723 canvas.fill_rounded_rect(
2725 x as f32,
2726 y as f32,
2727 16.0 * scale,
2728 icon_size,
2729 2.0 * scale,
2730 icon_color,
2731 );
2732 canvas.fill_rect(
2734 (x + (10.0 * scale) as i32) as f32,
2735 y as f32,
2736 6.0 * scale,
2737 6.0 * scale,
2738 darken(icon_color, 0.2),
2739 );
2740 let _ = colors;
2741}
2742
2743fn draw_quick_access_icon(
2744 canvas: &mut Canvas,
2745 x: i32,
2746 y: i32,
2747 icon: QuickAccessIcon,
2748 colors: &Colors,
2749 scale: f32,
2750) {
2751 let color = match icon {
2752 QuickAccessIcon::Home => rgb(100, 180, 100),
2753 QuickAccessIcon::Desktop => rgb(120, 120, 200),
2754 QuickAccessIcon::Documents => rgb(200, 180, 100),
2755 QuickAccessIcon::Downloads => rgb(100, 160, 220),
2756 QuickAccessIcon::Pictures => rgb(180, 120, 180),
2757 QuickAccessIcon::Music => rgb(220, 120, 120),
2758 QuickAccessIcon::Videos => rgb(180, 100, 200),
2759 };
2760
2761 canvas.fill_rounded_rect(
2762 x as f32,
2763 y as f32,
2764 16.0 * scale,
2765 16.0 * scale,
2766 3.0 * scale,
2767 color,
2768 );
2769 let _ = colors;
2770}
2771
2772fn draw_section_header(
2773 canvas: &mut Canvas,
2774 x: i32,
2775 y: i32,
2776 label: &str,
2777 colors: &Colors,
2778 font: &Font,
2779 scale: f32,
2780) {
2781 let header_color = rgb(140, 140, 140);
2782 let header_canvas = font.render(label).with_color(header_color).finish();
2783 canvas.draw_canvas(&header_canvas, x + (4.0 * scale) as i32, y);
2784
2785 canvas.fill_rect(
2786 x as f32,
2787 (y + (18.0 * scale) as i32) as f32,
2788 (BASE_SIDEBAR_WIDTH as f32 * scale) - (8.0 * scale),
2789 1.0,
2790 darken(colors.window_bg, 0.05),
2791 );
2792}
2793
2794fn draw_mount_icon(
2795 canvas: &mut Canvas,
2796 x: i32,
2797 y: i32,
2798 icon: MountIcon,
2799 colors: &Colors,
2800 scale: f32,
2801) {
2802 let icon_size = 16.0 * scale;
2803 let color = match icon {
2804 MountIcon::UsbDrive => rgb(100, 200, 200),
2805 MountIcon::ExternalHdd => rgb(150, 150, 180),
2806 MountIcon::Optical => rgb(200, 150, 100),
2807 MountIcon::Generic => rgb(140, 140, 140),
2808 };
2809
2810 canvas.fill_rounded_rect(x as f32, y as f32, icon_size, icon_size, 3.0 * scale, color);
2811
2812 match icon {
2813 MountIcon::UsbDrive => {
2814 canvas.fill_rect(
2815 (x + (6.0 * scale) as i32) as f32,
2816 (y + (10.0 * scale) as i32) as f32,
2817 4.0 * scale,
2818 4.0 * scale,
2819 rgb(50, 50, 50),
2820 );
2821 }
2822 MountIcon::Optical => {
2823 canvas.fill_rounded_rect(
2824 (x + (6.0 * scale) as i32) as f32,
2825 (y + (6.0 * scale) as i32) as f32,
2826 4.0 * scale,
2827 4.0 * scale,
2828 2.0 * scale,
2829 rgb(50, 50, 50),
2830 );
2831 }
2832 _ => {}
2833 }
2834
2835 let _ = colors;
2836}