Skip to main content

max / audiofiles

19.9 KB · 532 lines History Blame Raw
1 //! Top toolbar: VFS breadcrumb navigation, search bar, undo button, and import button.
2
3 use egui;
4
5 use crate::state::BrowserState;
6 use crate::ui::theme;
7 use crate::ui::widgets;
8
9 /// Draw the breadcrumb bar with VFS selector, path segments, search bar, and import button.
10 pub fn draw_toolbar(
11 ui: &mut egui::Ui,
12 state: &mut BrowserState,
13 sync_manager: Option<&audiofiles_sync::SyncManager>,
14 ) {
15 ui.horizontal(|ui| {
16 draw_breadcrumb(ui, state, sync_manager);
17 });
18
19 // M-9: similarity banner removed — Clear now lives inside the
20 // breadcrumb's "Similar to: <name>" segment (see draw_breadcrumb).
21
22 // Search bar
23 ui.horizontal(|ui| {
24 ui.spacing_mut().item_spacing.x = 4.0;
25 ui.label(egui::RichText::new("Search").small().color(theme::text_muted()))
26 .on_hover_text("Search (press / to focus)");
27
28 let search_edit = egui::TextEdit::singleline(&mut state.search_query)
29 .hint_text("Search samples... (/)")
30 .desired_width(ui.available_width() - 160.0);
31 let resp = ui.add(search_edit);
32
33 if state.focus_search {
34 resp.request_focus();
35 state.focus_search = false;
36 }
37
38 if resp.changed() {
39 state.apply_search();
40 }
41
42 if !state.search_query.is_empty()
43 && ui.button("Clear").on_hover_text("Clear search").clicked()
44 {
45 state.search_query.clear();
46 state.apply_search();
47 }
48
49 // Scope toggle: Folder / All
50 {
51 use audiofiles_core::search::SearchScope;
52 ui.label(egui::RichText::new("in:").small().color(theme::text_muted()));
53 if let Some(scope) = widgets::toggle_pills(
54 ui,
55 &state.search_filter.scope,
56 &[
57 (SearchScope::CurrentFolder, "Folder", "Search current folder only"),
58 (SearchScope::Global, "All", "Search all vaults"),
59 ],
60 ) {
61 state.search_filter.scope = scope;
62 state.apply_search();
63 }
64 }
65
66 // Result count when search/filters are active
67 if state.search_filter.is_active() {
68 let weak = ui.visuals().weak_text_color();
69 ui.label(egui::RichText::new(format!("{} results", state.contents.len())).small().color(weak));
70
71 // Save as Collection button (prominent when filters active)
72 let save_id = ui.make_persistent_id("save_collection_popup");
73 let save_btn = ui.button(egui::RichText::new("Save").small().color(theme::accent_blue()))
74 .on_hover_text("Save current filters as a dynamic collection");
75 if save_btn.clicked() {
76 if state.collection_filter_name_input.is_empty() {
77 state.collection_filter_name_input = state.search_filter.describe();
78 }
79 egui::Popup::toggle_id(ui.ctx(), save_id);
80 }
81 egui::Popup::from_response(&save_btn)
82 .id(save_id)
83 .open_memory(None)
84 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
85 .show(|ui| {
86 ui.set_min_width(200.0);
87 ui.label(egui::RichText::new("Save as Collection").strong());
88 ui.add_space(theme::space::SM);
89 let edit = egui::TextEdit::singleline(&mut state.collection_filter_name_input)
90 .hint_text("e.g. Kicks Under 120 BPM")
91 .desired_width(180.0);
92 let resp = ui.add(edit);
93 if resp.gained_focus() || state.collection_filter_name_input.is_empty() {
94 resp.request_focus();
95 }
96 ui.add_space(theme::space::SM);
97 let name = state.collection_filter_name_input.trim().to_string();
98 if ui.add_enabled(!name.is_empty(), egui::Button::new("Save Collection")).clicked() {
99 state.save_dynamic_collection(&name);
100 state.collection_filter_name_input.clear();
101 egui::Popup::close_id(ui.ctx(), save_id);
102 }
103 });
104 }
105
106 // Undo button
107 let undo_enabled = state.can_undo();
108 if ui
109 .add_enabled(undo_enabled, egui::Button::new("Undo"))
110 .on_hover_text("Undo (Cmd+Z)")
111 .clicked()
112 {
113 state.undo();
114 }
115
116 // M-3: collapse the six panel toggles into a single View menu when the
117 // window is too narrow to host them inline. Threshold ~900px keeps the
118 // expanded row on common desktop widths but rescues half-screen / DAW
119 // companion layouts. Same actions, single dropdown.
120 let screen_w = ui.ctx().content_rect().width();
121 let collapse_toggles = screen_w < 900.0;
122 // M-13 input (shared by both layouts).
123 let detail_too_narrow = screen_w < 700.0;
124 let detail_hidden = state.detail_visible && detail_too_narrow;
125
126 if collapse_toggles {
127 draw_view_menu(ui, state, detail_hidden);
128 } else {
129 draw_inline_panel_toggles(ui, state, detail_hidden);
130 }
131 });
132 }
133
134 /// Draw the full row of toolbar panel toggles (M-3 expanded layout).
135 fn draw_inline_panel_toggles(
136 ui: &mut egui::Ui,
137 state: &mut BrowserState,
138 detail_hidden: bool,
139 ) {
140 if widgets::toolbar_toggle(ui, "Sidebar", state.sidebar_visible, "Toggle sidebar (S)", None) {
141 state.toggle_sidebar();
142 }
143
144 // M-13: the Detail toggle conveys "active but hidden" via a muted colour
145 // and a tooltip explaining the cause; otherwise behaves like the other
146 // toolbar toggles.
147 let detail_tooltip = if detail_hidden {
148 "Detail panel hidden \u{2014} widen the window to show it. (D)"
149 } else {
150 "Toggle detail panel (D)"
151 };
152 let detail_colour = if detail_hidden {
153 theme::text_muted()
154 } else if state.detail_visible {
155 theme::accent_blue()
156 } else {
157 theme::text_muted()
158 };
159 if ui
160 .button(egui::RichText::new("Detail").color(detail_colour))
161 .on_hover_text(detail_tooltip)
162 .clicked()
163 {
164 state.toggle_detail();
165 }
166
167 if widgets::toolbar_toggle(ui, "Edit", state.edit.show_window, "Toggle sample editor (E)", None) {
168 toggle_edit_window(state);
169 }
170
171 if widgets::toolbar_toggle(ui, "Instr", state.show_midi_window, "Toggle instrument (I)", None) {
172 state.show_midi_window = !state.show_midi_window;
173 }
174
175 if widgets::toolbar_toggle(ui, "Loop", state.loop_enabled, "Toggle loop (L)", None) {
176 state.toggle_loop();
177 }
178
179 let filter_count = state.search_filter.active_count();
180 let show_count = filter_count > 0 && !state.filter_panel_open;
181 let hover = if show_count {
182 format!("{} filter{} active", filter_count, if filter_count == 1 { "" } else { "s" })
183 } else {
184 "Toggle filter panel".to_string()
185 };
186 if widgets::toolbar_toggle(ui, "Filters", state.filter_panel_open, &hover, show_count.then_some(filter_count)) {
187 state.toggle_filter_panel();
188 }
189 }
190
191 /// Collapsed "View" menu for narrow windows (M-3). Each entry mirrors a
192 /// toolbar toggle; the leading bullet marks the active state.
193 fn draw_view_menu(ui: &mut egui::Ui, state: &mut BrowserState, detail_hidden: bool) {
194 let filter_count = state.search_filter.active_count();
195 // Label hint when filters are active but the panel is closed (mirrors the
196 // count badge that the inline layout shows on the Filters toggle).
197 let label = if filter_count > 0 && !state.filter_panel_open {
198 format!("View ({filter_count}) \u{25BC}")
199 } else {
200 "View \u{25BC}".to_string()
201 };
202 ui.menu_button(label, |ui| {
203 let active_dot = |on: bool| if on { "\u{2022} " } else { " " };
204 if ui
205 .button(format!("{}Sidebar (S)", active_dot(state.sidebar_visible)))
206 .clicked()
207 {
208 state.toggle_sidebar();
209 ui.close();
210 }
211 let detail_label = if detail_hidden {
212 format!("{}Detail (D) \u{2014} hidden (widen window)", active_dot(state.detail_visible))
213 } else {
214 format!("{}Detail (D)", active_dot(state.detail_visible))
215 };
216 if ui.button(detail_label).clicked() {
217 state.toggle_detail();
218 ui.close();
219 }
220 if ui
221 .button(format!("{}Editor (E)", active_dot(state.edit.show_window)))
222 .clicked()
223 {
224 toggle_edit_window(state);
225 ui.close();
226 }
227 if ui
228 .button(format!("{}Instrument (I)", active_dot(state.show_midi_window)))
229 .clicked()
230 {
231 state.show_midi_window = !state.show_midi_window;
232 ui.close();
233 }
234 if ui
235 .button(format!("{}Loop (L)", active_dot(state.loop_enabled)))
236 .clicked()
237 {
238 state.toggle_loop();
239 ui.close();
240 }
241 let filters_label = if filter_count > 0 {
242 format!(
243 "{}Filters ({})",
244 active_dot(state.filter_panel_open),
245 filter_count
246 )
247 } else {
248 format!("{}Filters", active_dot(state.filter_panel_open))
249 };
250 if ui.button(filters_label).clicked() {
251 state.toggle_filter_panel();
252 ui.close();
253 }
254 });
255 }
256
257 /// Shared editor toggle path used by both the inline and collapsed layouts.
258 fn toggle_edit_window(state: &mut BrowserState) {
259 if state.edit.show_window {
260 state.close_edit_window();
261 } else if let Some(node) = state.selected_node()
262 && let Some(hash) = &node.node.sample_hash {
263 let hash = hash.clone();
264 state.open_edit_window(&hash);
265 }
266 }
267
268 /// Draw the VFS breadcrumb bar: VFS dropdown selector, clickable "/" root, path
269 /// segments for each ancestor directory, and a right-aligned Import button.
270 ///
271 /// Clicking a non-terminal breadcrumb segment navigates to that directory.
272 fn draw_breadcrumb(
273 ui: &mut egui::Ui,
274 state: &mut BrowserState,
275 sync_manager: Option<&audiofiles_sync::SyncManager>,
276 ) {
277 // Logo
278 ui.label(
279 egui::RichText::new("af/").family(egui::FontFamily::Name(theme::LOGO_FONT_FAMILY.into())).size(16.0).color(theme::text_primary()),
280 )
281 .on_hover_text(format!("audiofiles v{}", env!("CARGO_PKG_VERSION")));
282
283 // VFS selector dropdown
284 let current_name = state
285 .vfs_list
286 .get(state.current_vfs_idx)
287 .map(|v| v.name.as_str())
288 .unwrap_or("Vault");
289
290 let mut new_vfs_idx = None;
291 egui::ComboBox::from_id_salt("vfs_select")
292 .selected_text(current_name)
293 .show_ui(ui, |ui| {
294 for (i, vfs) in state.vfs_list.iter().enumerate() {
295 if ui
296 .selectable_label(i == state.current_vfs_idx, &vfs.name)
297 .clicked()
298 {
299 new_vfs_idx = Some(i);
300 }
301 }
302 });
303 if let Some(idx) = new_vfs_idx {
304 state.select_vfs(idx);
305 }
306
307 ui.separator();
308
309 // Similarity / duplicate search view: replace the folder path with a
310 // "Similar to: <name>" segment so the breadcrumb reflects the active mode
311 // instead of the folder the user happened to be in when they triggered it.
312 if state.similarity_search_hash.is_some() {
313 let name = state
314 .similarity_source_name
315 .as_deref()
316 .unwrap_or("sample");
317 ui.label("/");
318 ui.label(widgets::accent_strong(format!("Similar to: {name}")));
319 // M-9: Clear lives at the breadcrumb segment so the mode label and
320 // the exit affordance occupy one row, not two.
321 if ui
322 .small_button("Clear")
323 .on_hover_text("Return to normal browsing")
324 .clicked()
325 {
326 state.clear_similarity_search();
327 }
328 } else if let Some(active_id) = state.active_collection {
329 let coll_name = state.collections.iter()
330 .find(|c| c.id == active_id)
331 .map(|c| c.name.clone())
332 .unwrap_or_else(|| "Collection".to_string());
333 if ui
334 .selectable_label(false, "/")
335 .on_hover_text("Return to browsing")
336 .clicked()
337 {
338 state.deactivate_collection();
339 }
340 ui.label("/");
341 ui.label(widgets::accent_strong(&coll_name));
342 } else {
343 // Root link
344 if ui
345 .selectable_label(state.current_dir.is_none(), "/")
346 .on_hover_text("Go to root")
347 .clicked()
348 && state.current_dir.is_some()
349 {
350 state.current_dir = None;
351 state.breadcrumb.clear();
352 state.selection.clear();
353 state.refresh_contents();
354 }
355
356 // Breadcrumb path segments — iterate by reference, defer mutation
357 let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None;
358 let breadcrumb_len = state.breadcrumb.len();
359 for (i, crumb) in state.breadcrumb.iter().enumerate() {
360 ui.label("/");
361 let is_last = i == breadcrumb_len - 1;
362 let crumb_hover = if is_last {
363 format!("Current directory: {}", crumb.name)
364 } else {
365 format!("Navigate to {}", crumb.name)
366 };
367 if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last {
368 nav_to = Some((crumb.id, i + 1));
369 }
370 }
371 if let Some((dir_id, truncate_at)) = nav_to {
372 state.current_dir = Some(dir_id);
373 state.breadcrumb.truncate(truncate_at);
374 state.selection.clear();
375 state.refresh_contents();
376 }
377 }
378
379 // Import + Export buttons + Sync + theme selector (right-aligned)
380 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
381 let import_id = ui.make_persistent_id("import_menu");
382 // Empty-library hint: Import is the one action that does anything when
383 // the user has no samples yet. Bold the label so it stands out from the
384 // (functional but currently useless) Export/Sync/Settings/Help row.
385 let library_empty = state.contents.is_empty()
386 && state.current_dir.is_none()
387 && state.search_query.is_empty()
388 && !state.search_filter.is_active();
389 let import_label = if library_empty {
390 egui::RichText::new("Import").strong()
391 } else {
392 egui::RichText::new("Import")
393 };
394 let import_btn = ui.button(import_label);
395 if import_btn.clicked() {
396 egui::Popup::toggle_id(ui.ctx(), import_id);
397 }
398 egui::Popup::from_response(&import_btn)
399 .id(import_id)
400 .open_memory(None)
401 .close_behavior(egui::PopupCloseBehavior::CloseOnClick)
402 .show(|ui| {
403 ui.set_min_width(180.0);
404 // C-2: label every entry point with the action it performs.
405 // "Import folder..." now consistently means *the wizard* (strategy
406 // pick, tag folders, analyze, review); the no-config fast path is
407 // explicitly labelled "Quick import" so the user reads which
408 // commit semantics they're choosing.
409 if ui.button("Import folder...")
410 .on_hover_text("Choose folder, pick a strategy, then import")
411 .clicked()
412 && let Some(path) = rfd::FileDialog::new()
413 .set_title("Import folder")
414 .pick_folder()
415 {
416 state.show_import_options(path);
417 }
418 if ui.button("Quick import folder...")
419 .on_hover_text("Import without strategy or tagging review")
420 .clicked()
421 && let Some(path) = rfd::FileDialog::new()
422 .set_title("Quick import folder")
423 .pick_folder()
424 {
425 state.quick_import_folder(path);
426 }
427 ui.separator();
428 if ui.button("Import files...").clicked()
429 && let Some(paths) = rfd::FileDialog::new()
430 .set_title("Import files")
431 .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)
432 .pick_files()
433 {
434 for path in paths {
435 state.import_path(&path);
436 }
437 }
438 });
439
440 if ui.button("Export")
441 .on_hover_text("Export current vault subtree to filesystem")
442 .clicked()
443 {
444 state.start_export_flow(None);
445 }
446
447 // Sync button — fixed-width so the neighbouring Settings / Help
448 // buttons keep their horizontal positions when sync state changes.
449 // State communicated by a coloured bullet prefix instead of by
450 // label width (M-4).
451 let (sync_label, sync_color, sync_tooltip) = sync_label_color_tooltip(sync_manager);
452 let label_text = match sync_color {
453 Some(c) => egui::RichText::new(&sync_label).color(c),
454 None => egui::RichText::new(&sync_label),
455 };
456 if ui
457 .add(egui::Button::new(label_text).min_size(egui::vec2(96.0, 0.0)))
458 .on_hover_text(&sync_tooltip)
459 .clicked()
460 {
461 state.sync.show_panel = !state.sync.show_panel;
462 }
463
464 // Settings gear icon
465 if ui.button("Settings").on_hover_text("Settings").clicked() {
466 state.settings.show_manager = !state.settings.show_manager;
467 }
468
469 // Help menu: keyboard shortcuts + About. Previously a single Help
470 // button toggled the shortcuts overlay; About was only reachable via
471 // Cmd+I. The dropdown groups them under one visible entry point.
472 let help_id = ui.make_persistent_id("help_menu");
473 let help_btn = ui.button("Help").on_hover_text("Help (F1 opens shortcuts directly)");
474 if help_btn.clicked() {
475 egui::Popup::toggle_id(ui.ctx(), help_id);
476 }
477 egui::Popup::from_response(&help_btn)
478 .id(help_id)
479 .open_memory(None)
480 .close_behavior(egui::PopupCloseBehavior::CloseOnClick)
481 .show(|ui| {
482 ui.set_min_width(160.0);
483 if ui.button("Keyboard shortcuts").clicked() {
484 state.show_help = true;
485 }
486 if ui.button("About audiofiles").clicked() {
487 state.about_requested = true;
488 }
489 });
490 });
491 }
492
493 /// Compute the sync button label, optional state colour, and tooltip.
494 /// The bullet glyph (\u{2022}) carries the state visually so adjacent
495 /// toolbar buttons don't shift; the tooltip retains the long description.
496 fn sync_label_color_tooltip(
497 sync_manager: Option<&audiofiles_sync::SyncManager>,
498 ) -> (String, Option<egui::Color32>, String) {
499 use audiofiles_sync::SyncState;
500 let Some(sync) = sync_manager else {
501 return ("Sync".to_string(), None, "Cloud sync settings".to_string());
502 };
503 let status = sync.status();
504 match status.state {
505 SyncState::Syncing => (
506 "\u{2022} Sync".to_string(),
507 Some(theme::accent_blue()),
508 "Syncing...".to_string(),
509 ),
510 SyncState::Ready if status.pending_changes > 0 => (
511 format!("\u{2022} Sync ({})", status.pending_changes),
512 Some(theme::accent_yellow()),
513 format!("{} pending changes", status.pending_changes),
514 ),
515 SyncState::Ready => (
516 "Sync".to_string(),
517 None,
518 match status.last_sync_at {
519 Some(ref t) => format!("Synced: {t}"),
520 None => "Connected, not yet synced".to_string(),
521 },
522 ),
523 SyncState::Disconnected => (
524 "\u{2022} Sync".to_string(),
525 Some(theme::text_muted()),
526 "Not connected".to_string(),
527 ),
528 _ => ("Sync".to_string(), None, "Cloud sync settings".to_string()),
529 }
530 }
531
532