Skip to main content

max / audiofiles

36.0 KB · 776 lines History Blame Raw
1 //! Consolidated Settings window: Storage, Appearance, Preview, Display, License.
2
3 use egui;
4
5 use crate::state::BrowserState;
6 use super::theme;
7 use super::widgets;
8
9 /// Draw the Settings window with collapsing sections.
10 pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) {
11 let mut open = state.settings.show_manager;
12 widgets::modal_window_with_open(
13 ctx,
14 "Settings",
15 Some(&mut open),
16 true,
17 Some(420.0),
18 |ui| {
19 egui::ScrollArea::vertical().show(ui, |ui| {
20 draw_storage_section(ui, state);
21 ui.add_space(theme::space::SM);
22 draw_appearance_section(ui, state);
23 ui.add_space(theme::space::SM);
24 draw_preview_section(ui, state);
25 ui.add_space(theme::space::SM);
26 draw_forge_section(ui, state);
27 ui.add_space(theme::space::SM);
28 draw_display_section(ui, state);
29 ui.add_space(theme::space::SM);
30 draw_license_section(ui, state);
31 ui.add_space(theme::space::SM);
32 draw_advanced_section(ui, state);
33 });
34 },
35 );
36 state.settings.show_manager = open;
37 }
38
39 /// Format byte counts as B/KB/MB/GB.
40 /// Collapse the user's home directory to `~` for display, so library paths
41 /// don't overflow narrow Settings windows. The full path is intended to be
42 /// surfaced as a tooltip on hover. Returns the original path string if the
43 /// home directory can't be resolved or the path doesn't sit under it.
44 fn collapse_home(path: &std::path::Path) -> String {
45 let display = path.display().to_string();
46 let Some(home) = dirs::home_dir() else { return display };
47 let home_str = home.display().to_string();
48 if let Some(rest) = display.strip_prefix(&home_str) {
49 if rest.is_empty() {
50 return "~".to_string();
51 }
52 return format!("~{rest}");
53 }
54 display
55 }
56
57 /// Format storage scan freshness. Returns `(text, stale)` — `stale` is true
58 /// when results are older than 24 hours, signalling the user should re-scan.
59 fn format_scan_age(age_secs: i64) -> (String, bool) {
60 let stale = age_secs >= 86_400;
61 let suffix = if stale { " — re-scan to refresh." } else { "" };
62 let text = if age_secs < 120 {
63 format!("Last scanned just now.{suffix}")
64 } else if age_secs < 3_600 {
65 format!("Last scanned {} minutes ago.{suffix}", age_secs / 60)
66 } else if age_secs < 86_400 {
67 format!("Last scanned {} hour{} ago.{suffix}", age_secs / 3_600, if age_secs / 3_600 == 1 { "" } else { "s" })
68 } else {
69 let days = age_secs / 86_400;
70 format!("Last scanned {} day{} ago.{suffix}", days, if days == 1 { "" } else { "s" })
71 };
72 (text, stale)
73 }
74
75 fn format_bytes(bytes: u64) -> String {
76 if bytes < 1024 {
77 format!("{bytes} B")
78 } else if bytes < 1024 * 1024 {
79 format!("{:.1} KB", bytes as f64 / 1024.0)
80 } else if bytes < 1024 * 1024 * 1024 {
81 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
82 } else {
83 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
84 }
85 }
86
87 // ── Storage section ──
88
89 fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
90 egui::CollapsingHeader::new(egui::RichText::new("Storage").strong())
91 .default_open(true)
92 .show(ui, |ui| {
93 ui.label(
94 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).")
95 .small()
96 .color(theme::text_muted()),
97 );
98 ui.add_space(theme::space::SM);
99
100 // Vault list
101 let vault_list = state.settings.list.clone();
102 let active_path = state.data_dir.clone();
103 let mut remove_path = None;
104 let mut should_close = false;
105
106 let mut relocate_old_path: Option<std::path::PathBuf> = None;
107 // Mirror the sidebar's switch flow: clicking a non-active reachable
108 // row switches that library. The sidebar ComboBox stays the primary
109 // entry point, but Settings rows visually read as clickable list
110 // rows (Phase 3's `selectable_row` widget) — wiring the click here
111 // closes the false-affordance gap without duplicating logic.
112 let mut switch_to: Option<(std::path::PathBuf, String)> = None;
113
114 for (name, path, reachable) in &vault_list {
115 let is_active = path == &active_path;
116 ui.horizontal(|ui| {
117 let status = if is_active {
118 egui::RichText::new("active").small().color(theme::accent_blue())
119 } else if !reachable {
120 egui::RichText::new("offline").small().color(theme::accent_red())
121 } else {
122 egui::RichText::new("").small()
123 };
124
125 let row_resp = widgets::selectable_row(ui, is_active, name);
126 if row_resp.clicked() && !is_active && *reachable {
127 switch_to = Some((path.clone(), name.clone()));
128 }
129 // Offline badge surfaces the last-known path on hover so the
130 // user knows where the directory used to live.
131 let status_label = ui.label(status);
132 if !reachable && !is_active {
133 status_label.on_hover_text(format!(
134 "Last known path: {}. Use Locate… to repoint if the directory moved.",
135 path.display(),
136 ));
137 }
138
139 if ui.small_button("Rename").clicked() {
140 state.settings.rename_target = Some((path.clone(), name.clone()));
141 }
142 // Locate… replaces a stranded registry entry's path. Only
143 // surfaced for offline non-active vaults; active vault path
144 // is handled differently (it's the open DB).
145 if !reachable && !is_active && ui.small_button("Locate…").on_hover_text("Point this library at a new directory").clicked() {
146 relocate_old_path = Some(path.clone());
147 }
148 if !is_active && widgets::danger_small_button(ui, "Remove").clicked() {
149 remove_path = Some(path.clone());
150 }
151 });
152 ui.label(
153 egui::RichText::new(collapse_home(path))
154 .small()
155 .color(theme::text_muted()),
156 )
157 .on_hover_text(path.display().to_string());
158 // Per-vault sample count + total size for the active vault when
159 // a fresh scan exists. Makes "which vault is the small one?"
160 // legible without opening each one (m-8). Only the active vault
161 // has a cache today — non-active rows stay path-only.
162 if is_active
163 && let Some(ref stats) = state.settings.storage_cache {
164 ui.label(
165 egui::RichText::new(format!(
166 "{} samples \u{00B7} {}",
167 stats.sample_count,
168 format_bytes(stats.total_bytes),
169 ))
170 .small()
171 .color(theme::text_muted()),
172 );
173 }
174 ui.add_space(theme::space::SM);
175 }
176
177 if let Some((path, name)) = switch_to {
178 // Same guard as sidebar.rs: confirm only when in-flight work
179 // would be interrupted; otherwise switch directly. Closing
180 // Settings on switch matches the Create-New flow below.
181 if state.has_in_flight_work() {
182 state.pending_confirm = Some(
183 crate::state::ConfirmAction::SwitchLibrary {
184 path,
185 library_name: name,
186 },
187 );
188 } else {
189 state.settings.pending_action =
190 Some(crate::state::VaultAction::SwitchVault(path));
191 }
192 should_close = true;
193 }
194 if let Some(path) = remove_path {
195 state.settings.pending_action =
196 Some(crate::state::VaultAction::RemoveVault(path));
197 }
198 if let Some(old_path) = relocate_old_path
199 && let Some(new_path) = rfd::FileDialog::new()
200 .set_title("Locate library directory")
201 .pick_folder()
202 {
203 state.settings.pending_action =
204 Some(crate::state::VaultAction::RelocateVault { old_path, new_path });
205 }
206
207 // Inline rename
208 if let Some((ref rename_path, _)) = state.settings.rename_target.clone() {
209 ui.separator();
210 ui.horizontal(|ui| {
211 ui.label("New name:");
212 let resp = ui.text_edit_singleline(
213 &mut state.settings.rename_target.as_mut().unwrap().1,
214 );
215 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
216 let new_name = state.settings.rename_target.as_ref().unwrap().1.clone();
217 if !new_name.trim().is_empty() {
218 state.settings.pending_action =
219 Some(crate::state::VaultAction::RenameVault {
220 path: rename_path.clone(),
221 new_name: new_name.trim().to_string(),
222 });
223 }
224 state.settings.rename_target = None;
225 }
226 if ui.button("Cancel").clicked() {
227 state.settings.rename_target = None;
228 }
229 });
230 }
231
232 // Storage stats
233 ui.add_space(theme::space::SM);
234 let scanning = matches!(
235 state.settings.pending_action,
236 Some(crate::state::VaultAction::ScanStorage),
237 );
238 ui.horizontal(|ui| {
239 let (label, hover) = if scanning {
240 ("Scanning...", "Scan in progress")
241 } else {
242 ("Scan", "Scan storage usage for this library")
243 };
244 if ui
245 .add_enabled(!scanning, egui::Button::new(label))
246 .on_hover_text(hover)
247 .clicked()
248 {
249 state.settings.pending_action = Some(crate::state::VaultAction::ScanStorage);
250 }
251 if scanning {
252 ui.spinner();
253 } else if let Some(ref stats) = state.settings.storage_cache {
254 ui.label(format!(
255 "{} samples, {} total, {} database",
256 stats.sample_count,
257 format_bytes(stats.total_bytes),
258 format_bytes(stats.db_bytes),
259 ));
260 }
261 });
262 // Surface scan freshness so stale cached numbers aren't trusted blindly.
263 if let Some(at) = state.settings.storage_cache_at {
264 let now = std::time::SystemTime::now()
265 .duration_since(std::time::UNIX_EPOCH)
266 .map(|d| d.as_secs() as i64)
267 .unwrap_or(at);
268 let age_secs = now.saturating_sub(at).max(0);
269 let (text, stale) = format_scan_age(age_secs);
270 let color = if stale { theme::accent_yellow() } else { theme::text_muted() };
271 ui.label(
272 egui::RichText::new(text).small().color(color),
273 );
274 }
275
276 // Cleanup orphans: free disk by removing samples no longer
277 // referenced by any VFS placement. Sync triggers are
278 // suppressed for this operation (local-only by design — each
279 // synced device curates its own orphan set).
280 ui.add_space(theme::space::SM);
281 ui.horizontal(|ui| {
282 if ui
283 .button("Cleanup orphans")
284 .on_hover_text(
285 "Free disk by deleting samples no longer referenced anywhere in the library. \
286 Local-only: other synced devices keep their own copies.",
287 )
288 .clicked()
289 {
290 state.cleanup_orphans_now();
291 }
292 });
293
294 ui.add_space(theme::space::MD);
295 ui.separator();
296 ui.add_space(theme::space::SM);
297
298 // Loose-files mode indicator for active vault
299 if state.settings.is_loose_files {
300 ui.add_space(theme::space::SM);
301 ui.label(
302 egui::RichText::new("This library uses loose-files mode. Samples are referenced in place, not duplicated.")
303 .small()
304 .color(theme::accent_yellow()),
305 );
306 }
307
308 // Create new library
309 ui.label(egui::RichText::new("Add Library").strong());
310 ui.horizontal(|ui| {
311 ui.label("Name:");
312 ui.text_edit_singleline(&mut state.settings.create_name);
313 });
314 ui.horizontal(|ui| {
315 if ui.button("Choose folder...").clicked()
316 && let Some(path) = rfd::FileDialog::new().pick_folder() {
317 state.settings.create_path = Some(path);
318 }
319 if let Some(ref p) = state.settings.create_path {
320 ui.label(
321 egui::RichText::new(p.display().to_string())
322 .small()
323 .color(theme::text_secondary()),
324 );
325 }
326 });
327 // Storage style is a significant choice (copy vs reference in
328 // place) — promote it from a buried checkbox to an explicit radio
329 // choice so users opt into loose-files mode deliberately.
330 ui.label(egui::RichText::new("Storage style:").small().color(theme::text_secondary()));
331 let mut style = state.settings.create_loose_files;
332 if ui.radio_value(&mut style, false, "Copy samples into library (recommended)")
333 .on_hover_text("Samples are duplicated into the library's content-addressed store. Originals can be moved or deleted safely.")
334 .changed()
335 {
336 state.settings.create_loose_files = style;
337 }
338 if ui.radio_value(&mut style, true, "Reference samples in place (loose-files mode)")
339 .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.")
340 .changed()
341 {
342 state.settings.create_loose_files = style;
343 }
344 if state.settings.create_loose_files {
345 ui.label(
346 egui::RichText::new("Moving or deleting originals will break references. This cannot be undone.")
347 .small()
348 .color(theme::accent_yellow()),
349 );
350 }
351 ui.add_space(theme::space::SM);
352 ui.horizontal(|ui| {
353 let can_create = !state.settings.create_name.trim().is_empty()
354 && state.settings.create_path.is_some();
355 let has_partial = !state.settings.create_name.trim().is_empty()
356 || state.settings.create_path.is_some();
357 if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked()
358 && let Some(path) = state.settings.create_path.take() {
359 let name = state.settings.create_name.trim().to_string();
360 let loose_files = state.settings.create_loose_files;
361 state.settings.pending_action =
362 Some(crate::state::VaultAction::CreateVault { name, path, loose_files });
363 state.settings.create_name.clear();
364 state.settings.create_loose_files = false;
365 should_close = true;
366 }
367 if ui
368 .add_enabled(can_create, egui::Button::new("Add Existing"))
369 .on_hover_text("Add an existing audiofiles library directory")
370 .clicked()
371 && let Some(path) = state.settings.create_path.take() {
372 let name = state.settings.create_name.trim().to_string();
373 state.settings.pending_action =
374 Some(crate::state::VaultAction::AddExistingVault { name, path });
375 state.settings.create_name.clear();
376 state.settings.create_loose_files = false;
377 // Both commit paths now close Settings: a Create makes
378 // the new vault active, and an Add-Existing typically
379 // motivates immediate browsing too.
380 should_close = true;
381 }
382 // Cancel only enabled when the form has user-entered state to
383 // discard — keeps the button from looking permanently active.
384 if ui
385 .add_enabled(has_partial, egui::Button::new("Cancel"))
386 .on_hover_text("Discard the form without creating a library")
387 .clicked()
388 {
389 state.settings.create_name.clear();
390 state.settings.create_path = None;
391 state.settings.create_loose_files = false;
392 }
393 });
394
395 if should_close {
396 state.settings.show_manager = false;
397 }
398 });
399 }
400
401 // ── Appearance section ──
402
403 fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) {
404 egui::CollapsingHeader::new(egui::RichText::new("Appearance").strong())
405 .default_open(false)
406 .show(ui, |ui| {
407 let themes = theme::list_themes();
408 let current_name = themes
409 .iter()
410 .find(|t| t.id == state.current_theme_id)
411 .map(|t| t.name.as_str())
412 .unwrap_or(&state.current_theme_id);
413
414 let mut new_theme_id = None;
415 ui.horizontal(|ui| {
416 ui.label("Theme:");
417 egui::ComboBox::from_id_salt("settings_theme_select")
418 .selected_text(current_name)
419 .width(200.0)
420 .show_ui(ui, |ui| {
421 for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] {
422 // Pair each theme with its muted-text contrast tier and
423 // sort most-accessible-first, so readable themes surface
424 // at the top of each group and low-contrast curated
425 // palettes are clearly badged rather than silently mixed in.
426 let mut group: Vec<(&theme::ThemeMeta, theme::ContrastTier)> = themes
427 .iter()
428 .filter(|t| t.variant == variant)
429 .map(|t| (t, theme::theme_contrast_tier(&t.id)))
430 .collect();
431 if group.is_empty() {
432 continue;
433 }
434 group.sort_by_key(|(_, tier)| std::cmp::Reverse(*tier));
435 ui.label(egui::RichText::new(label).small().strong());
436 for (t, tier) in group {
437 let is_selected = t.id == state.current_theme_id;
438 ui.horizontal(|ui| {
439 // Color swatch (bg + accent)
440 if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) {
441 let size = egui::vec2(12.0, 12.0);
442 let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
443 ui.painter().rect_filled(rect, 2.0, bg);
444 let accent_rect = egui::Rect::from_min_size(
445 rect.min + egui::vec2(6.0, 0.0),
446 egui::vec2(6.0, 12.0),
447 );
448 ui.painter().rect_filled(accent_rect, 0.0, accent);
449 }
450 let display = if t.is_custom {
451 format!("{} (custom)", t.name)
452 } else {
453 t.name.clone()
454 };
455 if ui.selectable_label(is_selected, display).clicked() {
456 new_theme_id = Some(t.id.clone());
457 }
458 // Contrast-tier badge (text legibility, not the
459 // theme's accent palette).
460 let badge_color = match tier {
461 theme::ContrastTier::High => theme::accent_green(),
462 theme::ContrastTier::Standard => theme::text_muted(),
463 theme::ContrastTier::Low => theme::accent_yellow(),
464 };
465 ui.label(
466 egui::RichText::new(tier.badge())
467 .small()
468 .color(badge_color),
469 )
470 .on_hover_text(
471 "Muted-text legibility: AA = passes WCAG AA, OK = readable, low = subtle",
472 );
473 });
474 }
475 ui.separator();
476 }
477 });
478 });
479
480 if let Some(id) = new_theme_id {
481 theme::set_theme(&id);
482 state.current_theme_id = id;
483 state.save_theme_preference();
484 }
485
486 });
487 }
488
489 // ── Preview section ──
490
491 fn draw_preview_section(ui: &mut egui::Ui, state: &mut BrowserState) {
492 egui::CollapsingHeader::new(egui::RichText::new("Preview").strong())
493 .default_open(false)
494 .show(ui, |ui| {
495 let mut loop_enabled = state.loop_enabled;
496 if ui.checkbox(&mut loop_enabled, "Loop playback")
497 .on_hover_text("Loop sample preview (L)")
498 .changed()
499 {
500 state.toggle_loop();
501 }
502
503 let mut autoplay = state.autoplay;
504 if ui.checkbox(&mut autoplay, "Auto-play on navigate")
505 .on_hover_text("Automatically preview sample when navigating")
506 .changed()
507 {
508 state.toggle_autoplay();
509 }
510 });
511 }
512
513 // ── Forge section ──
514
515 fn draw_forge_section(ui: &mut egui::Ui, state: &mut BrowserState) {
516 egui::CollapsingHeader::new(egui::RichText::new("Forge").strong())
517 .default_open(false)
518 .show(ui, |ui| {
519 let mut auto_trim = state.forge_auto_trim_overshoot;
520 if ui
521 .checkbox(&mut auto_trim, "Auto-trim resample overshoot")
522 .on_hover_text(
523 "Resampling can push peaks just past full scale. Off (default): the \
524 signal is left untouched and a warning is shown if a conform will clip. \
525 On: the forge applies the smallest gain reduction to bring the peak back \
526 to full scale, avoiding the clip.",
527 )
528 .changed()
529 {
530 state.toggle_forge_auto_trim_overshoot();
531 }
532 });
533 }
534
535 // ── Display section ──
536
537 fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) {
538 egui::CollapsingHeader::new(egui::RichText::new("Display").strong())
539 .default_open(false)
540 .show(ui, |ui| {
541 ui.label(egui::RichText::new("Visible Columns").small().color(theme::text_secondary()));
542
543 let mut col_changed = false;
544 col_changed |= ui.checkbox(&mut state.column_config.show_classification, "Classification").changed();
545 col_changed |= ui.checkbox(&mut state.column_config.show_bpm, "BPM").changed();
546 col_changed |= ui.checkbox(&mut state.column_config.show_key, "Key").changed();
547 col_changed |= ui.checkbox(&mut state.column_config.show_duration, "Duration").changed();
548 col_changed |= ui.checkbox(&mut state.column_config.show_peak_db, "Peak dB").changed();
549 col_changed |= ui.checkbox(&mut state.column_config.show_tags, "Tags").changed();
550 if col_changed {
551 state.save_column_config();
552 }
553
554 ui.add_space(theme::space::SM);
555 if ui
556 .button("Reset columns")
557 .on_hover_text(
558 "Restore column visibility, sort, and row density to defaults. \
559 Column widths reset on next app launch.",
560 )
561 .clicked()
562 {
563 state.reset_columns();
564 }
565
566 ui.add_space(theme::space::MD);
567 ui.separator();
568 ui.add_space(theme::space::SM);
569 ui.label(egui::RichText::new("Row Density").small().color(theme::text_secondary()));
570 let mut row_height = state.row_height;
571 let label = if row_height <= 22.0 {
572 "Compact"
573 } else if row_height >= 28.0 {
574 "Spacious"
575 } else {
576 "Normal"
577 };
578 ui.horizontal(|ui| {
579 ui.label(label);
580 ui.label(
581 egui::RichText::new(format!("{} px", row_height as i32))
582 .small()
583 .color(theme::text_muted()),
584 );
585 if ui.add(egui::Slider::new(&mut row_height, 20.0..=32.0).step_by(2.0).show_value(false)).changed() {
586 state.row_height = row_height;
587 let _ = state.backend.set_config("row_height", &format!("{row_height}"));
588 }
589 });
590
591 ui.add_space(theme::space::MD);
592 ui.separator();
593 ui.add_space(theme::space::SM);
594 ui.label(egui::RichText::new("Tag Suggestions").small().color(theme::text_secondary()));
595 let dismissed_total: usize = state
596 .dismissed_suggestions
597 .values()
598 .map(|v| v.len())
599 .sum();
600 ui.horizontal(|ui| {
601 ui.label(
602 egui::RichText::new(format!(
603 "{dismissed_total} dismissed suggestion{}",
604 if dismissed_total == 1 { "" } else { "s" }
605 ))
606 .small()
607 .color(theme::text_muted()),
608 );
609 if ui
610 .add_enabled(dismissed_total > 0, egui::Button::new("Reset suggestions"))
611 .on_hover_text("Re-enable every classification tag suggestion you've dismissed")
612 .clicked()
613 {
614 state.reset_dismissed_suggestions();
615 }
616 });
617 });
618 }
619
620 // ── License section ──
621
622 fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) {
623 egui::CollapsingHeader::new(egui::RichText::new("License").strong())
624 .default_open(false)
625 .show(ui, |ui| {
626 if let Some(ref masked) = state.settings.license_key_masked {
627 ui.horizontal(|ui| {
628 ui.label("Key:");
629 ui.label(egui::RichText::new(masked).color(theme::text_secondary()));
630 });
631 } else if let Some(days) = state.settings.trial_days_remaining {
632 // "Trial: 0 days" was technically correct but uncomfortably
633 // terse at the expired state; rephrase so the dead-end reads
634 // as a status, not a counter (m-13). A Purchase button would
635 // belong here but the buy flow is not yet wired.
636 let text = if days > 0 {
637 format!("Trial: {days} days left")
638 } else {
639 "Trial expired".to_string()
640 };
641 let color = if days > 7 {
642 theme::text_secondary()
643 } else if days > 0 {
644 theme::accent_yellow()
645 } else {
646 theme::text_muted()
647 };
648 ui.label(egui::RichText::new(text).color(color));
649 }
650 if let Some(ref mid) = state.settings.machine_id {
651 ui.horizontal(|ui| {
652 ui.label("Machine:");
653 // selectable_label so the value can be selected/copied with
654 // a keyboard shortcut, plus an explicit Copy button for
655 // pointer users. Common ask when contacting support (m-14).
656 ui.add(egui::Label::new(
657 egui::RichText::new(mid).small().color(theme::text_muted()),
658 ).selectable(true));
659 if ui.small_button("Copy").on_hover_text("Copy machine id to clipboard").clicked() {
660 ui.ctx().copy_text(mid.clone());
661 state.status = "Copied machine id.".to_string();
662 }
663 });
664 }
665 if state.settings.license_key_masked.is_some() {
666 ui.add_space(theme::space::MD);
667 if widgets::danger_button(ui, "Deactivate").clicked() {
668 state.settings.pending_action = Some(crate::state::VaultAction::DeactivateLicense);
669 }
670 }
671 });
672 }
673
674 // ── Advanced section ──
675
676 fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) {
677 egui::CollapsingHeader::new(egui::RichText::new("Advanced").strong())
678 .default_open(false)
679 .show(ui, |ui| {
680 // Theme import/export
681 ui.label(egui::RichText::new("Custom Themes").small().color(theme::text_secondary()));
682 ui.horizontal(|ui| {
683 if ui.button("Import Theme...").clicked()
684 && let Some(path) = rfd::FileDialog::new()
685 .add_filter("Theme", &["toml"])
686 .pick_file()
687 {
688 let Some(custom_dir) = theme::custom_themes_dir() else {
689 state.status = "Theme import failed: no custom themes directory available.".to_string();
690 return;
691 };
692 match theme::load_theme(&path) {
693 Ok(_colors) => {
694 let id = path.file_stem()
695 .and_then(|s| s.to_str())
696 .unwrap_or("custom")
697 .to_string();
698 if let Err(e) = std::fs::create_dir_all(&custom_dir) {
699 tracing::error!("Failed to create custom themes dir: {e}");
700 state.status = format!("Theme import failed: {e}");
701 } else if let Err(e) = std::fs::copy(&path, custom_dir.join(format!("{id}.toml"))) {
702 tracing::error!("Failed to copy theme: {e}");
703 state.status = format!("Theme import failed: {e}");
704 } else {
705 theme::set_theme(&id);
706 state.current_theme_id = id.clone();
707 state.save_theme_preference();
708 state.status = format!("Imported theme: {id}");
709 }
710 }
711 Err(e) => {
712 tracing::error!("Failed to load theme: {e}");
713 state.status = format!("Theme import failed: {e}");
714 }
715 }
716 }
717 if ui.button("Export Current...").clicked()
718 && let Some(path) = rfd::FileDialog::new()
719 .set_file_name(format!("{}.toml", state.current_theme_id))
720 .add_filter("Theme", &["toml"])
721 .save_file()
722 {
723 if let Some(content) = theme::export_theme_content(&state.current_theme_id) {
724 match std::fs::write(&path, content) {
725 Ok(()) => {
726 state.status = format!("Exported theme to {}", path.display());
727 }
728 Err(e) => {
729 tracing::error!("Failed to export theme: {e}");
730 state.status = format!("Theme export failed: {e}");
731 }
732 }
733 } else {
734 tracing::warn!("Theme '{}' not found for export", state.current_theme_id);
735 state.status = format!("Theme export failed: '{}' not found.", state.current_theme_id);
736 }
737 }
738 });
739
740 // Library mirror (Unix only)
741 #[cfg(unix)]
742 {
743 ui.add_space(theme::space::MD);
744 ui.separator();
745 ui.add_space(theme::space::SM);
746 ui.label(egui::RichText::new("Library Mirror").small().color(theme::text_secondary()));
747 let mut mirror = state.mirror_enabled;
748 if ui.checkbox(&mut mirror, "Enable library mirror")
749 .on_hover_text("Create a symlink tree so DAWs can browse your library as a normal folder")
750 .changed()
751 {
752 state.set_mirror_enabled(mirror);
753 }
754 // Always surface the mirror path so the user knows where the
755 // symlink tree will live before enabling, and can change it
756 // without hunting for a hidden config. Pairs with the path
757 // picker pattern in Add Library above (m-12).
758 ui.horizontal(|ui| {
759 ui.label(
760 egui::RichText::new(collapse_home(&state.mirror_path))
761 .small()
762 .color(theme::text_muted()),
763 )
764 .on_hover_text(state.mirror_path.display().to_string());
765 if ui.small_button("Change...").on_hover_text("Pick a new location for the mirror").clicked()
766 && let Some(new_path) = rfd::FileDialog::new()
767 .set_title("Choose library mirror location")
768 .pick_folder()
769 {
770 state.set_mirror_path(new_path);
771 }
772 });
773 }
774 });
775 }
776