Skip to main content

max / audiofiles

27.0 KB · 687 lines History Blame Raw
1 //! Main file list: multi-column sortable table with selection and playback controls.
2
3 use egui;
4 use egui_extras::{Column, TableBuilder};
5
6 use crate::state::{BrowserState, SortColumn, SortDirection};
7 use audiofiles_core::vfs::{NodeType, VfsNodeWithAnalysis};
8
9 use super::file_list_menus::{draw_background_context_menu, draw_context_menu, draw_multi_context_menu};
10 use super::instrument_panel::DragPayload;
11 use super::theme;
12 use super::widgets;
13
14 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
15 use super::file_list_menus::start_os_drag;
16
17 /// Draw the sortable, multi-column file list.
18 pub fn draw_file_list(
19 ui: &mut egui::Ui,
20 state: &mut BrowserState,
21 sync_manager: Option<&audiofiles_sync::SyncManager>,
22 ) {
23 // After an OS drag that ends outside the app window, macOS swallows the
24 // mouse-up so egui's pointer state is stale (`resp.dragged()` stays true).
25 // Block new OS drags until egui sees the pointer released or a 2s safety
26 // timeout expires.
27 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
28 let os_drag_blocked = if let Some(t) = state.os_drag_cooldown {
29 let pointer_up = !ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary));
30 if pointer_up || t.elapsed() > std::time::Duration::from_secs(2) {
31 state.os_drag_cooldown = None;
32 false
33 } else {
34 true
35 }
36 } else {
37 false
38 };
39
40 // Empty state: no contents, at root level, no active search.
41 // The first-run guided onboarding has a custom layout (numbered steps with
42 // an inline Import link); other empty states route through `empty_state`.
43 if state.contents.is_empty() && state.current_dir.is_none() && state.search_query.is_empty() && !state.search_filter.is_active() {
44 if state.show_first_launch_hint {
45 ui.vertical_centered(|ui| {
46 ui.add_space(ui.available_height() * 0.08);
47 ui.label(
48 egui::RichText::new("Welcome to audiofiles")
49 .size(22.0)
50 .color(theme::text_primary()),
51 );
52 ui.add_space(theme::space::SECTION);
53 ui.label(
54 egui::RichText::new("Three steps to your first sample:")
55 .color(theme::text_secondary()),
56 );
57 ui.add_space(theme::space::LG);
58
59 ui.horizontal(|ui| {
60 widgets::step_number(ui, 1);
61 ui.label("Drop a folder of samples onto this window, or click ");
62 if ui.link("Import").clicked()
63 && let Some(path) = rfd::FileDialog::new()
64 .set_title("Quick Import Folder")
65 .pick_folder()
66 {
67 state.quick_import_folder(path);
68 }
69 });
70 ui.add_space(theme::space::SM);
71
72 ui.horizontal(|ui| {
73 widgets::step_number(ui, 2);
74 ui.label("audiofiles will analyze BPM, key, and type automatically");
75 });
76 ui.add_space(theme::space::SM);
77
78 ui.horizontal(|ui| {
79 widgets::step_number(ui, 3);
80 ui.label("Browse, filter, preview, and export to your hardware");
81 });
82
83 ui.add_space(theme::space::LG);
84 ui.label(
85 egui::RichText::new("Your files stay where they are \u{2014} audiofiles only indexes them.")
86 .small()
87 .color(theme::text_muted()),
88 );
89 ui.add_space(theme::space::MD);
90 ui.label(
91 egui::RichText::new("Press F1 for shortcuts \u{00B7} Right-click samples for options")
92 .small()
93 .color(theme::text_muted()),
94 );
95 ui.add_space(theme::space::SM);
96 if ui
97 .link(egui::RichText::new("Dismiss").small().color(theme::text_muted()))
98 .on_hover_text("Hide this welcome. Re-enable from Settings.")
99 .clicked()
100 {
101 state.dismiss_first_launch_hint();
102 }
103 });
104 } else {
105 let clicked = widgets::empty_state(
106 ui,
107 "No samples yet",
108 Some("Drop audio files here, or import a folder to get started."),
109 Some(widgets::EmptyStateCta {
110 label: "Import folder...",
111 tooltip: Some("Choose a folder of samples to import"),
112 }),
113 );
114 if clicked
115 && let Some(path) = rfd::FileDialog::new()
116 .set_title("Import folder")
117 .pick_folder()
118 {
119 state.quick_import_folder(path);
120 }
121 // Quiet link to bring the welcome screen back if the user dismissed it.
122 ui.vertical_centered(|ui| {
123 ui.add_space(theme::space::LG);
124 if ui.link(egui::RichText::new("Show welcome").small().color(theme::text_muted())).clicked() {
125 state.show_welcome();
126 }
127 });
128 }
129 return;
130 }
131
132 // Empty state: filters active but no results in this folder
133 if state.contents.is_empty() && (state.search_filter.is_active() || !state.search_query.is_empty()) {
134 let filter_count = state.search_filter.active_count();
135 let hint = if filter_count > 0 && !state.search_query.is_empty() {
136 format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" })
137 } else if filter_count > 0 {
138 format!("{} filter{} active \u{2014} try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" })
139 } else {
140 "No samples match your search in this folder.".to_string()
141 };
142 // C-3: label names every part of the action. The CTA clears both
143 // filters and the search query — matching the toolbar's already-fixed
144 // "Clear search and filters" rename from Phase 4 M-4.
145 if widgets::empty_state(
146 ui,
147 "No matches in this folder",
148 Some(&hint),
149 Some(widgets::EmptyStateCta { label: "Clear search and filters", tooltip: None }),
150 ) {
151 state.search_filter.clear();
152 state.search_query.clear();
153 state.apply_search();
154 }
155 return;
156 }
157
158 // Sync first-touch banner: surfaces once after the first import. Suppressed
159 // while the welcome hint is up (user hasn't imported yet) and dismissed
160 // permanently once the user clicks either button.
161 if state.show_sync_intro && !state.show_first_launch_hint {
162 widgets::info_banner(
163 ui,
164 "Your library is local. Set up cloud sync to back it up and use it on other devices.",
165 );
166 ui.horizontal(|ui| {
167 if ui.button("Maybe later").clicked() {
168 state.dismiss_sync_intro();
169 }
170 if ui.button("Set up sync").clicked() {
171 state.sync.show_panel = true;
172 state.dismiss_sync_intro();
173 }
174 });
175 ui.add_space(theme::space::SM);
176 }
177
178 let row_height = state.row_height;
179 let has_parent = state.current_dir.is_some();
180 let contents = state.contents.clone();
181 let offset = if has_parent { 1usize } else { 0 };
182 let col_cfg = &state.column_config;
183
184 // Build columns dynamically based on config
185 // Icon is merged into the Name column; Play button is merged into the last data column.
186 let mut table = TableBuilder::new(ui)
187 .striped(true)
188 .resizable(true)
189 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
190 .column(Column::remainder().at_least(120.0)); // Name (includes icon)
191
192 // Scroll to focused row when keyboard navigation requests it.
193 if let Some(row) = state.scroll_to_row.take() {
194 table = table.scroll_to_row(row, None);
195 }
196
197 if col_cfg.show_duration {
198 table = table.column(Column::exact(60.0));
199 }
200 if col_cfg.show_classification {
201 table = table.column(Column::exact(80.0));
202 }
203 if col_cfg.show_bpm {
204 table = table.column(Column::exact(50.0));
205 }
206 if col_cfg.show_key {
207 table = table.column(Column::exact(70.0));
208 }
209 if col_cfg.show_peak_db {
210 table = table.column(Column::exact(60.0));
211 }
212 if col_cfg.show_tags {
213 table = table.column(Column::exact(120.0));
214 }
215 table = table.column(Column::exact(36.0)); // Play button
216
217 // Snapshot column visibility flags into local bools. The `col_cfg` borrow
218 // from `state` can't survive into the table-builder closures which also
219 // borrow `state`, so we copy the flags here.
220 let show_classification = col_cfg.show_classification;
221 let show_bpm = col_cfg.show_bpm;
222 let show_key = col_cfg.show_key;
223 let show_duration = col_cfg.show_duration;
224 let show_peak_db = col_cfg.show_peak_db;
225 let show_tags = col_cfg.show_tags;
226
227 // Snapshot sort state so the header closure doesn't borrow `state` mutably.
228 let sort_col = state.sort_column;
229 let sort_dir = state.sort_direction.clone();
230 // While similarity / duplicate search is active, results come back sorted
231 // by similarity score — letting the user click a column header to "sort
232 // by name" silently in that view would scramble the ranking. Disable
233 // header clicks instead so the score order stays trustworthy.
234 let sort_enabled = state.similarity_search_hash.is_none();
235 let clicked_col = std::cell::Cell::new(None::<SortColumn>);
236
237 table
238 .header(20.0, |mut header| {
239 header.col(|ui| {
240 if draw_sort_header(ui, "Name", SortColumn::Name, &sort_col, &sort_dir, sort_enabled) {
241 clicked_col.set(Some(SortColumn::Name));
242 }
243 });
244 if show_duration {
245 header.col(|ui| {
246 if draw_sort_header(ui, "Dur", SortColumn::Duration, &sort_col, &sort_dir, sort_enabled) {
247 clicked_col.set(Some(SortColumn::Duration));
248 }
249 });
250 }
251 if show_classification {
252 header.col(|ui| {
253 if draw_sort_header(ui, "Class", SortColumn::Classification, &sort_col, &sort_dir, sort_enabled) {
254 clicked_col.set(Some(SortColumn::Classification));
255 }
256 });
257 }
258 if show_bpm {
259 header.col(|ui| {
260 if draw_sort_header(ui, "BPM", SortColumn::Bpm, &sort_col, &sort_dir, sort_enabled) {
261 clicked_col.set(Some(SortColumn::Bpm));
262 }
263 });
264 }
265 if show_key {
266 header.col(|ui| {
267 if draw_sort_header(ui, "Key", SortColumn::Key, &sort_col, &sort_dir, sort_enabled) {
268 clicked_col.set(Some(SortColumn::Key));
269 }
270 });
271 }
272 if show_peak_db {
273 header.col(|ui| {
274 ui.label(egui::RichText::new("Peak").color(theme::text_secondary()));
275 });
276 }
277 if show_tags {
278 header.col(|ui| {
279 ui.label(egui::RichText::new("Tags").color(theme::text_secondary()));
280 });
281 }
282 header.col(|ui| {
283 ui.label(egui::RichText::new("Play").color(theme::text_muted()));
284 });
285 })
286 .body(|mut body| {
287 // ".." parent entry
288 if has_parent {
289 body.row(row_height, |mut row| {
290 let selected = state.selection.contains(0);
291 row.set_selected(selected);
292 row.col(|ui| {
293 // Parent ".." entry: render muted so it reads as
294 // navigation rather than a sample row, and is visually
295 // distinct when scanning a selection with Cmd+A.
296 let resp = ui.selectable_label(
297 selected,
298 egui::RichText::new(" Up")
299 .color(theme::text_secondary()),
300 );
301 if resp.clicked() {
302 handle_click(state, 0, ui);
303 }
304 if resp.double_clicked() {
305 state.go_up();
306 }
307 });
308 if show_duration { row.col(|_ui| {}); }
309 if show_classification { row.col(|_ui| {}); }
310 if show_bpm { row.col(|_ui| {}); }
311 if show_key { row.col(|_ui| {}); }
312 if show_peak_db { row.col(|_ui| {}); }
313 if show_tags { row.col(|_ui| {}); }
314 row.col(|_ui| {});
315 });
316 }
317
318 for (i, node) in contents.iter().enumerate() {
319 let row_idx = i + offset;
320 body.row(row_height, |mut row| {
321 let selected = state.selection.contains(row_idx);
322 row.set_selected(selected);
323
324 // Name (with inline icon)
325 row.col(|ui| {
326 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
327 let drag_blocked = os_drag_blocked;
328 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
329 let drag_blocked = false;
330 draw_name_column(ui, state, node, row_idx, selected, drag_blocked, sync_manager);
331 });
332
333 // Analysis columns (duration, classification, BPM, key, peak dB, tags)
334 draw_analysis_columns(
335 &mut row, node,
336 show_duration, show_classification, show_bpm,
337 show_key, show_peak_db, show_tags,
338 );
339
340 // Play (or Download for cloud-only) button. C-1: cloud-only
341 // samples used to render an empty cell, leaving the row
342 // looking half-broken. The Download button surfaces the
343 // recovery path that previously lived only in the
344 // right-click context menu.
345 row.col(|ui| {
346 if node.node.node_type != NodeType::Sample {
347 return;
348 }
349 let Some(hash) = node.node.sample_hash.as_ref() else { return; };
350 if node.cloud_only {
351 if let Some(sync) = sync_manager
352 && ui
353 .button("Download")
354 .on_hover_text("Fetch this sample from the cloud")
355 .clicked()
356 {
357 let hash_str = hash.to_string();
358 if sync.download_sample(&hash_str) {
359 state.status = format!(
360 "Downloading {}...",
361 node.node.name,
362 );
363 } else {
364 state.status =
365 "Sync not ready — open the Sync panel first".to_string();
366 }
367 }
368 } else {
369 let is_playing = state.previewing_hash.as_deref() == Some(hash)
370 && state.shared.preview.lock().playing;
371 let btn_text = if is_playing { "Stop" } else { "Play" };
372 let hover = if is_playing { "Stop preview (Space)" } else { "Play preview (Space)" };
373 if ui.button(btn_text).on_hover_text(hover).clicked() {
374 if is_playing {
375 state.stop_preview();
376 } else {
377 let hash = hash.clone();
378 state.trigger_preview(&hash);
379 }
380 }
381 }
382 });
383 });
384 }
385 });
386
387 // Apply sort toggle after the table is fully drawn, so we don't conflict
388 // with borrows inside the header/body closures.
389 if let Some(col) = clicked_col.get() {
390 state.toggle_sort(col);
391 }
392
393 // Background context menu: right-click on empty space below the rows.
394 // Allocate the remaining vertical space as an invisible interactable area.
395 let remaining = ui.available_rect_before_wrap();
396 if remaining.height() > 0.0 {
397 let bg_resp = ui.interact(remaining, ui.id().with("file_list_bg"), egui::Sense::click());
398 // Click on empty space clears selection.
399 if bg_resp.clicked() {
400 state.selection.clear();
401 state.refresh_selected_tags();
402 state.refresh_selected_detail();
403 }
404 bg_resp.context_menu(|ui| {
405 draw_background_context_menu(ui, state);
406 });
407 }
408 }
409
410 /// Handle a click on a file list row, respecting modifier keys for multi-select.
411 fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) {
412 let modifiers = ui.input(|i| i.modifiers);
413 let len = state.visible_len();
414
415 if modifiers.command {
416 // Cmd/Ctrl+Click: toggle item
417 state.selection.toggle(row_idx);
418 } else if modifiers.shift {
419 // Shift+Click: range select
420 state.selection.extend_to(row_idx, len);
421 } else {
422 // Plain click: single select
423 state.selection.set_single(row_idx);
424 state.autoplay_current();
425 }
426
427 state.refresh_selected_tags();
428 state.refresh_selected_detail();
429 }
430
431 /// Draw the Name column contents for a single file-list row.
432 ///
433 /// Renders the icon + label, handles click/double-click, drag payloads
434 /// (instrument zone assignment and native OS drag-out), and the context menu.
435 #[allow(unused_variables)]
436 fn draw_name_column(
437 ui: &mut egui::Ui,
438 state: &mut BrowserState,
439 node: &VfsNodeWithAnalysis,
440 row_idx: usize,
441 selected: bool,
442 os_drag_blocked: bool,
443 sync_manager: Option<&audiofiles_sync::SyncManager>,
444 ) {
445 // Directories get a trailing "/" (Unix convention). Samples get no prefix
446 // — the name is the data, no decorative noise. Cloud-only samples already
447 // render in `theme::text_muted()` below, which carries the signal without
448 // emoji glyphs (brand rule).
449 let label = match node.node.node_type {
450 NodeType::Directory => format!("{}/", node.node.name),
451 NodeType::Sample => node.node.name.clone(),
452 };
453 let resp = if node.cloud_only {
454 ui.selectable_label(
455 selected,
456 egui::RichText::new(&label).color(theme::text_muted()),
457 )
458 } else {
459 ui.selectable_label(selected, &label)
460 };
461
462 // Add drag sense for native OS drag-out (Finder/DAW).
463 // Response::interact() re-registers the SAME widget id with
464 // click+drag sense so egui tracks drags on the selectable_label.
465 // While the post-drag cooldown is active, surface the wait state in the
466 // hover text — an invisible 2-second lockout otherwise reads as the app
467 // having silently stopped responding.
468 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
469 let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample {
470 let hover = if os_drag_blocked {
471 "Just dragged — ready again in a moment."
472 } else {
473 "Drag to Finder or DAW"
474 };
475 resp.interact(egui::Sense::drag())
476 .on_hover_text_at_pointer(hover)
477 } else {
478 // C-1: cloud-only hover dropped — the Download button in the Play
479 // column now carries the affordance, so the redundant name-column
480 // hover would just compete for the user's attention.
481 resp
482 };
483
484 if resp.clicked() {
485 handle_click(state, row_idx, ui);
486 }
487
488 if resp.double_clicked() {
489 match node.node.node_type {
490 NodeType::Directory => {
491 state.selection.set_single(row_idx);
492 state.enter_directory();
493 }
494 NodeType::Sample => {
495 if !node.cloud_only
496 && let Some(hash) = &node.node.sample_hash {
497 let hash = hash.clone();
498 state.trigger_preview(&hash);
499 }
500 }
501 }
502 }
503
504 // Drag source for instrument zone assignment (not for cloud-only)
505 if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only
506 && let Some(hash) = &node.node.sample_hash {
507 resp.dnd_set_drag_payload(DragPayload {
508 hash: hash.to_string(),
509 name: node.node.name.clone(),
510 });
511 }
512
513 // Native OS drag-out to Finder/DAW (only when instrument panel is closed)
514 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
515 if !os_drag_blocked
516 && !state.instrument_visible
517 && node.node.node_type == NodeType::Sample
518 && !node.cloud_only
519 && resp.dragged()
520 && resp.drag_delta().length() > 4.0
521 {
522 if !state.selection.contains(row_idx) {
523 state.selection.set_single(row_idx);
524 }
525 start_os_drag(state);
526 }
527 // Trace when the post-drag cooldown swallows a user's drag attempt. The
528 // hover-text change above is the user-visible signal; this log helps
529 // diagnose any lingering drag-pipeline bugs that hide behind the cooldown.
530 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
531 if os_drag_blocked
532 && node.node.node_type == NodeType::Sample
533 && !node.cloud_only
534 && resp.dragged()
535 && resp.drag_delta().length() > 4.0
536 {
537 tracing::warn!(
538 row = row_idx,
539 "OS drag suppressed by post-drag cooldown"
540 );
541 }
542
543 // Context menu: show bulk operations when right-clicking a row that's part
544 // of the multi-selection. Right-clicking a row that is NOT in the current
545 // selection collapses the selection to just that row first (matches Finder /
546 // Explorer / Nautilus convention), so the menu's actions clearly target the
547 // row under the cursor instead of an out-of-frame multi-selection.
548 if resp.secondary_clicked() && !state.selection.contains(row_idx) {
549 state.selection.set_single(row_idx);
550 }
551 resp.context_menu(|ui| {
552 if state.selection.count() > 1 && state.selection.contains(row_idx) {
553 draw_multi_context_menu(ui, state);
554 } else {
555 draw_context_menu(ui, state, row_idx, node, sync_manager);
556 }
557 });
558 }
559
560 /// Draw the analysis data columns (duration, classification, BPM, key, peak dB, tags)
561 /// for a single file-list row. Each visible column emits a `row.col()` call.
562 #[allow(clippy::too_many_arguments)] // one column-renderer; the args map 1:1 to visible columns
563 fn draw_analysis_columns(
564 row: &mut egui_extras::TableRow,
565 node: &VfsNodeWithAnalysis,
566 show_duration: bool,
567 show_classification: bool,
568 show_bpm: bool,
569 show_key: bool,
570 show_peak_db: bool,
571 show_tags: bool,
572 ) {
573 // Duration
574 if show_duration {
575 row.col(|ui| {
576 if let Some(dur) = node.duration {
577 ui.label(
578 egui::RichText::new(widgets::format_duration(dur))
579 .color(theme::text_secondary()),
580 );
581 }
582 });
583 }
584
585 // Classification
586 if show_classification {
587 row.col(|ui| {
588 if let Some(ref class) = node.classification {
589 widgets::classification_badge(ui, class);
590 }
591 });
592 }
593
594 // BPM
595 if show_bpm {
596 row.col(|ui| {
597 if let Some(bpm) = node.bpm {
598 ui.label(
599 egui::RichText::new(widgets::format_bpm(bpm))
600 .color(theme::text_secondary()),
601 );
602 }
603 });
604 }
605
606 // Key
607 if show_key {
608 row.col(|ui| {
609 if let Some(ref key) = node.musical_key {
610 ui.label(
611 egui::RichText::new(key.as_str())
612 .color(theme::text_secondary()),
613 );
614 }
615 });
616 }
617
618 // Peak dB
619 if show_peak_db {
620 row.col(|ui| {
621 if let Some(peak) = node.peak_db {
622 ui.label(
623 egui::RichText::new(format!("{:.1}", peak))
624 .color(theme::text_secondary()),
625 );
626 }
627 });
628 }
629
630 // Tags
631 if show_tags {
632 row.col(|ui| {
633 if !node.tags.is_empty() {
634 let tag_str = node.tags.join(", ");
635 ui.label(
636 egui::RichText::new(tag_str)
637 .small()
638 .color(theme::text_secondary()),
639 );
640 }
641 });
642 }
643 }
644
645 /// Draw a clickable column header label. Shows an up/down arrow when this column
646 /// is the active sort key. Uses `std::mem::discriminant` so we can compare enum
647 /// variants without requiring `PartialEq` on the payload.
648 fn draw_sort_header(
649 ui: &mut egui::Ui,
650 label: &str,
651 column: SortColumn,
652 current: &SortColumn,
653 direction: &SortDirection,
654 enabled: bool,
655 ) -> bool {
656 let is_active = std::mem::discriminant(current) == std::mem::discriminant(&column);
657 // Reserve a fixed-width glyph slot at the right of every sortable header
658 // so the column layout doesn't shift when the user toggles the active sort.
659 // Inactive columns paint a muted middle-dot in the same slot; active
660 // columns paint the direction arrow.
661 let arrow = if is_active {
662 match direction {
663 SortDirection::Ascending => " \u{25B2}",
664 SortDirection::Descending => " \u{25BC}",
665 }
666 } else {
667 " \u{00B7}"
668 };
669
670 let text = format!("{label}{arrow}");
671 if !enabled {
672 // Render disabled headers as a sensed label so on_disabled_hover_text
673 // surfaces — otherwise the user clicks an inert header and gets silence.
674 ui.add_enabled(
675 false,
676 egui::Label::new(egui::RichText::new(text).color(theme::text_muted()))
677 .sense(egui::Sense::click()),
678 )
679 .on_disabled_hover_text(
680 "Sort disabled - results ranked by similarity. Clear the similarity search to re-enable column sort.",
681 );
682 return false;
683 }
684 super::widgets::selectable_row_secondary(ui, is_active, text).clicked()
685 }
686
687