//! Tag-classifier UI: the deterministic rules builder (Layer A), drawn as a section //! inside the Settings window. Reads/mutates `state.classifier`; persistence and //! reconciliation go through the `classifier_*` methods on `BrowserState`. use egui; use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp}; use super::theme; use super::widgets; use crate::state::BrowserState; /// Fields offered in the condition builder (path/name + tags first, then DSP). const FIELDS: &[RuleField] = &[ RuleField::Name, RuleField::SourcePath, RuleField::VfsPath, RuleField::FileExtension, RuleField::Tag, RuleField::Duration, RuleField::Bpm, RuleField::MusicalKey, RuleField::IsLoop, RuleField::SpectralCentroid, RuleField::SpectralFlatness, RuleField::SpectralRolloff, RuleField::Zcr, RuleField::SpectralBandwidth, RuleField::CentroidVariance, RuleField::CrestFactor, RuleField::AttackTime, RuleField::PeakDb, RuleField::RmsDb, RuleField::Lufs, RuleField::SampleRate, RuleField::Channels, RuleField::FileSize, ]; fn field_label(f: RuleField) -> &'static str { use RuleField::{ AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension, FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath, SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath, Zcr, }; match f { Name => "File name", SourcePath => "Source path", VfsPath => "Folder path", FileExtension => "Extension", Tag => "Tag", Duration => "Duration (s)", Bpm => "BPM", MusicalKey => "Key", IsLoop => "Is loop", SpectralCentroid => "Brightness", SpectralFlatness => "Noisiness", SpectralRolloff => "Rolloff", Zcr => "Zero-crossing rate", SpectralBandwidth => "Bandwidth", CentroidVariance => "Spectral motion", CrestFactor => "Crest factor", AttackTime => "Attack (s)", PeakDb => "Peak dB", RmsDb => "RMS dB", Lufs => "LUFS", SampleRate => "Sample rate", Channels => "Channels", FileSize => "File size", } } enum Kind { Text, Number, Bool, } fn field_kind(f: RuleField) -> Kind { use RuleField::{FileExtension, IsLoop, MusicalKey, Name, SourcePath, Tag, VfsPath}; match f { Name | SourcePath | VfsPath | FileExtension | Tag | MusicalKey => Kind::Text, IsLoop => Kind::Bool, _ => Kind::Number, } } const STRING_OPS: &[RuleOp] = &[ RuleOp::Contains, RuleOp::NotContains, RuleOp::Equals, RuleOp::NotEquals, RuleOp::StartsWith, RuleOp::EndsWith, RuleOp::Exists, RuleOp::NotExists, ]; const NUM_OPS: &[RuleOp] = &[ RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals, RuleOp::Exists, RuleOp::NotExists, ]; const BOOL_OPS: &[RuleOp] = &[ RuleOp::IsTrue, RuleOp::IsFalse, RuleOp::Exists, RuleOp::NotExists, ]; fn ops_for(f: RuleField) -> &'static [RuleOp] { match field_kind(f) { Kind::Text => STRING_OPS, Kind::Number => NUM_OPS, Kind::Bool => BOOL_OPS, } } fn op_label(op: RuleOp) -> &'static str { use RuleOp::{ Contains, EndsWith, Equals, Exists, Ge, Gt, IsFalse, IsTrue, Le, Lt, NotContains, NotEquals, NotExists, StartsWith, }; match op { Contains => "contains", NotContains => "doesn't contain", Equals => "equals", NotEquals => "not equals", StartsWith => "starts with", EndsWith => "ends with", Lt => "<", Le => "\u{2264}", Gt => ">", Ge => "\u{2265}", IsTrue => "is true", IsFalse => "is false", Exists => "exists", NotExists => "missing", } } fn op_needs_value(op: RuleOp) -> bool { !matches!( op, RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse ) } /// Keep a condition's operator valid for its field's kind: if `op` isn't one the /// field offers, fall back to the field's first offered op. `ops_for` is never empty, /// so `ops[0]` always exists. fn coerce_op(field: RuleField, op: RuleOp) -> RuleOp { let ops = ops_for(field); if ops.contains(&op) { op } else { ops[0] } } /// Draw all tag-classifier sections in Settings. pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) { // A background job (train/auto-tag/cluster/export) is running: show progress and let // the disabled buttons below explain why they're inert. Footer status is hidden behind // the Settings modal, so feedback lives here. if let Some(label) = state.classifier.busy.clone() { ui.horizontal(|ui| { ui.add(egui::Spinner::new().size(14.0)); ui.label( egui::RichText::new(label) .small() .color(theme::content_secondary()), ); }); ui.add_space(theme::space::SM); } else if let Some(err) = state.classifier.last_error.clone() { widgets::warning_banner(ui, &err); ui.add_space(theme::space::SM); } draw_rules_header(ui, state); ui.add_space(theme::space::SM); draw_autotag_header(ui, state); ui.add_space(theme::space::SM); draw_cluster_header(ui, state); ui.add_space(theme::space::SM); draw_folder_header(ui, state); ui.add_space(theme::space::SM); draw_sharing_header(ui, state); } /// "Tag Rules" section (Layer A). fn draw_rules_header(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong()) .default_open(false) .show(ui, |ui| { state.ensure_rules_loaded(); ui.label( egui::RichText::new( "Deterministic rules that auto-apply tags by sample metadata and audio \ features. Rules never remove tags you added by hand.", ) .small() .color(theme::content_muted()), ); ui.add_space(theme::space::SM); if state.classifier.editing.is_some() { draw_editor(ui, state); } else { draw_list(ui, state); } }); } fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) { ui.horizontal(|ui| { if ui.button("New rule").clicked() { state.classifier_new_draft(); } if ui .button("Apply rules now") .on_hover_text("Re-evaluate every rule across the whole library") .clicked() { state.classifier_apply_all(); } }); if let Some(n) = state.classifier.last_apply { ui.label( egui::RichText::new(format!( "Last apply: {n} sample{} updated", if n == 1 { "" } else { "s" } )) .small() .color(theme::content_muted()), ); } ui.add_space(theme::space::SM); if state.classifier.rules.is_empty() { ui.label( egui::RichText::new( "No rules yet \u{2014} new rules start empty; you decide what gets tagged.", ) .small() .color(theme::content_muted()), ); return; } // Move the list out to iterate it, then move it back before applying the // deferred actions (a zero-copy swap replacing a per-frame deep clone of the // whole rules Vec). The loop body reads only the local list + `ui`, never // `state.classifier.rules`, so the temporary empty field is never observed. let rules = std::mem::take(&mut state.classifier.rules); let count = rules.len(); let mut toggle: Option<(String, bool)> = None; let mut edit: Option = None; let mut delete: Option = None; let mut move_rule: Option<(usize, bool)> = None; for (i, rule) in rules.iter().enumerate() { ui.horizontal(|ui| { let mut enabled = rule.enabled; if ui.checkbox(&mut enabled, "").changed() { toggle = Some((rule.id.clone(), enabled)); } let name = if rule.name.trim().is_empty() { "(unnamed)" } else { rule.name.as_str() }; ui.label(name); ui.label( egui::RichText::new(format!( "{} cond \u{2192} {} act", rule.conditions.len(), rule.actions.len() )) .small() .color(theme::content_muted()), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::danger_small_button(ui, "Delete").clicked() { delete = Some(rule.id.clone()); } if ui.small_button("Edit").clicked() { edit = Some(i); } if ui .add_enabled(i + 1 < count, egui::Button::new("\u{25bc}").small()) .clicked() { move_rule = Some((i, false)); } if ui .add_enabled(i > 0, egui::Button::new("\u{25b2}").small()) .clicked() { move_rule = Some((i, true)); } }); }); } // Restore the list before applying deferred actions (which mutate it). state.classifier.rules = rules; if let Some((id, enabled)) = toggle { state.classifier_toggle_rule(&id, enabled); } if let Some(i) = edit { let rule = state.classifier.rules[i].clone(); state.classifier_edit_rule(&rule); } if let Some(id) = delete { state.classifier_delete_rule(&id); } if let Some((i, up)) = move_rule { state.classifier_move_rule(i, up); } } fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) { let mut do_save = false; let mut do_cancel = false; let mut do_test = false; { let Some(draft) = state.classifier.editing.as_mut() else { return; }; ui.horizontal(|ui| { ui.label("Name"); ui.text_edit_singleline(&mut draft.name); }); ui.checkbox(&mut draft.enabled, "Enabled"); ui.horizontal(|ui| { ui.label("Match"); ui.selectable_value(&mut draft.match_mode, MatchMode::All, "All"); ui.selectable_value(&mut draft.match_mode, MatchMode::Any, "Any"); ui.label( egui::RichText::new("of these conditions") .small() .color(theme::content_muted()), ); }); // --- Conditions --- widgets::subsection_label(ui, "When"); let mut remove_cond: Option = None; let cond_count = draft.conditions.len(); for (i, cond) in draft.conditions.iter_mut().enumerate() { ui.horizontal(|ui| { egui::ComboBox::from_id_salt(("rule_field", i)) .selected_text(field_label(cond.field)) .width(120.0) .show_ui(ui, |ui| { for f in FIELDS { ui.selectable_value(&mut cond.field, *f, field_label(*f)); } }); // Keep the operator valid for the chosen field's kind. cond.op = coerce_op(cond.field, cond.op); let ops = ops_for(cond.field); egui::ComboBox::from_id_salt(("rule_op", i)) .selected_text(op_label(cond.op)) .width(110.0) .show_ui(ui, |ui| { for op in ops { ui.selectable_value(&mut cond.op, *op, op_label(*op)); } }); if op_needs_value(cond.op) { ui.add( egui::TextEdit::singleline(&mut cond.value) .desired_width(70.0) .hint_text("value"), ); } if ui .add_enabled(cond_count > 1, egui::Button::new("\u{2715}").small()) .clicked() { remove_cond = Some(i); } }); } if let Some(i) = remove_cond { draft.conditions.remove(i); draft.match_count = None; } if ui.small_button("Add condition").clicked() { draft.conditions.push(RuleCondition { field: RuleField::Name, op: RuleOp::Contains, value: String::new(), }); draft.match_count = None; } // --- Actions --- ui.add_space(theme::space::SM); widgets::subsection_label(ui, "Then"); let mut remove_act: Option = None; for (i, action) in draft.actions.iter_mut().enumerate() { ui.horizontal(|ui| { // 0 = add tag, 1 = remove tag, 2 = stop. let mut kind = match action { RuleAction::AddTag(_) => 0u8, RuleAction::RemoveTag(_) => 1, RuleAction::Stop => 2, }; let kind_before = kind; egui::ComboBox::from_id_salt(("rule_action", i)) .selected_text(match kind { 0 => "Add tag", 1 => "Remove tag", _ => "Stop", }) .width(100.0) .show_ui(ui, |ui| { ui.selectable_value(&mut kind, 0, "Add tag"); ui.selectable_value(&mut kind, 1, "Remove tag"); ui.selectable_value(&mut kind, 2, "Stop"); }); if kind != kind_before { let tag = match action { RuleAction::AddTag(s) | RuleAction::RemoveTag(s) => s.clone(), RuleAction::Stop => String::new(), }; *action = match kind { 0 => RuleAction::AddTag(tag), 1 => RuleAction::RemoveTag(tag), _ => RuleAction::Stop, }; } if let RuleAction::AddTag(s) | RuleAction::RemoveTag(s) = action { ui.add( egui::TextEdit::singleline(s) .desired_width(150.0) .hint_text("instrument.drum.kick"), ); } if ui.small_button("\u{2715}").clicked() { remove_act = Some(i); } }); } if let Some(i) = remove_act { draft.actions.remove(i); } if ui.small_button("Add action").clicked() { draft.actions.push(RuleAction::AddTag(String::new())); } // --- Dry run + footer --- ui.add_space(theme::space::SM); ui.horizontal(|ui| { if ui .button("Test") .on_hover_text("Count how many samples match") .clicked() { do_test = true; } if let Some(n) = draft.match_count { ui.label( egui::RichText::new(format!( "Matches {n} sample{}", if n == 1 { "" } else { "s" } )) .small() .color(theme::content_secondary()), ); } }); ui.add_space(theme::space::SM); ui.horizontal(|ui| { if widgets::primary_button(ui, "Save").clicked() { do_save = true; } if ui.button("Cancel").clicked() { do_cancel = true; } }); } if do_test { state.classifier_test_draft(); } if do_save { state.classifier_save_draft(); } if do_cancel { state.classifier_cancel_draft(); } } /// "Auto-Tagging" section (Layer B): library-wide suggest + per-tag thresholds. fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Auto-Tagging").strong()) .default_open(false) .show(ui, |ui| { ui.label( egui::RichText::new( "Suggests tags from samples that look like ones you've already tagged. \ Tags above a per-tag threshold are applied automatically.", ) .small() .color(theme::content_muted()), ); ui.add_space(theme::space::SM); let busy = state.classifier.busy.is_some(); ui.horizontal(|ui| { if ui .add_enabled(!busy, egui::Button::new("Suggest tags across library")) .on_hover_text( "Build the model from your tagged samples and auto-apply confident tags", ) .clicked() { state.classifier_auto_apply_ml(); } if ui .add_enabled(!busy, egui::Button::new("Undo")) .on_hover_text("Remove every tag applied by auto-tagging") .clicked() { state.undo_tag_source("ml"); } }); if let Some(n) = state.classifier.last_ml_apply { ui.label( egui::RichText::new(format!( "Last run: {n} tag{} applied", if n == 1 { "" } else { "s" } )) .small() .color(theme::content_muted()), ); } ui.add_space(theme::space::MD); draw_trained_head(ui, state); ui.add_space(theme::space::MD); widgets::subsection_label(ui, "Per-tag thresholds"); ui.label( egui::RichText::new( "review = surface for review \u{00b7} auto = apply automatically", ) .small() .color(theme::content_muted()), ); state.ensure_policies_loaded(); let policies = std::mem::take(&mut state.classifier.policies); let mut changed: Option<(String, f64, f64)> = None; for (tag, policy) in &policies { let mut review = policy.review_threshold as f32; let mut auto = policy.auto_threshold as f32; ui.horizontal(|ui| { ui.label(egui::RichText::new(tag).small()); }); ui.horizontal(|ui| { ui.label( egui::RichText::new("review") .small() .color(theme::content_muted()), ); let r = ui.add(egui::Slider::new(&mut review, 0.0..=1.0).show_value(true)); ui.label( egui::RichText::new("auto") .small() .color(theme::content_muted()), ); let a = ui.add(egui::Slider::new(&mut auto, 0.0..=1.0).show_value(true)); if r.drag_stopped() || a.drag_stopped() || (r.changed() && !r.dragged()) || (a.changed() && !a.dragged()) { changed = Some((tag.clone(), review as f64, auto as f64)); } }); } state.classifier.policies = policies; if let Some((tag, review, auto)) = changed { state.classifier_set_policy(&tag, review, auto); state.refresh_policies(); } ui.add_space(theme::space::SM); ui.horizontal(|ui| { ui.add( egui::TextEdit::singleline(&mut state.classifier.new_policy_tag) .desired_width(170.0) .hint_text("tag to configure"), ); if ui.button("Add").clicked() { state.classifier_add_policy(); } }); }); } /// "Trained model" subsection: the optional distilled head that speeds up the /// library-wide auto-tagging pass on large libraries. Optional, without it, auto-tagging /// uses nearest-neighbor matching directly. fn draw_trained_head(ui: &mut egui::Ui, state: &mut BrowserState) { state.ensure_head_loaded(); widgets::subsection_label(ui, "Trained model (optional)"); ui.label( egui::RichText::new( "For large libraries, distil your tagged samples into a compact model so \ auto-tagging runs much faster. Optional: auto-tagging works without it.", ) .small() .color(theme::content_muted()), ); match state.classifier.head_info { Some(info) => { ui.label( egui::RichText::new(format!( "Active: {} tag{} from {} sample{}.", info.class_count, if info.class_count == 1 { "" } else { "s" }, info.exemplar_count, if info.exemplar_count == 1 { "" } else { "s" }, )) .small(), ); } None => { // Surface the size hint only when there's no model yet. if let Some((n, worthwhile)) = state.classifier.head_readiness { let hint = if worthwhile { format!("No model yet. With {n} tagged samples, training is recommended.") } else { format!( "No model: nearest-neighbor matching is fast enough at {n} tagged \ sample{}.", if n == 1 { "" } else { "s" } ) }; ui.label( egui::RichText::new(hint) .small() .color(theme::content_muted()), ); } } } ui.add_space(theme::space::SM); let busy = state.classifier.busy.is_some(); let has_head = state.classifier.head_info.is_some(); ui.horizontal(|ui| { let train_label = if has_head { "Retrain model" } else { "Train model" }; // Fixed width so the "Train model" ↔ "Retrain model" swap doesn't shift the row. if ui .add_enabled( !busy, egui::Button::new(train_label).min_size(egui::vec2(96.0, 0.0)), ) .on_hover_text("Distil your tagged library into a compact per-library classifier") .clicked() { state.classifier_train_head(); } if has_head { let clear = ui .add_enabled(!busy, egui::Button::new("Clear")) .on_hover_text( "Remove the model; auto-tagging reverts to nearest-neighbor matching", ); if clear.clicked() { state.classifier_clear_head(); } } }); } /// "Clustering" section: unsupervised cold-start grouping. fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Clustering").strong()) .default_open(false) .show(ui, |ui| { ui.label( egui::RichText::new( "Groups similar samples so you can name them and seed your first tags \ \u{2014} useful when nothing is tagged yet.", ) .small() .color(theme::content_muted()), ); ui.add_space(theme::space::SM); let busy = state.classifier.busy.is_some(); ui.horizontal(|ui| { let mut k = state.classifier.cluster_k.max(2); ui.label("Groups"); if ui.add(egui::Slider::new(&mut k, 2..=24)).changed() { state.classifier.cluster_k = k; } if ui .add_enabled(!busy, egui::Button::new("Find clusters")) .clicked() { state.classifier.cluster_k = k; state.run_clustering(); } }); if state.classifier.clusters.is_empty() { return; } ui.add_space(theme::space::SM); let clusters = std::mem::take(&mut state.classifier.clusters); let mut play: Option = None; let mut apply: Option = None; for (i, cluster) in clusters.iter().enumerate() { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("{} samples", cluster.member_hashes.len())) .small() .color(theme::content_secondary()), ); if ui .small_button("Play") .on_hover_text("Preview a representative sample") .clicked() { play = Some(cluster.medoid_hash.clone()); } if let Some(name) = state.classifier.cluster_names.get_mut(i) { ui.add( egui::TextEdit::singleline(name) .desired_width(150.0) .hint_text("tag for this group"), ); } if ui.small_button("Tag").clicked() { apply = Some(i); } }); } state.classifier.clusters = clusters; if let Some(hash) = play { state.trigger_preview(&hash); } if let Some(i) = apply { state.apply_cluster(i); } ui.add_space(theme::space::SM); if ui .button("Remove all cluster tags") .on_hover_text("Undo every tag applied from clustering") .clicked() { state.undo_tag_source("cluster"); } }); } /// "Folder Tags" section: derive tags from the library's folder structure. fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Folder Tags").strong()) .default_open(false) .show(ui, |ui| { ui.label( egui::RichText::new( "Turns folders that directly contain samples into tags \u{2014} useful when \ your library is already organized into folders.", ) .small() .color(theme::content_muted()), ); ui.add_space(theme::space::SM); ui.horizontal(|ui| { if ui.button("Scan folders").clicked() { state.refresh_folder_labels(); } if ui .button("Undo") .on_hover_text("Remove every tag applied from folders") .clicked() { state.undo_tag_source("harvest"); } }); if !state.classifier.folder_loaded { return; } if state.classifier.folder_labels.is_empty() { ui.label( egui::RichText::new("No folders with samples found.") .small() .color(theme::content_muted()), ); return; } ui.add_space(theme::space::SM); let labels = std::mem::take(&mut state.classifier.folder_labels); let mut apply: Option = None; for (i, label) in labels.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(&label.folder_name).small()); ui.label( egui::RichText::new(format!("({})", label.sample_hashes.len())) .small() .color(theme::content_muted()), ); }); ui.horizontal(|ui| { if let Some(tag) = state.classifier.folder_tags.get_mut(i) { ui.add(egui::TextEdit::singleline(tag).desired_width(180.0)); } if ui.small_button("Apply").clicked() { apply = Some(i); } }); } state.classifier.folder_labels = labels; if let Some(i) = apply { state.apply_folder_label(i); } }); } /// "Shared Classifiers (.afcl)" section: export your own data to a portable file, import /// someone else's as a removable layer, and manage imported layers. fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { egui::CollapsingHeader::new(egui::RichText::new("Shared Classifiers (.afcl)").strong()) .default_open(false) .show(ui, |ui| { state.ensure_layers_loaded(); ui.label( egui::RichText::new( "Share your tagging as a portable .afcl file \u{2014} it carries your feature \ vectors, tags, rules, and thresholds, but never any audio. Imported files \ become removable layers that sit below your own tagging.", ) .small() .color(theme::content_muted()), ); let busy = state.classifier.busy.is_some(); // ── Export ── ui.add_space(theme::space::MD); widgets::subsection_label(ui, "Export"); // Load what's exportable so the button can disable when there's nothing to share. state.ensure_head_loaded(); state.ensure_rules_loaded(); state.ensure_policies_loaded(); ui.horizontal(|ui| { ui.label( egui::RichText::new("Name") .small() .color(theme::content_muted()), ); ui.add( egui::TextEdit::singleline(&mut state.classifier.export_name) .desired_width(180.0) .hint_text("My classifier"), ); }); ui.horizontal(|ui| { ui.checkbox(&mut state.classifier.export_include_exemplars, "Exemplars"); ui.checkbox(&mut state.classifier.export_include_rules, "Rules"); ui.checkbox(&mut state.classifier.export_include_policy, "Thresholds"); }); let exemplars_avail = state.classifier.head_readiness.is_some_and(|(n, _)| n > 0); let rules_avail = !state.classifier.rules.is_empty(); let policy_avail = !state.classifier.policies.is_empty(); let has_content = export_has_content( (state.classifier.export_include_exemplars, exemplars_avail), (state.classifier.export_include_rules, rules_avail), (state.classifier.export_include_policy, policy_avail), ); ui.add_enabled_ui(!busy && has_content, |ui| { if widgets::primary_button(ui, "Export to file...") .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included") .clicked() { let file_name = format!("{}.afcl", sanitize_filename(&state.classifier.export_name)); state.dialogs.save_file( "Export classifier", file_name, &[("AF classifier", &["afcl"])], |s, p| s.classifier_export_afcl(&p), ); } }); if !has_content { ui.label( egui::RichText::new( "Nothing to export yet \u{2014} tag samples or add rules first.", ) .small() .color(theme::content_muted()), ); } if let Some(msg) = &state.classifier.last_export_msg { ui.label( egui::RichText::new(msg) .small() .color(theme::content_secondary()), ); } // ── Import ── ui.add_space(theme::space::MD); widgets::subsection_label(ui, "Import"); ui.add_enabled_ui(!busy, |ui| { if widgets::primary_button(ui, "Import .afcl file...") .on_hover_text("Add someone's shared classifier as a removable layer") .clicked() { state.dialogs.pick_file( "Import classifier", &[("AF classifier", &["afcl"])], |s, p| s.classifier_import_afcl(&p), ); } }); if let Some(msg) = &state.classifier.last_import_msg { ui.label( egui::RichText::new(msg) .small() .color(theme::content_secondary()), ); } // ── Imported layers ── let layers = std::mem::take(&mut state.classifier.layers); if layers.is_empty() { state.classifier.layers = layers; // restore (empty), keep field consistent return; } ui.add_space(theme::space::MD); widgets::subsection_label(ui, "Imported layers"); ui.label( egui::RichText::new( "Imported rules arrive disabled \u{2014} review them in Tag Rules above before \ enabling. Weight sets how much a layer counts next to your own tagging.", ) .small() .color(theme::content_muted()), ); let pending = state.classifier.pending_layer_remove.clone(); let mut toggle: Option<(String, bool)> = None; let mut weight_change: Option<(String, f64)> = None; let mut remove: Option = None; let mut set_pending: Option> = None; for layer in &layers { ui.add_space(theme::space::SM); let confirming = pending.as_deref() == Some(layer.id.as_str()); // Row A: enable + name (truncated, dimmed when disabled) + counts. ui.horizontal(|ui| { let mut enabled = layer.enabled; if ui .checkbox(&mut enabled, "") .on_hover_text("Use this layer when auto-tagging") .changed() { toggle = Some((layer.id.clone(), enabled)); } let name_color = if layer.enabled { theme::content() } else { theme::content_muted() }; ui.add( egui::Label::new( egui::RichText::new(&layer.name) .small() .strong() .color(name_color), ) .truncate(), ) .on_hover_text(&layer.name); ui.label( egui::RichText::new(format!( "{} ex \u{00b7} {} rule{}", layer.exemplar_count, layer.rule_count, if layer.rule_count == 1 { "" } else { "s" }, )) .small() .color(theme::content_muted()), ); }); // Row B: weight + remove (with inline two-step confirm, destructive, no undo). ui.horizontal(|ui| { ui.label( egui::RichText::new("weight") .small() .color(theme::content_muted()), ); let mut w = layer.weight as f32; let r = ui .add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true)) .on_hover_text( "How much this layer counts when matching. Your own tags are always \ 1.0; lower means weaker.", ); if r.drag_stopped() || (r.changed() && !r.dragged()) { weight_change = Some((layer.id.clone(), w as f64)); } if confirming { if widgets::danger_small_button(ui, "Remove") .on_hover_text("Permanently delete this layer and its imported rules") .clicked() { remove = Some(layer.id.clone()); set_pending = Some(None); } if ui.small_button("Cancel").clicked() { set_pending = Some(None); } } else if widgets::danger_small_button(ui, "Remove").clicked() { set_pending = Some(Some(layer.id.clone())); } }); if confirming { ui.label( egui::RichText::new( "Removing deletes this layer's exemplars and imported rules. You'll \ need the .afcl file to add it again.", ) .small() .color(theme::danger()), ); } } state.classifier.layers = layers; if let Some(p) = set_pending { state.classifier.pending_layer_remove = p; } if let Some((id, enabled)) = toggle { state.classifier_set_layer_enabled(&id, enabled); } if let Some((id, w)) = weight_change { state.classifier_set_layer_weight(&id, w); state.refresh_layers(); } if let Some(id) = remove { state.classifier_remove_layer(&id); } }); } /// Whether the current export selection covers anything actually present. Each toggle /// only counts when its data exists, so "export everything" with nothing tagged still /// reads as empty and the button stays disabled. fn export_has_content(exemplars: (bool, bool), rules: (bool, bool), policy: (bool, bool)) -> bool { (exemplars.0 && exemplars.1) || (rules.0 && rules.1) || (policy.0 && policy.1) } /// Reduce a user-entered name to a safe-ish default filename stem. fn sanitize_filename(name: &str) -> String { let cleaned: String = name .trim() .chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect(); if cleaned.is_empty() { "classifier".to_string() } else { cleaned } } #[cfg(test)] mod tests { use super::*; // field kind / operator sets #[test] fn field_kind_classifies_text_number_bool() { assert!(matches!(field_kind(RuleField::Name), Kind::Text)); assert!(matches!(field_kind(RuleField::MusicalKey), Kind::Text)); assert!(matches!(field_kind(RuleField::IsLoop), Kind::Bool)); assert!(matches!(field_kind(RuleField::Bpm), Kind::Number)); assert!(matches!(field_kind(RuleField::Lufs), Kind::Number)); } #[test] fn ops_for_matches_the_field_kind() { assert_eq!(ops_for(RuleField::Name), STRING_OPS); assert_eq!(ops_for(RuleField::Bpm), NUM_OPS); assert_eq!(ops_for(RuleField::IsLoop), BOOL_OPS); } #[test] fn every_offered_field_has_a_nonempty_op_set() { // draw_editor relies on ops[0] as a fallback, so no field may offer zero ops. for f in FIELDS { assert!(!ops_for(*f).is_empty(), "{f:?} offered no operators"); } } // coerce_op #[test] fn coerce_op_keeps_a_valid_operator() { // Equals is valid for a text field, so it survives unchanged. assert_eq!(coerce_op(RuleField::Name, RuleOp::Equals), RuleOp::Equals); } #[test] fn coerce_op_replaces_an_invalid_operator_with_the_first_offered() { // Lt is number-only; on a text field it falls back to STRING_OPS[0]. assert_eq!(coerce_op(RuleField::Name, RuleOp::Lt), STRING_OPS[0]); // Contains is text-only; on a number field it falls back to NUM_OPS[0]. assert_eq!(coerce_op(RuleField::Bpm, RuleOp::Contains), NUM_OPS[0]); // ...and on a bool field to BOOL_OPS[0]. assert_eq!(coerce_op(RuleField::IsLoop, RuleOp::Contains), BOOL_OPS[0]); } #[test] fn coerce_op_output_is_always_valid_for_the_field() { // For every field, coercing any operator yields one the field actually offers. for f in FIELDS { for op in ops_for(RuleField::Bpm) .iter() .chain(STRING_OPS) .chain(BOOL_OPS) { let coerced = coerce_op(*f, *op); assert!( ops_for(*f).contains(&coerced), "coerce_op({f:?}, {op:?}) = {coerced:?} not in the field's op set", ); } } } // op_needs_value #[test] fn presence_and_bool_operators_need_no_value() { for op in [ RuleOp::Exists, RuleOp::NotExists, RuleOp::IsTrue, RuleOp::IsFalse, ] { assert!(!op_needs_value(op), "{op:?} should not require a value"); } } #[test] fn comparison_operators_need_a_value() { for op in [ RuleOp::Contains, RuleOp::Equals, RuleOp::Lt, RuleOp::StartsWith, ] { assert!(op_needs_value(op), "{op:?} should require a value"); } } // export_has_content #[test] fn export_is_empty_when_nothing_is_selected() { assert!(!export_has_content( (false, true), (false, true), (false, true) )); } #[test] fn export_is_empty_when_selected_data_is_absent() { // Everything checked, but none of the underlying data exists. assert!(!export_has_content( (true, false), (true, false), (true, false) )); } #[test] fn export_has_content_when_a_selected_category_has_data() { assert!(export_has_content( (true, true), (false, false), (false, false) )); assert!(export_has_content( (false, false), (true, true), (false, false) )); assert!(export_has_content( (false, false), (false, false), (true, true) )); } // sanitize_filename #[test] fn sanitize_replaces_unsafe_chars_with_underscore() { assert_eq!(sanitize_filename("My Classifier!"), "My_Classifier_"); } #[test] fn sanitize_preserves_alphanumeric_dash_underscore() { assert_eq!(sanitize_filename("kick-drums_v2"), "kick-drums_v2"); } #[test] fn sanitize_trims_surrounding_whitespace() { assert_eq!(sanitize_filename(" spacey "), "spacey"); } #[test] fn sanitize_falls_back_when_empty_or_all_unsafe() { assert_eq!(sanitize_filename(""), "classifier"); assert_eq!(sanitize_filename(" "), "classifier"); } }