//! Overlay windows: help dialog, delete confirmation, and bulk operation modals. use egui; use super::theme; use super::widgets::{self, ConfirmOutcome, ConfirmSpec, NameModalOutcome}; use crate::state::{BrowserState, BulkModal, ConfirmAction}; // p-5: render platform-correct modifier name in shortcut copy. Sticking with // the text "Cmd" rather than the glyph keeps us inside the unicode allowlist. fn cmd_key() -> &'static str { #[cfg(target_os = "macos")] return "Cmd"; #[cfg(not(target_os = "macos"))] return "Ctrl"; } /// Draw the help overlay showing keyboard shortcuts and feature guide. pub fn draw_help_overlay(ctx: &egui::Context, state: &mut BrowserState) { // M-2/M-3: shuttle show_help via a local so the closure can borrow state // freely for the tab body (which now reads/writes other fields). let mut open = state.show_help; widgets::modal_window_with_open( ctx, "audiofiles", Some(&mut open), false, Some(400.0), |ui| { // m-12: toggle_pills gives the two tabs a stronger affordance // contract than bare selectable_value (which reads as radio). if let Some(next) = widgets::toggle_pills( ui, &state.help_tab, &[ (0u8, "Shortcuts", "Keyboard shortcuts"), (1u8, "Features", "Feature guide"), ], ) { state.help_tab = next; } ui.separator(); if state.help_tab == 0 { draw_shortcuts_tab(ui, state); } else { draw_features_tab(ui, state); } }, ); // M-3: a link click inside the body may have closed the help dialog // already. Honour both close paths; the X-button win is `open == false`. if state.show_help { state.show_help = open; } } // M-2: shortcut groups with muted section labels + live substring filter. // Group order is roughly first-encounter readable: navigation first, then // selection, then progressively heavier ops. fn draw_shortcuts_tab(ui: &mut egui::Ui, state: &mut BrowserState) { // M-2: search input at the top filters both columns case-insensitively. ui.horizontal(|ui| { ui.label("Filter:"); ui.add( egui::TextEdit::singleline(&mut state.help_shortcut_search) .hint_text("e.g. tag, search, Cmd") .desired_width(220.0), ); if !state.help_shortcut_search.is_empty() && ui.small_button("Clear").clicked() { state.help_shortcut_search.clear(); } }); ui.add_space(theme::space::SM); let query = state.help_shortcut_search.trim().to_lowercase(); // p-5: build chord strings with the platform modifier name. m-2 audit: // slashes for alternative bindings, plus for chord (hold both) bindings. let cmd = cmd_key(); let nav: &[(String, &str)] = &[ ("j / Down".to_string(), "Move down"), ("k / Up".to_string(), "Move up"), ("Enter / Right".to_string(), "Open / preview"), ("Backspace / Left".to_string(), "Go up"), ("Space".to_string(), "Play / pause"), ]; let selection: &[(String, &str)] = &[ (format!("{cmd}+A"), "Select all"), ("Shift+Click".to_string(), "Range select"), (format!("{cmd}+Click"), "Toggle select"), ]; let bulk: &[(String, &str)] = &[ (format!("{cmd}+T"), "Bulk tag (multi-select)"), (format!("{cmd}+Shift+M"), "Bulk move (multi-select)"), ("F2".to_string(), "Bulk rename (multi-select)"), ("Delete".to_string(), "Delete selected"), ]; let search: &[(String, &str)] = &[ ("/".to_string(), "Focus search"), ]; let discovery: &[(String, &str)] = &[ ("Shift+F".to_string(), "Find similar samples"), ("Shift+D".to_string(), "Find duplicates"), ]; let toggles: &[(String, &str)] = &[ ("E".to_string(), "Toggle sample editor"), ("F".to_string(), "Toggle sample forge (chop / conform / batch)"), ("I".to_string(), "Toggle instrument panel"), ("L".to_string(), "Toggle loop"), ("S".to_string(), "Toggle sidebar"), ("D".to_string(), "Toggle detail panel"), ]; let system: &[(String, &str)] = &[ ("F1".to_string(), "Toggle this help"), ("Escape".to_string(), "Close dialog / clear search"), (format!("{cmd}+Z"), "Undo last bulk action"), ]; let groups: &[(&str, &[(String, &str)])] = &[ ("Navigation", nav), ("Selection", selection), ("Bulk", bulk), ("Search", search), ("Discovery", discovery), ("Toggles", toggles), ("System", system), ]; let matches = |key: &str, desc: &str| -> bool { if query.is_empty() { return true; } key.to_lowercase().contains(&query) || desc.to_lowercase().contains(&query) }; egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { for (group_name, rows) in groups { let visible: Vec<&(String, &str)> = rows.iter().filter(|(k, d)| matches(k, d)).collect(); if visible.is_empty() { continue; } ui.add_space(theme::space::SM); ui.label( egui::RichText::new(*group_name) .small() .strong() .color(theme::text_muted()), ); egui::Grid::new(format!("shortcuts_{group_name}")).show(ui, |ui| { for (key, desc) in visible { ui.label(key.as_str()); ui.label(*desc); ui.end_row(); } }); } }); } // M-3: shortcut references in feature copy render as clickable links that // close the help dialog and dispatch the underlying action. Mouse-only or // data-dependent flows (right-click menus) stay as plain text. fn draw_features_tab(ui: &mut egui::Ui, state: &mut BrowserState) { egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { ui.heading("Search & Filter"); ui.horizontal_wrapped(|ui| { ui.label("Use"); // M-3: focus the search bar. if ui.link("/").clicked() { state.show_help = false; state.focus_search = true; } 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."); }); ui.add_space(theme::space::MD); ui.heading("Collections"); 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."); ui.add_space(theme::space::MD); ui.heading("Tags"); ui.horizontal_wrapped(|ui| { ui.label("Use dot-notation for hierarchy (e.g. drums.kick, genre.house). Filter by tag in the sidebar tag tree. Bulk-tag with"); // M-3: opens the bulk tag modal for the current selection. If // nothing is selected, open_bulk_tag_modal is a no-op (it early- // returns on empty hashes), which is the right behaviour. if ui.link(format!("{}+T", cmd_key())).clicked() { state.show_help = false; state.open_bulk_tag_modal(); } ui.label(". Tag suggestions appear in the detail panel based on classification."); }); ui.add_space(theme::space::MD); ui.heading("Import"); 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."); ui.add_space(theme::space::MD); ui.heading("Export"); 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."); ui.add_space(theme::space::MD); ui.heading("Instrument / MIDI"); ui.horizontal_wrapped(|ui| { ui.label("Press"); // M-3: toggles the floating MIDI/instrument window. if ui.link("I").clicked() { state.show_help = false; state.show_midi_window = !state.show_midi_window; } 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."); }); ui.add_space(theme::space::MD); ui.heading("Sample Editor"); ui.horizontal_wrapped(|ui| { ui.label("Press"); // M-3: open the sample editor for the focused sample if any. if ui.link("E").clicked() { state.show_help = false; // M-3: mirror toolbar's Edit toggle — open the editor for the // currently selected sample if any. if state.edit.show_window { state.close_edit_window(); } else if let Some(node) = state.selected_node() && let Some(hash) = node.node.sample_hash.clone() { state.open_edit_window(&hash); } } 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."); }); ui.add_space(theme::space::MD); ui.heading("Drag & Drop"); 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."); ui.add_space(theme::space::MD); ui.heading("Cloud Sync"); 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."); ui.add_space(theme::space::MD); ui.heading("System Tray"); 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."); }); } /// Draw the delete confirmation dialog. pub fn draw_confirm_dialog(ctx: &egui::Context, state: &mut BrowserState) { // Per-variant prompt, confirm label, detail, and danger styling. Most // confirms are destructive ("Delete"); the SwitchLibrary variant isn't — // its confirm label is "Switch" and renders without the red treatment. // detail is owned (Option) so variants can format dynamic detail // text without leaking a &'static str. The borrow happens at the call site // below via `.as_deref()`. // m-15: title varies per variant for better window-list integration and // so the modal's chrome reads as the operation being performed rather // than a generic "Confirm". let (title, prompt, detail, confirm_label, danger): (&str, String, Option, &str, bool) = match &state.pending_confirm { Some(ConfirmAction::DeleteNode { node_name, .. }) => ( "Delete", format!("Delete \"{}\"?", node_name), None, "Delete", true, ), Some(ConfirmAction::DeleteVfs { vfs_name, .. }) => ( "Delete vault", format!("Delete vault \"{}\" and all its contents?", vfs_name), None, "Delete", true, ), Some(ConfirmAction::DeleteMultiple { count, .. }) => ( "Delete", format!("Delete {} items?", count), None, "Delete", true, ), Some(ConfirmAction::DeleteCollection { coll_name, .. }) => ( "Delete collection", format!("Delete collection \"{}\"?", coll_name), None, "Delete", true, ), Some(ConfirmAction::RemoveTagGlobally { tag }) => ( "Remove tag", format!("Remove tag \"{}\" from every sample that has it?", tag), None, "Remove tag", true, ), Some(ConfirmAction::SwitchLibrary { library_name, .. }) => ( "Switch library", format!("Switch to library \"{}\"?", library_name), Some("You have in-flight work (import, sync, or bulk operation) that will be interrupted.".to_string()), "Switch", // m-1: detail copy frames this as destructive of in-flight work, // so the affordance should match — render the confirm as danger. true, ), Some(ConfirmAction::DisconnectSync { pending_changes }) => { // Detail surfaces pending unsynced metadata (if any) plus the // always-true reminder that reconnecting requires the encryption // password — a typo there would brick the cloud blob (see C-1). let detail = if *pending_changes > 0 { format!( "{pending_changes} unsynced change{} will be discarded. You'll need your encryption password to reconnect.", if *pending_changes == 1 { "" } else { "s" }, ) } else { "You'll need your encryption password to reconnect.".to_string() }; ( "Disconnect sync", "Disconnect from cloud sync?".to_string(), Some(detail), "Disconnect", true, ) } Some(ConfirmAction::RemoveFailedSamples { single_index, count, name }) => { let (prompt_str, detail_str) = match single_index { Some(_) => { let n = name.as_deref().unwrap_or("this file"); ( format!("Remove \"{n}\" from the library?"), "The file will be permanently deleted from the content store. This cannot be undone.".to_string(), ) } None => ( format!( "Remove {count} failed file{}?", if *count == 1 { "" } else { "s" }, ), format!( "{count} file{} will be permanently deleted from the content store. This cannot be undone.", if *count == 1 { "" } else { "s" }, ), ), }; ("Remove samples", prompt_str, Some(detail_str), "Remove", true) } Some(ConfirmAction::ReanalyzeOverwrite { sample_hashes, overwrite_count }) => ( "Re-analyze", format!( "Re-analyze {} sample{}?", sample_hashes.len(), if sample_hashes.len() == 1 { "" } else { "s" } ), Some(if *overwrite_count == sample_hashes.len() { "This will overwrite existing BPM, key, and classification values.".to_string() } else { "Some samples already have BPM/key/classification — those values will be overwritten.".to_string() }), "Re-analyze", true, ), Some(ConfirmAction::ReverseSamples { count }) => ( "Reverse samples", format!("Reverse {count} samples?"), Some("Each sample will be reversed in place. Click Reverse again on the same selection to restore.".to_string()), "Reverse", true, ), None => return, }; let outcome = widgets::confirm_modal(ctx, &ConfirmSpec { title, prompt: &prompt, detail: detail.as_deref(), confirm_label, danger, }); match outcome { ConfirmOutcome::Confirmed => state.execute_confirmed_action(), ConfirmOutcome::Cancelled => state.dismiss_confirm(), ConfirmOutcome::None => {} } } /// Draw the Quick-Import preflight: confirms the file count and size with the /// user before any files are touched. Triggered only for large imports /// (≥ 100 files OR ≥ 1 GiB) so the small-folder path stays frictionless. pub fn draw_import_preflight(ctx: &egui::Context, state: &mut BrowserState) { let Some(preflight) = state.pending_import_preflight.clone() else { return }; let prompt = format!( "About to import {} audio file{} (~{}) from {}", preflight.file_count, if preflight.file_count == 1 { "" } else { "s" }, widgets::format_bytes(preflight.total_bytes), preflight.source.display(), ); // M-9: custom-render the modal (instead of using confirm_modal) so the // "Don't ask again" checkbox can live above the action row. confirm_modal // is off-limits for this batch. let mut outcome = ConfirmOutcome::None; widgets::modal_window(ctx, "Import folder", false, None, |ui| { ui.label(&prompt); ui.add_space(theme::space::SM); ui.label( egui::RichText::new( "Files stay where they are \u{2014} audiofiles only indexes them.", ) .small() .color(theme::text_secondary()), ); ui.add_space(theme::space::MD); // M-9: checkbox state is transient on BrowserState; committed only // when the user confirms. ui.checkbox( &mut state.preflight_dont_ask, "Don't ask again for folders this size", ); ui.add_space(theme::space::LG); outcome = widgets::confirm_action_row(ui, "Import", true, false); }); match outcome { ConfirmOutcome::Confirmed => { // M-9: persist the dismissal before starting the import. Both the // transient flag and the in-memory `import_preflight_disabled` // mirror update so the next quick_import_folder bypass-check sees // the new value without a reload. if state.preflight_dont_ask { if let Err(e) = state .backend .set_config("import_preflight_disabled", "1") { tracing::warn!("Failed to persist preflight dismissal: {e}"); } state.import_preflight_disabled = true; } state.preflight_dont_ask = false; state.accept_import_preflight(); } ConfirmOutcome::Cancelled => { state.preflight_dont_ask = false; state.cancel_import_preflight(); } ConfirmOutcome::None => {} } } /// Draw the loose-files mode integrity warning overlay. /// /// Three-button layout (C-2): Locate (recover sources), Purge (delete the /// registry entries plus their tags / analysis / history), Cancel (dismiss /// without acting). Locate is the recovery path that the prior two-button /// version was missing entirely. pub fn draw_loose_files_warning(ctx: &egui::Context, state: &mut BrowserState) { let count = state.loose_files_missing_count; if count == 0 { return; } let prompt = format!( "{count} sample{} in this vault {} missing source {}.", if count == 1 { "" } else { "s" }, if count == 1 { "has a" } else { "have" }, if count == 1 { "file" } else { "files" }, ); let mut action: Option = None; widgets::modal_window(ctx, "Loose-files mode warning", false, Some(480.0), |ui| { ui.label(egui::RichText::new(&prompt).strong()); ui.add_space(theme::space::SM); ui.label( "The original files may have been moved or deleted. These samples \ cannot be played or exported until the files are restored.", ); ui.add_space(theme::space::SM); // C-2: name the blast radius of Purge explicitly. Tags, analysis, and // history are the data the user has invested time in — they should // see what Purge takes before reaching for it. ui.label( egui::RichText::new( "Tags, analysis results, and history for these samples will \ be permanently deleted by Purge.", ) .small() .color(theme::accent_yellow()), ); ui.add_space(theme::space::MD); ui.horizontal(|ui| { if ui.button("Cancel").on_hover_text("Dismiss without acting").clicked() { action = Some(LooseFilesAction::Cancel); } if ui .button("Locate missing files...") .on_hover_text( "Pick a folder; audiofiles will hash-verify and re-point any \ samples that match. Tags and analysis are preserved.", ) .clicked() { action = Some(LooseFilesAction::Locate); } if widgets::danger_button(ui, "Purge").clicked() { action = Some(LooseFilesAction::Purge); } }); }); match action { Some(LooseFilesAction::Cancel) => state.dismiss_loose_files_warning(), Some(LooseFilesAction::Purge) => state.purge_missing_loose_files(), Some(LooseFilesAction::Locate) => { if let Some(path) = rfd::FileDialog::new() .set_title("Locate missing sample files") .pick_folder() { state.locate_missing_loose_files(path); } } None => {} } } /// Outcome of the loose-files-warning dialog (C-2). enum LooseFilesAction { Cancel, Locate, Purge, } /// Draw the active bulk modal (tag, move, or rename). pub fn draw_bulk_modal(ctx: &egui::Context, state: &mut BrowserState) { let modal_kind = match &state.bulk_modal { Some(BulkModal::Tag { .. }) => "tag", Some(BulkModal::Move { .. }) => "move", Some(BulkModal::Rename { .. }) => "rename", None => return, }; match modal_kind { "tag" => draw_bulk_tag_modal(ctx, state), "move" => draw_bulk_move_modal(ctx, state), "rename" => draw_bulk_rename_modal(ctx, state), _ => {} } } /// Draw the bulk tag modal: add or remove a tag from all selected samples. /// /// Uses a two-flag pattern (`should_close`, `should_execute`) because egui closures /// borrow `state` — mutations must happen after the window closure returns. fn draw_bulk_tag_modal(ctx: &egui::Context, state: &mut BrowserState) { let mut should_close = false; let mut should_execute = false; // M-4: clone the tag list once up front so the autocomplete row can read // it without conflicting with the &mut borrow of state.bulk_modal inside // the closure. let all_tags: Vec = state.all_tags.iter().cloned().collect(); widgets::modal_window(ctx, "Bulk Tag", false, Some(350.0), |ui| { if let Some(BulkModal::Tag { ref mut tag_input, ref mut adding, ref names, .. }) = state.bulk_modal { ui.heading(format!("Tag {} samples", names.len())); ui.add_space(theme::space::SM); ui.horizontal(|ui| { ui.selectable_value(adding, true, "Add tag"); ui.selectable_value(adding, false, "Remove tag"); }); ui.add_space(theme::space::SM); let resp = ui.add( egui::TextEdit::singleline(tag_input) .hint_text("e.g. genre.electronic") .desired_width(300.0), ); if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { should_execute = true; } ui.add_space(theme::space::SM); // M-4: autocomplete chips. Substring match, case-insensitive, // capped at 12. Click replaces tag_input (single-tag modal). let trimmed = tag_input.trim().to_lowercase(); if !trimmed.is_empty() { let suggestions: Vec<&String> = all_tags .iter() .filter(|t| t.to_lowercase().contains(&trimmed) && t.as_str() != tag_input) .take(12) .collect(); if !suggestions.is_empty() { ui.horizontal_wrapped(|ui| { for tag in suggestions { if widgets::selectable_tag(ui, false, tag.as_str()).clicked() { *tag_input = tag.clone(); } } }); ui.add_space(theme::space::SM); } } // M-5: when removing, surface whether the tag is even known. We // intentionally avoid the O(samples) per-frame check via // get_sample_tags — for 1000-sample selections that would // re-scan every frame. Cheaper proxy: if the tag isn't in // all_tags at all, count is provably 0 and we can disable // Apply. Otherwise we hedge with copy that names the // uncertainty. let mut apply_enabled = true; let mut apply_hover_disabled: Option<&'static str> = None; if !*adding && !tag_input.trim().is_empty() { let typed = tag_input.trim(); let known = all_tags.iter().any(|t| t == typed); if !known { ui.label( egui::RichText::new("None of the selected samples have this tag.") .small() .color(theme::accent_yellow()), ); apply_enabled = false; apply_hover_disabled = Some("None of the selected samples have this tag."); } else { ui.label( egui::RichText::new( "Will remove from selected samples that have this tag.", ) .small() .color(theme::text_secondary()), ); } ui.add_space(theme::space::SM); } egui::ScrollArea::vertical() .max_height(120.0) .show(ui, |ui| { for name in names { ui.label( egui::RichText::new(name).small().color(theme::text_secondary()), ); } }); ui.add_space(theme::space::MD); // p-4: Cmd+Enter (Ctrl+Enter on non-mac) confirms primary action, // gated by the same apply_enabled check the button uses (m-5). if apply_enabled && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) { should_execute = true; } // M-5: confirm_action_row doesn't accept a disabled flag, so we // hand-render the row to add `on_disabled_hover_text` honestly. ui.horizontal(|ui| { if ui.button("Cancel").clicked() { should_close = true; } let btn = ui.add_enabled(apply_enabled, egui::Button::new("Apply")); let btn = if let Some(hint) = apply_hover_disabled { btn.on_disabled_hover_text(hint) } else { btn }; if btn.clicked() { should_execute = true; } }); } }); if should_execute { state.execute_bulk_tag(); } else if should_close { state.close_bulk_modal(); } } /// Draw the bulk move modal: pick a destination directory for all selected items. /// /// Presents a scrollable list of directories in the current VFS, plus a root option. fn draw_bulk_move_modal(ctx: &egui::Context, state: &mut BrowserState) { let mut should_close = false; let mut should_execute = false; widgets::modal_window(ctx, "Move Items", false, Some(400.0), |ui| { if let Some(BulkModal::Move { ref names, ref directories, ref mut selected_idx, .. }) = state.bulk_modal { ui.heading(format!("Move {} items", names.len())); ui.add_space(theme::space::SM); ui.label("Select destination folder:"); ui.add_space(theme::space::SM); // M-6: substring filter (case-insensitive) over the directory paths. ui.add( egui::TextEdit::singleline(&mut state.bulk_move_filter) .hint_text("Filter folders...") .desired_width(360.0), ); ui.add_space(theme::space::SM); let filter = state.bulk_move_filter.trim().to_lowercase(); egui::ScrollArea::vertical() .max_height(200.0) .show(ui, |ui| { // Show the root only when the filter is empty — the // string "/" is too short to be meaningful in a filter. if filter.is_empty() { let is_root_selected = selected_idx.is_none(); if ui .selectable_label(is_root_selected, "/") .clicked() { *selected_idx = None; } } for (i, (_, path)) in directories.iter().enumerate() { if !filter.is_empty() && !path.to_lowercase().contains(&filter) { continue; } let is_selected = *selected_idx == Some(i); if ui.selectable_label(is_selected, path).clicked() { *selected_idx = Some(i); } } }); ui.add_space(theme::space::MD); // p-4: Cmd+Enter confirms the primary action. if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) { should_execute = true; } match widgets::confirm_action_row(ui, "Move", true, false) { ConfirmOutcome::Confirmed => should_execute = true, ConfirmOutcome::Cancelled => should_close = true, ConfirmOutcome::None => {} } } }); if should_execute { // M-6: reset the filter when the modal closes (execute path). state.bulk_move_filter.clear(); state.execute_bulk_move(); } else if should_close { // M-6: reset the filter when the modal closes (cancel path). state.bulk_move_filter.clear(); state.close_bulk_modal(); } } /// Draw the bulk rename modal: pattern-based renaming with live preview. /// /// Tokens like `{name}`, `{bpm}`, `{key}` are expanded per-sample. A side-by-side /// preview grid shows old→new names, updated on every keystroke. The Rename button /// is disabled when the pattern produces an error (e.g., empty result). fn draw_bulk_rename_modal(ctx: &egui::Context, state: &mut BrowserState) { let mut should_close = false; let mut should_execute = false; let mut pattern_changed = false; widgets::modal_window(ctx, "Bulk Rename", true, Some(500.0), |ui| { if let Some(BulkModal::Rename { ref mut pattern_input, ref previews, ref error, .. }) = state.bulk_modal { ui.heading("Rename Pattern"); ui.add_space(theme::space::SM); ui.horizontal_wrapped(|ui| { for token in &[ "{name}", "{ext}", "{bpm}", "{key}", "{class}", "{duration}", "{n}", "{nn}", "{nnn}", ] { if ui .small_button( egui::RichText::new(*token) .small() .color(theme::accent_blue()), ) .clicked() { pattern_input.push_str(token); pattern_changed = true; } } }); ui.add_space(theme::space::SM); let resp = ui.add( egui::TextEdit::singleline(pattern_input) .hint_text("{name}_{bpm}") .desired_width(460.0), ); if resp.changed() { pattern_changed = true; } if let Some(err) = error { ui.colored_label(theme::accent_red(), err); } ui.add_space(theme::space::SM); if !previews.is_empty() { // M-8: count duplicate output names once per frame so we can // highlight colliding rows. Counting once is the whole point — // doing it per-row would be O(n^2). let mut new_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::with_capacity(previews.len()); for (_, new) in previews { *new_counts.entry(new.as_str()).or_insert(0) += 1; } // M-7: cap the rendered preview to keep the modal responsive // on big selections. egui::Grid materialises every cell every // frame; for a 500-row rename this matters. const PREVIEW_CAP: usize = 50; let total = previews.len(); let visible_count = total.min(PREVIEW_CAP); egui::ScrollArea::vertical() .max_height(200.0) .show(ui, |ui| { egui::Grid::new("rename_preview") .striped(true) .show(ui, |ui| { ui.label( egui::RichText::new("Old") .strong() .color(theme::text_secondary()), ); ui.label( egui::RichText::new("New") .strong() .color(theme::accent_blue()), ); ui.end_row(); for (old, new) in previews.iter().take(visible_count) { ui.label(old); // M-8: duplicate output names render in // accent_yellow with an honest hover — // backend collision behaviour isn't // verified here, so we describe risk not // outcome. let is_dup = new_counts .get(new.as_str()) .copied() .unwrap_or(0) > 1; let color = if is_dup { theme::accent_yellow() } else { theme::accent_blue() }; let label = ui.label( egui::RichText::new(new).color(color), ); if is_dup { label.on_hover_text( "Duplicate output name \u{2014} rename will collide on commit.", ); } ui.end_row(); } }); // M-7: muted overflow notice when we've capped the preview. if total > visible_count { ui.add_space(theme::space::XS); ui.label( egui::RichText::new(format!( "...and {} more (preview only shows the first {}).", total - visible_count, PREVIEW_CAP, )) .small() .color(theme::text_muted()), ); } }); } ui.add_space(theme::space::MD); let can_rename = error.is_none() && !previews.is_empty(); // p-4: Cmd+Enter confirms primary action, gated by can_rename. if can_rename && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) { should_execute = true; } match widgets::confirm_action_row(ui, "Rename", can_rename, false) { ConfirmOutcome::Confirmed => should_execute = true, ConfirmOutcome::Cancelled => should_close = true, ConfirmOutcome::None => {} } } }); if pattern_changed { state.update_rename_previews(); } if should_execute { state.execute_bulk_rename(); } else if should_close { state.close_bulk_modal(); } } /// Draw the "New Vault" modal: text input for vault name. pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) { 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."; // C-3: clone the error into a local so the &mut input borrow doesn't // conflict with the immutable error borrow into name_modal. let error_owned = state.name_modal_error.clone(); match widgets::name_modal( ctx, "New Vault", Some(hint), "Vault name:", &mut state.vfs_create_input, "Create", error_owned.as_deref(), ) { NameModalOutcome::Submitted(name) => { if name.is_empty() { // Empty submit = no-op cancel. Close without erroring. state.show_vfs_create = false; state.name_modal_error = None; } else { match state.backend.create_vfs(&name) { Ok(_) => { state.refresh_vfs_list(); state.status = format!("Created vault: {name}"); state.show_vfs_create = false; state.name_modal_error = None; } Err(e) => { // C-3: keep modal open; surface error inline so the // user can edit and retry without re-typing the name. state.name_modal_error = Some(format!("{e}")); } } } } NameModalOutcome::Cancelled => { state.show_vfs_create = false; state.name_modal_error = None; } NameModalOutcome::None => {} } } /// Draw the "Rename Vault" modal: text input pre-filled with current name. pub fn draw_vfs_rename_modal(ctx: &egui::Context, state: &mut BrowserState) { let error_owned = state.name_modal_error.clone(); let outcome = if let Some((_, ref mut name_buf)) = state.vfs_rename_target { widgets::name_modal( ctx, "Rename Vault", Some("Vault names can contain spaces."), "New name:", name_buf, "Save", error_owned.as_deref(), ) } else { return; }; match outcome { NameModalOutcome::Submitted(new_name) => { if new_name.is_empty() { state.vfs_rename_target = None; state.name_modal_error = None; } else { let vfs_id = state.vfs_rename_target.as_ref().map(|(id, _)| *id); if let Some(vfs_id) = vfs_id { match state.backend.rename_vfs(vfs_id, &new_name) { Ok(()) => { state.refresh_vfs_list(); state.status = format!("Renamed vault to: {new_name}"); state.vfs_rename_target = None; state.name_modal_error = None; } Err(e) => { // C-3: keep modal open with the typed name preserved. state.name_modal_error = Some(format!("{e}")); } } } } } NameModalOutcome::Cancelled => { state.vfs_rename_target = None; state.name_modal_error = None; } NameModalOutcome::None => {} } } /// Draw the "New Folder" modal: text input for folder name. pub fn draw_dir_create_modal(ctx: &egui::Context, state: &mut BrowserState) { let error_owned = state.name_modal_error.clone(); match widgets::name_modal( ctx, "New Folder", Some("Folder names cannot contain /"), "Folder name:", &mut state.dir_create_input, "Create", error_owned.as_deref(), ) { NameModalOutcome::Submitted(name) => { if name.is_empty() { state.show_dir_create = false; state.name_modal_error = None; } else { let vfs_id = state.vfs_list[state.current_vfs_idx].id; match state.backend.create_directory(vfs_id, state.current_dir, &name) { Ok(_) => { state.refresh_contents(); state.status = format!("Created folder: {name}"); state.show_dir_create = false; state.name_modal_error = None; } Err(e) => { // C-3. state.name_modal_error = Some(format!("{e}")); } } } } NameModalOutcome::Cancelled => { state.show_dir_create = false; state.name_modal_error = None; } NameModalOutcome::None => {} } } /// Draw the "Rename Folder" modal: text input pre-filled with current name. pub fn draw_dir_rename_modal(ctx: &egui::Context, state: &mut BrowserState) { let error_owned = state.name_modal_error.clone(); let outcome = if let Some((_, ref mut name_buf)) = state.dir_rename_target { widgets::name_modal( ctx, "Rename", None, "New name:", name_buf, "Save", error_owned.as_deref(), ) } else { return; }; match outcome { NameModalOutcome::Submitted(new_name) => { if new_name.is_empty() { state.dir_rename_target = None; state.name_modal_error = None; } else { let node_id = state.dir_rename_target.as_ref().map(|(id, _)| *id); if let Some(node_id) = node_id { match state.backend.rename_node(node_id, &new_name) { Ok(()) => { state.refresh_contents(); state.status = format!("Renamed to: {new_name}"); state.dir_rename_target = None; state.name_modal_error = None; } Err(e) => { // C-3. state.name_modal_error = Some(format!("{e}")); } } } } } NameModalOutcome::Cancelled => { state.dir_rename_target = None; state.name_modal_error = None; } NameModalOutcome::None => {} } }