Skip to main content

max / audiofiles

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