Skip to main content

max / audiofiles

44.0 KB · 1089 lines History Blame Raw
1 //! Overlay windows: help dialog, delete confirmation, and bulk operation modals.
2
3 use egui;
4 use super::theme;
5 use super::widgets::{self, ConfirmOutcome, ConfirmSpec, NameModalOutcome};
6
7 use crate::state::{BrowserState, BulkModal, ConfirmAction};
8
9 // p-5: render platform-correct modifier name in shortcut copy. Sticking with
10 // the text "Cmd" rather than the glyph keeps us inside the unicode allowlist.
11 fn cmd_key() -> &'static str {
12 #[cfg(target_os = "macos")]
13 return "Cmd";
14 #[cfg(not(target_os = "macos"))]
15 return "Ctrl";
16 }
17
18 /// Draw the help overlay showing keyboard shortcuts and feature guide.
19 pub fn draw_help_overlay(ctx: &egui::Context, state: &mut BrowserState) {
20 // M-2/M-3: shuttle show_help via a local so the closure can borrow state
21 // freely for the tab body (which now reads/writes other fields).
22 let mut open = state.show_help;
23 widgets::modal_window_with_open(
24 ctx,
25 "audiofiles",
26 Some(&mut open),
27 false,
28 Some(400.0),
29 |ui| {
30 // m-12: toggle_pills gives the two tabs a stronger affordance
31 // contract than bare selectable_value (which reads as radio).
32 if let Some(next) = widgets::toggle_pills(
33 ui,
34 &state.help_tab,
35 &[
36 (0u8, "Shortcuts", "Keyboard shortcuts"),
37 (1u8, "Features", "Feature guide"),
38 ],
39 ) {
40 state.help_tab = next;
41 }
42 ui.separator();
43
44 if state.help_tab == 0 {
45 draw_shortcuts_tab(ui, state);
46 } else {
47 draw_features_tab(ui, state);
48 }
49 },
50 );
51 // M-3: a link click inside the body may have closed the help dialog
52 // already. Honour both close paths; the X-button win is `open == false`.
53 if state.show_help {
54 state.show_help = open;
55 }
56 }
57
58 // M-2: shortcut groups with muted section labels + live substring filter.
59 // Group order is roughly first-encounter readable: navigation first, then
60 // selection, then progressively heavier ops.
61 fn draw_shortcuts_tab(ui: &mut egui::Ui, state: &mut BrowserState) {
62 // M-2: search input at the top filters both columns case-insensitively.
63 ui.horizontal(|ui| {
64 ui.label("Filter:");
65 ui.add(
66 egui::TextEdit::singleline(&mut state.help_shortcut_search)
67 .hint_text("e.g. tag, search, Cmd")
68 .desired_width(220.0),
69 );
70 if !state.help_shortcut_search.is_empty() && ui.small_button("Clear").clicked() {
71 state.help_shortcut_search.clear();
72 }
73 });
74 ui.add_space(theme::space::SM);
75
76 let query = state.help_shortcut_search.trim().to_lowercase();
77 // p-5: build chord strings with the platform modifier name. m-2 audit:
78 // slashes for alternative bindings, plus for chord (hold both) bindings.
79 let cmd = cmd_key();
80 let nav: &[(String, &str)] = &[
81 ("j / Down".to_string(), "Move down"),
82 ("k / Up".to_string(), "Move up"),
83 ("Enter / Right".to_string(), "Open / preview"),
84 ("Backspace / Left".to_string(), "Go up"),
85 ("Space".to_string(), "Play / pause"),
86 ];
87 let selection: &[(String, &str)] = &[
88 (format!("{cmd}+A"), "Select all"),
89 ("Shift+Click".to_string(), "Range select"),
90 (format!("{cmd}+Click"), "Toggle select"),
91 ];
92 let bulk: &[(String, &str)] = &[
93 (format!("{cmd}+T"), "Bulk tag (multi-select)"),
94 (format!("{cmd}+Shift+M"), "Bulk move (multi-select)"),
95 ("F2".to_string(), "Bulk rename (multi-select)"),
96 ("Delete".to_string(), "Delete selected"),
97 ];
98 let search: &[(String, &str)] = &[
99 ("/".to_string(), "Focus search"),
100 ];
101 let discovery: &[(String, &str)] = &[
102 ("Shift+F".to_string(), "Find similar samples"),
103 ("Shift+D".to_string(), "Find duplicates"),
104 ];
105 let toggles: &[(String, &str)] = &[
106 ("E".to_string(), "Toggle sample editor"),
107 ("F".to_string(), "Toggle sample forge (chop / conform / batch)"),
108 ("I".to_string(), "Toggle instrument panel"),
109 ("L".to_string(), "Toggle loop"),
110 ("S".to_string(), "Toggle sidebar"),
111 ("D".to_string(), "Toggle detail panel"),
112 ];
113 let system: &[(String, &str)] = &[
114 ("F1".to_string(), "Toggle this help"),
115 ("Escape".to_string(), "Close dialog / clear search"),
116 (format!("{cmd}+Z"), "Undo last bulk action"),
117 ];
118 let groups: &[(&str, &[(String, &str)])] = &[
119 ("Navigation", nav),
120 ("Selection", selection),
121 ("Bulk", bulk),
122 ("Search", search),
123 ("Discovery", discovery),
124 ("Toggles", toggles),
125 ("System", system),
126 ];
127
128 let matches = |key: &str, desc: &str| -> bool {
129 if query.is_empty() {
130 return true;
131 }
132 key.to_lowercase().contains(&query) || desc.to_lowercase().contains(&query)
133 };
134
135 egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
136 for (group_name, rows) in groups {
137 let visible: Vec<&(String, &str)> =
138 rows.iter().filter(|(k, d)| matches(k, d)).collect();
139 if visible.is_empty() {
140 continue;
141 }
142 ui.add_space(theme::space::SM);
143 ui.label(
144 egui::RichText::new(*group_name)
145 .small()
146 .strong()
147 .color(theme::text_muted()),
148 );
149 egui::Grid::new(format!("shortcuts_{group_name}")).show(ui, |ui| {
150 for (key, desc) in visible {
151 ui.label(key.as_str());
152 ui.label(*desc);
153 ui.end_row();
154 }
155 });
156 }
157 });
158 }
159
160 // M-3: shortcut references in feature copy render as clickable links that
161 // close the help dialog and dispatch the underlying action. Mouse-only or
162 // data-dependent flows (right-click menus) stay as plain text.
163 fn draw_features_tab(ui: &mut egui::Ui, state: &mut BrowserState) {
164 egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
165 ui.heading("Search & Filter");
166 ui.horizontal_wrapped(|ui| {
167 ui.label("Use");
168 // M-3: focus the search bar.
169 if ui.link("/").clicked() {
170 state.show_help = false;
171 state.focus_search = true;
172 }
173 ui.label("to focus the search bar. Filter by BPM range, duration, loudness, key, and classification from the filter panel (hamburger icon). Save any filter combination as a dynamic collection.");
174 });
175 ui.add_space(theme::space::MD);
176
177 ui.heading("Collections");
178 ui.label("Manual collections: right-click samples \u{2192} Add to Collection. Dynamic collections: set filters, then click Save. Dynamic collections update automatically when new samples match.");
179 ui.add_space(theme::space::MD);
180
181 ui.heading("Tags");
182 ui.horizontal_wrapped(|ui| {
183 ui.label("Use dot-notation for hierarchy (e.g. drums.kick, genre.house). Filter by tag in the sidebar tag tree. Bulk-tag with");
184 // M-3: opens the bulk tag modal for the current selection. If
185 // nothing is selected, open_bulk_tag_modal is a no-op (it early-
186 // returns on empty hashes), which is the right behaviour.
187 if ui.link(format!("{}+T", cmd_key())).clicked() {
188 state.show_help = false;
189 state.open_bulk_tag_modal();
190 }
191 ui.label(". Tag suggestions appear in the detail panel based on classification.");
192 });
193 ui.add_space(theme::space::MD);
194
195 ui.heading("Import");
196 ui.label("Quick Import: choose a folder and audiofiles indexes + analyzes everything. Files stay where they are (not copied). Duplicates are auto-skipped via content hashing.");
197 ui.add_space(theme::space::MD);
198
199 ui.heading("Export");
200 ui.label("Export to hardware samplers with device profiles (SP-404, Digitakt, MPC, etc.). Profiles auto-set format, sample rate, naming rules. Or export manually with custom settings.");
201 ui.add_space(theme::space::MD);
202
203 ui.heading("Instrument / MIDI");
204 ui.horizontal_wrapped(|ui| {
205 ui.label("Press");
206 // M-3: toggles the floating MIDI/instrument window.
207 if ui.link("I").clicked() {
208 state.show_help = false;
209 state.show_midi_window = !state.show_midi_window;
210 }
211 ui.label("to open the instrument panel. Right-click a sample \u{2192} Play as Instrument to load it. Click piano keys to play chromatically. Right-click a key to set the root note. Connect a MIDI controller for external playback.");
212 });
213 ui.add_space(theme::space::MD);
214
215 ui.heading("Sample Editor");
216 ui.horizontal_wrapped(|ui| {
217 ui.label("Press");
218 // M-3: open the sample editor for the focused sample if any.
219 if ui.link("E").clicked() {
220 state.show_help = false;
221 // M-3: mirror toolbar's Edit toggle — open the editor for the
222 // currently selected sample if any.
223 if state.edit.show_window {
224 state.close_edit_window();
225 } else if let Some(node) = state.selected_node()
226 && let Some(hash) = node.node.sample_hash.clone() {
227 state.open_edit_window(&hash);
228 }
229 }
230 ui.label("to open the editor. Trim, normalize (peak/LUFS), gain, reverse, fade in/out. Select multiple samples for batch normalize/gain/reverse. Result mode: replace original or create sibling.");
231 });
232 ui.add_space(theme::space::MD);
233
234 ui.heading("Drag & Drop");
235 ui.label("Drag samples from the file list directly into your DAW or Finder/Explorer. Drop audio files or folders onto the window to import them.");
236 ui.add_space(theme::space::MD);
237
238 ui.heading("Cloud Sync");
239 ui.label("Sync metadata (tags, organization) across devices. Set up in the Sync panel (toolbar). Metadata sync is free. Blob sync (sample files) is tiered by storage.");
240 ui.add_space(theme::space::MD);
241
242 ui.heading("System Tray");
243 ui.label("audiofiles runs in the system tray when you close the window. Right-click the tray icon for Show Window and Quit. Playback continues in the background while minimized.");
244 });
245 }
246
247 /// Draw the delete confirmation dialog.
248 pub fn draw_confirm_dialog(ctx: &egui::Context, state: &mut BrowserState) {
249 // Per-variant prompt, confirm label, detail, and danger styling. Most
250 // confirms are destructive ("Delete"); the SwitchLibrary variant isn't —
251 // its confirm label is "Switch" and renders without the red treatment.
252 // detail is owned (Option<String>) so variants can format dynamic detail
253 // text without leaking a &'static str. The borrow happens at the call site
254 // below via `.as_deref()`.
255 // m-15: title varies per variant for better window-list integration and
256 // so the modal's chrome reads as the operation being performed rather
257 // than a generic "Confirm".
258 let (title, prompt, detail, confirm_label, danger): (&str, String, Option<String>, &str, bool) = match &state.pending_confirm {
259 Some(ConfirmAction::DeleteNode { node_name, .. }) => (
260 "Delete",
261 format!("Delete \"{}\"?", node_name),
262 None,
263 "Delete",
264 true,
265 ),
266 Some(ConfirmAction::DeleteVfs { vfs_name, .. }) => (
267 "Delete vault",
268 format!("Delete vault \"{}\" and all its contents?", vfs_name),
269 None,
270 "Delete",
271 true,
272 ),
273 Some(ConfirmAction::DeleteMultiple { count, .. }) => (
274 "Delete",
275 format!("Delete {} items?", count),
276 None,
277 "Delete",
278 true,
279 ),
280 Some(ConfirmAction::DeleteCollection { coll_name, .. }) => (
281 "Delete collection",
282 format!("Delete collection \"{}\"?", coll_name),
283 None,
284 "Delete",
285 true,
286 ),
287 Some(ConfirmAction::RemoveTagGlobally { tag }) => (
288 "Remove tag",
289 format!("Remove tag \"{}\" from every sample that has it?", tag),
290 None,
291 "Remove tag",
292 true,
293 ),
294 Some(ConfirmAction::SwitchLibrary { library_name, .. }) => (
295 "Switch library",
296 format!("Switch to library \"{}\"?", library_name),
297 Some("You have in-flight work (import, sync, or bulk operation) that will be interrupted.".to_string()),
298 "Switch",
299 // m-1: detail copy frames this as destructive of in-flight work,
300 // so the affordance should match — render the confirm as danger.
301 true,
302 ),
303 Some(ConfirmAction::DisconnectSync { pending_changes }) => {
304 // Detail surfaces pending unsynced metadata (if any) plus the
305 // always-true reminder that reconnecting requires the encryption
306 // password — a typo there would brick the cloud blob (see C-1).
307 let detail = if *pending_changes > 0 {
308 format!(
309 "{pending_changes} unsynced change{} will be discarded. You'll need your encryption password to reconnect.",
310 if *pending_changes == 1 { "" } else { "s" },
311 )
312 } else {
313 "You'll need your encryption password to reconnect.".to_string()
314 };
315 (
316 "Disconnect sync",
317 "Disconnect from cloud sync?".to_string(),
318 Some(detail),
319 "Disconnect",
320 true,
321 )
322 }
323 Some(ConfirmAction::RemoveFailedSamples { single_index, count, name }) => {
324 let (prompt_str, detail_str) = match single_index {
325 Some(_) => {
326 let n = name.as_deref().unwrap_or("this file");
327 (
328 format!("Remove \"{n}\" from the library?"),
329 "The file will be permanently deleted from the content store. This cannot be undone.".to_string(),
330 )
331 }
332 None => (
333 format!(
334 "Remove {count} failed file{}?",
335 if *count == 1 { "" } else { "s" },
336 ),
337 format!(
338 "{count} file{} will be permanently deleted from the content store. This cannot be undone.",
339 if *count == 1 { "" } else { "s" },
340 ),
341 ),
342 };
343 ("Remove samples", prompt_str, Some(detail_str), "Remove", true)
344 }
345 Some(ConfirmAction::ReanalyzeOverwrite { sample_hashes, overwrite_count }) => (
346 "Re-analyze",
347 format!(
348 "Re-analyze {} sample{}?",
349 sample_hashes.len(),
350 if sample_hashes.len() == 1 { "" } else { "s" }
351 ),
352 Some(if *overwrite_count == sample_hashes.len() {
353 "This will overwrite existing BPM, key, and classification values.".to_string()
354 } else {
355 "Some samples already have BPM/key/classification — those values will be overwritten.".to_string()
356 }),
357 "Re-analyze",
358 true,
359 ),
360 Some(ConfirmAction::ReverseSamples { count }) => (
361 "Reverse samples",
362 format!("Reverse {count} samples?"),
363 Some("Each sample will be reversed in place. Click Reverse again on the same selection to restore.".to_string()),
364 "Reverse",
365 true,
366 ),
367 None => return,
368 };
369
370 let outcome = widgets::confirm_modal(ctx, &ConfirmSpec {
371 title,
372 prompt: &prompt,
373 detail: detail.as_deref(),
374 confirm_label,
375 danger,
376 });
377 match outcome {
378 ConfirmOutcome::Confirmed => state.execute_confirmed_action(),
379 ConfirmOutcome::Cancelled => state.dismiss_confirm(),
380 ConfirmOutcome::None => {}
381 }
382 }
383
384 /// Draw the Quick-Import preflight: confirms the file count and size with the
385 /// user before any files are touched. Triggered only for large imports
386 /// (≥ 100 files OR ≥ 1 GiB) so the small-folder path stays frictionless.
387 pub fn draw_import_preflight(ctx: &egui::Context, state: &mut BrowserState) {
388 let Some(preflight) = state.pending_import_preflight.clone() else { return };
389 let prompt = format!(
390 "About to import {} audio file{} (~{}) from {}",
391 preflight.file_count,
392 if preflight.file_count == 1 { "" } else { "s" },
393 widgets::format_bytes(preflight.total_bytes),
394 preflight.source.display(),
395 );
396
397 // M-9: custom-render the modal (instead of using confirm_modal) so the
398 // "Don't ask again" checkbox can live above the action row. confirm_modal
399 // is off-limits for this batch.
400 let mut outcome = ConfirmOutcome::None;
401 widgets::modal_window(ctx, "Import folder", false, None, |ui| {
402 ui.label(&prompt);
403 ui.add_space(theme::space::SM);
404 ui.label(
405 egui::RichText::new(
406 "Files stay where they are \u{2014} audiofiles only indexes them.",
407 )
408 .small()
409 .color(theme::text_secondary()),
410 );
411 ui.add_space(theme::space::MD);
412 // M-9: checkbox state is transient on BrowserState; committed only
413 // when the user confirms.
414 ui.checkbox(
415 &mut state.preflight_dont_ask,
416 "Don't ask again for folders this size",
417 );
418 ui.add_space(theme::space::LG);
419 outcome = widgets::confirm_action_row(ui, "Import", true, false);
420 });
421
422 match outcome {
423 ConfirmOutcome::Confirmed => {
424 // M-9: persist the dismissal before starting the import. Both the
425 // transient flag and the in-memory `import_preflight_disabled`
426 // mirror update so the next quick_import_folder bypass-check sees
427 // the new value without a reload.
428 if state.preflight_dont_ask {
429 if let Err(e) = state
430 .backend
431 .set_config("import_preflight_disabled", "1")
432 {
433 tracing::warn!("Failed to persist preflight dismissal: {e}");
434 }
435 state.import_preflight_disabled = true;
436 }
437 state.preflight_dont_ask = false;
438 state.accept_import_preflight();
439 }
440 ConfirmOutcome::Cancelled => {
441 state.preflight_dont_ask = false;
442 state.cancel_import_preflight();
443 }
444 ConfirmOutcome::None => {}
445 }
446 }
447
448 /// Draw the loose-files mode integrity warning overlay.
449 ///
450 /// Three-button layout (C-2): Locate (recover sources), Purge (delete the
451 /// registry entries plus their tags / analysis / history), Cancel (dismiss
452 /// without acting). Locate is the recovery path that the prior two-button
453 /// version was missing entirely.
454 pub fn draw_loose_files_warning(ctx: &egui::Context, state: &mut BrowserState) {
455 let count = state.loose_files_missing_count;
456 if count == 0 {
457 return;
458 }
459
460 let prompt = format!(
461 "{count} sample{} in this vault {} missing source {}.",
462 if count == 1 { "" } else { "s" },
463 if count == 1 { "has a" } else { "have" },
464 if count == 1 { "file" } else { "files" },
465 );
466
467 let mut action: Option<LooseFilesAction> = None;
468 widgets::modal_window(ctx, "Loose-files mode warning", false, Some(480.0), |ui| {
469 ui.label(egui::RichText::new(&prompt).strong());
470 ui.add_space(theme::space::SM);
471 ui.label(
472 "The original files may have been moved or deleted. These samples \
473 cannot be played or exported until the files are restored.",
474 );
475 ui.add_space(theme::space::SM);
476 // C-2: name the blast radius of Purge explicitly. Tags, analysis, and
477 // history are the data the user has invested time in — they should
478 // see what Purge takes before reaching for it.
479 ui.label(
480 egui::RichText::new(
481 "Tags, analysis results, and history for these samples will \
482 be permanently deleted by Purge.",
483 )
484 .small()
485 .color(theme::accent_yellow()),
486 );
487 ui.add_space(theme::space::MD);
488 ui.horizontal(|ui| {
489 if ui.button("Cancel").on_hover_text("Dismiss without acting").clicked() {
490 action = Some(LooseFilesAction::Cancel);
491 }
492 if ui
493 .button("Locate missing files...")
494 .on_hover_text(
495 "Pick a folder; audiofiles will hash-verify and re-point any \
496 samples that match. Tags and analysis are preserved.",
497 )
498 .clicked()
499 {
500 action = Some(LooseFilesAction::Locate);
501 }
502 if widgets::danger_button(ui, "Purge").clicked() {
503 action = Some(LooseFilesAction::Purge);
504 }
505 });
506 });
507
508 match action {
509 Some(LooseFilesAction::Cancel) => state.dismiss_loose_files_warning(),
510 Some(LooseFilesAction::Purge) => state.purge_missing_loose_files(),
511 Some(LooseFilesAction::Locate) => {
512 if let Some(path) = rfd::FileDialog::new()
513 .set_title("Locate missing sample files")
514 .pick_folder()
515 {
516 state.locate_missing_loose_files(path);
517 }
518 }
519 None => {}
520 }
521 }
522
523 /// Outcome of the loose-files-warning dialog (C-2).
524 enum LooseFilesAction {
525 Cancel,
526 Locate,
527 Purge,
528 }
529
530 /// Draw the active bulk modal (tag, move, or rename).
531 pub fn draw_bulk_modal(ctx: &egui::Context, state: &mut BrowserState) {
532 let modal_kind = match &state.bulk_modal {
533 Some(BulkModal::Tag { .. }) => "tag",
534 Some(BulkModal::Move { .. }) => "move",
535 Some(BulkModal::Rename { .. }) => "rename",
536 None => return,
537 };
538
539 match modal_kind {
540 "tag" => draw_bulk_tag_modal(ctx, state),
541 "move" => draw_bulk_move_modal(ctx, state),
542 "rename" => draw_bulk_rename_modal(ctx, state),
543 _ => {}
544 }
545 }
546
547 /// Draw the bulk tag modal: add or remove a tag from all selected samples.
548 ///
549 /// Uses a two-flag pattern (`should_close`, `should_execute`) because egui closures
550 /// borrow `state` — mutations must happen after the window closure returns.
551 fn draw_bulk_tag_modal(ctx: &egui::Context, state: &mut BrowserState) {
552 let mut should_close = false;
553 let mut should_execute = false;
554
555 // M-4: clone the tag list once up front so the autocomplete row can read
556 // it without conflicting with the &mut borrow of state.bulk_modal inside
557 // the closure.
558 let all_tags: Vec<String> = state.all_tags.iter().cloned().collect();
559
560 widgets::modal_window(ctx, "Bulk Tag", false, Some(350.0), |ui| {
561 if let Some(BulkModal::Tag {
562 ref mut tag_input,
563 ref mut adding,
564 ref names,
565 ..
566 }) = state.bulk_modal
567 {
568 ui.heading(format!("Tag {} samples", names.len()));
569 ui.add_space(theme::space::SM);
570
571 ui.horizontal(|ui| {
572 ui.selectable_value(adding, true, "Add tag");
573 ui.selectable_value(adding, false, "Remove tag");
574 });
575 ui.add_space(theme::space::SM);
576
577 let resp = ui.add(
578 egui::TextEdit::singleline(tag_input)
579 .hint_text("e.g. genre.electronic")
580 .desired_width(300.0),
581 );
582 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
583 should_execute = true;
584 }
585
586 ui.add_space(theme::space::SM);
587
588 // M-4: autocomplete chips. Substring match, case-insensitive,
589 // capped at 12. Click replaces tag_input (single-tag modal).
590 let trimmed = tag_input.trim().to_lowercase();
591 if !trimmed.is_empty() {
592 let suggestions: Vec<&String> = all_tags
593 .iter()
594 .filter(|t| t.to_lowercase().contains(&trimmed) && t.as_str() != tag_input)
595 .take(12)
596 .collect();
597 if !suggestions.is_empty() {
598 ui.horizontal_wrapped(|ui| {
599 for tag in suggestions {
600 if widgets::selectable_tag(ui, false, tag.as_str()).clicked() {
601 *tag_input = tag.clone();
602 }
603 }
604 });
605 ui.add_space(theme::space::SM);
606 }
607 }
608
609 // M-5: when removing, surface whether the tag is even known. We
610 // intentionally avoid the O(samples) per-frame check via
611 // get_sample_tags — for 1000-sample selections that would
612 // re-scan every frame. Cheaper proxy: if the tag isn't in
613 // all_tags at all, count is provably 0 and we can disable
614 // Apply. Otherwise we hedge with copy that names the
615 // uncertainty.
616 let mut apply_enabled = true;
617 let mut apply_hover_disabled: Option<&'static str> = None;
618 if !*adding && !tag_input.trim().is_empty() {
619 let typed = tag_input.trim();
620 let known = all_tags.iter().any(|t| t == typed);
621 if !known {
622 ui.label(
623 egui::RichText::new("None of the selected samples have this tag.")
624 .small()
625 .color(theme::accent_yellow()),
626 );
627 apply_enabled = false;
628 apply_hover_disabled = Some("None of the selected samples have this tag.");
629 } else {
630 ui.label(
631 egui::RichText::new(
632 "Will remove from selected samples that have this tag.",
633 )
634 .small()
635 .color(theme::text_secondary()),
636 );
637 }
638 ui.add_space(theme::space::SM);
639 }
640
641 egui::ScrollArea::vertical()
642 .max_height(120.0)
643 .show(ui, |ui| {
644 for name in names {
645 ui.label(
646 egui::RichText::new(name).small().color(theme::text_secondary()),
647 );
648 }
649 });
650
651 ui.add_space(theme::space::MD);
652 // p-4: Cmd+Enter (Ctrl+Enter on non-mac) confirms primary action,
653 // gated by the same apply_enabled check the button uses (m-5).
654 if apply_enabled
655 && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))
656 {
657 should_execute = true;
658 }
659 // M-5: confirm_action_row doesn't accept a disabled flag, so we
660 // hand-render the row to add `on_disabled_hover_text` honestly.
661 ui.horizontal(|ui| {
662 if ui.button("Cancel").clicked() {
663 should_close = true;
664 }
665 let btn = ui.add_enabled(apply_enabled, egui::Button::new("Apply"));
666 let btn = if let Some(hint) = apply_hover_disabled {
667 btn.on_disabled_hover_text(hint)
668 } else {
669 btn
670 };
671 if btn.clicked() {
672 should_execute = true;
673 }
674 });
675 }
676 });
677
678 if should_execute {
679 state.execute_bulk_tag();
680 } else if should_close {
681 state.close_bulk_modal();
682 }
683 }
684
685 /// Draw the bulk move modal: pick a destination directory for all selected items.
686 ///
687 /// Presents a scrollable list of directories in the current VFS, plus a root option.
688 fn draw_bulk_move_modal(ctx: &egui::Context, state: &mut BrowserState) {
689 let mut should_close = false;
690 let mut should_execute = false;
691
692 widgets::modal_window(ctx, "Move Items", false, Some(400.0), |ui| {
693 if let Some(BulkModal::Move {
694 ref names,
695 ref directories,
696 ref mut selected_idx,
697 ..
698 }) = state.bulk_modal
699 {
700 ui.heading(format!("Move {} items", names.len()));
701 ui.add_space(theme::space::SM);
702 ui.label("Select destination folder:");
703 ui.add_space(theme::space::SM);
704
705 // M-6: substring filter (case-insensitive) over the directory paths.
706 ui.add(
707 egui::TextEdit::singleline(&mut state.bulk_move_filter)
708 .hint_text("Filter folders...")
709 .desired_width(360.0),
710 );
711 ui.add_space(theme::space::SM);
712
713 let filter = state.bulk_move_filter.trim().to_lowercase();
714
715 egui::ScrollArea::vertical()
716 .max_height(200.0)
717 .show(ui, |ui| {
718 // Show the root only when the filter is empty — the
719 // string "/" is too short to be meaningful in a filter.
720 if filter.is_empty() {
721 let is_root_selected = selected_idx.is_none();
722 if ui
723 .selectable_label(is_root_selected, "/")
724 .clicked()
725 {
726 *selected_idx = None;
727 }
728 }
729
730 for (i, (_, path)) in directories.iter().enumerate() {
731 if !filter.is_empty() && !path.to_lowercase().contains(&filter) {
732 continue;
733 }
734 let is_selected = *selected_idx == Some(i);
735 if ui.selectable_label(is_selected, path).clicked() {
736 *selected_idx = Some(i);
737 }
738 }
739 });
740
741 ui.add_space(theme::space::MD);
742 // p-4: Cmd+Enter confirms the primary action.
743 if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) {
744 should_execute = true;
745 }
746 match widgets::confirm_action_row(ui, "Move", true, false) {
747 ConfirmOutcome::Confirmed => should_execute = true,
748 ConfirmOutcome::Cancelled => should_close = true,
749 ConfirmOutcome::None => {}
750 }
751 }
752 });
753
754 if should_execute {
755 // M-6: reset the filter when the modal closes (execute path).
756 state.bulk_move_filter.clear();
757 state.execute_bulk_move();
758 } else if should_close {
759 // M-6: reset the filter when the modal closes (cancel path).
760 state.bulk_move_filter.clear();
761 state.close_bulk_modal();
762 }
763 }
764
765 /// Draw the bulk rename modal: pattern-based renaming with live preview.
766 ///
767 /// Tokens like `{name}`, `{bpm}`, `{key}` are expanded per-sample. A side-by-side
768 /// preview grid shows old→new names, updated on every keystroke. The Rename button
769 /// is disabled when the pattern produces an error (e.g., empty result).
770 fn draw_bulk_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
771 let mut should_close = false;
772 let mut should_execute = false;
773 let mut pattern_changed = false;
774
775 widgets::modal_window(ctx, "Bulk Rename", true, Some(500.0), |ui| {
776 if let Some(BulkModal::Rename {
777 ref mut pattern_input,
778 ref previews,
779 ref error,
780 ..
781 }) = state.bulk_modal
782 {
783 ui.heading("Rename Pattern");
784 ui.add_space(theme::space::SM);
785
786 ui.horizontal_wrapped(|ui| {
787 for token in &[
788 "{name}", "{ext}", "{bpm}", "{key}", "{class}", "{duration}", "{n}",
789 "{nn}", "{nnn}",
790 ] {
791 if ui
792 .small_button(
793 egui::RichText::new(*token)
794 .small()
795 .color(theme::accent_blue()),
796 )
797 .clicked()
798 {
799 pattern_input.push_str(token);
800 pattern_changed = true;
801 }
802 }
803 });
804 ui.add_space(theme::space::SM);
805
806 let resp = ui.add(
807 egui::TextEdit::singleline(pattern_input)
808 .hint_text("{name}_{bpm}")
809 .desired_width(460.0),
810 );
811 if resp.changed() {
812 pattern_changed = true;
813 }
814
815 if let Some(err) = error {
816 ui.colored_label(theme::accent_red(), err);
817 }
818
819 ui.add_space(theme::space::SM);
820
821 if !previews.is_empty() {
822 // M-8: count duplicate output names once per frame so we can
823 // highlight colliding rows. Counting once is the whole point —
824 // doing it per-row would be O(n^2).
825 let mut new_counts: std::collections::HashMap<&str, usize> =
826 std::collections::HashMap::with_capacity(previews.len());
827 for (_, new) in previews {
828 *new_counts.entry(new.as_str()).or_insert(0) += 1;
829 }
830
831 // M-7: cap the rendered preview to keep the modal responsive
832 // on big selections. egui::Grid materialises every cell every
833 // frame; for a 500-row rename this matters.
834 const PREVIEW_CAP: usize = 50;
835 let total = previews.len();
836 let visible_count = total.min(PREVIEW_CAP);
837
838 egui::ScrollArea::vertical()
839 .max_height(200.0)
840 .show(ui, |ui| {
841 egui::Grid::new("rename_preview")
842 .striped(true)
843 .show(ui, |ui| {
844 ui.label(
845 egui::RichText::new("Old")
846 .strong()
847 .color(theme::text_secondary()),
848 );
849 ui.label(
850 egui::RichText::new("New")
851 .strong()
852 .color(theme::accent_blue()),
853 );
854 ui.end_row();
855
856 for (old, new) in previews.iter().take(visible_count) {
857 ui.label(old);
858 // M-8: duplicate output names render in
859 // accent_yellow with an honest hover —
860 // backend collision behaviour isn't
861 // verified here, so we describe risk not
862 // outcome.
863 let is_dup = new_counts
864 .get(new.as_str())
865 .copied()
866 .unwrap_or(0)
867 > 1;
868 let color = if is_dup {
869 theme::accent_yellow()
870 } else {
871 theme::accent_blue()
872 };
873 let label = ui.label(
874 egui::RichText::new(new).color(color),
875 );
876 if is_dup {
877 label.on_hover_text(
878 "Duplicate output name \u{2014} rename will collide on commit.",
879 );
880 }
881 ui.end_row();
882 }
883 });
884
885 // M-7: muted overflow notice when we've capped the preview.
886 if total > visible_count {
887 ui.add_space(theme::space::XS);
888 ui.label(
889 egui::RichText::new(format!(
890 "...and {} more (preview only shows the first {}).",
891 total - visible_count,
892 PREVIEW_CAP,
893 ))
894 .small()
895 .color(theme::text_muted()),
896 );
897 }
898 });
899 }
900
901 ui.add_space(theme::space::MD);
902 let can_rename = error.is_none() && !previews.is_empty();
903 // p-4: Cmd+Enter confirms primary action, gated by can_rename.
904 if can_rename
905 && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))
906 {
907 should_execute = true;
908 }
909 match widgets::confirm_action_row(ui, "Rename", can_rename, false) {
910 ConfirmOutcome::Confirmed => should_execute = true,
911 ConfirmOutcome::Cancelled => should_close = true,
912 ConfirmOutcome::None => {}
913 }
914 }
915 });
916
917 if pattern_changed {
918 state.update_rename_previews();
919 }
920 if should_execute {
921 state.execute_bulk_rename();
922 } else if should_close {
923 state.close_bulk_modal();
924 }
925 }
926
927 /// Draw the "New Vault" modal: text input for vault name.
928 pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
929 let hint = "A vault is a separate sample collection \u{2014} like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.";
930 // C-3: clone the error into a local so the &mut input borrow doesn't
931 // conflict with the immutable error borrow into name_modal.
932 let error_owned = state.name_modal_error.clone();
933 match widgets::name_modal(
934 ctx, "New Vault", Some(hint), "Vault name:",
935 &mut state.vfs_create_input, "Create",
936 error_owned.as_deref(),
937 ) {
938 NameModalOutcome::Submitted(name) => {
939 if name.is_empty() {
940 // Empty submit = no-op cancel. Close without erroring.
941 state.show_vfs_create = false;
942 state.name_modal_error = None;
943 } else {
944 match state.backend.create_vfs(&name) {
945 Ok(_) => {
946 state.refresh_vfs_list();
947 state.status = format!("Created vault: {name}");
948 state.show_vfs_create = false;
949 state.name_modal_error = None;
950 }
951 Err(e) => {
952 // C-3: keep modal open; surface error inline so the
953 // user can edit and retry without re-typing the name.
954 state.name_modal_error = Some(format!("{e}"));
955 }
956 }
957 }
958 }
959 NameModalOutcome::Cancelled => {
960 state.show_vfs_create = false;
961 state.name_modal_error = None;
962 }
963 NameModalOutcome::None => {}
964 }
965 }
966
967 /// Draw the "Rename Vault" modal: text input pre-filled with current name.
968 pub fn draw_vfs_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
969 let error_owned = state.name_modal_error.clone();
970 let outcome = if let Some((_, ref mut name_buf)) = state.vfs_rename_target {
971 widgets::name_modal(
972 ctx, "Rename Vault", Some("Vault names can contain spaces."),
973 "New name:", name_buf, "Save",
974 error_owned.as_deref(),
975 )
976 } else {
977 return;
978 };
979 match outcome {
980 NameModalOutcome::Submitted(new_name) => {
981 if new_name.is_empty() {
982 state.vfs_rename_target = None;
983 state.name_modal_error = None;
984 } else {
985 let vfs_id = state.vfs_rename_target.as_ref().map(|(id, _)| *id);
986 if let Some(vfs_id) = vfs_id {
987 match state.backend.rename_vfs(vfs_id, &new_name) {
988 Ok(()) => {
989 state.refresh_vfs_list();
990 state.status = format!("Renamed vault to: {new_name}");
991 state.vfs_rename_target = None;
992 state.name_modal_error = None;
993 }
994 Err(e) => {
995 // C-3: keep modal open with the typed name preserved.
996 state.name_modal_error = Some(format!("{e}"));
997 }
998 }
999 }
1000 }
1001 }
1002 NameModalOutcome::Cancelled => {
1003 state.vfs_rename_target = None;
1004 state.name_modal_error = None;
1005 }
1006 NameModalOutcome::None => {}
1007 }
1008 }
1009
1010 /// Draw the "New Folder" modal: text input for folder name.
1011 pub fn draw_dir_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
1012 let error_owned = state.name_modal_error.clone();
1013 match widgets::name_modal(
1014 ctx, "New Folder", Some("Folder names cannot contain /"),
1015 "Folder name:",
1016 &mut state.dir_create_input, "Create",
1017 error_owned.as_deref(),
1018 ) {
1019 NameModalOutcome::Submitted(name) => {
1020 if name.is_empty() {
1021 state.show_dir_create = false;
1022 state.name_modal_error = None;
1023 } else {
1024 let vfs_id = state.vfs_list[state.current_vfs_idx].id;
1025 match state.backend.create_directory(vfs_id, state.current_dir, &name) {
1026 Ok(_) => {
1027 state.refresh_contents();
1028 state.status = format!("Created folder: {name}");
1029 state.show_dir_create = false;
1030 state.name_modal_error = None;
1031 }
1032 Err(e) => {
1033 // C-3.
1034 state.name_modal_error = Some(format!("{e}"));
1035 }
1036 }
1037 }
1038 }
1039 NameModalOutcome::Cancelled => {
1040 state.show_dir_create = false;
1041 state.name_modal_error = None;
1042 }
1043 NameModalOutcome::None => {}
1044 }
1045 }
1046
1047 /// Draw the "Rename Folder" modal: text input pre-filled with current name.
1048 pub fn draw_dir_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
1049 let error_owned = state.name_modal_error.clone();
1050 let outcome = if let Some((_, ref mut name_buf)) = state.dir_rename_target {
1051 widgets::name_modal(
1052 ctx, "Rename", None, "New name:", name_buf, "Save",
1053 error_owned.as_deref(),
1054 )
1055 } else {
1056 return;
1057 };
1058 match outcome {
1059 NameModalOutcome::Submitted(new_name) => {
1060 if new_name.is_empty() {
1061 state.dir_rename_target = None;
1062 state.name_modal_error = None;
1063 } else {
1064 let node_id = state.dir_rename_target.as_ref().map(|(id, _)| *id);
1065 if let Some(node_id) = node_id {
1066 match state.backend.rename_node(node_id, &new_name) {
1067 Ok(()) => {
1068 state.refresh_contents();
1069 state.status = format!("Renamed to: {new_name}");
1070 state.dir_rename_target = None;
1071 state.name_modal_error = None;
1072 }
1073 Err(e) => {
1074 // C-3.
1075 state.name_modal_error = Some(format!("{e}"));
1076 }
1077 }
1078 }
1079 }
1080 }
1081 NameModalOutcome::Cancelled => {
1082 state.dir_rename_target = None;
1083 state.name_modal_error = None;
1084 }
1085 NameModalOutcome::None => {}
1086 }
1087 }
1088
1089