max / audiofiles
10 files changed,
+236 insertions,
-234 deletions
| @@ -439,6 +439,7 @@ dependencies = [ | |||
| 439 | 439 | "serde_json", | |
| 440 | 440 | "symphonia", | |
| 441 | 441 | "tempfile", | |
| 442 | + | "theme-common", | |
| 442 | 443 | "thiserror 2.0.18", | |
| 443 | 444 | "toml", | |
| 444 | 445 | "tracing", | |
| @@ -4315,9 +4316,9 @@ dependencies = [ | |||
| 4315 | 4316 | ||
| 4316 | 4317 | [[package]] | |
| 4317 | 4318 | name = "rustls-webpki" | |
| 4318 | - | version = "0.103.9" | |
| 4319 | + | version = "0.103.11" | |
| 4319 | 4320 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4320 | - | checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" | |
| 4321 | + | checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" | |
| 4321 | 4322 | dependencies = [ | |
| 4322 | 4323 | "ring", | |
| 4323 | 4324 | "rustls-pki-types", | |
| @@ -4943,6 +4944,7 @@ dependencies = [ | |||
| 4943 | 4944 | "serde_json", | |
| 4944 | 4945 | "thiserror 2.0.18", | |
| 4945 | 4946 | "tokio", | |
| 4947 | + | "tokio-stream", | |
| 4946 | 4948 | "tracing", | |
| 4947 | 4949 | "unicode-normalization", | |
| 4948 | 4950 | "urlencoding", | |
| @@ -5028,6 +5030,14 @@ dependencies = [ | |||
| 5028 | 5030 | ] | |
| 5029 | 5031 | ||
| 5030 | 5032 | [[package]] | |
| 5033 | + | name = "theme-common" | |
| 5034 | + | version = "0.3.0" | |
| 5035 | + | dependencies = [ | |
| 5036 | + | "serde", | |
| 5037 | + | "toml", | |
| 5038 | + | ] | |
| 5039 | + | ||
| 5040 | + | [[package]] | |
| 5031 | 5041 | name = "thin-vec" | |
| 5032 | 5042 | version = "0.2.14" | |
| 5033 | 5043 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5177,6 +5187,17 @@ dependencies = [ | |||
| 5177 | 5187 | ] | |
| 5178 | 5188 | ||
| 5179 | 5189 | [[package]] | |
| 5190 | + | name = "tokio-stream" | |
| 5191 | + | version = "0.1.18" | |
| 5192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5193 | + | checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" | |
| 5194 | + | dependencies = [ | |
| 5195 | + | "futures-core", | |
| 5196 | + | "pin-project-lite", | |
| 5197 | + | "tokio", | |
| 5198 | + | ] | |
| 5199 | + | ||
| 5200 | + | [[package]] | |
| 5180 | 5201 | name = "tokio-util" | |
| 5181 | 5202 | version = "0.7.18" | |
| 5182 | 5203 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -47,3 +47,4 @@ rayon = "1.10" | |||
| 47 | 47 | midir = "0.10" | |
| 48 | 48 | docengine = { path = "../../Shared/docengine" } | |
| 49 | 49 | tagtree = { path = "../../Shared/tagtree" } | |
| 50 | + | theme-common = { path = "../../Shared/theme-common" } |
| @@ -24,6 +24,7 @@ serde = { workspace = true } | |||
| 24 | 24 | serde_json = { workspace = true } | |
| 25 | 25 | rusqlite = { workspace = true } | |
| 26 | 26 | tracing = { workspace = true } | |
| 27 | + | theme-common = { workspace = true } | |
| 27 | 28 | ||
| 28 | 29 | [dev-dependencies] | |
| 29 | 30 | tempfile = "3.25.0" |
| @@ -82,64 +82,66 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 82 | 82 | ||
| 83 | 83 | // Analysis metadata grid | |
| 84 | 84 | if let Some(ref analysis) = state.selected_analysis { | |
| 85 | - | egui::Grid::new("detail_metadata") | |
| 86 | - | .num_columns(2) | |
| 87 | - | .spacing([8.0, 4.0]) | |
| 88 | - | .show(ui, |ui| { | |
| 89 | - | ui.label(egui::RichText::new("Duration").color(theme::text_secondary())); | |
| 90 | - | ui.label(widgets::format_duration(analysis.duration)); | |
| 91 | - | ui.end_row(); | |
| 92 | - | ||
| 93 | - | if let Some(bpm) = analysis.bpm { | |
| 94 | - | ui.label(egui::RichText::new("BPM").color(theme::text_secondary())); | |
| 95 | - | ui.label(widgets::format_bpm(bpm)); | |
| 85 | + | ui.group(|ui| { | |
| 86 | + | egui::Grid::new("detail_metadata") | |
| 87 | + | .num_columns(2) | |
| 88 | + | .spacing([8.0, 4.0]) | |
| 89 | + | .show(ui, |ui| { | |
| 90 | + | ui.label(egui::RichText::new("Duration").color(theme::text_secondary())); | |
| 91 | + | ui.label(widgets::format_duration(analysis.duration)); | |
| 96 | 92 | ui.end_row(); | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | if let Some(ref key) = analysis.musical_key { | |
| 100 | - | ui.label(egui::RichText::new("Key").color(theme::text_secondary())); | |
| 101 | - | ui.label(key); | |
| 102 | - | ui.end_row(); | |
| 103 | - | } | |
| 104 | 93 | ||
| 105 | - | if let Some(ref class) = analysis.classification { | |
| 106 | - | ui.label(egui::RichText::new("Class").color(theme::text_secondary())); | |
| 107 | - | widgets::classification_badge(ui, class.as_str()); | |
| 108 | - | ui.end_row(); | |
| 109 | - | } | |
| 94 | + | if let Some(bpm) = analysis.bpm { | |
| 95 | + | ui.label(egui::RichText::new("BPM").color(theme::text_secondary())); | |
| 96 | + | ui.label(widgets::format_bpm(bpm)); | |
| 97 | + | ui.end_row(); | |
| 98 | + | } | |
| 110 | 99 | ||
| 111 | - | ui.label(egui::RichText::new("Sample Rate").color(theme::text_secondary())); | |
| 112 | - | ui.label(format!("{} Hz", analysis.sample_rate)); | |
| 113 | - | ui.end_row(); | |
| 100 | + | if let Some(ref key) = analysis.musical_key { | |
| 101 | + | ui.label(egui::RichText::new("Key").color(theme::text_secondary())); | |
| 102 | + | ui.label(key); | |
| 103 | + | ui.end_row(); | |
| 104 | + | } | |
| 114 | 105 | ||
| 115 | - | ui.label(egui::RichText::new("Channels").color(theme::text_secondary())); | |
| 116 | - | ui.label(format!("{}", analysis.channels)); | |
| 117 | - | ui.end_row(); | |
| 106 | + | if let Some(ref class) = analysis.classification { | |
| 107 | + | ui.label(egui::RichText::new("Class").color(theme::text_secondary())); | |
| 108 | + | widgets::classification_badge(ui, class.as_str()); | |
| 109 | + | ui.end_row(); | |
| 110 | + | } | |
| 118 | 111 | ||
| 119 | - | if let Some(peak) = analysis.peak_db { | |
| 120 | - | ui.label(egui::RichText::new("Peak").color(theme::text_secondary())); | |
| 121 | - | ui.label(format!("{:.1} dB", peak)); | |
| 112 | + | ui.label(egui::RichText::new("Sample Rate").color(theme::text_secondary())); | |
| 113 | + | ui.label(format!("{} Hz", analysis.sample_rate)); | |
| 122 | 114 | ui.end_row(); | |
| 123 | - | } | |
| 124 | 115 | ||
| 125 | - | if let Some(rms) = analysis.rms_db { | |
| 126 | - | ui.label(egui::RichText::new("RMS").color(theme::text_secondary())); | |
| 127 | - | ui.label(format!("{:.1} dB", rms)); | |
| 116 | + | ui.label(egui::RichText::new("Channels").color(theme::text_secondary())); | |
| 117 | + | ui.label(format!("{}", analysis.channels)); | |
| 128 | 118 | ui.end_row(); | |
| 129 | - | } | |
| 130 | 119 | ||
| 131 | - | if let Some(lufs) = analysis.lufs { | |
| 132 | - | ui.label(egui::RichText::new("LUFS").color(theme::text_secondary())); | |
| 133 | - | ui.label(format!("{:.1}", lufs)); | |
| 134 | - | ui.end_row(); | |
| 135 | - | } | |
| 120 | + | if let Some(peak) = analysis.peak_db { | |
| 121 | + | ui.label(egui::RichText::new("Peak").color(theme::text_secondary())); | |
| 122 | + | ui.label(format!("{:.1} dB", peak)); | |
| 123 | + | ui.end_row(); | |
| 124 | + | } | |
| 136 | 125 | ||
| 137 | - | if let Some(is_loop) = analysis.is_loop { | |
| 138 | - | ui.label(egui::RichText::new("Loop").color(theme::text_secondary())); | |
| 139 | - | ui.label(if is_loop { "Yes" } else { "No" }); | |
| 140 | - | ui.end_row(); | |
| 141 | - | } | |
| 142 | - | }); | |
| 126 | + | if let Some(rms) = analysis.rms_db { | |
| 127 | + | ui.label(egui::RichText::new("RMS").color(theme::text_secondary())); | |
| 128 | + | ui.label(format!("{:.1} dB", rms)); | |
| 129 | + | ui.end_row(); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | if let Some(lufs) = analysis.lufs { | |
| 133 | + | ui.label(egui::RichText::new("LUFS").color(theme::text_secondary())); | |
| 134 | + | ui.label(format!("{:.1}", lufs)); | |
| 135 | + | ui.end_row(); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | if let Some(is_loop) = analysis.is_loop { | |
| 139 | + | ui.label(egui::RichText::new("Loop").color(theme::text_secondary())); | |
| 140 | + | ui.label(if is_loop { "Yes" } else { "No" }); | |
| 141 | + | ui.end_row(); | |
| 142 | + | } | |
| 143 | + | }); | |
| 144 | + | }); | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | 147 | ui.add_space(12.0); |
| @@ -16,63 +16,76 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 16 | 16 | // when min is 0 or max is 300, the corresponding filter is cleared to None | |
| 17 | 17 | // so the SQL query omits that bound. 300 matches the ceiling in bpm.rs's | |
| 18 | 18 | // plausible-BPM filter. | |
| 19 | - | ui.label("BPM Range"); | |
| 20 | - | ui.horizontal(|ui| { | |
| 21 | - | let mut min = state.search_filter.bpm_min.unwrap_or(0.0); | |
| 22 | - | let mut max = state.search_filter.bpm_max.unwrap_or(300.0); | |
| 23 | - | if ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0).prefix("Min: ")).changed() { | |
| 24 | - | state.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None }; | |
| 25 | - | changed = true; | |
| 26 | - | } | |
| 27 | - | if ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0).prefix("Max: ")).changed() { | |
| 28 | - | state.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None }; | |
| 29 | - | changed = true; | |
| 30 | - | } | |
| 31 | - | }); | |
| 32 | - | ||
| 33 | - | ui.add_space(8.0); | |
| 19 | + | let bpm_active = state.search_filter.bpm_min.is_some() || state.search_filter.bpm_max.is_some(); | |
| 20 | + | let bpm_header = if bpm_active { "BPM Range *" } else { "BPM Range" }; | |
| 21 | + | egui::CollapsingHeader::new(bpm_header) | |
| 22 | + | .default_open(bpm_active) | |
| 23 | + | .show(ui, |ui| { | |
| 24 | + | ui.horizontal(|ui| { | |
| 25 | + | let mut min = state.search_filter.bpm_min.unwrap_or(0.0); | |
| 26 | + | let mut max = state.search_filter.bpm_max.unwrap_or(300.0); | |
| 27 | + | if ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0).prefix("Min: ")).changed() { | |
| 28 | + | state.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None }; | |
| 29 | + | changed = true; | |
| 30 | + | } | |
| 31 | + | if ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0).prefix("Max: ")).changed() { | |
| 32 | + | state.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None }; | |
| 33 | + | changed = true; | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | }); | |
| 34 | 37 | ||
| 35 | 38 | // Duration range | |
| 36 | - | ui.label("Duration (s)"); | |
| 37 | - | ui.horizontal(|ui| { | |
| 38 | - | let mut min = state.search_filter.duration_min.unwrap_or(0.0); | |
| 39 | - | let mut max = state.search_filter.duration_max.unwrap_or(600.0); | |
| 40 | - | if ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0).prefix("Min: ")).changed() { | |
| 41 | - | state.search_filter.duration_min = if min > 0.0 { Some(min) } else { None }; | |
| 42 | - | changed = true; | |
| 43 | - | } | |
| 44 | - | if ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0).prefix("Max: ")).changed() { | |
| 45 | - | state.search_filter.duration_max = if max < 600.0 { Some(max) } else { None }; | |
| 46 | - | changed = true; | |
| 47 | - | } | |
| 48 | - | }); | |
| 49 | - | ||
| 50 | - | ui.add_space(8.0); | |
| 39 | + | let dur_active = state.search_filter.duration_min.is_some() || state.search_filter.duration_max.is_some(); | |
| 40 | + | let dur_header = if dur_active { "Duration (s) *" } else { "Duration (s)" }; | |
| 41 | + | egui::CollapsingHeader::new(dur_header) | |
| 42 | + | .default_open(dur_active) | |
| 43 | + | .show(ui, |ui| { | |
| 44 | + | ui.horizontal(|ui| { | |
| 45 | + | let mut min = state.search_filter.duration_min.unwrap_or(0.0); | |
| 46 | + | let mut max = state.search_filter.duration_max.unwrap_or(600.0); | |
| 47 | + | if ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0).prefix("Min: ")).changed() { | |
| 48 | + | state.search_filter.duration_min = if min > 0.0 { Some(min) } else { None }; | |
| 49 | + | changed = true; | |
| 50 | + | } | |
| 51 | + | if ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0).prefix("Max: ")).changed() { | |
| 52 | + | state.search_filter.duration_max = if max < 600.0 { Some(max) } else { None }; | |
| 53 | + | changed = true; | |
| 54 | + | } | |
| 55 | + | }); | |
| 56 | + | }); | |
| 51 | 57 | ||
| 52 | 58 | // Classification checkboxes | |
| 53 | - | ui.label("Classification"); | |
| 54 | - | let classes = [ | |
| 55 | - | "kick", "snare", "hihat", "cymbal", "percussion", "bass", | |
| 56 | - | "vocal", "synth", "pad", "misc", "noise", "music", | |
| 57 | - | ]; | |
| 58 | - | for class in &classes { | |
| 59 | - | let active = state.search_filter.classifications.contains(&class.to_string()); | |
| 60 | - | let color = theme::classification_color(class); | |
| 61 | - | let label = egui::RichText::new(*class).color(color); | |
| 62 | - | if ui.selectable_label(active, label).clicked() { | |
| 63 | - | if active { | |
| 64 | - | state.search_filter.classifications.retain(|c| c != *class); | |
| 65 | - | } else { | |
| 66 | - | state.search_filter.classifications.push(class.to_string()); | |
| 59 | + | let class_active = !state.search_filter.classifications.is_empty(); | |
| 60 | + | let class_header = if class_active { "Classification *" } else { "Classification" }; | |
| 61 | + | egui::CollapsingHeader::new(class_header) | |
| 62 | + | .default_open(class_active) | |
| 63 | + | .show(ui, |ui| { | |
| 64 | + | let classes = [ | |
| 65 | + | "kick", "snare", "hihat", "cymbal", "percussion", "bass", | |
| 66 | + | "vocal", "synth", "pad", "misc", "noise", "music", | |
| 67 | + | ]; | |
| 68 | + | for class in &classes { | |
| 69 | + | let active = state.search_filter.classifications.contains(&class.to_string()); | |
| 70 | + | let color = theme::classification_color(class); | |
| 71 | + | let label = egui::RichText::new(*class).color(color); | |
| 72 | + | if ui.selectable_label(active, label).clicked() { | |
| 73 | + | if active { | |
| 74 | + | state.search_filter.classifications.retain(|c| c != *class); | |
| 75 | + | } else { | |
| 76 | + | state.search_filter.classifications.push(class.to_string()); | |
| 77 | + | } | |
| 78 | + | changed = true; | |
| 79 | + | } | |
| 67 | 80 | } | |
| 68 | - | changed = true; | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | ui.add_space(8.0); | |
| 81 | + | }); | |
| 73 | 82 | ||
| 74 | 83 | // Key filter | |
| 75 | - | ui.collapsing("Key Filter", |ui| { | |
| 84 | + | let key_active = !state.search_filter.keys.is_empty(); | |
| 85 | + | let key_header = if key_active { "Key Filter *" } else { "Key Filter" }; | |
| 86 | + | egui::CollapsingHeader::new(key_header) | |
| 87 | + | .default_open(key_active) | |
| 88 | + | .show(ui, |ui| { | |
| 76 | 89 | // Exact / Compatible toggle | |
| 77 | 90 | ui.horizontal(|ui| { | |
| 78 | 91 | use audiofiles_core::search::KeyFilterMode; | |
| @@ -114,8 +127,6 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 114 | 127 | } | |
| 115 | 128 | }); | |
| 116 | 129 | ||
| 117 | - | ui.add_space(8.0); | |
| 118 | - | ||
| 119 | 130 | // Active tag filters | |
| 120 | 131 | if !state.search_filter.required_tags.is_empty() { | |
| 121 | 132 | ui.label("Active Tag Filters"); |
| @@ -7,6 +7,6 @@ mod summary; | |||
| 7 | 7 | mod tagging; | |
| 8 | 8 | ||
| 9 | 9 | pub use configure::{draw_configure_analysis, draw_configure_import}; | |
| 10 | - | pub use progress::{draw_analysis_progress, draw_import_progress}; | |
| 10 | + | pub use progress::{draw_analysis_progress, draw_cleanup_progress, draw_import_progress}; | |
| 11 | 11 | pub use summary::draw_review_errors; | |
| 12 | 12 | pub use tagging::{draw_review_suggestions, draw_tag_folders}; |
| @@ -101,6 +101,49 @@ pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 101 | 101 | ctx.request_repaint(); | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | /// Draw the cleanup (orphaned sample removal) progress screen. | |
| 105 | + | pub fn draw_cleanup_progress(ctx: &egui::Context, state: &mut BrowserState) { | |
| 106 | + | let (completed, total, current_name) = match &state.import_mode { | |
| 107 | + | ImportMode::Cleaning { | |
| 108 | + | completed, | |
| 109 | + | total, | |
| 110 | + | current_name, | |
| 111 | + | } => (*completed, *total, current_name.clone()), | |
| 112 | + | _ => return, | |
| 113 | + | }; | |
| 114 | + | ||
| 115 | + | egui::CentralPanel::default().show(ctx, |ui| { | |
| 116 | + | ui.heading("Cleaning Up Samples..."); | |
| 117 | + | ui.add_space(12.0); | |
| 118 | + | ||
| 119 | + | if total == 0 { | |
| 120 | + | ui.horizontal(|ui| { | |
| 121 | + | ui.spinner(); | |
| 122 | + | ui.label("Scanning for orphaned samples..."); | |
| 123 | + | }); | |
| 124 | + | } else { | |
| 125 | + | let progress = completed as f32 / total as f32; | |
| 126 | + | let pct = (progress * 100.0) as u32; | |
| 127 | + | ui.add( | |
| 128 | + | egui::ProgressBar::new(progress) | |
| 129 | + | .text(format!("{pct}% \u{2014} {completed}/{total} samples")), | |
| 130 | + | ); | |
| 131 | + | ||
| 132 | + | ui.add_space(8.0); | |
| 133 | + | if !current_name.is_empty() { | |
| 134 | + | ui.label(format!("Removing: {current_name}")); | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | ui.add_space(16.0); | |
| 139 | + | if ui.button("Cancel").clicked() { | |
| 140 | + | state.cancel_cleanup(); | |
| 141 | + | } | |
| 142 | + | }); | |
| 143 | + | ||
| 144 | + | ctx.request_repaint(); | |
| 145 | + | } | |
| 146 | + | ||
| 104 | 147 | /// Draw the analysis progress screen. | |
| 105 | 148 | pub fn draw_analysis_progress(ctx: &egui::Context, state: &mut BrowserState) { | |
| 106 | 149 | let (completed, total, current_name) = match &state.import_mode { |
| @@ -284,7 +284,7 @@ fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 284 | 284 | } | |
| 285 | 285 | if ui.button("Export Current...").clicked() { | |
| 286 | 286 | if let Some(path) = rfd::FileDialog::new() | |
| 287 | - | .set_file_name(&format!("{}.toml", state.current_theme_id)) | |
| 287 | + | .set_file_name(format!("{}.toml", state.current_theme_id)) | |
| 288 | 288 | .add_filter("Theme", &["toml"]) | |
| 289 | 289 | .save_file() | |
| 290 | 290 | { |
| @@ -77,6 +77,10 @@ pub struct ThemeColors { | |||
| 77 | 77 | pub accent_cyan: Color32, | |
| 78 | 78 | // Border | |
| 79 | 79 | pub border_default: Color32, | |
| 80 | + | // Spacing (optional TOML overrides, with sensible defaults) | |
| 81 | + | pub rounding: f32, | |
| 82 | + | pub item_spacing_x: f32, | |
| 83 | + | pub item_spacing_y: f32, | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 82 | 86 | impl Default for ThemeColors { | |
| @@ -97,17 +101,14 @@ impl Default for ThemeColors { | |||
| 97 | 101 | accent_purple: Color32::from_rgb(0xbf, 0x5a, 0xf2), | |
| 98 | 102 | accent_cyan: Color32::from_rgb(0x64, 0xd2, 0xff), | |
| 99 | 103 | border_default: Color32::from_rgb(0x33, 0x33, 0x33), | |
| 104 | + | rounding: 4.0, | |
| 105 | + | item_spacing_x: 8.0, | |
| 106 | + | item_spacing_y: 5.0, | |
| 100 | 107 | } | |
| 101 | 108 | } | |
| 102 | 109 | } | |
| 103 | 110 | ||
| 104 | - | /// Metadata extracted from a theme's `[meta]` section. | |
| 105 | - | pub struct ThemeMeta { | |
| 106 | - | pub id: String, | |
| 107 | - | pub name: String, | |
| 108 | - | pub variant: String, | |
| 109 | - | pub is_custom: bool, | |
| 110 | - | } | |
| 111 | + | pub use theme_common::ThemeMeta; | |
| 111 | 112 | ||
| 112 | 113 | static THEME: LazyLock<RwLock<ThemeColors>> = LazyLock::new(|| RwLock::new(ThemeColors::default())); | |
| 113 | 114 | ||
| @@ -196,15 +197,6 @@ pub fn custom_themes_dir() -> Option<PathBuf> { | |||
| 196 | 197 | dirs::config_dir().map(|c| c.join("audiofiles").join("themes")) | |
| 197 | 198 | } | |
| 198 | 199 | ||
| 199 | - | /// Extract `[meta]` fields from TOML content without full deserialization. | |
| 200 | - | fn parse_meta(content: &str) -> Option<(String, String)> { | |
| 201 | - | let table: toml::Table = content.parse().ok()?; | |
| 202 | - | let meta = table.get("meta")?.as_table()?; | |
| 203 | - | let name = meta.get("name")?.as_str()?.to_string(); | |
| 204 | - | let variant = meta.get("variant")?.as_str()?.to_string(); | |
| 205 | - | Some((name, variant)) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | 200 | /// List all available themes (bundled + custom). Custom themes override bundled by ID. | |
| 209 | 201 | pub fn list_themes() -> Vec<ThemeMeta> { | |
| 210 | 202 | let mut themes: Vec<ThemeMeta> = Vec::new(); | |
| @@ -212,15 +204,12 @@ pub fn list_themes() -> Vec<ThemeMeta> { | |||
| 212 | 204 | ||
| 213 | 205 | // Bundled themes first | |
| 214 | 206 | for (id, content) in BUNDLED_THEMES { | |
| 215 | - | let (name, variant) = parse_meta(content) | |
| 216 | - | .unwrap_or_else(|| (id.to_string(), "dark".to_string())); | |
| 207 | + | let table: toml::Table = match content.parse() { | |
| 208 | + | Ok(t) => t, | |
| 209 | + | Err(_) => continue, | |
| 210 | + | }; | |
| 217 | 211 | seen.insert(id.to_string()); | |
| 218 | - | themes.push(ThemeMeta { | |
| 219 | - | id: id.to_string(), | |
| 220 | - | name, | |
| 221 | - | variant, | |
| 222 | - | is_custom: false, | |
| 223 | - | }); | |
| 212 | + | themes.push(theme_common::parse_meta(id, &table, false)); | |
| 224 | 213 | } | |
| 225 | 214 | ||
| 226 | 215 | // Custom themes from config dir (override bundled by ID) | |
| @@ -234,23 +223,20 @@ pub fn list_themes() -> Vec<ThemeMeta> { | |||
| 234 | 223 | .to_string_lossy() | |
| 235 | 224 | .to_string(); | |
| 236 | 225 | if let Ok(content) = std::fs::read_to_string(&path) { | |
| 237 | - | let (name, variant) = parse_meta(&content) | |
| 238 | - | .unwrap_or_else(|| (id.clone(), "dark".to_string())); | |
| 226 | + | let table: toml::Table = match content.parse() { | |
| 227 | + | Ok(t) => t, | |
| 228 | + | Err(_) => continue, | |
| 229 | + | }; | |
| 230 | + | let meta = theme_common::parse_meta(&id, &table, true); | |
| 239 | 231 | if seen.contains(&id) { | |
| 240 | - | // Override bundled theme | |
| 241 | 232 | if let Some(existing) = themes.iter_mut().find(|t| t.id == id) { | |
| 242 | - | existing.name = name; | |
| 243 | - | existing.variant = variant; | |
| 233 | + | existing.name = meta.name; | |
| 234 | + | existing.variant = meta.variant; | |
| 244 | 235 | existing.is_custom = true; | |
| 245 | 236 | } | |
| 246 | 237 | } else { | |
| 247 | 238 | seen.insert(id.clone()); | |
| 248 | - | themes.push(ThemeMeta { | |
| 249 | - | id, | |
| 250 | - | name, | |
| 251 | - | variant, | |
| 252 | - | is_custom: true, | |
| 253 | - | }); | |
| 239 | + | themes.push(meta); | |
| 254 | 240 | } | |
| 255 | 241 | } | |
| 256 | 242 | } | |
| @@ -286,16 +272,16 @@ fn get_color(colors: &HashMap<String, String>, key: &str) -> Option<Color32> { | |||
| 286 | 272 | fn parse_theme(content: &str) -> Result<ThemeColors, toml::de::Error> { | |
| 287 | 273 | let table: toml::Table = content.parse()?; | |
| 288 | 274 | ||
| 289 | - | let mut colors = HashMap::new(); | |
| 290 | - | for section in &["background", "foreground", "accent", "border"] { | |
| 291 | - | if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) { | |
| 292 | - | for (key, val) in sect { | |
| 293 | - | if let Some(color) = val.as_str() { | |
| 294 | - | colors.insert(format!("{section}.{key}"), color.to_string()); | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | } | |
| 298 | - | } | |
| 275 | + | let colors = theme_common::extract_colors(&table); | |
| 276 | + | ||
| 277 | + | // Parse optional [spacing] section | |
| 278 | + | let spacing = table.get("spacing").and_then(|s| s.as_table()); | |
| 279 | + | let get_f32 = |key: &str, default: f32| -> f32 { | |
| 280 | + | spacing | |
| 281 | + | .and_then(|s| s.get(key)) | |
| 282 | + | .and_then(|v| v.as_float().map(|f| f as f32).or_else(|| v.as_integer().map(|i| i as f32))) | |
| 283 | + | .unwrap_or(default) | |
| 284 | + | }; | |
| 299 | 285 | ||
| 300 | 286 | Ok(ThemeColors { | |
| 301 | 287 | bg_primary: get_color(&colors, "background.primary").unwrap_or(Color32::BLACK), | |
| @@ -312,6 +298,9 @@ fn parse_theme(content: &str) -> Result<ThemeColors, toml::de::Error> { | |||
| 312 | 298 | accent_purple: get_color(&colors, "accent.purple").unwrap_or(Color32::from_rgb(0xBD, 0x93, 0xF9)), | |
| 313 | 299 | accent_cyan: get_color(&colors, "accent.cyan").unwrap_or(Color32::from_rgb(0x88, 0xC0, 0xD0)), | |
| 314 | 300 | border_default: get_color(&colors, "border.default").unwrap_or(Color32::DARK_GRAY), | |
| 301 | + | rounding: get_f32("rounding", 4.0), | |
| 302 | + | item_spacing_x: get_f32("item_spacing_x", 8.0), | |
| 303 | + | item_spacing_y: get_f32("item_spacing_y", 5.0), | |
| 315 | 304 | }) | |
| 316 | 305 | } | |
| 317 | 306 | ||
| @@ -461,19 +450,21 @@ pub fn apply_theme(ctx: &egui::Context) { | |||
| 461 | 450 | visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.border_default); | |
| 462 | 451 | ||
| 463 | 452 | // Softer edges on all widgets | |
| 464 | - | let rounding = egui::CornerRadius::same(4); | |
| 453 | + | let rounding = egui::CornerRadius::same(t.rounding as u8); | |
| 465 | 454 | visuals.widgets.noninteractive.corner_radius = rounding; | |
| 466 | 455 | visuals.widgets.inactive.corner_radius = rounding; | |
| 467 | 456 | visuals.widgets.hovered.corner_radius = rounding; | |
| 468 | 457 | visuals.widgets.active.corner_radius = rounding; | |
| 469 | 458 | visuals.widgets.open.corner_radius = rounding; | |
| 470 | 459 | ||
| 460 | + | let spacing_x = t.item_spacing_x; | |
| 461 | + | let spacing_y = t.item_spacing_y; | |
| 471 | 462 | drop(t); | |
| 472 | 463 | ctx.set_visuals(visuals); | |
| 473 | 464 | ||
| 474 | - | // Increase spacing for less dense layouts | |
| 465 | + | // Apply theme spacing | |
| 475 | 466 | let mut style = (*ctx.style()).clone(); | |
| 476 | - | style.spacing.item_spacing = egui::vec2(8.0, 5.0); | |
| 467 | + | style.spacing.item_spacing = egui::vec2(spacing_x, spacing_y); | |
| 477 | 468 | style.spacing.button_padding = egui::vec2(6.0, 3.0); | |
| 478 | 469 | ctx.set_style(style); | |
| 479 | 470 | } | |
| @@ -663,74 +654,6 @@ mod tests { | |||
| 663 | 654 | } | |
| 664 | 655 | ||
| 665 | 656 | // --------------------------------------------------------------- | |
| 666 | - | // parse_meta | |
| 667 | - | // --------------------------------------------------------------- | |
| 668 | - | ||
| 669 | - | #[test] | |
| 670 | - | fn parse_meta_valid() { | |
| 671 | - | let toml = r##" | |
| 672 | - | [meta] | |
| 673 | - | name = "Tokyo Night" | |
| 674 | - | variant = "dark" | |
| 675 | - | ||
| 676 | - | [background] | |
| 677 | - | primary = "#1a1b26" | |
| 678 | - | "##; | |
| 679 | - | let (name, variant) = parse_meta(toml).unwrap(); | |
| 680 | - | assert_eq!(name, "Tokyo Night"); | |
| 681 | - | assert_eq!(variant, "dark"); | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | #[test] | |
| 685 | - | fn parse_meta_light_variant() { | |
| 686 | - | let toml = r##" | |
| 687 | - | [meta] | |
| 688 | - | name = "Ayu Light" | |
| 689 | - | variant = "light" | |
| 690 | - | "##; | |
| 691 | - | let (name, variant) = parse_meta(toml).unwrap(); | |
| 692 | - | assert_eq!(name, "Ayu Light"); | |
| 693 | - | assert_eq!(variant, "light"); | |
| 694 | - | } | |
| 695 | - | ||
| 696 | - | #[test] | |
| 697 | - | fn parse_meta_missing_meta_section() { | |
| 698 | - | let toml = r##" | |
| 699 | - | [background] | |
| 700 | - | primary = "#000000" | |
| 701 | - | "##; | |
| 702 | - | assert!(parse_meta(toml).is_none()); | |
| 703 | - | } | |
| 704 | - | ||
| 705 | - | #[test] | |
| 706 | - | fn parse_meta_missing_name() { | |
| 707 | - | let toml = r##" | |
| 708 | - | [meta] | |
| 709 | - | variant = "dark" | |
| 710 | - | "##; | |
| 711 | - | assert!(parse_meta(toml).is_none()); | |
| 712 | - | } | |
| 713 | - | ||
| 714 | - | #[test] | |
| 715 | - | fn parse_meta_missing_variant() { | |
| 716 | - | let toml = r##" | |
| 717 | - | [meta] | |
| 718 | - | name = "Test Theme" | |
| 719 | - | "##; | |
| 720 | - | assert!(parse_meta(toml).is_none()); | |
| 721 | - | } | |
| 722 | - | ||
| 723 | - | #[test] | |
| 724 | - | fn parse_meta_invalid_toml() { | |
| 725 | - | assert!(parse_meta("not valid toml [[[").is_none()); | |
| 726 | - | } | |
| 727 | - | ||
| 728 | - | #[test] | |
| 729 | - | fn parse_meta_empty_string() { | |
| 730 | - | assert!(parse_meta("").is_none()); | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | // --------------------------------------------------------------- | |
| 734 | 657 | // parse_theme | |
| 735 | 658 | // --------------------------------------------------------------- | |
| 736 | 659 | ||
| @@ -934,11 +857,11 @@ secondary = "#16161e" | |||
| 934 | 857 | #[test] | |
| 935 | 858 | fn all_bundled_themes_have_valid_meta() { | |
| 936 | 859 | for (id, content) in BUNDLED_THEMES { | |
| 937 | - | let meta = parse_meta(content); | |
| 938 | - | assert!(meta.is_some(), "Bundled theme '{id}' has no valid [meta] section"); | |
| 939 | - | let (name, variant) = meta.unwrap(); | |
| 940 | - | assert!(!name.is_empty(), "Bundled theme '{id}' has empty name"); | |
| 941 | - | assert!(!variant.is_empty(), "Bundled theme '{id}' has empty variant"); | |
| 860 | + | let table: toml::Table = content.parse() | |
| 861 | + | .unwrap_or_else(|e| panic!("Bundled theme '{id}' is invalid TOML: {e}")); | |
| 862 | + | let meta = theme_common::parse_meta(id, &table, false); | |
| 863 | + | assert!(!meta.name.is_empty(), "Bundled theme '{id}' has empty name"); | |
| 864 | + | assert!(!meta.variant.is_empty(), "Bundled theme '{id}' has empty variant"); | |
| 942 | 865 | } | |
| 943 | 866 | } | |
| 944 | 867 |
| @@ -113,19 +113,19 @@ pub fn draw_toolbar(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 113 | 113 | state.detail_visible = !state.detail_visible; | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | - | // Filter panel toggle with active indicator | |
| 117 | - | let filter_active = state.search_filter.is_active() && !state.filter_panel_open; | |
| 116 | + | // Filter panel toggle with active count | |
| 117 | + | let filter_count = state.search_filter.active_count(); | |
| 118 | 118 | let filter_label = if state.filter_panel_open { | |
| 119 | - | "\u{2716}" | |
| 120 | - | } else if filter_active { | |
| 121 | - | "\u{2630} \u{2022}" // hamburger + dot when filters active | |
| 119 | + | "\u{2716}".to_string() | |
| 120 | + | } else if filter_count > 0 { | |
| 121 | + | format!("\u{2630} ({})", filter_count) | |
| 122 | 122 | } else { | |
| 123 | - | "\u{2630}" | |
| 123 | + | "\u{2630}".to_string() | |
| 124 | 124 | }; | |
| 125 | - | let hover = if filter_active { | |
| 126 | - | "Filters active \u{2014} click to view" | |
| 125 | + | let hover = if filter_count > 0 && !state.filter_panel_open { | |
| 126 | + | format!("{} filter{} active", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 127 | 127 | } else { | |
| 128 | - | "Toggle filter panel" | |
| 128 | + | "Toggle filter panel".to_string() | |
| 129 | 129 | }; | |
| 130 | 130 | if ui.button(filter_label).on_hover_text(hover).clicked() { | |
| 131 | 131 | state.filter_panel_open = !state.filter_panel_open; |