Skip to main content

max / audiofiles

UI: theme-common extraction, collapsible filters, cleanup progress screen Extract theme parsing to shared theme-common crate. Add spacing config to theme TOML. Filter panel sections collapse with active indicators. Toolbar shows active filter count. Add cleanup progress screen. Detail panel metadata in visual group. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 21:58 UTC
Commit: 718c4ad7fb4a63068fd80cd08ad719daf520d452
Parent: 4aa1482
10 files changed, +236 insertions, -234 deletions
M Cargo.lock +23 -2
@@ -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"
M Cargo.toml +1
@@ -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;