Skip to main content

max / audiofiles

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