Skip to main content

max / audiofiles

Add tag rules builder UI + provenance view (classifier Phase 4a) The deterministic half of the tag dashboard, as sections in Settings. - Settings > Tag Rules: list rules (enable toggle, reorder, match summary, edit, delete), an "Apply rules now" action, and a structured rule builder (name, match all/any, condition rows with field/operator/value combos filtered by field kind, add/remove/stop actions, and a dry-run "Test" that counts matching samples). Saving applies the rule immediately. - Detail panel > Tag sources: per-tag provenance badge (manual / rule / suggested / cluster / folder) answering "why does this sample have this tag". - Backend trait gains list/create/update/delete/set_enabled/preview/apply_all rule methods + sample_tag_provenance; BrowserState classifier_* action helpers wrap them and keep the cached list fresh. - MatchMode derives Default. Browser 211 tests green; clippy clean. (ML suggestions review + clustering UI land in 4b.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 00:14 UTC
Commit: 8fed5ff030823ea2c2da151b740d59a6899e86e2
Parent: 6060988
12 files changed, +799 insertions, -2 deletions
@@ -581,6 +581,52 @@ impl Backend for DirectBackend {
581 581 Ok(audiofiles_core::analysis::samples_needing_features(&db)?)
582 582 }
583 583
584 + fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>> {
585 + let db = self.db.lock();
586 + Ok(audiofiles_core::rules::list_rules(&db)?)
587 + }
588 +
589 + fn create_rule(
590 + &self,
591 + rule: audiofiles_core::rules::NewRule,
592 + ) -> BackendResult<audiofiles_core::rules::Rule> {
593 + let db = self.db.lock();
594 + Ok(audiofiles_core::rules::create_rule(&db, rule)?)
595 + }
596 +
597 + fn update_rule(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<()> {
598 + let db = self.db.lock();
599 + Ok(audiofiles_core::rules::update_rule(&db, rule)?)
600 + }
601 +
602 + fn delete_rule(&self, id: &str) -> BackendResult<()> {
603 + let db = self.db.lock();
604 + Ok(audiofiles_core::rules::delete_rule(&db, id)?)
605 + }
606 +
607 + fn set_rule_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> {
608 + let db = self.db.lock();
609 + Ok(audiofiles_core::rules::set_rule_enabled(&db, id, enabled)?)
610 + }
611 +
612 + fn preview_rule_matches(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<usize> {
613 + let db = self.db.lock();
614 + Ok(audiofiles_core::rules::preview_rule_matches(&db, rule)?)
615 + }
616 +
617 + fn apply_all_rules(&self) -> BackendResult<usize> {
618 + let db = self.db.lock();
619 + Ok(audiofiles_core::rules::apply_all_rules(&db)?)
620 + }
621 +
622 + fn sample_tag_provenance(
623 + &self,
624 + hash: &str,
625 + ) -> BackendResult<Vec<(String, String, Option<String>)>> {
626 + let db = self.db.lock();
627 + Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?)
628 + }
629 +
584 630 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> {
585 631 let db = self.db.lock();
586 632 Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash))
@@ -354,6 +354,39 @@ pub trait Backend: Send + Sync {
354 354 /// (the feature-backfill work-list).
355 355 fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>>;
356 356
357 + // --- Tag rules (classifier Layer A) ---
358 +
359 + /// All tag rules in evaluation (priority) order.
360 + fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>>;
361 +
362 + /// Create a rule; returns it with its assigned id.
363 + fn create_rule(
364 + &self,
365 + rule: audiofiles_core::rules::NewRule,
366 + ) -> BackendResult<audiofiles_core::rules::Rule>;
367 +
368 + /// Replace an existing rule (matched by id).
369 + fn update_rule(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<()>;
370 +
371 + /// Delete a rule and reconcile the samples it had tagged.
372 + fn delete_rule(&self, id: &str) -> BackendResult<()>;
373 +
374 + /// Toggle a rule's enabled flag.
375 + fn set_rule_enabled(&self, id: &str, enabled: bool) -> BackendResult<()>;
376 +
377 + /// Dry-run: how many samples a rule's conditions match.
378 + fn preview_rule_matches(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<usize>;
379 +
380 + /// Re-apply all rules across the library; returns the number of samples changed.
381 + fn apply_all_rules(&self) -> BackendResult<usize>;
382 +
383 + /// Provenance of each machine-applied tag on a sample: `(tag, source, rule_id)`.
384 + /// Tags absent here (but present on the sample) are manual.
385 + fn sample_tag_provenance(
386 + &self,
387 + hash: &str,
388 + ) -> BackendResult<Vec<(String, String, Option<String>)>>;
389 +
357 390 /// Get waveform display data for a sample.
358 391 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>;
359 392
@@ -0,0 +1,182 @@
1 + //! Tag-classifier actions (Layer A rules builder) on `BrowserState`.
2 + //!
3 + //! The UI (`ui/classifier.rs`) reads `state.classifier` and calls these methods; they
4 + //! wrap the backend rule operations and keep the cached rule list fresh.
5 +
6 + use audiofiles_core::rules::{MatchMode, NewRule, Rule, RuleAction, RuleCondition, RuleField, RuleOp};
7 +
8 + use super::{BrowserState, RuleDraft};
9 +
10 + impl BrowserState {
11 + /// Reload the cached rule list from the backend.
12 + pub fn refresh_rules(&mut self) {
13 + self.classifier.rules = self.backend.list_rules().unwrap_or_default();
14 + self.classifier.loaded = true;
15 + }
16 +
17 + /// Ensure rules are loaded at least once (called when the section opens).
18 + pub fn ensure_rules_loaded(&mut self) {
19 + if !self.classifier.loaded {
20 + self.refresh_rules();
21 + }
22 + }
23 +
24 + /// Begin authoring a new rule (one empty condition, one add-tag action).
25 + pub fn classifier_new_draft(&mut self) {
26 + self.classifier.editing = Some(RuleDraft {
27 + id: None,
28 + name: String::new(),
29 + enabled: true,
30 + match_mode: MatchMode::All,
31 + conditions: vec![RuleCondition {
32 + field: RuleField::Name,
33 + op: RuleOp::Contains,
34 + value: String::new(),
35 + }],
36 + actions: vec![RuleAction::AddTag(String::new())],
37 + priority: 0,
38 + created_at: 0,
39 + match_count: None,
40 + });
41 + }
42 +
43 + /// Begin editing an existing rule.
44 + pub fn classifier_edit_rule(&mut self, rule: &Rule) {
45 + self.classifier.editing = Some(RuleDraft {
46 + id: Some(rule.id.clone()),
47 + name: rule.name.clone(),
48 + enabled: rule.enabled,
49 + match_mode: rule.match_mode,
50 + conditions: rule.conditions.clone(),
51 + actions: rule.actions.clone(),
52 + priority: rule.priority,
53 + created_at: rule.created_at,
54 + match_count: None,
55 + });
56 + }
57 +
58 + /// Discard the in-progress draft.
59 + pub fn classifier_cancel_draft(&mut self) {
60 + self.classifier.editing = None;
61 + }
62 +
63 + /// Build a `Rule` from the current draft (placeholder id/priority for new rules —
64 + /// fine for dry-run, which only reads conditions + match_mode).
65 + fn draft_as_rule(draft: &RuleDraft) -> Rule {
66 + Rule {
67 + id: draft.id.clone().unwrap_or_default(),
68 + name: draft.name.clone(),
69 + enabled: draft.enabled,
70 + priority: draft.priority,
71 + match_mode: draft.match_mode,
72 + conditions: draft.conditions.clone(),
73 + actions: draft.actions.clone(),
74 + created_at: draft.created_at,
75 + }
76 + }
77 +
78 + /// Dry-run the current draft against the library, caching the match count.
79 + pub fn classifier_test_draft(&mut self) {
80 + let Some(draft) = self.classifier.editing.as_ref() else { return };
81 + let rule = Self::draft_as_rule(draft);
82 + match self.backend.preview_rule_matches(&rule) {
83 + Ok(n) => {
84 + if let Some(d) = self.classifier.editing.as_mut() {
85 + d.match_count = Some(n);
86 + }
87 + self.status = format!("Rule matches {n} sample{}", if n == 1 { "" } else { "s" });
88 + }
89 + Err(e) => self.status = format!("Rule test failed: {e}"),
90 + }
91 + }
92 +
93 + /// Persist the current draft (create or update), then reconcile + refresh.
94 + pub fn classifier_save_draft(&mut self) {
95 + let Some(draft) = self.classifier.editing.clone() else { return };
96 + if draft.name.trim().is_empty() {
97 + self.status = "Name the rule before saving.".to_string();
98 + return;
99 + }
100 + let result = match draft.id {
101 + None => self
102 + .backend
103 + .create_rule(NewRule {
104 + name: draft.name.trim().to_string(),
105 + enabled: draft.enabled,
106 + priority: None,
107 + match_mode: draft.match_mode,
108 + conditions: draft.conditions.clone(),
109 + actions: draft.actions.clone(),
110 + })
111 + .map(|_| ()),
112 + Some(_) => self.backend.update_rule(&Self::draft_as_rule(&draft)),
113 + };
114 + match result {
115 + Ok(()) => {
116 + self.classifier.editing = None;
117 + self.refresh_rules();
118 + // Apply immediately so the rule's effect is visible.
119 + self.classifier_apply_all();
120 + }
121 + Err(e) => self.status = format!("Could not save rule: {e}"),
122 + }
123 + }
124 +
125 + /// Delete a rule (reconciling its tags away) and refresh.
126 + pub fn classifier_delete_rule(&mut self, id: &str) {
127 + match self.backend.delete_rule(id) {
128 + Ok(()) => {
129 + self.refresh_rules();
130 + self.refresh_selected_tags();
131 + self.status = "Rule deleted.".to_string();
132 + }
133 + Err(e) => self.status = format!("Could not delete rule: {e}"),
134 + }
135 + }
136 +
137 + /// Toggle a rule's enabled flag, then re-apply so the change takes effect.
138 + pub fn classifier_toggle_rule(&mut self, id: &str, enabled: bool) {
139 + if let Err(e) = self.backend.set_rule_enabled(id, enabled) {
140 + self.status = format!("Could not update rule: {e}");
141 + return;
142 + }
143 + self.refresh_rules();
144 + self.classifier_apply_all();
145 + }
146 +
147 + /// Move a rule up or down in evaluation order by swapping priorities with its
148 + /// neighbor. `idx` is the position in the (priority-ordered) cached list.
149 + pub fn classifier_move_rule(&mut self, idx: usize, up: bool) {
150 + let rules = &self.classifier.rules;
151 + let other = if up {
152 + if idx == 0 {
153 + return;
154 + }
155 + idx - 1
156 + } else {
157 + if idx + 1 >= rules.len() {
158 + return;
159 + }
160 + idx + 1
161 + };
162 + let mut a = rules[idx].clone();
163 + let mut b = rules[other].clone();
164 + std::mem::swap(&mut a.priority, &mut b.priority);
165 + if self.backend.update_rule(&a).is_ok() && self.backend.update_rule(&b).is_ok() {
166 + self.refresh_rules();
167 + self.classifier_apply_all();
168 + }
169 + }
170 +
171 + /// Re-apply all rules across the library; records how many samples changed.
172 + pub fn classifier_apply_all(&mut self) {
173 + match self.backend.apply_all_rules() {
174 + Ok(n) => {
175 + self.classifier.last_apply = Some(n);
176 + self.refresh_selected_tags();
177 + self.status = format!("Rules applied — {n} sample{} updated", if n == 1 { "" } else { "s" });
178 + }
179 + Err(e) => self.status = format!("Could not apply rules: {e}"),
180 + }
181 + }
182 + }
@@ -31,6 +31,7 @@ use crate::import::{ImportedFolder, ImportStrategy};
31 31 use crate::instrument::InstrumentPlayback;
32 32 use crate::preview::PreviewPlayback;
33 33
34 + mod classifier;
34 35 mod navigation;
35 36 pub mod import_workflow;
36 37 mod bulk_ops;
@@ -99,6 +100,9 @@ pub struct BrowserState {
99 100 pub contents: Arc<Vec<VfsNodeWithAnalysis>>,
100 101 pub selection: Selection,
101 102 pub selected_tags: Arc<Vec<String>>,
103 + /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag
104 + /// absent from this map is manual. Refreshed alongside `selected_tags`.
105 + pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>,
102 106 pub status: String,
103 107 /// When the current `status` message was posted. Drives the footer's
104 108 /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means
@@ -317,6 +321,9 @@ pub struct BrowserState {
317 321 // Settings (consolidated window)
318 322 pub settings: SettingsUiState,
319 323
324 + /// Tag-classifier UI state (rules builder in Settings).
325 + pub classifier: ClassifierUiState,
326 +
320 327 // Loose-files mode integrity
321 328 /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files).
322 329 pub loose_files_missing_count: usize,
@@ -454,6 +461,7 @@ impl BrowserState {
454 461 contents: Arc::new(contents),
455 462 selection: Selection::new(),
456 463 selected_tags: Arc::new(Vec::new()),
464 + selected_tag_sources: std::collections::HashMap::new(),
457 465 status: String::new(),
458 466 status_set_at: None,
459 467 selected_analysis: None,
@@ -548,6 +556,7 @@ impl BrowserState {
548 556 mirror_dirty: mirror_enabled,
549 557 sync: SyncUiState::default(),
550 558 settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() },
559 + classifier: ClassifierUiState::default(),
551 560 loose_files_missing_count: 0,
552 561 show_loose_files_warning: false,
553 562 })
@@ -118,12 +118,20 @@ impl BrowserState {
118 118 /// Reload the tag list for the currently focused sample (shown in the detail panel).
119 119 pub fn refresh_selected_tags(&mut self) {
120 120 self.selected_tags = Arc::new(Vec::new());
121 + self.selected_tag_sources.clear();
121 122 if let Some(node) = self.selected_node()
122 123 && let Some(hash) = &node.node.sample_hash {
123 - self.selected_tags = Arc::new(self.backend.get_sample_tags(hash).unwrap_or_else(|e| {
124 + let hash = hash.clone();
125 + self.selected_tags = Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_else(|e| {
124 126 warn!("Failed to load tags: {e}");
125 127 Vec::new()
126 128 }));
129 + if let Ok(prov) = self.backend.sample_tag_provenance(&hash) {
130 + self.selected_tag_sources = prov
131 + .into_iter()
132 + .map(|(tag, source, rule_id)| (tag, (source, rule_id)))
133 + .collect();
134 + }
127 135 }
128 136 }
129 137
@@ -1839,4 +1839,37 @@ mod misc {
1839 1839 // No rules configured -> no change.
1840 1840 assert!(!state.backend.apply_rules_to_sample("aaa111").unwrap());
1841 1841 }
1842 +
1843 + #[test]
1844 + fn classifier_create_rule_applies_tag() {
1845 + let (mut state, _dir) = make_state();
1846 + insert_fake_sample(&state, "aaa111");
1847 + state.refresh_rules();
1848 +
1849 + // Author an unconditional rule that adds a tag, via the UI action methods.
1850 + state.classifier_new_draft();
1851 + {
1852 + let d = state.classifier.editing.as_mut().unwrap();
1853 + d.name = "tag everything".to_string();
1854 + d.conditions.clear(); // empty conditions => matches all samples
1855 + d.actions = vec![audiofiles_core::rules::RuleAction::AddTag("auto.tagged".to_string())];
1856 + }
1857 + state.classifier_save_draft();
1858 +
1859 + // Rule persisted and the editor closed.
1860 + assert!(state.classifier.editing.is_none());
1861 + assert_eq!(state.classifier.rules.len(), 1);
1862 +
1863 + // Tag applied to the sample with 'rule' provenance.
1864 + let tags = state.backend.get_sample_tags("aaa111").unwrap();
1865 + assert!(tags.contains(&"auto.tagged".to_string()));
1866 + let prov = state.backend.sample_tag_provenance("aaa111").unwrap();
1867 + assert!(prov.iter().any(|(t, s, _)| t == "auto.tagged" && s == "rule"));
1868 +
1869 + // Deleting the rule reconciles the tag away.
1870 + let id = state.classifier.rules[0].id.clone();
1871 + state.classifier_delete_rule(&id);
1872 + assert!(state.classifier.rules.is_empty());
1873 + assert!(!state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.tagged".to_string()));
1874 + }
1842 1875 }
@@ -279,6 +279,37 @@ pub struct SettingsUiState {
279 279 pub trial_days_remaining: Option<i64>,
280 280 }
281 281
282 + /// GUI state for the tag-classifier sections in Settings (Layer A rules builder).
283 + #[derive(Default)]
284 + pub struct ClassifierUiState {
285 + /// Cached rules, refreshed when the section opens and after edits.
286 + pub rules: Vec<audiofiles_core::rules::Rule>,
287 + /// True once `rules` has been loaded at least once this session.
288 + pub loaded: bool,
289 + /// The rule currently being created/edited (None = list view).
290 + pub editing: Option<RuleDraft>,
291 + /// Number of samples changed by the last "Apply rules now" run.
292 + pub last_apply: Option<usize>,
293 + }
294 +
295 + /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule`
296 + /// minus the persisted id/created_at (kept in `id` when editing an existing rule).
297 + #[derive(Default, Clone)]
298 + pub struct RuleDraft {
299 + /// Existing rule id, or `None` for a new rule.
300 + pub id: Option<String>,
301 + pub name: String,
302 + pub enabled: bool,
303 + pub match_mode: audiofiles_core::rules::MatchMode,
304 + pub conditions: Vec<audiofiles_core::rules::RuleCondition>,
305 + pub actions: Vec<audiofiles_core::rules::RuleAction>,
306 + /// Original priority/created_at, carried through so editing preserves order.
307 + pub priority: i64,
308 + pub created_at: i64,
309 + /// Cached dry-run match count (recomputed when the draft changes).
310 + pub match_count: Option<usize>,
311 + }
312 +
282 313 /// GUI state for the sync setup and panel.
283 314 pub struct SyncUiState {
284 315 /// Whether the sync panel overlay is open.
@@ -0,0 +1,422 @@
1 + //! Tag-classifier UI: the deterministic rules builder (Layer A), drawn as a section
2 + //! inside the Settings window. Reads/mutates `state.classifier`; persistence and
3 + //! reconciliation go through the `classifier_*` methods on `BrowserState`.
4 +
5 + use egui;
6 +
7 + use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp};
8 +
9 + use super::theme;
10 + use super::widgets;
11 + use crate::state::BrowserState;
12 +
13 + /// Fields offered in the condition builder (path/name + tags first, then DSP).
14 + const FIELDS: &[RuleField] = &[
15 + RuleField::Name,
16 + RuleField::SourcePath,
17 + RuleField::VfsPath,
18 + RuleField::FileExtension,
19 + RuleField::Tag,
20 + RuleField::Duration,
21 + RuleField::Bpm,
22 + RuleField::MusicalKey,
23 + RuleField::IsLoop,
24 + RuleField::SpectralCentroid,
25 + RuleField::SpectralFlatness,
26 + RuleField::SpectralRolloff,
27 + RuleField::Zcr,
28 + RuleField::SpectralBandwidth,
29 + RuleField::CentroidVariance,
30 + RuleField::CrestFactor,
31 + RuleField::AttackTime,
32 + RuleField::PeakDb,
33 + RuleField::RmsDb,
34 + RuleField::Lufs,
35 + RuleField::SampleRate,
36 + RuleField::Channels,
37 + RuleField::FileSize,
38 + ];
39 +
40 + fn field_label(f: RuleField) -> &'static str {
41 + use RuleField::*;
42 + match f {
43 + Name => "File name",
44 + SourcePath => "Source path",
45 + VfsPath => "Folder path",
46 + FileExtension => "Extension",
47 + Tag => "Tag",
48 + Duration => "Duration (s)",
49 + Bpm => "BPM",
50 + MusicalKey => "Key",
51 + IsLoop => "Is loop",
52 + SpectralCentroid => "Brightness",
53 + SpectralFlatness => "Noisiness",
54 + SpectralRolloff => "Rolloff",
55 + Zcr => "Zero-crossing rate",
56 + SpectralBandwidth => "Bandwidth",
57 + CentroidVariance => "Spectral motion",
58 + CrestFactor => "Crest factor",
59 + AttackTime => "Attack (s)",
60 + PeakDb => "Peak dB",
61 + RmsDb => "RMS dB",
62 + Lufs => "LUFS",
63 + SampleRate => "Sample rate",
64 + Channels => "Channels",
65 + FileSize => "File size",
66 + }
67 + }
68 +
69 + enum Kind {
70 + Text,
71 + Number,
72 + Bool,
73 + }
74 +
75 + fn field_kind(f: RuleField) -> Kind {
76 + use RuleField::*;
77 + match f {
78 + Name | SourcePath | VfsPath | FileExtension | Tag | MusicalKey => Kind::Text,
79 + IsLoop => Kind::Bool,
80 + _ => Kind::Number,
81 + }
82 + }
83 +
84 + const STRING_OPS: &[RuleOp] = &[
85 + RuleOp::Contains,
86 + RuleOp::NotContains,
87 + RuleOp::Equals,
88 + RuleOp::NotEquals,
89 + RuleOp::StartsWith,
90 + RuleOp::EndsWith,
91 + RuleOp::Exists,
92 + RuleOp::NotExists,
93 + ];
94 + const NUM_OPS: &[RuleOp] = &[
95 + RuleOp::Lt,
96 + RuleOp::Le,
97 + RuleOp::Gt,
98 + RuleOp::Ge,
99 + RuleOp::Equals,
100 + RuleOp::NotEquals,
101 + RuleOp::Exists,
102 + RuleOp::NotExists,
103 + ];
104 + const BOOL_OPS: &[RuleOp] = &[RuleOp::IsTrue, RuleOp::IsFalse, RuleOp::Exists, RuleOp::NotExists];
105 +
106 + fn ops_for(f: RuleField) -> &'static [RuleOp] {
107 + match field_kind(f) {
108 + Kind::Text => STRING_OPS,
109 + Kind::Number => NUM_OPS,
110 + Kind::Bool => BOOL_OPS,
111 + }
112 + }
113 +
114 + fn op_label(op: RuleOp) -> &'static str {
115 + use RuleOp::*;
116 + match op {
117 + Contains => "contains",
118 + NotContains => "doesn't contain",
119 + Equals => "equals",
120 + NotEquals => "not equals",
121 + StartsWith => "starts with",
122 + EndsWith => "ends with",
123 + Lt => "<",
124 + Le => "\u{2264}",
125 + Gt => ">",
126 + Ge => "\u{2265}",
127 + IsTrue => "is true",
128 + IsFalse => "is false",
129 + Exists => "exists",
130 + NotExists => "missing",
131 + }
132 + }
133 +
134 + fn op_needs_value(op: RuleOp) -> bool {
135 + !matches!(op, RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse)
136 + }
137 +
138 + /// Draw the "Tag Rules" Settings section.
139 + pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
140 + egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong())
141 + .default_open(false)
142 + .show(ui, |ui| {
143 + state.ensure_rules_loaded();
144 + ui.label(
145 + egui::RichText::new(
146 + "Deterministic rules that auto-apply tags by sample metadata and audio \
147 + features. Rules never remove tags you added by hand.",
148 + )
149 + .small()
150 + .color(theme::content_muted()),
151 + );
152 + ui.add_space(theme::space::SM);
153 +
154 + if state.classifier.editing.is_some() {
155 + draw_editor(ui, state);
156 + } else {
157 + draw_list(ui, state);
158 + }
159 + });
160 + }
161 +
162 + fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) {
163 + ui.horizontal(|ui| {
164 + if ui.button("New rule").clicked() {
165 + state.classifier_new_draft();
166 + }
167 + if ui
168 + .button("Apply rules now")
169 + .on_hover_text("Re-evaluate every rule across the whole library")
170 + .clicked()
171 + {
172 + state.classifier_apply_all();
173 + }
174 + });
175 +
176 + if let Some(n) = state.classifier.last_apply {
177 + ui.label(
178 + egui::RichText::new(format!(
179 + "Last apply: {n} sample{} updated",
180 + if n == 1 { "" } else { "s" }
181 + ))
182 + .small()
183 + .color(theme::content_muted()),
184 + );
185 + }
186 + ui.add_space(theme::space::SM);
187 +
188 + if state.classifier.rules.is_empty() {
189 + ui.label(
190 + egui::RichText::new("No rules yet \u{2014} new rules start empty; you decide what gets tagged.")
191 + .small()
192 + .color(theme::content_muted()),
193 + );
194 + return;
195 + }
196 +
197 + // Collect actions to apply after the immutable iteration over the cached list.
198 + let rules = state.classifier.rules.clone();
199 + let count = rules.len();
200 + let mut toggle: Option<(String, bool)> = None;
201 + let mut edit: Option<usize> = None;
202 + let mut delete: Option<String> = None;
203 + let mut move_rule: Option<(usize, bool)> = None;
204 +
205 + for (i, rule) in rules.iter().enumerate() {
206 + ui.horizontal(|ui| {
207 + let mut enabled = rule.enabled;
208 + if ui.checkbox(&mut enabled, "").changed() {
209 + toggle = Some((rule.id.clone(), enabled));
210 + }
211 + let name = if rule.name.trim().is_empty() { "(unnamed)" } else { rule.name.as_str() };
212 + ui.label(name);
213 + ui.label(
214 + egui::RichText::new(format!(
215 + "{} cond \u{2192} {} act",
216 + rule.conditions.len(),
217 + rule.actions.len()
218 + ))
219 + .small()
220 + .color(theme::content_muted()),
221 + );
222 +
223 + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
224 + if widgets::danger_small_button(ui, "Delete").clicked() {
225 + delete = Some(rule.id.clone());
226 + }
227 + if ui.small_button("Edit").clicked() {
228 + edit = Some(i);
229 + }
230 + if ui.add_enabled(i + 1 < count, egui::Button::new("\u{25bc}").small()).clicked() {
231 + move_rule = Some((i, false));
232 + }
233 + if ui.add_enabled(i > 0, egui::Button::new("\u{25b2}").small()).clicked() {
234 + move_rule = Some((i, true));
235 + }
236 + });
237 + });
238 + }
239 +
240 + if let Some((id, enabled)) = toggle {
241 + state.classifier_toggle_rule(&id, enabled);
242 + }
243 + if let Some(i) = edit {
244 + let rule = rules[i].clone();
245 + state.classifier_edit_rule(&rule);
246 + }
247 + if let Some(id) = delete {
248 + state.classifier_delete_rule(&id);
249 + }
250 + if let Some((i, up)) = move_rule {
251 + state.classifier_move_rule(i, up);
252 + }
253 + }
254 +
255 + fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) {
256 + let mut do_save = false;
257 + let mut do_cancel = false;
258 + let mut do_test = false;
259 +
260 + {
261 + let Some(draft) = state.classifier.editing.as_mut() else { return };
262 +
263 + ui.horizontal(|ui| {
264 + ui.label("Name");
265 + ui.text_edit_singleline(&mut draft.name);
266 + });
267 + ui.checkbox(&mut draft.enabled, "Enabled");
268 +
269 + ui.horizontal(|ui| {
270 + ui.label("Match");
271 + ui.selectable_value(&mut draft.match_mode, MatchMode::All, "All");
272 + ui.selectable_value(&mut draft.match_mode, MatchMode::Any, "Any");
273 + ui.label(
274 + egui::RichText::new("of these conditions")
275 + .small()
276 + .color(theme::content_muted()),
277 + );
278 + });
279 +
280 + // --- Conditions ---
281 + widgets::subsection_label(ui, "When");
282 + let mut remove_cond: Option<usize> = None;
283 + let cond_count = draft.conditions.len();
284 + for (i, cond) in draft.conditions.iter_mut().enumerate() {
285 + ui.horizontal(|ui| {
286 + egui::ComboBox::from_id_salt(("rule_field", i))
287 + .selected_text(field_label(cond.field))
288 + .width(120.0)
289 + .show_ui(ui, |ui| {
290 + for f in FIELDS {
291 + ui.selectable_value(&mut cond.field, *f, field_label(*f));
292 + }
293 + });
294 + // Keep the operator valid for the chosen field's kind.
295 + let ops = ops_for(cond.field);
296 + if !ops.contains(&cond.op) {
297 + cond.op = ops[0];
298 + }
299 + egui::ComboBox::from_id_salt(("rule_op", i))
300 + .selected_text(op_label(cond.op))
301 + .width(110.0)
302 + .show_ui(ui, |ui| {
303 + for op in ops {
304 + ui.selectable_value(&mut cond.op, *op, op_label(*op));
305 + }
306 + });
307 + if op_needs_value(cond.op) {
308 + ui.add(
309 + egui::TextEdit::singleline(&mut cond.value)
310 + .desired_width(70.0)
311 + .hint_text("value"),
312 + );
313 + }
314 + if ui.add_enabled(cond_count > 1, egui::Button::new("\u{2715}").small()).clicked() {
315 + remove_cond = Some(i);
316 + }
317 + });
318 + }
319 + if let Some(i) = remove_cond {
320 + draft.conditions.remove(i);
321 + draft.match_count = None;
322 + }
323 + if ui.small_button("Add condition").clicked() {
324 + draft.conditions.push(RuleCondition {
325 + field: RuleField::Name,
326 + op: RuleOp::Contains,
327 + value: String::new(),
328 + });
329 + draft.match_count = None;
330 + }
331 +
332 + // --- Actions ---
333 + ui.add_space(theme::space::SM);
334 + widgets::subsection_label(ui, "Then");
335 + let mut remove_act: Option<usize> = None;
336 + for (i, action) in draft.actions.iter_mut().enumerate() {
337 + ui.horizontal(|ui| {
338 + // 0 = add tag, 1 = remove tag, 2 = stop.
339 + let mut kind = match action {
340 + RuleAction::AddTag(_) => 0u8,
341 + RuleAction::RemoveTag(_) => 1,
342 + RuleAction::Stop => 2,
343 + };
344 + let kind_before = kind;
345 + egui::ComboBox::from_id_salt(("rule_action", i))
346 + .selected_text(match kind {
347 + 0 => "Add tag",
348 + 1 => "Remove tag",
349 + _ => "Stop",
350 + })
351 + .width(100.0)
352 + .show_ui(ui, |ui| {
353 + ui.selectable_value(&mut kind, 0, "Add tag");
354 + ui.selectable_value(&mut kind, 1, "Remove tag");
355 + ui.selectable_value(&mut kind, 2, "Stop");
356 + });
357 + if kind != kind_before {
358 + let tag = match action {
359 + RuleAction::AddTag(s) | RuleAction::RemoveTag(s) => s.clone(),
360 + RuleAction::Stop => String::new(),
361 + };
362 + *action = match kind {
363 + 0 => RuleAction::AddTag(tag),
364 + 1 => RuleAction::RemoveTag(tag),
365 + _ => RuleAction::Stop,
366 + };
367 + }
368 + if let RuleAction::AddTag(s) | RuleAction::RemoveTag(s) = action {
369 + ui.add(
370 + egui::TextEdit::singleline(s)
371 + .desired_width(150.0)
372 + .hint_text("instrument.drum.kick"),
373 + );
374 + }
375 + if ui.small_button("\u{2715}").clicked() {
376 + remove_act = Some(i);
377 + }
378 + });
379 + }
380 + if let Some(i) = remove_act {
381 + draft.actions.remove(i);
382 + }
383 + if ui.small_button("Add action").clicked() {
384 + draft.actions.push(RuleAction::AddTag(String::new()));
385 + }
386 +
387 + // --- Dry run + footer ---
388 + ui.add_space(theme::space::SM);
389 + ui.horizontal(|ui| {
390 + if ui.button("Test").on_hover_text("Count how many samples match").clicked() {
391 + do_test = true;
392 + }
393 + if let Some(n) = draft.match_count {
394 + ui.label(
395 + egui::RichText::new(format!("Matches {n} sample{}", if n == 1 { "" } else { "s" }))
396 + .small()
397 + .color(theme::content_secondary()),
398 + );
399 + }
400 + });
401 +
402 + ui.add_space(theme::space::SM);
403 + ui.horizontal(|ui| {
404 + if widgets::primary_button(ui, "Save").clicked() {
405 + do_save = true;
406 + }
407 + if ui.button("Cancel").clicked() {
408 + do_cancel = true;
409 + }
410 + });
411 + }
412 +
413 + if do_test {
414 + state.classifier_test_draft();
415 + }
416 + if do_save {
417 + state.classifier_save_draft();
418 + }
419 + if do_cancel {
420 + state.classifier_cancel_draft();
421 + }
422 + }
@@ -236,6 +236,35 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
236 236 }
237 237 });
238 238
239 + // Tag provenance: where each tag came from (manual vs rule/ml/cluster/folder),
240 + // so "why does this sample have this tag?" is answerable at a glance.
241 + if !state.selected_tags.is_empty() {
242 + egui::CollapsingHeader::new("Tag sources")
243 + .id_salt("detail_tag_sources")
244 + .default_open(false)
245 + .show(ui, |ui| {
246 + let tags = state.selected_tags.clone();
247 + for tag in tags.iter() {
248 + let (label, color) = match state.selected_tag_sources.get(tag) {
249 + None => ("manual", theme::content_muted()),
250 + Some((source, _)) => match source.as_str() {
251 + "rule" => ("rule", theme::action()),
252 + "ml" => ("suggested", theme::category_five()),
253 + "cluster" => ("cluster", theme::category_six()),
254 + "harvest" => ("folder", theme::success()),
255 + other => (other, theme::content_muted()),
256 + },
257 + };
258 + ui.horizontal(|ui| {
259 + ui.label(egui::RichText::new(tag).small());
260 + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
261 + ui.label(egui::RichText::new(label).small().color(color));
262 + });
263 + });
264 + }
265 + });
266 + }
267 +
239 268 // Tag suggestions based on classification. Per-classification dismissals
240 269 // let the user say "I never tag kicks with `percussion`" once and have
241 270 // the suggestion stop appearing on every future kick.
@@ -1,5 +1,6 @@
1 1 //! UI submodules: each panel and widget type in its own file.
2 2
3 + pub mod classifier;
3 4 pub mod detail;
4 5 pub mod edit_panel;
5 6 pub mod export_screens;
@@ -25,6 +25,8 @@ pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) {
25 25 ui.add_space(theme::space::SM);
26 26 draw_forge_section(ui, state);
27 27 ui.add_space(theme::space::SM);
28 + super::classifier::draw_classifier_section(ui, state);
29 + ui.add_space(theme::space::SM);
28 30 draw_display_section(ui, state);
29 31 ui.add_space(theme::space::SM);
30 32 draw_license_section(ui, state);
@@ -22,10 +22,11 @@ use crate::tags::validate_tag;
22 22 // ── Model ──
23 23
24 24 /// How a rule's conditions combine.
25 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
26 26 #[serde(rename_all = "snake_case")]
27 27 pub enum MatchMode {
28 28 /// All conditions must hold (AND).
29 + #[default]
29 30 All,
30 31 /// Any condition may hold (OR).
31 32 Any,