//! Main file list: multi-column sortable table with selection and playback controls. use egui; use egui_extras::{Column, TableBuilder}; use crate::state::{BrowserState, SortColumn, SortDirection}; use audiofiles_core::vfs::{NodeType, VfsNodeWithAnalysis}; use super::file_list_menus::{draw_background_context_menu, draw_context_menu, draw_multi_context_menu}; use super::instrument_panel::DragPayload; use super::theme; use super::widgets; #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] use super::file_list_menus::start_os_drag; /// Draw the sortable, multi-column file list. pub fn draw_file_list( ui: &mut egui::Ui, state: &mut BrowserState, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { // After an OS drag that ends outside the app window, macOS swallows the // mouse-up so egui's pointer state is stale (`resp.dragged()` stays true). // Block new OS drags until egui sees the pointer released or a 2s safety // timeout expires. #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] let os_drag_blocked = if let Some(t) = state.os_drag_cooldown { let pointer_up = !ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary)); if pointer_up || t.elapsed() > std::time::Duration::from_secs(2) { state.os_drag_cooldown = None; false } else { true } } else { false }; // Empty state: no contents, at root level, no active search. // The first-run guided onboarding has a custom layout (numbered steps with // an inline Import link); other empty states route through `empty_state`. if state.contents.is_empty() && state.current_dir.is_none() && state.search_query.is_empty() && !state.search_filter.is_active() { if state.show_first_launch_hint { ui.vertical_centered(|ui| { ui.add_space(ui.available_height() * 0.08); ui.label( egui::RichText::new("Welcome to audiofiles") .size(22.0) .color(theme::text_primary()), ); ui.add_space(theme::space::SECTION); ui.label( egui::RichText::new("Three steps to your first sample:") .color(theme::text_secondary()), ); ui.add_space(theme::space::LG); ui.horizontal(|ui| { widgets::step_number(ui, 1); ui.label("Drop a folder of samples onto this window, or click "); if ui.link("Import").clicked() && let Some(path) = rfd::FileDialog::new() .set_title("Quick Import Folder") .pick_folder() { state.quick_import_folder(path); } }); ui.add_space(theme::space::SM); ui.horizontal(|ui| { widgets::step_number(ui, 2); ui.label("audiofiles will analyze BPM, key, and type automatically"); }); ui.add_space(theme::space::SM); ui.horizontal(|ui| { widgets::step_number(ui, 3); ui.label("Browse, filter, preview, and export to your hardware"); }); ui.add_space(theme::space::LG); ui.label( egui::RichText::new("Your files stay where they are \u{2014} audiofiles only indexes them.") .small() .color(theme::text_muted()), ); ui.add_space(theme::space::MD); ui.label( egui::RichText::new("Press F1 for shortcuts \u{00B7} Right-click samples for options") .small() .color(theme::text_muted()), ); ui.add_space(theme::space::SM); if ui .link(egui::RichText::new("Dismiss").small().color(theme::text_muted())) .on_hover_text("Hide this welcome. Re-enable from Settings.") .clicked() { state.dismiss_first_launch_hint(); } }); } else { let clicked = widgets::empty_state( ui, "No samples yet", Some("Drop audio files here, or import a folder to get started."), Some(widgets::EmptyStateCta { label: "Import folder...", tooltip: Some("Choose a folder of samples to import"), }), ); if clicked && let Some(path) = rfd::FileDialog::new() .set_title("Import folder") .pick_folder() { state.quick_import_folder(path); } // Quiet link to bring the welcome screen back if the user dismissed it. ui.vertical_centered(|ui| { ui.add_space(theme::space::LG); if ui.link(egui::RichText::new("Show welcome").small().color(theme::text_muted())).clicked() { state.show_welcome(); } }); } return; } // Empty state: filters active but no results in this folder if state.contents.is_empty() && (state.search_filter.is_active() || !state.search_query.is_empty()) { let filter_count = state.search_filter.active_count(); let hint = if filter_count > 0 && !state.search_query.is_empty() { format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" }) } else if filter_count > 0 { format!("{} filter{} active \u{2014} try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" }) } else { "No samples match your search in this folder.".to_string() }; // C-3: label names every part of the action. The CTA clears both // filters and the search query — matching the toolbar's already-fixed // "Clear search and filters" rename from Phase 4 M-4. if widgets::empty_state( ui, "No matches in this folder", Some(&hint), Some(widgets::EmptyStateCta { label: "Clear search and filters", tooltip: None }), ) { state.search_filter.clear(); state.search_query.clear(); state.apply_search(); } return; } // Sync first-touch banner: surfaces once after the first import. Suppressed // while the welcome hint is up (user hasn't imported yet) and dismissed // permanently once the user clicks either button. if state.show_sync_intro && !state.show_first_launch_hint { widgets::info_banner( ui, "Your library is local. Set up cloud sync to back it up and use it on other devices.", ); ui.horizontal(|ui| { if ui.button("Maybe later").clicked() { state.dismiss_sync_intro(); } if ui.button("Set up sync").clicked() { state.sync.show_panel = true; state.dismiss_sync_intro(); } }); ui.add_space(theme::space::SM); } let row_height = state.row_height; let has_parent = state.current_dir.is_some(); let contents = state.contents.clone(); let offset = if has_parent { 1usize } else { 0 }; let col_cfg = &state.column_config; // Build columns dynamically based on config // Icon is merged into the Name column; Play button is merged into the last data column. let mut table = TableBuilder::new(ui) .striped(true) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .column(Column::remainder().at_least(120.0)); // Name (includes icon) // Scroll to focused row when keyboard navigation requests it. if let Some(row) = state.scroll_to_row.take() { table = table.scroll_to_row(row, None); } if col_cfg.show_duration { table = table.column(Column::exact(60.0)); } if col_cfg.show_classification { table = table.column(Column::exact(80.0)); } if col_cfg.show_bpm { table = table.column(Column::exact(50.0)); } if col_cfg.show_key { table = table.column(Column::exact(70.0)); } if col_cfg.show_peak_db { table = table.column(Column::exact(60.0)); } if col_cfg.show_tags { table = table.column(Column::exact(120.0)); } table = table.column(Column::exact(36.0)); // Play button // Snapshot column visibility flags into local bools. The `col_cfg` borrow // from `state` can't survive into the table-builder closures which also // borrow `state`, so we copy the flags here. let show_classification = col_cfg.show_classification; let show_bpm = col_cfg.show_bpm; let show_key = col_cfg.show_key; let show_duration = col_cfg.show_duration; let show_peak_db = col_cfg.show_peak_db; let show_tags = col_cfg.show_tags; // Snapshot sort state so the header closure doesn't borrow `state` mutably. let sort_col = state.sort_column; let sort_dir = state.sort_direction.clone(); // While similarity / duplicate search is active, results come back sorted // by similarity score — letting the user click a column header to "sort // by name" silently in that view would scramble the ranking. Disable // header clicks instead so the score order stays trustworthy. let sort_enabled = state.similarity_search_hash.is_none(); let clicked_col = std::cell::Cell::new(None::); table .header(20.0, |mut header| { header.col(|ui| { if draw_sort_header(ui, "Name", SortColumn::Name, &sort_col, &sort_dir, sort_enabled) { clicked_col.set(Some(SortColumn::Name)); } }); if show_duration { header.col(|ui| { if draw_sort_header(ui, "Dur", SortColumn::Duration, &sort_col, &sort_dir, sort_enabled) { clicked_col.set(Some(SortColumn::Duration)); } }); } if show_classification { header.col(|ui| { if draw_sort_header(ui, "Class", SortColumn::Classification, &sort_col, &sort_dir, sort_enabled) { clicked_col.set(Some(SortColumn::Classification)); } }); } if show_bpm { header.col(|ui| { if draw_sort_header(ui, "BPM", SortColumn::Bpm, &sort_col, &sort_dir, sort_enabled) { clicked_col.set(Some(SortColumn::Bpm)); } }); } if show_key { header.col(|ui| { if draw_sort_header(ui, "Key", SortColumn::Key, &sort_col, &sort_dir, sort_enabled) { clicked_col.set(Some(SortColumn::Key)); } }); } if show_peak_db { header.col(|ui| { ui.label(egui::RichText::new("Peak").color(theme::text_secondary())); }); } if show_tags { header.col(|ui| { ui.label(egui::RichText::new("Tags").color(theme::text_secondary())); }); } header.col(|ui| { ui.label(egui::RichText::new("Play").color(theme::text_muted())); }); }) .body(|mut body| { // ".." parent entry if has_parent { body.row(row_height, |mut row| { let selected = state.selection.contains(0); row.set_selected(selected); row.col(|ui| { // Parent ".." entry: render muted so it reads as // navigation rather than a sample row, and is visually // distinct when scanning a selection with Cmd+A. let resp = ui.selectable_label( selected, egui::RichText::new(" Up") .color(theme::text_secondary()), ); if resp.clicked() { handle_click(state, 0, ui); } if resp.double_clicked() { state.go_up(); } }); if show_duration { row.col(|_ui| {}); } if show_classification { row.col(|_ui| {}); } if show_bpm { row.col(|_ui| {}); } if show_key { row.col(|_ui| {}); } if show_peak_db { row.col(|_ui| {}); } if show_tags { row.col(|_ui| {}); } row.col(|_ui| {}); }); } for (i, node) in contents.iter().enumerate() { let row_idx = i + offset; body.row(row_height, |mut row| { let selected = state.selection.contains(row_idx); row.set_selected(selected); // Name (with inline icon) row.col(|ui| { #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] let drag_blocked = os_drag_blocked; #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] let drag_blocked = false; draw_name_column(ui, state, node, row_idx, selected, drag_blocked, sync_manager); }); // Analysis columns (duration, classification, BPM, key, peak dB, tags) draw_analysis_columns( &mut row, node, show_duration, show_classification, show_bpm, show_key, show_peak_db, show_tags, ); // Play (or Download for cloud-only) button. C-1: cloud-only // samples used to render an empty cell, leaving the row // looking half-broken. The Download button surfaces the // recovery path that previously lived only in the // right-click context menu. row.col(|ui| { if node.node.node_type != NodeType::Sample { return; } let Some(hash) = node.node.sample_hash.as_ref() else { return; }; if node.cloud_only { if let Some(sync) = sync_manager && ui .button("Download") .on_hover_text("Fetch this sample from the cloud") .clicked() { let hash_str = hash.to_string(); if sync.download_sample(&hash_str) { state.status = format!( "Downloading {}...", node.node.name, ); } else { state.status = "Sync not ready — open the Sync panel first".to_string(); } } } else { let is_playing = state.previewing_hash.as_deref() == Some(hash) && state.shared.preview.lock().playing; let btn_text = if is_playing { "Stop" } else { "Play" }; let hover = if is_playing { "Stop preview (Space)" } else { "Play preview (Space)" }; if ui.button(btn_text).on_hover_text(hover).clicked() { if is_playing { state.stop_preview(); } else { let hash = hash.clone(); state.trigger_preview(&hash); } } } }); }); } }); // Apply sort toggle after the table is fully drawn, so we don't conflict // with borrows inside the header/body closures. if let Some(col) = clicked_col.get() { state.toggle_sort(col); } // Background context menu: right-click on empty space below the rows. // Allocate the remaining vertical space as an invisible interactable area. let remaining = ui.available_rect_before_wrap(); if remaining.height() > 0.0 { let bg_resp = ui.interact(remaining, ui.id().with("file_list_bg"), egui::Sense::click()); // Click on empty space clears selection. if bg_resp.clicked() { state.selection.clear(); state.refresh_selected_tags(); state.refresh_selected_detail(); } bg_resp.context_menu(|ui| { draw_background_context_menu(ui, state); }); } } /// Handle a click on a file list row, respecting modifier keys for multi-select. fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) { let modifiers = ui.input(|i| i.modifiers); let len = state.visible_len(); if modifiers.command { // Cmd/Ctrl+Click: toggle item state.selection.toggle(row_idx); } else if modifiers.shift { // Shift+Click: range select state.selection.extend_to(row_idx, len); } else { // Plain click: single select state.selection.set_single(row_idx); state.autoplay_current(); } state.refresh_selected_tags(); state.refresh_selected_detail(); } /// Draw the Name column contents for a single file-list row. /// /// Renders the icon + label, handles click/double-click, drag payloads /// (instrument zone assignment and native OS drag-out), and the context menu. #[allow(unused_variables)] fn draw_name_column( ui: &mut egui::Ui, state: &mut BrowserState, node: &VfsNodeWithAnalysis, row_idx: usize, selected: bool, os_drag_blocked: bool, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { // Directories get a trailing "/" (Unix convention). Samples get no prefix // — the name is the data, no decorative noise. Cloud-only samples already // render in `theme::text_muted()` below, which carries the signal without // emoji glyphs (brand rule). let label = match node.node.node_type { NodeType::Directory => format!("{}/", node.node.name), NodeType::Sample => node.node.name.clone(), }; let resp = if node.cloud_only { ui.selectable_label( selected, egui::RichText::new(&label).color(theme::text_muted()), ) } else { ui.selectable_label(selected, &label) }; // Add drag sense for native OS drag-out (Finder/DAW). // Response::interact() re-registers the SAME widget id with // click+drag sense so egui tracks drags on the selectable_label. // While the post-drag cooldown is active, surface the wait state in the // hover text — an invisible 2-second lockout otherwise reads as the app // having silently stopped responding. #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample { let hover = if os_drag_blocked { "Just dragged — ready again in a moment." } else { "Drag to Finder or DAW" }; resp.interact(egui::Sense::drag()) .on_hover_text_at_pointer(hover) } else { // C-1: cloud-only hover dropped — the Download button in the Play // column now carries the affordance, so the redundant name-column // hover would just compete for the user's attention. resp }; if resp.clicked() { handle_click(state, row_idx, ui); } if resp.double_clicked() { match node.node.node_type { NodeType::Directory => { state.selection.set_single(row_idx); state.enter_directory(); } NodeType::Sample => { if !node.cloud_only && let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.trigger_preview(&hash); } } } } // Drag source for instrument zone assignment (not for cloud-only) if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only && let Some(hash) = &node.node.sample_hash { resp.dnd_set_drag_payload(DragPayload { hash: hash.to_string(), name: node.node.name.clone(), }); } // Native OS drag-out to Finder/DAW (only when instrument panel is closed) #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] if !os_drag_blocked && !state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only && resp.dragged() && resp.drag_delta().length() > 4.0 { if !state.selection.contains(row_idx) { state.selection.set_single(row_idx); } start_os_drag(state); } // Trace when the post-drag cooldown swallows a user's drag attempt. The // hover-text change above is the user-visible signal; this log helps // diagnose any lingering drag-pipeline bugs that hide behind the cooldown. #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] if os_drag_blocked && node.node.node_type == NodeType::Sample && !node.cloud_only && resp.dragged() && resp.drag_delta().length() > 4.0 { tracing::warn!( row = row_idx, "OS drag suppressed by post-drag cooldown" ); } // Context menu: show bulk operations when right-clicking a row that's part // of the multi-selection. Right-clicking a row that is NOT in the current // selection collapses the selection to just that row first (matches Finder / // Explorer / Nautilus convention), so the menu's actions clearly target the // row under the cursor instead of an out-of-frame multi-selection. if resp.secondary_clicked() && !state.selection.contains(row_idx) { state.selection.set_single(row_idx); } resp.context_menu(|ui| { if state.selection.count() > 1 && state.selection.contains(row_idx) { draw_multi_context_menu(ui, state); } else { draw_context_menu(ui, state, row_idx, node, sync_manager); } }); } /// Draw the analysis data columns (duration, classification, BPM, key, peak dB, tags) /// for a single file-list row. Each visible column emits a `row.col()` call. #[allow(clippy::too_many_arguments)] // one column-renderer; the args map 1:1 to visible columns fn draw_analysis_columns( row: &mut egui_extras::TableRow, node: &VfsNodeWithAnalysis, show_duration: bool, show_classification: bool, show_bpm: bool, show_key: bool, show_peak_db: bool, show_tags: bool, ) { // Duration if show_duration { row.col(|ui| { if let Some(dur) = node.duration { ui.label( egui::RichText::new(widgets::format_duration(dur)) .color(theme::text_secondary()), ); } }); } // Classification if show_classification { row.col(|ui| { if let Some(ref class) = node.classification { widgets::classification_badge(ui, class); } }); } // BPM if show_bpm { row.col(|ui| { if let Some(bpm) = node.bpm { ui.label( egui::RichText::new(widgets::format_bpm(bpm)) .color(theme::text_secondary()), ); } }); } // Key if show_key { row.col(|ui| { if let Some(ref key) = node.musical_key { ui.label( egui::RichText::new(key.as_str()) .color(theme::text_secondary()), ); } }); } // Peak dB if show_peak_db { row.col(|ui| { if let Some(peak) = node.peak_db { ui.label( egui::RichText::new(format!("{:.1}", peak)) .color(theme::text_secondary()), ); } }); } // Tags if show_tags { row.col(|ui| { if !node.tags.is_empty() { let tag_str = node.tags.join(", "); ui.label( egui::RichText::new(tag_str) .small() .color(theme::text_secondary()), ); } }); } } /// Draw a clickable column header label. Shows an up/down arrow when this column /// is the active sort key. Uses `std::mem::discriminant` so we can compare enum /// variants without requiring `PartialEq` on the payload. fn draw_sort_header( ui: &mut egui::Ui, label: &str, column: SortColumn, current: &SortColumn, direction: &SortDirection, enabled: bool, ) -> bool { let is_active = std::mem::discriminant(current) == std::mem::discriminant(&column); // Reserve a fixed-width glyph slot at the right of every sortable header // so the column layout doesn't shift when the user toggles the active sort. // Inactive columns paint a muted middle-dot in the same slot; active // columns paint the direction arrow. let arrow = if is_active { match direction { SortDirection::Ascending => " \u{25B2}", SortDirection::Descending => " \u{25BC}", } } else { " \u{00B7}" }; let text = format!("{label}{arrow}"); if !enabled { // Render disabled headers as a sensed label so on_disabled_hover_text // surfaces — otherwise the user clicks an inert header and gets silence. ui.add_enabled( false, egui::Label::new(egui::RichText::new(text).color(theme::text_muted())) .sense(egui::Sense::click()), ) .on_disabled_hover_text( "Sort disabled - results ranked by similarity. Clear the similarity search to re-enable column sort.", ); return false; } super::widgets::selectable_row_secondary(ui, is_active, text).clicked() }