//! Browser GUI: thin dispatcher that routes to UI submodules based on the current workflow state. use egui; use crate::state::{BrowserState, ImportMode}; use crate::ui::{detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel, import_screens, instrument_panel, overlays, sidebar, theme, toolbar}; use audiofiles_core::vfs::NodeType; /// Top-level draw function called each frame from the update closure. /// /// Pass `sync_manager: None` when sync is not configured (e.g. from the CLAP plugin). pub fn draw_browser( ui: &mut egui::Ui, state: &mut BrowserState, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { let ctx = ui.ctx().clone(); let ctx = &ctx; theme::apply_theme(ctx); match &state.import_mode { ImportMode::None => { // Poll edit worker events during normal browsing if state.poll_workers() { ctx.request_repaint(); } handle_keyboard(ctx, state); draw_normal_browser(ui, state, sync_manager); } ImportMode::ConfigureImport { .. } => { import_screens::draw_configure_import(ui, state); } ImportMode::Importing { .. } | ImportMode::Analyzing { .. } | ImportMode::Exporting { .. } | ImportMode::Cleaning { .. } => { if state.poll_workers() { ctx.request_repaint(); } match &state.import_mode { ImportMode::Importing { .. } => { import_screens::draw_import_progress(ui, state); } ImportMode::Analyzing { .. } => { import_screens::draw_analysis_progress(ui, state); } ImportMode::Exporting { .. } => { export_screens::draw_export_progress(ui, state); } ImportMode::Cleaning { .. } => { import_screens::draw_cleanup_progress(ui, state); } // poll_workers may have transitioned the mode ImportMode::TagFolders { .. } => { import_screens::draw_tag_folders(ui, state); } ImportMode::ConfigureAnalysis { .. } => { import_screens::draw_configure_analysis(ui, state); } ImportMode::ReviewSuggestions { .. } => { import_screens::draw_review_suggestions(ui, state); } ImportMode::ExportComplete { .. } => { export_screens::draw_export_complete(ui, state); } ImportMode::ReviewErrors => { import_screens::draw_review_errors(ui, state); } _ => {} } } ImportMode::TagFolders { .. } => { import_screens::draw_tag_folders(ui, state); } ImportMode::ConfigureAnalysis { .. } => { import_screens::draw_configure_analysis(ui, state); } ImportMode::ReviewSuggestions { .. } => { import_screens::draw_review_suggestions(ui, state); } ImportMode::ConfigureExport { .. } => { export_screens::draw_configure_export(ui, state); } ImportMode::ExportComplete { .. } => { export_screens::draw_export_complete(ui, state); } ImportMode::ReviewErrors => { import_screens::draw_review_errors(ui, state); } ImportMode::OperationCancelled { .. } => { import_screens::draw_operation_cancelled(ui, state); } } // Scrim behind genuine modals (not the floating tool windows). Painted once // before any modal so it sits below the topmost modal but blocks pointer // input to the live UI underneath (P2). let modal_active = state.pending_confirm.is_some() || state.bulk_modal.is_some() || state.show_help || state.show_vfs_create || state.vfs_rename_target.is_some() || state.show_dir_create || state.dir_rename_target.is_some() || state.show_loose_files_warning || state.pending_import_preflight.is_some(); if modal_active { crate::ui::widgets::modal_scrim(ctx); } // Overlays drawn on top of any screen if state.pending_confirm.is_some() { overlays::draw_confirm_dialog(ctx, state); } if state.bulk_modal.is_some() { overlays::draw_bulk_modal(ctx, state); } if state.show_help { overlays::draw_help_overlay(ctx, state); } if state.show_vfs_create { overlays::draw_vfs_create_modal(ctx, state); } if state.vfs_rename_target.is_some() { overlays::draw_vfs_rename_modal(ctx, state); } if state.show_dir_create { overlays::draw_dir_create_modal(ctx, state); } if state.dir_rename_target.is_some() { overlays::draw_dir_rename_modal(ctx, state); } if state.show_loose_files_warning { overlays::draw_loose_files_warning(ctx, state); } if state.pending_import_preflight.is_some() { overlays::draw_import_preflight(ctx, state); } // Settings window if state.settings.show_manager { crate::ui::settings_panel::draw_settings_panel(ctx, state); } // Sync panel overlay if state.sync.show_panel { if let Some(sync) = sync_manager { crate::ui::sync_panel::draw_sync_panel(ctx, state, sync); } else { crate::ui::sync_panel::draw_sync_not_configured(ctx, state); } } // Floating sample editor window if state.edit.show_window { edit_panel::draw_edit_window(ctx, state); } // Floating sample forge window if state.forge.show_window { forge_panel::draw_forge_window(ctx, state); } } /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list. fn draw_normal_browser( ui: &mut egui::Ui, state: &mut BrowserState, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { let ctx = ui.ctx().clone(); // Top toolbar (breadcrumb + search) egui::Panel::top("toolbar") .exact_size(56.0) .show_inside(ui, |ui| { toolbar::draw_toolbar(ui, state, sync_manager); }); // Bottom footer egui::Panel::bottom("footer").show_inside(ui, |ui| { let ctx = ui.ctx().clone(); footer::draw_footer(ui, &ctx, state); }); // Floating MIDI/instrument window if state.show_midi_window { instrument_panel::draw_midi_window(&ctx, state); } // Left sidebar (or filter panel) if state.filter_panel_open { egui::Panel::left("filter_panel") .default_size(200.0) .size_range(160.0..=300.0) .show_inside(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { filter_panel::draw_filter_panel(ui, state); }); }); } else if state.sidebar_visible { egui::Panel::left("sidebar") .default_size(180.0) .size_range(120.0..=280.0) .show_inside(ui, |ui| { sidebar::draw_sidebar(ui, state); }); } // Right detail panel (auto-hide below 700px). // 700.0 is the minimum window width at which the detail panel is shown. // Below this, the file list alone needs the full width to remain usable. if state.detail_visible && ctx.content_rect().width() >= 700.0 { egui::Panel::right("detail") .default_size(250.0) .size_range(200.0..=400.0) .show_inside(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { detail::draw_detail(ui, state); }); }); } // Central file list egui::CentralPanel::default().show_inside(ui, |ui| { file_list::draw_file_list(ui, state, sync_manager); }); } /// Process keyboard shortcuts. fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { // Don't handle keyboard shortcuts if a text field has focus if ctx.memory(|m| m.focused().is_some()) { // Still handle Escape to clear search ctx.input(|input| { if input.key_pressed(egui::Key::Escape) && !state.search_query.is_empty() { state.search_query.clear(); state.apply_search(); } }); return; } ctx.input(|input| { // Escape: dismiss dialogs in priority order if input.key_pressed(egui::Key::Escape) { if state.settings.show_manager { state.settings.show_manager = false; } else if state.forge.show_window { state.close_forge_window(); } else if state.edit.show_window { state.close_edit_window(); } else if state.sync.show_panel { state.sync.show_panel = false; } else if state.bulk_modal.is_some() { state.close_bulk_modal(); } else if state.pending_import_preflight.is_some() { state.cancel_import_preflight(); } else if state.pending_confirm.is_some() { state.dismiss_confirm(); } else if state.show_help { state.show_help = false; } else if matches!( state.import_mode, ImportMode::ConfigureImport { .. } | ImportMode::TagFolders { .. } | ImportMode::ConfigureAnalysis { .. } | ImportMode::ReviewSuggestions { .. } | ImportMode::ConfigureExport { .. } | ImportMode::ReviewErrors ) { // Safe wizard screens (no in-flight work) — Escape backs out. This // covers both the import wizard and the export configuration screen. // Active modes (Importing/Analyzing/Cleaning/Exporting) require the // explicit Cancel button to avoid losing in-progress work. state.cancel_import(); } else if matches!(state.import_mode, ImportMode::OperationCancelled { .. }) { // Cancel-acknowledgement (C-3) dismisses on Escape just like the // Done button — the file work has already been cancelled. state.import_mode = ImportMode::None; } else if !state.search_query.is_empty() { state.search_query.clear(); state.apply_search(); } return; } if input.key_pressed(egui::Key::F1) { state.show_help = !state.show_help; } if input.key_pressed(egui::Key::F2) && state.selection.count() > 1 { state.open_bulk_rename_modal(); if state.bulk_modal.is_some() { state.update_rename_previews(); } } if input.key_pressed(egui::Key::Delete) { state.confirm_delete_selected(); } // Cmd+A: select all (skip the ".." parent row when present — it's not a sample // and must never be part of a bulk operation). if input.modifiers.command && input.key_pressed(egui::Key::A) && !input.modifiers.shift { let len = state.visible_len(); let start = if state.current_dir.is_some() { 1 } else { 0 }; state.selection.select_all_from(start, len); return; } // Cmd+Shift+I: invert selection (over sample rows; parent row excluded). if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::I) { state.invert_selection(); return; } // Tab: focus the detail-panel tag input (opens the detail panel if hidden). if input.key_pressed(egui::Key::Tab) && !input.modifiers.shift { if !state.detail_visible { state.set_detail_visible(true); } state.focus_tag_input = true; return; } // Cmd+Z: undo if input.modifiers.command && input.key_pressed(egui::Key::Z) { state.undo(); return; } // Cmd+T: bulk tag if input.modifiers.command && input.key_pressed(egui::Key::T) { if state.selection.count() > 1 { state.open_bulk_tag_modal(); } return; } let shift = input.modifiers.shift; if input.key_pressed(egui::Key::ArrowDown) || input.key_pressed(egui::Key::J) { if shift { state.selection.extend_down(state.visible_len()); state.scroll_to_row = Some(state.selection.focus); } else { state.select_next(); state.autoplay_current(); } } if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) { if shift { state.selection.extend_up(); state.scroll_to_row = Some(state.selection.focus); } else { state.select_prev(); state.autoplay_current(); } } if input.key_pressed(egui::Key::Enter) || input.key_pressed(egui::Key::ArrowRight) { if let Some(node) = state.selected_node() { match node.node.node_type { NodeType::Directory => state.enter_directory(), NodeType::Sample => { if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.trigger_preview(&hash); } } } } else if state.current_dir.is_some() && state.selection.focus == 0 { state.go_up(); } } if input.key_pressed(egui::Key::Backspace) || input.key_pressed(egui::Key::ArrowLeft) { // Two-step Backspace: while in similarity / duplicate mode, the // first press exits the mode; a follow-up press then falls through // to "go up one folder." Matches the "Esc closes the dialog, then // Esc closes the parent" muscle memory. if state.similarity_search_hash.is_some() { state.clear_similarity_search(); } else { state.go_up(); } } if input.key_pressed(egui::Key::Space) { state.toggle_preview(); } // "/" focuses the search bar if input.key_pressed(egui::Key::Slash) { state.focus_search = true; } // "I" toggles floating MIDI/instrument window if input.key_pressed(egui::Key::I) { state.show_midi_window = !state.show_midi_window; } // "E" toggles floating sample editor window if input.key_pressed(egui::Key::E) { if state.edit.show_window { state.close_edit_window(); } else if let Some(node) = state.selected_node() && let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.open_edit_window(&hash); } } // "F" toggles the floating Sample Forge window for the selected sample if input.key_pressed(egui::Key::F) && !shift { if state.forge.show_window { state.close_forge_window(); } else if let Some(node) = state.selected_node() && let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.open_forge_window(&hash); } } // "L" toggles loop if input.key_pressed(egui::Key::L) { state.toggle_loop(); } // "S" toggles sidebar if input.key_pressed(egui::Key::S) { state.toggle_sidebar(); } // "D" toggles detail panel if input.key_pressed(egui::Key::D) && !shift { state.toggle_detail(); } // Shift+F: find similar if shift && input.key_pressed(egui::Key::F) && let Some(node) = state.selected_node() && let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.find_similar(&hash); } // Shift+D: find duplicates if shift && input.key_pressed(egui::Key::D) && let Some(node) = state.selected_node() && let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.find_near_duplicates(&hash); } // Cmd+Shift+M: bulk move (Cmd+M alone conflicts with macOS minimize) if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::M) && state.selection.count() > 1 { state.open_bulk_move_modal(); } }); }