//! Consolidated Settings window: Storage, Appearance, Preview, Display, License. use egui; use crate::state::BrowserState; use super::theme; use super::widgets; /// Draw the Settings window with collapsing sections. pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) { let mut open = state.settings.show_manager; widgets::modal_window_with_open( ctx, "Settings", Some(&mut open), true, Some(420.0), |ui| { egui::ScrollArea::vertical().show(ui, |ui| { draw_storage_section(ui, state); ui.add_space(theme::space::SM); draw_appearance_section(ui, state); ui.add_space(theme::space::SM); draw_preview_section(ui, state); ui.add_space(theme::space::SM); draw_forge_section(ui, state); ui.add_space(theme::space::SM); draw_display_section(ui, state); ui.add_space(theme::space::SM); draw_license_section(ui, state); ui.add_space(theme::space::SM); draw_advanced_section(ui, state); }); }, ); state.settings.show_manager = open; } /// Format byte counts as B/KB/MB/GB. /// Collapse the user's home directory to `~` for display, so library paths /// don't overflow narrow Settings windows. The full path is intended to be /// surfaced as a tooltip on hover. Returns the original path string if the /// home directory can't be resolved or the path doesn't sit under it. fn collapse_home(path: &std::path::Path) -> String { let display = path.display().to_string(); let Some(home) = dirs::home_dir() else { return display }; let home_str = home.display().to_string(); if let Some(rest) = display.strip_prefix(&home_str) { if rest.is_empty() { return "~".to_string(); } return format!("~{rest}"); } display } /// Format storage scan freshness. Returns `(text, stale)` — `stale` is true /// when results are older than 24 hours, signalling the user should re-scan. fn format_scan_age(age_secs: i64) -> (String, bool) { let stale = age_secs >= 86_400; let suffix = if stale { " — re-scan to refresh." } else { "" }; let text = if age_secs < 120 { format!("Last scanned just now.{suffix}") } else if age_secs < 3_600 { format!("Last scanned {} minutes ago.{suffix}", age_secs / 60) } else if age_secs < 86_400 { format!("Last scanned {} hour{} ago.{suffix}", age_secs / 3_600, if age_secs / 3_600 == 1 { "" } else { "s" }) } else { let days = age_secs / 86_400; format!("Last scanned {} day{} ago.{suffix}", days, if days == 1 { "" } else { "s" }) }; (text, stale) } fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } // ── Storage section ── fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Storage").strong()) .default_open(true) .show(ui, |ui| { ui.label( egui::RichText::new("Each library is an independent sample collection with its own database and files. A library can contain multiple vaults (top-level browse buckets).") .small() .color(theme::text_muted()), ); ui.add_space(theme::space::SM); // Vault list let vault_list = state.settings.list.clone(); let active_path = state.data_dir.clone(); let mut remove_path = None; let mut should_close = false; let mut relocate_old_path: Option = None; // Mirror the sidebar's switch flow: clicking a non-active reachable // row switches that library. The sidebar ComboBox stays the primary // entry point, but Settings rows visually read as clickable list // rows (Phase 3's `selectable_row` widget) — wiring the click here // closes the false-affordance gap without duplicating logic. let mut switch_to: Option<(std::path::PathBuf, String)> = None; for (name, path, reachable) in &vault_list { let is_active = path == &active_path; ui.horizontal(|ui| { let status = if is_active { egui::RichText::new("active").small().color(theme::accent_blue()) } else if !reachable { egui::RichText::new("offline").small().color(theme::accent_red()) } else { egui::RichText::new("").small() }; let row_resp = widgets::selectable_row(ui, is_active, name); if row_resp.clicked() && !is_active && *reachable { switch_to = Some((path.clone(), name.clone())); } // Offline badge surfaces the last-known path on hover so the // user knows where the directory used to live. let status_label = ui.label(status); if !reachable && !is_active { status_label.on_hover_text(format!( "Last known path: {}. Use Locate… to repoint if the directory moved.", path.display(), )); } if ui.small_button("Rename").clicked() { state.settings.rename_target = Some((path.clone(), name.clone())); } // Locate… replaces a stranded registry entry's path. Only // surfaced for offline non-active vaults; active vault path // is handled differently (it's the open DB). if !reachable && !is_active && ui.small_button("Locate…").on_hover_text("Point this library at a new directory").clicked() { relocate_old_path = Some(path.clone()); } if !is_active && widgets::danger_small_button(ui, "Remove").clicked() { remove_path = Some(path.clone()); } }); ui.label( egui::RichText::new(collapse_home(path)) .small() .color(theme::text_muted()), ) .on_hover_text(path.display().to_string()); // Per-vault sample count + total size for the active vault when // a fresh scan exists. Makes "which vault is the small one?" // legible without opening each one (m-8). Only the active vault // has a cache today — non-active rows stay path-only. if is_active && let Some(ref stats) = state.settings.storage_cache { ui.label( egui::RichText::new(format!( "{} samples \u{00B7} {}", stats.sample_count, format_bytes(stats.total_bytes), )) .small() .color(theme::text_muted()), ); } ui.add_space(theme::space::SM); } if let Some((path, name)) = switch_to { // Same guard as sidebar.rs: confirm only when in-flight work // would be interrupted; otherwise switch directly. Closing // Settings on switch matches the Create-New flow below. if state.has_in_flight_work() { state.pending_confirm = Some( crate::state::ConfirmAction::SwitchLibrary { path, library_name: name, }, ); } else { state.settings.pending_action = Some(crate::state::VaultAction::SwitchVault(path)); } should_close = true; } if let Some(path) = remove_path { state.settings.pending_action = Some(crate::state::VaultAction::RemoveVault(path)); } if let Some(old_path) = relocate_old_path && let Some(new_path) = rfd::FileDialog::new() .set_title("Locate library directory") .pick_folder() { state.settings.pending_action = Some(crate::state::VaultAction::RelocateVault { old_path, new_path }); } // Inline rename if let Some((ref rename_path, _)) = state.settings.rename_target.clone() { ui.separator(); ui.horizontal(|ui| { ui.label("New name:"); let resp = ui.text_edit_singleline( &mut state.settings.rename_target.as_mut().unwrap().1, ); if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { let new_name = state.settings.rename_target.as_ref().unwrap().1.clone(); if !new_name.trim().is_empty() { state.settings.pending_action = Some(crate::state::VaultAction::RenameVault { path: rename_path.clone(), new_name: new_name.trim().to_string(), }); } state.settings.rename_target = None; } if ui.button("Cancel").clicked() { state.settings.rename_target = None; } }); } // Storage stats ui.add_space(theme::space::SM); let scanning = matches!( state.settings.pending_action, Some(crate::state::VaultAction::ScanStorage), ); ui.horizontal(|ui| { let (label, hover) = if scanning { ("Scanning...", "Scan in progress") } else { ("Scan", "Scan storage usage for this library") }; if ui .add_enabled(!scanning, egui::Button::new(label)) .on_hover_text(hover) .clicked() { state.settings.pending_action = Some(crate::state::VaultAction::ScanStorage); } if scanning { ui.spinner(); } else if let Some(ref stats) = state.settings.storage_cache { ui.label(format!( "{} samples, {} total, {} database", stats.sample_count, format_bytes(stats.total_bytes), format_bytes(stats.db_bytes), )); } }); // Surface scan freshness so stale cached numbers aren't trusted blindly. if let Some(at) = state.settings.storage_cache_at { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(at); let age_secs = now.saturating_sub(at).max(0); let (text, stale) = format_scan_age(age_secs); let color = if stale { theme::accent_yellow() } else { theme::text_muted() }; ui.label( egui::RichText::new(text).small().color(color), ); } // Cleanup orphans: free disk by removing samples no longer // referenced by any VFS placement. Sync triggers are // suppressed for this operation (local-only by design — each // synced device curates its own orphan set). ui.add_space(theme::space::SM); ui.horizontal(|ui| { if ui .button("Cleanup orphans") .on_hover_text( "Free disk by deleting samples no longer referenced anywhere in the library. \ Local-only: other synced devices keep their own copies.", ) .clicked() { state.cleanup_orphans_now(); } }); ui.add_space(theme::space::MD); ui.separator(); ui.add_space(theme::space::SM); // Loose-files mode indicator for active vault if state.settings.is_loose_files { ui.add_space(theme::space::SM); ui.label( egui::RichText::new("This library uses loose-files mode. Samples are referenced in place, not duplicated.") .small() .color(theme::accent_yellow()), ); } // Create new library ui.label(egui::RichText::new("Add Library").strong()); ui.horizontal(|ui| { ui.label("Name:"); ui.text_edit_singleline(&mut state.settings.create_name); }); ui.horizontal(|ui| { if ui.button("Choose folder...").clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() { state.settings.create_path = Some(path); } if let Some(ref p) = state.settings.create_path { ui.label( egui::RichText::new(p.display().to_string()) .small() .color(theme::text_secondary()), ); } }); // Storage style is a significant choice (copy vs reference in // place) — promote it from a buried checkbox to an explicit radio // choice so users opt into loose-files mode deliberately. ui.label(egui::RichText::new("Storage style:").small().color(theme::text_secondary())); let mut style = state.settings.create_loose_files; if ui.radio_value(&mut style, false, "Copy samples into library (recommended)") .on_hover_text("Samples are duplicated into the library's content-addressed store. Originals can be moved or deleted safely.") .changed() { state.settings.create_loose_files = style; } if ui.radio_value(&mut style, true, "Reference samples in place (loose-files mode)") .on_hover_text("Reference files in place instead of duplicating. Saves disk space but samples break if originals are moved or deleted. Cannot be changed later.") .changed() { state.settings.create_loose_files = style; } if state.settings.create_loose_files { ui.label( egui::RichText::new("Moving or deleting originals will break references. This cannot be undone.") .small() .color(theme::accent_yellow()), ); } ui.add_space(theme::space::SM); ui.horizontal(|ui| { let can_create = !state.settings.create_name.trim().is_empty() && state.settings.create_path.is_some(); let has_partial = !state.settings.create_name.trim().is_empty() || state.settings.create_path.is_some(); if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked() && let Some(path) = state.settings.create_path.take() { let name = state.settings.create_name.trim().to_string(); let loose_files = state.settings.create_loose_files; state.settings.pending_action = Some(crate::state::VaultAction::CreateVault { name, path, loose_files }); state.settings.create_name.clear(); state.settings.create_loose_files = false; should_close = true; } if ui .add_enabled(can_create, egui::Button::new("Add Existing")) .on_hover_text("Add an existing audiofiles library directory") .clicked() && let Some(path) = state.settings.create_path.take() { let name = state.settings.create_name.trim().to_string(); state.settings.pending_action = Some(crate::state::VaultAction::AddExistingVault { name, path }); state.settings.create_name.clear(); state.settings.create_loose_files = false; // Both commit paths now close Settings: a Create makes // the new vault active, and an Add-Existing typically // motivates immediate browsing too. should_close = true; } // Cancel only enabled when the form has user-entered state to // discard — keeps the button from looking permanently active. if ui .add_enabled(has_partial, egui::Button::new("Cancel")) .on_hover_text("Discard the form without creating a library") .clicked() { state.settings.create_name.clear(); state.settings.create_path = None; state.settings.create_loose_files = false; } }); if should_close { state.settings.show_manager = false; } }); } // ── Appearance section ── fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Appearance").strong()) .default_open(false) .show(ui, |ui| { let themes = theme::list_themes(); let current_name = themes .iter() .find(|t| t.id == state.current_theme_id) .map(|t| t.name.as_str()) .unwrap_or(&state.current_theme_id); let mut new_theme_id = None; ui.horizontal(|ui| { ui.label("Theme:"); egui::ComboBox::from_id_salt("settings_theme_select") .selected_text(current_name) .width(200.0) .show_ui(ui, |ui| { for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] { // Pair each theme with its muted-text contrast tier and // sort most-accessible-first, so readable themes surface // at the top of each group and low-contrast curated // palettes are clearly badged rather than silently mixed in. let mut group: Vec<(&theme::ThemeMeta, theme::ContrastTier)> = themes .iter() .filter(|t| t.variant == variant) .map(|t| (t, theme::theme_contrast_tier(&t.id))) .collect(); if group.is_empty() { continue; } group.sort_by_key(|(_, tier)| std::cmp::Reverse(*tier)); ui.label(egui::RichText::new(label).small().strong()); for (t, tier) in group { let is_selected = t.id == state.current_theme_id; ui.horizontal(|ui| { // Color swatch (bg + accent) if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) { let size = egui::vec2(12.0, 12.0); let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); ui.painter().rect_filled(rect, 2.0, bg); let accent_rect = egui::Rect::from_min_size( rect.min + egui::vec2(6.0, 0.0), egui::vec2(6.0, 12.0), ); ui.painter().rect_filled(accent_rect, 0.0, accent); } let display = if t.is_custom { format!("{} (custom)", t.name) } else { t.name.clone() }; if ui.selectable_label(is_selected, display).clicked() { new_theme_id = Some(t.id.clone()); } // Contrast-tier badge (text legibility, not the // theme's accent palette). let badge_color = match tier { theme::ContrastTier::High => theme::accent_green(), theme::ContrastTier::Standard => theme::text_muted(), theme::ContrastTier::Low => theme::accent_yellow(), }; ui.label( egui::RichText::new(tier.badge()) .small() .color(badge_color), ) .on_hover_text( "Muted-text legibility: AA = passes WCAG AA, OK = readable, low = subtle", ); }); } ui.separator(); } }); }); if let Some(id) = new_theme_id { theme::set_theme(&id); state.current_theme_id = id; state.save_theme_preference(); } }); } // ── Preview section ── fn draw_preview_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Preview").strong()) .default_open(false) .show(ui, |ui| { let mut loop_enabled = state.loop_enabled; if ui.checkbox(&mut loop_enabled, "Loop playback") .on_hover_text("Loop sample preview (L)") .changed() { state.toggle_loop(); } let mut autoplay = state.autoplay; if ui.checkbox(&mut autoplay, "Auto-play on navigate") .on_hover_text("Automatically preview sample when navigating") .changed() { state.toggle_autoplay(); } }); } // ── Forge section ── fn draw_forge_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Forge").strong()) .default_open(false) .show(ui, |ui| { let mut auto_trim = state.forge_auto_trim_overshoot; if ui .checkbox(&mut auto_trim, "Auto-trim resample overshoot") .on_hover_text( "Resampling can push peaks just past full scale. Off (default): the \ signal is left untouched and a warning is shown if a conform will clip. \ On: the forge applies the smallest gain reduction to bring the peak back \ to full scale, avoiding the clip.", ) .changed() { state.toggle_forge_auto_trim_overshoot(); } }); } // ── Display section ── fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Display").strong()) .default_open(false) .show(ui, |ui| { ui.label(egui::RichText::new("Visible Columns").small().color(theme::text_secondary())); let mut col_changed = false; col_changed |= ui.checkbox(&mut state.column_config.show_classification, "Classification").changed(); col_changed |= ui.checkbox(&mut state.column_config.show_bpm, "BPM").changed(); col_changed |= ui.checkbox(&mut state.column_config.show_key, "Key").changed(); col_changed |= ui.checkbox(&mut state.column_config.show_duration, "Duration").changed(); col_changed |= ui.checkbox(&mut state.column_config.show_peak_db, "Peak dB").changed(); col_changed |= ui.checkbox(&mut state.column_config.show_tags, "Tags").changed(); if col_changed { state.save_column_config(); } ui.add_space(theme::space::SM); if ui .button("Reset columns") .on_hover_text( "Restore column visibility, sort, and row density to defaults. \ Column widths reset on next app launch.", ) .clicked() { state.reset_columns(); } ui.add_space(theme::space::MD); ui.separator(); ui.add_space(theme::space::SM); ui.label(egui::RichText::new("Row Density").small().color(theme::text_secondary())); let mut row_height = state.row_height; let label = if row_height <= 22.0 { "Compact" } else if row_height >= 28.0 { "Spacious" } else { "Normal" }; ui.horizontal(|ui| { ui.label(label); ui.label( egui::RichText::new(format!("{} px", row_height as i32)) .small() .color(theme::text_muted()), ); if ui.add(egui::Slider::new(&mut row_height, 20.0..=32.0).step_by(2.0).show_value(false)).changed() { state.row_height = row_height; let _ = state.backend.set_config("row_height", &format!("{row_height}")); } }); ui.add_space(theme::space::MD); ui.separator(); ui.add_space(theme::space::SM); ui.label(egui::RichText::new("Tag Suggestions").small().color(theme::text_secondary())); let dismissed_total: usize = state .dismissed_suggestions .values() .map(|v| v.len()) .sum(); ui.horizontal(|ui| { ui.label( egui::RichText::new(format!( "{dismissed_total} dismissed suggestion{}", if dismissed_total == 1 { "" } else { "s" } )) .small() .color(theme::text_muted()), ); if ui .add_enabled(dismissed_total > 0, egui::Button::new("Reset suggestions")) .on_hover_text("Re-enable every classification tag suggestion you've dismissed") .clicked() { state.reset_dismissed_suggestions(); } }); }); } // ── License section ── fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("License").strong()) .default_open(false) .show(ui, |ui| { if let Some(ref masked) = state.settings.license_key_masked { ui.horizontal(|ui| { ui.label("Key:"); ui.label(egui::RichText::new(masked).color(theme::text_secondary())); }); } else if let Some(days) = state.settings.trial_days_remaining { // "Trial: 0 days" was technically correct but uncomfortably // terse at the expired state; rephrase so the dead-end reads // as a status, not a counter (m-13). A Purchase button would // belong here but the buy flow is not yet wired. let text = if days > 0 { format!("Trial: {days} days left") } else { "Trial expired".to_string() }; let color = if days > 7 { theme::text_secondary() } else if days > 0 { theme::accent_yellow() } else { theme::text_muted() }; ui.label(egui::RichText::new(text).color(color)); } if let Some(ref mid) = state.settings.machine_id { ui.horizontal(|ui| { ui.label("Machine:"); // selectable_label so the value can be selected/copied with // a keyboard shortcut, plus an explicit Copy button for // pointer users. Common ask when contacting support (m-14). ui.add(egui::Label::new( egui::RichText::new(mid).small().color(theme::text_muted()), ).selectable(true)); if ui.small_button("Copy").on_hover_text("Copy machine id to clipboard").clicked() { ui.ctx().copy_text(mid.clone()); state.status = "Copied machine id.".to_string(); } }); } if state.settings.license_key_masked.is_some() { ui.add_space(theme::space::MD); if widgets::danger_button(ui, "Deactivate").clicked() { state.settings.pending_action = Some(crate::state::VaultAction::DeactivateLicense); } } }); } // ── Advanced section ── fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Advanced").strong()) .default_open(false) .show(ui, |ui| { // Theme import/export ui.label(egui::RichText::new("Custom Themes").small().color(theme::text_secondary())); ui.horizontal(|ui| { if ui.button("Import Theme...").clicked() && let Some(path) = rfd::FileDialog::new() .add_filter("Theme", &["toml"]) .pick_file() { let Some(custom_dir) = theme::custom_themes_dir() else { state.status = "Theme import failed: no custom themes directory available.".to_string(); return; }; match theme::load_theme(&path) { Ok(_colors) => { let id = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("custom") .to_string(); if let Err(e) = std::fs::create_dir_all(&custom_dir) { tracing::error!("Failed to create custom themes dir: {e}"); state.status = format!("Theme import failed: {e}"); } else if let Err(e) = std::fs::copy(&path, custom_dir.join(format!("{id}.toml"))) { tracing::error!("Failed to copy theme: {e}"); state.status = format!("Theme import failed: {e}"); } else { theme::set_theme(&id); state.current_theme_id = id.clone(); state.save_theme_preference(); state.status = format!("Imported theme: {id}"); } } Err(e) => { tracing::error!("Failed to load theme: {e}"); state.status = format!("Theme import failed: {e}"); } } } if ui.button("Export Current...").clicked() && let Some(path) = rfd::FileDialog::new() .set_file_name(format!("{}.toml", state.current_theme_id)) .add_filter("Theme", &["toml"]) .save_file() { if let Some(content) = theme::export_theme_content(&state.current_theme_id) { match std::fs::write(&path, content) { Ok(()) => { state.status = format!("Exported theme to {}", path.display()); } Err(e) => { tracing::error!("Failed to export theme: {e}"); state.status = format!("Theme export failed: {e}"); } } } else { tracing::warn!("Theme '{}' not found for export", state.current_theme_id); state.status = format!("Theme export failed: '{}' not found.", state.current_theme_id); } } }); // Library mirror (Unix only) #[cfg(unix)] { ui.add_space(theme::space::MD); ui.separator(); ui.add_space(theme::space::SM); ui.label(egui::RichText::new("Library Mirror").small().color(theme::text_secondary())); let mut mirror = state.mirror_enabled; if ui.checkbox(&mut mirror, "Enable library mirror") .on_hover_text("Create a symlink tree so DAWs can browse your library as a normal folder") .changed() { state.set_mirror_enabled(mirror); } // Always surface the mirror path so the user knows where the // symlink tree will live before enabling, and can change it // without hunting for a hidden config. Pairs with the path // picker pattern in Add Library above (m-12). ui.horizontal(|ui| { ui.label( egui::RichText::new(collapse_home(&state.mirror_path)) .small() .color(theme::text_muted()), ) .on_hover_text(state.mirror_path.display().to_string()); if ui.small_button("Change...").on_hover_text("Pick a new location for the mirror").clicked() && let Some(new_path) = rfd::FileDialog::new() .set_title("Choose library mirror location") .pick_folder() { state.set_mirror_path(new_path); } }); } }); }