//! Top toolbar: VFS breadcrumb navigation, search bar, undo button, and import button. use egui; use crate::state::BrowserState; use crate::ui::theme; use crate::ui::widgets; /// Draw the breadcrumb bar with VFS selector, path segments, search bar, and import button. pub fn draw_toolbar( ui: &mut egui::Ui, state: &mut BrowserState, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { ui.horizontal(|ui| { draw_breadcrumb(ui, state, sync_manager); }); // M-9: similarity banner removed — Clear now lives inside the // breadcrumb's "Similar to: " segment (see draw_breadcrumb). // Search bar ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; ui.label(egui::RichText::new("Search").small().color(theme::text_muted())) .on_hover_text("Search (press / to focus)"); let search_edit = egui::TextEdit::singleline(&mut state.search_query) .hint_text("Search samples... (/)") .desired_width(ui.available_width() - 160.0); let resp = ui.add(search_edit); if state.focus_search { resp.request_focus(); state.focus_search = false; } if resp.changed() { state.apply_search(); } if !state.search_query.is_empty() && ui.button("Clear").on_hover_text("Clear search").clicked() { state.search_query.clear(); state.apply_search(); } // Scope toggle: Folder / All { use audiofiles_core::search::SearchScope; ui.label(egui::RichText::new("in:").small().color(theme::text_muted())); if let Some(scope) = widgets::toggle_pills( ui, &state.search_filter.scope, &[ (SearchScope::CurrentFolder, "Folder", "Search current folder only"), (SearchScope::Global, "All", "Search all vaults"), ], ) { state.search_filter.scope = scope; state.apply_search(); } } // Result count when search/filters are active if state.search_filter.is_active() { let weak = ui.visuals().weak_text_color(); ui.label(egui::RichText::new(format!("{} results", state.contents.len())).small().color(weak)); // Save as Collection button (prominent when filters active) let save_id = ui.make_persistent_id("save_collection_popup"); let save_btn = ui.button(egui::RichText::new("Save").small().color(theme::accent_blue())) .on_hover_text("Save current filters as a dynamic collection"); if save_btn.clicked() { if state.collection_filter_name_input.is_empty() { state.collection_filter_name_input = state.search_filter.describe(); } egui::Popup::toggle_id(ui.ctx(), save_id); } egui::Popup::from_response(&save_btn) .id(save_id) .open_memory(None) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { ui.set_min_width(200.0); ui.label(egui::RichText::new("Save as Collection").strong()); ui.add_space(theme::space::SM); let edit = egui::TextEdit::singleline(&mut state.collection_filter_name_input) .hint_text("e.g. Kicks Under 120 BPM") .desired_width(180.0); let resp = ui.add(edit); if resp.gained_focus() || state.collection_filter_name_input.is_empty() { resp.request_focus(); } ui.add_space(theme::space::SM); let name = state.collection_filter_name_input.trim().to_string(); if ui.add_enabled(!name.is_empty(), egui::Button::new("Save Collection")).clicked() { state.save_dynamic_collection(&name); state.collection_filter_name_input.clear(); egui::Popup::close_id(ui.ctx(), save_id); } }); } // Undo button let undo_enabled = state.can_undo(); if ui .add_enabled(undo_enabled, egui::Button::new("Undo")) .on_hover_text("Undo (Cmd+Z)") .clicked() { state.undo(); } // M-3: collapse the six panel toggles into a single View menu when the // window is too narrow to host them inline. Threshold ~900px keeps the // expanded row on common desktop widths but rescues half-screen / DAW // companion layouts. Same actions, single dropdown. let screen_w = ui.ctx().content_rect().width(); let collapse_toggles = screen_w < 900.0; // M-13 input (shared by both layouts). let detail_too_narrow = screen_w < 700.0; let detail_hidden = state.detail_visible && detail_too_narrow; if collapse_toggles { draw_view_menu(ui, state, detail_hidden); } else { draw_inline_panel_toggles(ui, state, detail_hidden); } }); } /// Draw the full row of toolbar panel toggles (M-3 expanded layout). fn draw_inline_panel_toggles( ui: &mut egui::Ui, state: &mut BrowserState, detail_hidden: bool, ) { if widgets::toolbar_toggle(ui, "Sidebar", state.sidebar_visible, "Toggle sidebar (S)", None) { state.toggle_sidebar(); } // M-13: the Detail toggle conveys "active but hidden" via a muted colour // and a tooltip explaining the cause; otherwise behaves like the other // toolbar toggles. let detail_tooltip = if detail_hidden { "Detail panel hidden \u{2014} widen the window to show it. (D)" } else { "Toggle detail panel (D)" }; let detail_colour = if detail_hidden { theme::text_muted() } else if state.detail_visible { theme::accent_blue() } else { theme::text_muted() }; if ui .button(egui::RichText::new("Detail").color(detail_colour)) .on_hover_text(detail_tooltip) .clicked() { state.toggle_detail(); } if widgets::toolbar_toggle(ui, "Edit", state.edit.show_window, "Toggle sample editor (E)", None) { toggle_edit_window(state); } if widgets::toolbar_toggle(ui, "Instr", state.show_midi_window, "Toggle instrument (I)", None) { state.show_midi_window = !state.show_midi_window; } if widgets::toolbar_toggle(ui, "Loop", state.loop_enabled, "Toggle loop (L)", None) { state.toggle_loop(); } let filter_count = state.search_filter.active_count(); let show_count = filter_count > 0 && !state.filter_panel_open; let hover = if show_count { format!("{} filter{} active", filter_count, if filter_count == 1 { "" } else { "s" }) } else { "Toggle filter panel".to_string() }; if widgets::toolbar_toggle(ui, "Filters", state.filter_panel_open, &hover, show_count.then_some(filter_count)) { state.toggle_filter_panel(); } } /// Collapsed "View" menu for narrow windows (M-3). Each entry mirrors a /// toolbar toggle; the leading bullet marks the active state. fn draw_view_menu(ui: &mut egui::Ui, state: &mut BrowserState, detail_hidden: bool) { let filter_count = state.search_filter.active_count(); // Label hint when filters are active but the panel is closed (mirrors the // count badge that the inline layout shows on the Filters toggle). let label = if filter_count > 0 && !state.filter_panel_open { format!("View ({filter_count}) \u{25BC}") } else { "View \u{25BC}".to_string() }; ui.menu_button(label, |ui| { let active_dot = |on: bool| if on { "\u{2022} " } else { " " }; if ui .button(format!("{}Sidebar (S)", active_dot(state.sidebar_visible))) .clicked() { state.toggle_sidebar(); ui.close(); } let detail_label = if detail_hidden { format!("{}Detail (D) \u{2014} hidden (widen window)", active_dot(state.detail_visible)) } else { format!("{}Detail (D)", active_dot(state.detail_visible)) }; if ui.button(detail_label).clicked() { state.toggle_detail(); ui.close(); } if ui .button(format!("{}Editor (E)", active_dot(state.edit.show_window))) .clicked() { toggle_edit_window(state); ui.close(); } if ui .button(format!("{}Instrument (I)", active_dot(state.show_midi_window))) .clicked() { state.show_midi_window = !state.show_midi_window; ui.close(); } if ui .button(format!("{}Loop (L)", active_dot(state.loop_enabled))) .clicked() { state.toggle_loop(); ui.close(); } let filters_label = if filter_count > 0 { format!( "{}Filters ({})", active_dot(state.filter_panel_open), filter_count ) } else { format!("{}Filters", active_dot(state.filter_panel_open)) }; if ui.button(filters_label).clicked() { state.toggle_filter_panel(); ui.close(); } }); } /// Shared editor toggle path used by both the inline and collapsed layouts. fn toggle_edit_window(state: &mut BrowserState) { 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); } } /// Draw the VFS breadcrumb bar: VFS dropdown selector, clickable "/" root, path /// segments for each ancestor directory, and a right-aligned Import button. /// /// Clicking a non-terminal breadcrumb segment navigates to that directory. fn draw_breadcrumb( ui: &mut egui::Ui, state: &mut BrowserState, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { // Logo ui.label( egui::RichText::new("af/").family(egui::FontFamily::Name(theme::LOGO_FONT_FAMILY.into())).size(16.0).color(theme::text_primary()), ) .on_hover_text(format!("audiofiles v{}", env!("CARGO_PKG_VERSION"))); // VFS selector dropdown let current_name = state .vfs_list .get(state.current_vfs_idx) .map(|v| v.name.as_str()) .unwrap_or("Vault"); let mut new_vfs_idx = None; egui::ComboBox::from_id_salt("vfs_select") .selected_text(current_name) .show_ui(ui, |ui| { for (i, vfs) in state.vfs_list.iter().enumerate() { if ui .selectable_label(i == state.current_vfs_idx, &vfs.name) .clicked() { new_vfs_idx = Some(i); } } }); if let Some(idx) = new_vfs_idx { state.select_vfs(idx); } ui.separator(); // Similarity / duplicate search view: replace the folder path with a // "Similar to: " segment so the breadcrumb reflects the active mode // instead of the folder the user happened to be in when they triggered it. if state.similarity_search_hash.is_some() { let name = state .similarity_source_name .as_deref() .unwrap_or("sample"); ui.label("/"); ui.label(widgets::accent_strong(format!("Similar to: {name}"))); // M-9: Clear lives at the breadcrumb segment so the mode label and // the exit affordance occupy one row, not two. if ui .small_button("Clear") .on_hover_text("Return to normal browsing") .clicked() { state.clear_similarity_search(); } } else if let Some(active_id) = state.active_collection { let coll_name = state.collections.iter() .find(|c| c.id == active_id) .map(|c| c.name.clone()) .unwrap_or_else(|| "Collection".to_string()); if ui .selectable_label(false, "/") .on_hover_text("Return to browsing") .clicked() { state.deactivate_collection(); } ui.label("/"); ui.label(widgets::accent_strong(&coll_name)); } else { // Root link if ui .selectable_label(state.current_dir.is_none(), "/") .on_hover_text("Go to root") .clicked() && state.current_dir.is_some() { state.current_dir = None; state.breadcrumb.clear(); state.selection.clear(); state.refresh_contents(); } // Breadcrumb path segments — iterate by reference, defer mutation let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None; let breadcrumb_len = state.breadcrumb.len(); for (i, crumb) in state.breadcrumb.iter().enumerate() { ui.label("/"); let is_last = i == breadcrumb_len - 1; let crumb_hover = if is_last { format!("Current directory: {}", crumb.name) } else { format!("Navigate to {}", crumb.name) }; if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last { nav_to = Some((crumb.id, i + 1)); } } if let Some((dir_id, truncate_at)) = nav_to { state.current_dir = Some(dir_id); state.breadcrumb.truncate(truncate_at); state.selection.clear(); state.refresh_contents(); } } // Import + Export buttons + Sync + theme selector (right-aligned) ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let import_id = ui.make_persistent_id("import_menu"); // Empty-library hint: Import is the one action that does anything when // the user has no samples yet. Bold the label so it stands out from the // (functional but currently useless) Export/Sync/Settings/Help row. let library_empty = state.contents.is_empty() && state.current_dir.is_none() && state.search_query.is_empty() && !state.search_filter.is_active(); let import_label = if library_empty { egui::RichText::new("Import").strong() } else { egui::RichText::new("Import") }; let import_btn = ui.button(import_label); if import_btn.clicked() { egui::Popup::toggle_id(ui.ctx(), import_id); } egui::Popup::from_response(&import_btn) .id(import_id) .open_memory(None) .close_behavior(egui::PopupCloseBehavior::CloseOnClick) .show(|ui| { ui.set_min_width(180.0); // C-2: label every entry point with the action it performs. // "Import folder..." now consistently means *the wizard* (strategy // pick, tag folders, analyze, review); the no-config fast path is // explicitly labelled "Quick import" so the user reads which // commit semantics they're choosing. if ui.button("Import folder...") .on_hover_text("Choose folder, pick a strategy, then import") .clicked() && let Some(path) = rfd::FileDialog::new() .set_title("Import folder") .pick_folder() { state.show_import_options(path); } if ui.button("Quick import folder...") .on_hover_text("Import without strategy or tagging review") .clicked() && let Some(path) = rfd::FileDialog::new() .set_title("Quick import folder") .pick_folder() { state.quick_import_folder(path); } ui.separator(); if ui.button("Import files...").clicked() && let Some(paths) = rfd::FileDialog::new() .set_title("Import files") .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) .pick_files() { for path in paths { state.import_path(&path); } } }); if ui.button("Export") .on_hover_text("Export current vault subtree to filesystem") .clicked() { state.start_export_flow(None); } // Sync button — fixed-width so the neighbouring Settings / Help // buttons keep their horizontal positions when sync state changes. // State communicated by a coloured bullet prefix instead of by // label width (M-4). let (sync_label, sync_color, sync_tooltip) = sync_label_color_tooltip(sync_manager); let label_text = match sync_color { Some(c) => egui::RichText::new(&sync_label).color(c), None => egui::RichText::new(&sync_label), }; if ui .add(egui::Button::new(label_text).min_size(egui::vec2(96.0, 0.0))) .on_hover_text(&sync_tooltip) .clicked() { state.sync.show_panel = !state.sync.show_panel; } // Settings gear icon if ui.button("Settings").on_hover_text("Settings").clicked() { state.settings.show_manager = !state.settings.show_manager; } // Help menu: keyboard shortcuts + About. Previously a single Help // button toggled the shortcuts overlay; About was only reachable via // Cmd+I. The dropdown groups them under one visible entry point. let help_id = ui.make_persistent_id("help_menu"); let help_btn = ui.button("Help").on_hover_text("Help (F1 opens shortcuts directly)"); if help_btn.clicked() { egui::Popup::toggle_id(ui.ctx(), help_id); } egui::Popup::from_response(&help_btn) .id(help_id) .open_memory(None) .close_behavior(egui::PopupCloseBehavior::CloseOnClick) .show(|ui| { ui.set_min_width(160.0); if ui.button("Keyboard shortcuts").clicked() { state.show_help = true; } if ui.button("About audiofiles").clicked() { state.about_requested = true; } }); }); } /// Compute the sync button label, optional state colour, and tooltip. /// The bullet glyph (\u{2022}) carries the state visually so adjacent /// toolbar buttons don't shift; the tooltip retains the long description. fn sync_label_color_tooltip( sync_manager: Option<&audiofiles_sync::SyncManager>, ) -> (String, Option, String) { use audiofiles_sync::SyncState; let Some(sync) = sync_manager else { return ("Sync".to_string(), None, "Cloud sync settings".to_string()); }; let status = sync.status(); match status.state { SyncState::Syncing => ( "\u{2022} Sync".to_string(), Some(theme::accent_blue()), "Syncing...".to_string(), ), SyncState::Ready if status.pending_changes > 0 => ( format!("\u{2022} Sync ({})", status.pending_changes), Some(theme::accent_yellow()), format!("{} pending changes", status.pending_changes), ), SyncState::Ready => ( "Sync".to_string(), None, match status.last_sync_at { Some(ref t) => format!("Synced: {t}"), None => "Connected, not yet synced".to_string(), }, ), SyncState::Disconnected => ( "\u{2022} Sync".to_string(), Some(theme::text_muted()), "Not connected".to_string(), ), _ => ("Sync".to_string(), None, "Cloud sync settings".to_string()), } }