max / audiofiles
11 files changed,
+506 insertions,
-81 deletions
| @@ -65,6 +65,7 @@ pub struct DirectBackend { | |||
| 65 | 65 | cleanup_worker: Mutex<Option<CleanupHandle>>, | |
| 66 | 66 | edit_worker: Mutex<Option<EditWorkerHandle>>, | |
| 67 | 67 | forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>, | |
| 68 | + | classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>, | |
| 68 | 69 | // Import context for the in-flight forge op, applied when its CPU stage | |
| 69 | 70 | // completes (the worker produces temp files; the GUI thread imports them). | |
| 70 | 71 | forge_pending: Mutex<Option<ForgePending>>, | |
| @@ -89,6 +90,7 @@ impl DirectBackend { | |||
| 89 | 90 | cleanup_worker: Mutex::new(None), | |
| 90 | 91 | edit_worker: Mutex::new(None), | |
| 91 | 92 | forge_worker: Mutex::new(None), | |
| 93 | + | classifier_worker: Mutex::new(None), | |
| 92 | 94 | forge_pending: Mutex::new(None), | |
| 93 | 95 | fingerprint_index: Mutex::new(None), | |
| 94 | 96 | similarity_index: Mutex::new(None), | |
| @@ -681,6 +683,16 @@ impl Backend for DirectBackend { | |||
| 681 | 683 | Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS)) | |
| 682 | 684 | } | |
| 683 | 685 | ||
| 686 | + | fn start_classifier_job(&self, job: super::ClassifierJob) -> BackendResult<()> { | |
| 687 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 688 | + | // Drop (join) any prior job before starting a new one — the UI gates to one at a time. | |
| 689 | + | *self.classifier_worker.lock() = None; | |
| 690 | + | let handle = crate::classifier_worker::spawn_classifier_job(db_path, job) | |
| 691 | + | .map_err(|e| BackendError::Other(format!("failed to spawn classifier worker: {e}")))?; | |
| 692 | + | *self.classifier_worker.lock() = Some(handle); | |
| 693 | + | Ok(()) | |
| 694 | + | } | |
| 695 | + | ||
| 684 | 696 | fn export_afcl( | |
| 685 | 697 | &self, | |
| 686 | 698 | path: &Path, | |
| @@ -1675,6 +1687,25 @@ impl Backend for DirectBackend { | |||
| 1675 | 1687 | } | |
| 1676 | 1688 | } | |
| 1677 | 1689 | ||
| 1690 | + | // Poll classifier worker (one-shot job). | |
| 1691 | + | let job_done = { | |
| 1692 | + | let guard = self.classifier_worker.lock(); | |
| 1693 | + | match guard.as_ref().and_then(|w| w.try_recv()) { | |
| 1694 | + | Some(crate::classifier_worker::ClassifierWorkerEvent::Done(result)) => { | |
| 1695 | + | events.push(BackendEvent::ClassifierJobDone(result)); | |
| 1696 | + | true | |
| 1697 | + | } | |
| 1698 | + | Some(crate::classifier_worker::ClassifierWorkerEvent::Failed(error)) => { | |
| 1699 | + | events.push(BackendEvent::ClassifierJobFailed(error)); | |
| 1700 | + | true | |
| 1701 | + | } | |
| 1702 | + | None => false, | |
| 1703 | + | } | |
| 1704 | + | }; | |
| 1705 | + | if job_done { | |
| 1706 | + | *self.classifier_worker.lock() = None; // join the finished thread | |
| 1707 | + | } | |
| 1708 | + | ||
| 1678 | 1709 | events | |
| 1679 | 1710 | } | |
| 1680 | 1711 | } |
| @@ -137,6 +137,10 @@ pub enum BackendEvent { | |||
| 137 | 137 | ForgeError { | |
| 138 | 138 | error: String, | |
| 139 | 139 | }, | |
| 140 | + | ||
| 141 | + | // Classifier events (train / auto-tag / cluster / export run on a worker thread) | |
| 142 | + | ClassifierJobDone(ClassifierJobResult), | |
| 143 | + | ClassifierJobFailed(String), | |
| 140 | 144 | } | |
| 141 | 145 | ||
| 142 | 146 | /// Serializable summary of a conform overshoot, for the completion event. | |
| @@ -186,7 +190,7 @@ pub const FORGE_AUTO_TRIM_OVERSHOOT_KEY: &str = "forge.auto_trim_overshoot"; | |||
| 186 | 190 | ||
| 187 | 191 | /// Lightweight summary of a persisted trained head for the Settings UI (the model's | |
| 188 | 192 | /// weights stay in core). | |
| 189 | - | #[derive(Debug, Clone, Copy)] | |
| 193 | + | #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] | |
| 190 | 194 | pub struct TrainedHeadInfo { | |
| 191 | 195 | /// Number of tags the model can score. | |
| 192 | 196 | pub class_count: usize, | |
| @@ -196,6 +200,30 @@ pub struct TrainedHeadInfo { | |||
| 196 | 200 | pub trained_at: i64, | |
| 197 | 201 | } | |
| 198 | 202 | ||
| 203 | + | /// A library-wide classifier operation heavy enough to run off the GUI thread (see | |
| 204 | + | /// [`Backend::start_classifier_job`]). The completion arrives as a | |
| 205 | + | /// [`BackendEvent::ClassifierJobDone`] / [`BackendEvent::ClassifierJobFailed`]. | |
| 206 | + | #[derive(Debug, Clone)] | |
| 207 | + | pub enum ClassifierJob { | |
| 208 | + | /// Distil the tagged library into a trained head. | |
| 209 | + | TrainHead, | |
| 210 | + | /// Auto-apply above-threshold tags across the library (head-routed or k-NN). | |
| 211 | + | AutoApply { k: usize }, | |
| 212 | + | /// Cluster the library into `k` groups. | |
| 213 | + | Cluster { k: usize }, | |
| 214 | + | /// Export the user's classifier data to a `.afcl` file. | |
| 215 | + | Export { path: std::path::PathBuf, opts: audiofiles_core::analysis::afcl::ExportOptions }, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// The result of a completed [`ClassifierJob`]. | |
| 219 | + | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | |
| 220 | + | pub enum ClassifierJobResult { | |
| 221 | + | Trained(Option<TrainedHeadInfo>), | |
| 222 | + | AutoApplied(usize), | |
| 223 | + | Clustered(Vec<audiofiles_core::analysis::cluster::Cluster>), | |
| 224 | + | Exported(std::path::PathBuf), | |
| 225 | + | } | |
| 226 | + | ||
| 199 | 227 | /// The core abstraction separating UI from data access. | |
| 200 | 228 | /// | |
| 201 | 229 | /// Every method is synchronous and blocking. The trait is `Send + Sync` so it | |
| @@ -444,6 +472,11 @@ pub trait Backend: Send + Sync { | |||
| 444 | 472 | /// advisory hint in the UI. | |
| 445 | 473 | fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)>; | |
| 446 | 474 | ||
| 475 | + | /// Run a heavy classifier op on a worker thread; completion arrives via `poll_events` | |
| 476 | + | /// as [`BackendEvent::ClassifierJobDone`] / [`BackendEvent::ClassifierJobFailed`]. | |
| 477 | + | /// Keeps the GUI responsive on large libraries. | |
| 478 | + | fn start_classifier_job(&self, job: ClassifierJob) -> BackendResult<()>; | |
| 479 | + | ||
| 447 | 480 | // --- .afcl file sharing (classifier layers) --- | |
| 448 | 481 | ||
| 449 | 482 | /// Write the user's selected classifier data to a `.afcl` file. |
| @@ -0,0 +1,187 @@ | |||
| 1 | + | //! Background classifier worker: runs heavy library-wide classifier operations off the | |
| 2 | + | //! GUI thread so the window stays responsive (training, library auto-tagging, clustering, | |
| 3 | + | //! and `.afcl` export can each take seconds-to-minutes on a large library). | |
| 4 | + | //! | |
| 5 | + | //! Mirrors `cleanup.rs`: a one-shot thread with its own `Database` connection (WAL + | |
| 6 | + | //! `busy_timeout`, so it runs concurrently with the GUI connection), reporting back over a | |
| 7 | + | //! channel the GUI polls each frame. One job runs at a time — the UI disables the trigger | |
| 8 | + | //! buttons while a job is in flight, and dispatching a new job drops (joins) the prior one. | |
| 9 | + | ||
| 10 | + | use std::path::PathBuf; | |
| 11 | + | use std::sync::{mpsc, Mutex}; | |
| 12 | + | use std::thread; | |
| 13 | + | ||
| 14 | + | use tracing::{error, instrument}; | |
| 15 | + | ||
| 16 | + | use audiofiles_core::analysis::{afcl, cluster, exemplar, trained_head}; | |
| 17 | + | use audiofiles_core::db::Database; | |
| 18 | + | ||
| 19 | + | use crate::backend::{ClassifierJob, ClassifierJobResult, TrainedHeadInfo}; | |
| 20 | + | ||
| 21 | + | /// Event sent from the classifier worker back to the GUI thread. | |
| 22 | + | pub enum ClassifierWorkerEvent { | |
| 23 | + | Done(ClassifierJobResult), | |
| 24 | + | Failed(String), | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Handle for a single in-flight classifier job. The receiver is wrapped in a `Mutex` so | |
| 28 | + | /// `BrowserState` stays `Sync` (required by nih-plug); only the GUI thread calls `try_recv`. | |
| 29 | + | pub struct ClassifierHandle { | |
| 30 | + | event_rx: Mutex<mpsc::Receiver<ClassifierWorkerEvent>>, | |
| 31 | + | _thread: Option<thread::JoinHandle<()>>, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl ClassifierHandle { | |
| 35 | + | /// Poll for the job's result without blocking. | |
| 36 | + | pub fn try_recv(&self) -> Option<ClassifierWorkerEvent> { | |
| 37 | + | self.event_rx.lock().ok()?.try_recv().ok() | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl Drop for ClassifierHandle { | |
| 42 | + | fn drop(&mut self) { | |
| 43 | + | // The worker is one-shot and detached; join so a replaced job finishes cleanly. | |
| 44 | + | if let Some(handle) = self._thread.take() { | |
| 45 | + | let _ = handle.join(); | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Spawn a one-shot worker thread to run `job` against the database at `db_path`. | |
| 51 | + | #[instrument(skip_all)] | |
| 52 | + | pub fn spawn_classifier_job(db_path: PathBuf, job: ClassifierJob) -> std::io::Result<ClassifierHandle> { | |
| 53 | + | let (event_tx, event_rx) = mpsc::channel::<ClassifierWorkerEvent>(); | |
| 54 | + | ||
| 55 | + | let thread = thread::Builder::new() | |
| 56 | + | .name("classifier-job".to_string()) | |
| 57 | + | .spawn(move || { | |
| 58 | + | let event = match Database::open(&db_path) { | |
| 59 | + | Ok(db) => run_job(&db, job), | |
| 60 | + | Err(e) => { | |
| 61 | + | error!("Classifier worker failed to open database: {e}"); | |
| 62 | + | ClassifierWorkerEvent::Failed(format!("could not open database: {e}")) | |
| 63 | + | } | |
| 64 | + | }; | |
| 65 | + | let _ = event_tx.send(event); | |
| 66 | + | })?; | |
| 67 | + | ||
| 68 | + | Ok(ClassifierHandle { event_rx: Mutex::new(event_rx), _thread: Some(thread) }) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | fn run_job(db: &Database, job: ClassifierJob) -> ClassifierWorkerEvent { | |
| 72 | + | let result = match job { | |
| 73 | + | ClassifierJob::TrainHead => trained_head::train_and_save(db) | |
| 74 | + | .map(|h| ClassifierJobResult::Trained(h.map(head_info))), | |
| 75 | + | ClassifierJob::AutoApply { k } => auto_apply(db, k).map(ClassifierJobResult::AutoApplied), | |
| 76 | + | ClassifierJob::Cluster { k } => cluster::cluster_library(db, k, 50) | |
| 77 | + | .map(|r| ClassifierJobResult::Clustered(r.clusters)), | |
| 78 | + | ClassifierJob::Export { path, opts } => { | |
| 79 | + | afcl::export_to_path(db, &path, &opts).map(|()| ClassifierJobResult::Exported(path)) | |
| 80 | + | } | |
| 81 | + | }; | |
| 82 | + | match result { | |
| 83 | + | Ok(r) => ClassifierWorkerEvent::Done(r), | |
| 84 | + | Err(e) => ClassifierWorkerEvent::Failed(e.to_string()), | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Library auto-tagging: route through the trained head when one is current (O(tags) per | |
| 89 | + | /// sample), else brute-force k-NN. Mirrors `DirectBackend::ml_auto_apply_all`. | |
| 90 | + | fn auto_apply(db: &Database, k: usize) -> audiofiles_core::error::Result<usize> { | |
| 91 | + | if let Some(head) = trained_head::load(db)? { | |
| 92 | + | trained_head::auto_apply_library(db, &head) | |
| 93 | + | } else { | |
| 94 | + | exemplar::auto_apply_library(db, k) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | fn head_info(h: trained_head::TrainedHead) -> TrainedHeadInfo { | |
| 99 | + | TrainedHeadInfo { | |
| 100 | + | class_count: h.classes.len(), | |
| 101 | + | exemplar_count: h.exemplar_count, | |
| 102 | + | trained_at: h.trained_at, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[cfg(test)] | |
| 107 | + | mod tests { | |
| 108 | + | use super::*; | |
| 109 | + | use audiofiles_core::analysis::classify::{FEATURE_VERSION, NUM_FEATURES}; | |
| 110 | + | ||
| 111 | + | fn seed(db_path: &std::path::Path) { | |
| 112 | + | let db = Database::open(db_path).unwrap(); | |
| 113 | + | for i in 0..6 { | |
| 114 | + | let hash = format!("k{i}"); | |
| 115 | + | db.conn() | |
| 116 | + | .execute( | |
| 117 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ | |
| 118 | + | VALUES (?1, ?1, 'wav', 1, 0, 0)", | |
| 119 | + | [&hash], | |
| 120 | + | ) | |
| 121 | + | .unwrap(); | |
| 122 | + | let v = serde_json::to_string(&vec![0.01 * i as f64; NUM_FEATURES]).unwrap(); | |
| 123 | + | db.conn() | |
| 124 | + | .execute( | |
| 125 | + | "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)", | |
| 126 | + | rusqlite::params![hash, FEATURE_VERSION, v], | |
| 127 | + | ) | |
| 128 | + | .unwrap(); | |
| 129 | + | audiofiles_core::tags::add_tag(&db, &hash, "instrument.drum.kick").unwrap(); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | fn run_to_completion(db_path: PathBuf, job: ClassifierJob) -> ClassifierWorkerEvent { | |
| 134 | + | let handle = spawn_classifier_job(db_path, job).unwrap(); | |
| 135 | + | loop { | |
| 136 | + | if let Some(ev) = handle.try_recv() { | |
| 137 | + | return ev; | |
| 138 | + | } | |
| 139 | + | std::thread::sleep(std::time::Duration::from_millis(5)); | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn train_job_completes_on_worker() { | |
| 145 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 146 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 147 | + | seed(&db_path); | |
| 148 | + | match run_to_completion(db_path, ClassifierJob::TrainHead) { | |
| 149 | + | ClassifierWorkerEvent::Done(ClassifierJobResult::Trained(Some(info))) => { | |
| 150 | + | assert_eq!(info.class_count, 1); | |
| 151 | + | } | |
| 152 | + | _ => panic!("expected a trained head"), | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn cluster_job_completes_on_worker() { | |
| 158 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 159 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 160 | + | seed(&db_path); | |
| 161 | + | match run_to_completion(db_path, ClassifierJob::Cluster { k: 2 }) { | |
| 162 | + | ClassifierWorkerEvent::Done(ClassifierJobResult::Clustered(clusters)) => { | |
| 163 | + | assert!(!clusters.is_empty()); | |
| 164 | + | } | |
| 165 | + | _ => panic!("expected clusters"), | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn export_job_writes_file_on_worker() { | |
| 171 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 172 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 173 | + | seed(&db_path); | |
| 174 | + | let out = dir.path().join("out.afcl"); | |
| 175 | + | let job = ClassifierJob::Export { | |
| 176 | + | path: out.clone(), | |
| 177 | + | opts: afcl::ExportOptions::default(), | |
| 178 | + | }; | |
| 179 | + | match run_to_completion(db_path, job) { | |
| 180 | + | ClassifierWorkerEvent::Done(ClassifierJobResult::Exported(p)) => { | |
| 181 | + | assert_eq!(p, out); | |
| 182 | + | assert!(out.exists()); | |
| 183 | + | } | |
| 184 | + | _ => panic!("expected export"), | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } |
| @@ -24,6 +24,11 @@ pub fn draw_browser( | |||
| 24 | 24 | if state.poll_workers() { | |
| 25 | 25 | ctx.request_repaint(); | |
| 26 | 26 | } | |
| 27 | + | // Keep polling while a background classifier job runs (no input would | |
| 28 | + | // otherwise repaint, so its completion event would sit unread). | |
| 29 | + | if state.classifier.busy.is_some() { | |
| 30 | + | ctx.request_repaint(); | |
| 31 | + | } | |
| 27 | 32 | handle_keyboard(ctx, state); | |
| 28 | 33 | draw_normal_browser(ui, state, sync_manager); | |
| 29 | 34 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Browser UI for audiofiles — file browsing, import, preview, and waveform display. | |
| 2 | 2 | ||
| 3 | 3 | pub mod backend; | |
| 4 | + | pub mod classifier_worker; | |
| 4 | 5 | pub mod cleanup; | |
| 5 | 6 | pub mod editor; | |
| 6 | 7 | pub mod error; |
| @@ -182,16 +182,71 @@ impl BrowserState { | |||
| 182 | 182 | ||
| 183 | 183 | // ── Layer B: k-NN auto-tagging ── | |
| 184 | 184 | ||
| 185 | - | /// Auto-apply above-threshold k-NN suggestions across the whole library. | |
| 185 | + | /// Auto-apply above-threshold suggestions across the whole library — runs on a worker | |
| 186 | + | /// thread (O(N) k-NN over the library is heavy), completing via `poll_workers`. | |
| 186 | 187 | pub fn classifier_auto_apply_ml(&mut self) { | |
| 187 | 188 | use audiofiles_core::analysis::exemplar::DEFAULT_K; | |
| 188 | - | match self.backend.ml_auto_apply_all(DEFAULT_K) { | |
| 189 | - | Ok(n) => { | |
| 189 | + | self.start_classifier_job( | |
| 190 | + | crate::backend::ClassifierJob::AutoApply { k: DEFAULT_K }, | |
| 191 | + | "Auto-tagging library\u{2026}", | |
| 192 | + | ); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Apply a finished classifier job's result (called from `poll_workers`). The worker | |
| 196 | + | /// wrote to its own DB connection, so refresh the affected cached views here. | |
| 197 | + | pub fn on_classifier_job_done(&mut self, result: crate::backend::ClassifierJobResult) { | |
| 198 | + | use crate::backend::ClassifierJobResult as R; | |
| 199 | + | match result { | |
| 200 | + | R::Trained(info) => { | |
| 201 | + | self.classifier.head_info = info; | |
| 202 | + | self.refresh_head(); | |
| 203 | + | self.status = match info { | |
| 204 | + | Some(i) => format!( | |
| 205 | + | "Trained model on {} sample{} across {} tag{}.", | |
| 206 | + | i.exemplar_count, if i.exemplar_count == 1 { "" } else { "s" }, | |
| 207 | + | i.class_count, if i.class_count == 1 { "" } else { "s" }, | |
| 208 | + | ), | |
| 209 | + | None => "Not enough tagged samples to train a model yet.".to_string(), | |
| 210 | + | }; | |
| 211 | + | } | |
| 212 | + | R::AutoApplied(n) => { | |
| 190 | 213 | self.classifier.last_ml_apply = Some(n); | |
| 191 | 214 | self.refresh_selected_tags(); | |
| 192 | - | self.status = format!("Auto-tagging applied {n} tag{}", if n == 1 { "" } else { "s" }); | |
| 215 | + | self.status = format!("Auto-tagging applied {n} tag{}.", if n == 1 { "" } else { "s" }); | |
| 216 | + | } | |
| 217 | + | R::Clustered(clusters) => { | |
| 218 | + | self.classifier.cluster_names = vec![String::new(); clusters.len()]; | |
| 219 | + | self.classifier.clusters = clusters; | |
| 220 | + | self.status = format!( | |
| 221 | + | "Found {} cluster{}.", | |
| 222 | + | self.classifier.clusters.len(), | |
| 223 | + | if self.classifier.clusters.len() == 1 { "" } else { "s" }, | |
| 224 | + | ); | |
| 225 | + | } | |
| 226 | + | R::Exported(path) => { | |
| 227 | + | let msg = format!("Exported classifier to {}", path.display()); | |
| 228 | + | self.classifier.last_export_msg = Some(msg.clone()); | |
| 229 | + | self.status = msg; | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Dispatch a heavy classifier job to the worker, marking the section busy. Buttons are | |
| 235 | + | /// disabled while `busy` is set; the result lands in `poll_workers`. | |
| 236 | + | fn start_classifier_job(&mut self, job: crate::backend::ClassifierJob, label: &str) { | |
| 237 | + | if self.classifier.busy.is_some() { | |
| 238 | + | return; // one at a time | |
| 239 | + | } | |
| 240 | + | self.classifier.last_error = None; | |
| 241 | + | match self.backend.start_classifier_job(job) { | |
| 242 | + | Ok(()) => { | |
| 243 | + | self.classifier.busy = Some(label.to_string()); | |
| 244 | + | self.status = label.to_string(); | |
| 245 | + | } | |
| 246 | + | Err(e) => { | |
| 247 | + | self.classifier.last_error = Some(format!("Could not start: {e}")); | |
| 248 | + | self.status = format!("Could not start: {e}"); | |
| 193 | 249 | } | |
| 194 | - | Err(e) => self.status = format!("Auto-tagging failed: {e}"), | |
| 195 | 250 | } | |
| 196 | 251 | } | |
| 197 | 252 | ||
| @@ -216,7 +271,8 @@ impl BrowserState { | |||
| 216 | 271 | self.classifier.layers_loaded = true; | |
| 217 | 272 | } | |
| 218 | 273 | ||
| 219 | - | /// Export the user's classifier data to `path` using the export-form selections. | |
| 274 | + | /// Export the user's classifier data to `path` using the export-form selections — | |
| 275 | + | /// runs on a worker thread (reads + serializes the whole library). | |
| 220 | 276 | pub fn classifier_export_afcl(&mut self, path: &std::path::Path) { | |
| 221 | 277 | use audiofiles_core::analysis::afcl::ExportOptions; | |
| 222 | 278 | let opts = ExportOptions { | |
| @@ -230,29 +286,36 @@ impl BrowserState { | |||
| 230 | 286 | include_rules: self.classifier.export_include_rules, | |
| 231 | 287 | include_policy: self.classifier.export_include_policy, | |
| 232 | 288 | }; | |
| 233 | - | match self.backend.export_afcl(path, &opts) { | |
| 234 | - | Ok(()) => self.status = format!("Exported classifier to {}", path.display()), | |
| 235 | - | Err(e) => self.status = format!("Export failed: {e}"), | |
| 236 | - | } | |
| 289 | + | self.classifier.last_export_msg = None; | |
| 290 | + | self.start_classifier_job( | |
| 291 | + | crate::backend::ClassifierJob::Export { path: path.to_path_buf(), opts }, | |
| 292 | + | "Exporting\u{2026}", | |
| 293 | + | ); | |
| 237 | 294 | } | |
| 238 | 295 | ||
| 239 | 296 | /// Import a `.afcl` file as a new removable layer, refreshing dependent views. | |
| 240 | 297 | pub fn classifier_import_afcl(&mut self, path: &std::path::Path) { | |
| 241 | 298 | match self.backend.import_afcl(path) { | |
| 242 | 299 | Ok(s) => { | |
| 243 | - | self.status = format!( | |
| 244 | - | "Imported \"{}\" — {} exemplar{}, {} rule{} (disabled), {} threshold{}", | |
| 300 | + | let msg = format!( | |
| 301 | + | "Imported \"{}\": {} exemplar{}, {} rule{} (disabled), {} threshold{}.", | |
| 245 | 302 | s.name, | |
| 246 | 303 | s.exemplars, if s.exemplars == 1 { "" } else { "s" }, | |
| 247 | 304 | s.rules, if s.rules == 1 { "" } else { "s" }, | |
| 248 | 305 | s.policies, if s.policies == 1 { "" } else { "s" }, | |
| 249 | 306 | ); | |
| 307 | + | self.status = msg.clone(); | |
| 308 | + | self.classifier.last_import_msg = Some(msg); | |
| 250 | 309 | // Import adds disabled rules + policies; refresh those views too. | |
| 251 | 310 | self.refresh_layers(); | |
| 252 | 311 | self.refresh_rules(); | |
| 253 | 312 | self.refresh_policies(); | |
| 254 | 313 | } | |
| 255 | - | Err(e) => self.status = format!("Import failed: {e}"), | |
| 314 | + | Err(e) => { | |
| 315 | + | let msg = format!("Import failed: {e}"); | |
| 316 | + | self.status = msg.clone(); | |
| 317 | + | self.classifier.last_import_msg = Some(msg); | |
| 318 | + | } | |
| 256 | 319 | } | |
| 257 | 320 | } | |
| 258 | 321 | ||
| @@ -297,26 +360,10 @@ impl BrowserState { | |||
| 297 | 360 | self.classifier.head_loaded = true; | |
| 298 | 361 | } | |
| 299 | 362 | ||
| 300 | - | /// Distill the tagged library into a trained head and persist it. Once present, the | |
| 301 | - | /// library-wide "Suggest tags" pass routes through it instead of brute-force k-NN. | |
| 363 | + | /// Distill the tagged library into a trained head and persist it — runs on a worker | |
| 364 | + | /// thread. Once present, the library-wide "Suggest tags" pass routes through it. | |
| 302 | 365 | pub fn classifier_train_head(&mut self) { | |
| 303 | - | match self.backend.train_classifier_head() { | |
| 304 | - | Ok(Some(info)) => { | |
| 305 | - | self.status = format!( | |
| 306 | - | "Trained model on {} sample{} across {} tag{}", | |
| 307 | - | info.exemplar_count, | |
| 308 | - | if info.exemplar_count == 1 { "" } else { "s" }, | |
| 309 | - | info.class_count, | |
| 310 | - | if info.class_count == 1 { "" } else { "s" }, | |
| 311 | - | ); | |
| 312 | - | } | |
| 313 | - | Ok(None) => { | |
| 314 | - | self.status = | |
| 315 | - | "Not enough tagged samples to train a model yet — tag more first.".to_string(); | |
| 316 | - | } | |
| 317 | - | Err(e) => self.status = format!("Training failed: {e}"), | |
| 318 | - | } | |
| 319 | - | self.refresh_head(); | |
| 366 | + | self.start_classifier_job(crate::backend::ClassifierJob::TrainHead, "Training model\u{2026}"); | |
| 320 | 367 | } | |
| 321 | 368 | ||
| 322 | 369 | /// Discard the trained head; auto-tagging reverts to k-NN. | |
| @@ -407,18 +454,13 @@ impl BrowserState { | |||
| 407 | 454 | ||
| 408 | 455 | // ── Clustering bootstrap ── | |
| 409 | 456 | ||
| 410 | - | /// Cluster the library; `cluster_k < 2` means use a default of 8. | |
| 457 | + | /// Cluster the library on a worker thread; `cluster_k < 2` means use a default of 8. | |
| 411 | 458 | pub fn run_clustering(&mut self) { | |
| 412 | 459 | let k = if self.classifier.cluster_k < 2 { 8 } else { self.classifier.cluster_k as usize }; | |
| 413 | - | match self.backend.cluster_library(k) { | |
| 414 | - | Ok(clusters) => { | |
| 415 | - | self.classifier.cluster_names = vec![String::new(); clusters.len()]; | |
| 416 | - | self.classifier.clusters = clusters; | |
| 417 | - | self.status = format!("Found {} cluster{}", self.classifier.clusters.len(), | |
| 418 | - | if self.classifier.clusters.len() == 1 { "" } else { "s" }); | |
| 419 | - | } | |
| 420 | - | Err(e) => self.status = format!("Clustering failed: {e}"), | |
| 421 | - | } | |
| 460 | + | self.start_classifier_job( | |
| 461 | + | crate::backend::ClassifierJob::Cluster { k }, | |
| 462 | + | "Finding clusters\u{2026}", | |
| 463 | + | ); | |
| 422 | 464 | } | |
| 423 | 465 | ||
| 424 | 466 | /// Apply the named tag to every member of cluster `idx`. |
| @@ -755,6 +755,19 @@ impl BrowserState { | |||
| 755 | 755 | self.forge.busy = false; | |
| 756 | 756 | self.status = format!("Forge failed: {error}"); | |
| 757 | 757 | } | |
| 758 | + | ||
| 759 | + | // --- Classifier worker (train / auto-tag / cluster / export) --- | |
| 760 | + | BackendEvent::ClassifierJobDone(result) => { | |
| 761 | + | self.classifier.busy = None; | |
| 762 | + | self.on_classifier_job_done(result); | |
| 763 | + | } | |
| 764 | + | BackendEvent::ClassifierJobFailed(error) => { | |
| 765 | + | let label = self.classifier.busy.take(); | |
| 766 | + | let prefix = label.as_deref().unwrap_or("Classifier job").trim_end_matches('\u{2026}'); | |
| 767 | + | let msg = format!("{prefix} failed: {error}"); | |
| 768 | + | self.classifier.last_error = Some(msg.clone()); | |
| 769 | + | self.status = msg; | |
| 770 | + | } | |
| 758 | 771 | } | |
| 759 | 772 | } | |
| 760 | 773 |
| @@ -1905,6 +1905,19 @@ mod misc { | |||
| 1905 | 1905 | } | |
| 1906 | 1906 | } | |
| 1907 | 1907 | ||
| 1908 | + | /// Drive a dispatched classifier worker job to completion (the heavy ops now run | |
| 1909 | + | /// off-thread; `poll_workers` applies the result and clears `busy`). | |
| 1910 | + | fn await_classifier_job(state: &mut BrowserState) { | |
| 1911 | + | for _ in 0..4000 { | |
| 1912 | + | if state.classifier.busy.is_none() { | |
| 1913 | + | return; | |
| 1914 | + | } | |
| 1915 | + | state.poll_workers(); | |
| 1916 | + | std::thread::sleep(std::time::Duration::from_millis(2)); | |
| 1917 | + | } | |
| 1918 | + | panic!("classifier job did not complete"); | |
| 1919 | + | } | |
| 1920 | + | ||
| 1908 | 1921 | #[test] | |
| 1909 | 1922 | fn classifier_train_head_and_route() { | |
| 1910 | 1923 | let (mut state, _dir) = make_state(); | |
| @@ -1922,14 +1935,16 @@ mod misc { | |||
| 1922 | 1935 | assert!(state.classifier.head_info.is_none()); | |
| 1923 | 1936 | assert!(state.classifier.head_readiness.is_some()); | |
| 1924 | 1937 | ||
| 1925 | - | // Train: head appears, covering both tags. | |
| 1938 | + | // Train (off-thread): head appears, covering both tags. | |
| 1926 | 1939 | state.classifier_train_head(); | |
| 1940 | + | await_classifier_job(&mut state); | |
| 1927 | 1941 | let info = state.classifier.head_info.expect("head trained"); | |
| 1928 | 1942 | assert_eq!(info.class_count, 2); | |
| 1929 | 1943 | assert_eq!(info.exemplar_count, 12); | |
| 1930 | 1944 | ||
| 1931 | 1945 | // Library-wide auto-tagging now routes through the head and tags the query. | |
| 1932 | 1946 | state.classifier_auto_apply_ml(); | |
| 1947 | + | await_classifier_job(&mut state); | |
| 1933 | 1948 | let tags = state.backend.get_sample_tags("q1").unwrap(); | |
| 1934 | 1949 | assert!(tags.contains(&"instrument.drum.kick".to_string()), "got {tags:?}"); | |
| 1935 | 1950 | ||
| @@ -1949,6 +1964,7 @@ mod misc { | |||
| 1949 | 1964 | src.classifier.export_include_rules = true; | |
| 1950 | 1965 | src.classifier.export_include_policy = true; | |
| 1951 | 1966 | src.classifier_export_afcl(&path); | |
| 1967 | + | await_classifier_job(&mut src); | |
| 1952 | 1968 | assert!(path.exists(), "export wrote the file"); | |
| 1953 | 1969 | ||
| 1954 | 1970 | // Destination library: import it as a layer. | |
| @@ -1974,6 +1990,7 @@ mod misc { | |||
| 1974 | 1990 | insert_featured_sample(&state, "k0", 0.0, Some("x.kick")); | |
| 1975 | 1991 | insert_featured_sample(&state, "k1", 0.1, Some("x.kick")); | |
| 1976 | 1992 | state.classifier_train_head(); | |
| 1993 | + | await_classifier_job(&mut state); | |
| 1977 | 1994 | assert!(state.classifier.head_info.is_none()); | |
| 1978 | 1995 | assert!(state.status.contains("Not enough")); | |
| 1979 | 1996 | } |
| @@ -331,6 +331,19 @@ pub struct ClassifierUiState { | |||
| 331 | 331 | pub export_include_exemplars: bool, | |
| 332 | 332 | pub export_include_rules: bool, | |
| 333 | 333 | pub export_include_policy: bool, | |
| 334 | + | /// Inline result line for the last export / import (the footer status is hidden | |
| 335 | + | /// behind the Settings modal, so results are surfaced in-section). | |
| 336 | + | pub last_export_msg: Option<String>, | |
| 337 | + | pub last_import_msg: Option<String>, | |
| 338 | + | ||
| 339 | + | // --- Background-job state (heavy ops run off the GUI thread) --- | |
| 340 | + | /// Label of the in-flight classifier job (train/auto-tag/cluster/export), or `None`. | |
| 341 | + | /// While set, trigger buttons are disabled and a progress line is shown. | |
| 342 | + | pub busy: Option<String>, | |
| 343 | + | /// Last classifier-job error, shown as an in-section banner. | |
| 344 | + | pub last_error: Option<String>, | |
| 345 | + | /// Layer id awaiting a remove confirmation (inline two-step confirm). | |
| 346 | + | pub pending_layer_remove: Option<String>, | |
| 334 | 347 | } | |
| 335 | 348 | ||
| 336 | 349 | /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule` |
| @@ -137,6 +137,20 @@ fn op_needs_value(op: RuleOp) -> bool { | |||
| 137 | 137 | ||
| 138 | 138 | /// Draw all tag-classifier sections in Settings. | |
| 139 | 139 | pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 140 | + | // A background job (train/auto-tag/cluster/export) is running: show progress and let | |
| 141 | + | // the disabled buttons below explain why they're inert. Footer status is hidden behind | |
| 142 | + | // the Settings modal, so feedback lives here. | |
| 143 | + | if let Some(label) = state.classifier.busy.clone() { | |
| 144 | + | ui.horizontal(|ui| { | |
| 145 | + | ui.add(egui::Spinner::new().size(14.0)); | |
| 146 | + | ui.label(egui::RichText::new(label).small().color(theme::content_secondary())); | |
| 147 | + | }); | |
| 148 | + | ui.add_space(theme::space::SM); | |
| 149 | + | } else if let Some(err) = state.classifier.last_error.clone() { | |
| 150 | + | widgets::warning_banner(ui, &err); | |
| 151 | + | ui.add_space(theme::space::SM); | |
| 152 | + | } | |
| 153 | + | ||
| 140 | 154 | draw_rules_header(ui, state); | |
| 141 | 155 | ui.add_space(theme::space::SM); | |
| 142 | 156 | draw_autotag_header(ui, state); | |
| @@ -448,16 +462,17 @@ fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 448 | 462 | .color(theme::content_muted()), | |
| 449 | 463 | ); | |
| 450 | 464 | ui.add_space(theme::space::SM); | |
| 465 | + | let busy = state.classifier.busy.is_some(); | |
| 451 | 466 | ui.horizontal(|ui| { | |
| 452 | 467 | if ui | |
| 453 | - | .button("Suggest tags across library") | |
| 468 | + | .add_enabled(!busy, egui::Button::new("Suggest tags across library")) | |
| 454 | 469 | .on_hover_text("Build the model from your tagged samples and auto-apply confident tags") | |
| 455 | 470 | .clicked() | |
| 456 | 471 | { | |
| 457 | 472 | state.classifier_auto_apply_ml(); | |
| 458 | 473 | } | |
| 459 | 474 | if ui | |
| 460 | - | .button("Undo") | |
| 475 | + | .add_enabled(!busy, egui::Button::new("Undo")) | |
| 461 | 476 | .on_hover_text("Remove every tag applied by auto-tagging") | |
| 462 | 477 | .clicked() | |
| 463 | 478 | { | |
| @@ -570,23 +585,25 @@ fn draw_trained_head(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 570 | 585 | } | |
| 571 | 586 | ||
| 572 | 587 | ui.add_space(theme::space::SM); | |
| 588 | + | let busy = state.classifier.busy.is_some(); | |
| 589 | + | let has_head = state.classifier.head_info.is_some(); | |
| 573 | 590 | ui.horizontal(|ui| { | |
| 574 | - | let train_label = | |
| 575 | - | if state.classifier.head_info.is_some() { "Retrain model" } else { "Train model" }; | |
| 591 | + | let train_label = if has_head { "Retrain model" } else { "Train model" }; | |
| 592 | + | // Fixed width so the "Train model" ↔ "Retrain model" swap doesn't shift the row. | |
| 576 | 593 | if ui | |
| 577 | - | .button(train_label) | |
| 594 | + | .add_enabled(!busy, egui::Button::new(train_label).min_size(egui::vec2(96.0, 0.0))) | |
| 578 | 595 | .on_hover_text("Distil your tagged library into a compact per-library classifier") | |
| 579 | 596 | .clicked() | |
| 580 | 597 | { | |
| 581 | 598 | state.classifier_train_head(); | |
| 582 | 599 | } | |
| 583 | - | if state.classifier.head_info.is_some() | |
| 584 | - | && ui | |
| 585 | - | .button("Clear") | |
| 586 | - | .on_hover_text("Remove the model; auto-tagging reverts to nearest-neighbor matching") | |
| 587 | - | .clicked() | |
| 588 | - | { | |
| 589 | - | state.classifier_clear_head(); | |
| 600 | + | if has_head { | |
| 601 | + | let clear = ui | |
| 602 | + | .add_enabled(!busy, egui::Button::new("Clear")) | |
| 603 | + | .on_hover_text("Remove the model; auto-tagging reverts to nearest-neighbor matching"); | |
| 604 | + | if clear.clicked() { | |
| 605 | + | state.classifier_clear_head(); | |
| 606 | + | } | |
| 590 | 607 | } | |
| 591 | 608 | }); | |
| 592 | 609 | } | |
| @@ -605,13 +622,14 @@ fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 605 | 622 | .color(theme::content_muted()), | |
| 606 | 623 | ); | |
| 607 | 624 | ui.add_space(theme::space::SM); | |
| 625 | + | let busy = state.classifier.busy.is_some(); | |
| 608 | 626 | ui.horizontal(|ui| { | |
| 609 | 627 | let mut k = state.classifier.cluster_k.max(2); | |
| 610 | 628 | ui.label("Groups"); | |
| 611 | 629 | if ui.add(egui::Slider::new(&mut k, 2..=24)).changed() { | |
| 612 | 630 | state.classifier.cluster_k = k; | |
| 613 | 631 | } | |
| 614 | - | if ui.button("Find clusters").clicked() { | |
| 632 | + | if ui.add_enabled(!busy, egui::Button::new("Find clusters")).clicked() { | |
| 615 | 633 | state.classifier.cluster_k = k; | |
| 616 | 634 | state.run_clustering(); | |
| 617 | 635 | } | |
| @@ -748,9 +766,15 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 748 | 766 | .color(theme::content_muted()), | |
| 749 | 767 | ); | |
| 750 | 768 | ||
| 769 | + | let busy = state.classifier.busy.is_some(); | |
| 770 | + | ||
| 751 | 771 | // ── Export ── | |
| 752 | 772 | ui.add_space(theme::space::MD); | |
| 753 | 773 | widgets::subsection_label(ui, "Export"); | |
| 774 | + | // Load what's exportable so the button can disable when there's nothing to share. | |
| 775 | + | state.ensure_head_loaded(); | |
| 776 | + | state.ensure_rules_loaded(); | |
| 777 | + | state.ensure_policies_loaded(); | |
| 754 | 778 | ui.horizontal(|ui| { | |
| 755 | 779 | ui.label(egui::RichText::new("Name").small().color(theme::content_muted())); | |
| 756 | 780 | ui.add( | |
| @@ -764,12 +788,14 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 764 | 788 | ui.checkbox(&mut state.classifier.export_include_rules, "Rules"); | |
| 765 | 789 | ui.checkbox(&mut state.classifier.export_include_policy, "Thresholds"); | |
| 766 | 790 | }); | |
| 767 | - | let nothing_selected = !state.classifier.export_include_exemplars | |
| 768 | - | && !state.classifier.export_include_rules | |
| 769 | - | && !state.classifier.export_include_policy; | |
| 770 | - | ui.add_enabled_ui(!nothing_selected, |ui| { | |
| 771 | - | if ui | |
| 772 | - | .button("Export to file...") | |
| 791 | + | let exemplars_avail = state.classifier.head_readiness.map(|(n, _)| n > 0).unwrap_or(false); | |
| 792 | + | let rules_avail = !state.classifier.rules.is_empty(); | |
| 793 | + | let policy_avail = !state.classifier.policies.is_empty(); | |
| 794 | + | let has_content = (state.classifier.export_include_exemplars && exemplars_avail) | |
| 795 | + | || (state.classifier.export_include_rules && rules_avail) | |
| 796 | + | || (state.classifier.export_include_policy && policy_avail); | |
| 797 | + | ui.add_enabled_ui(!busy && has_content, |ui| { | |
| 798 | + | if widgets::primary_button(ui, "Export to file...") | |
| 773 | 799 | .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included") | |
| 774 | 800 | .clicked() | |
| 775 | 801 | && let Some(path) = rfd::FileDialog::new() | |
| @@ -780,19 +806,33 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 780 | 806 | state.classifier_export_afcl(&path); | |
| 781 | 807 | } | |
| 782 | 808 | }); | |
| 809 | + | if !has_content { | |
| 810 | + | ui.label( | |
| 811 | + | egui::RichText::new("Nothing to export yet \u{2014} tag samples or add rules first.") | |
| 812 | + | .small() | |
| 813 | + | .color(theme::content_muted()), | |
| 814 | + | ); | |
| 815 | + | } | |
| 816 | + | if let Some(msg) = &state.classifier.last_export_msg { | |
| 817 | + | ui.label(egui::RichText::new(msg).small().color(theme::content_secondary())); | |
| 818 | + | } | |
| 783 | 819 | ||
| 784 | 820 | // ── Import ── | |
| 785 | 821 | ui.add_space(theme::space::MD); | |
| 786 | 822 | widgets::subsection_label(ui, "Import"); | |
| 787 | - | if ui | |
| 788 | - | .button("Import .afcl file...") | |
| 789 | - | .on_hover_text("Add someone's shared classifier as a removable layer") | |
| 790 | - | .clicked() | |
| 791 | - | && let Some(path) = rfd::FileDialog::new() | |
| 792 | - | .add_filter("AF classifier", &["afcl"]) | |
| 793 | - | .pick_file() | |
| 794 | - | { | |
| 795 | - | state.classifier_import_afcl(&path); | |
| 823 | + | ui.add_enabled_ui(!busy, |ui| { | |
| 824 | + | if widgets::primary_button(ui, "Import .afcl file...") | |
| 825 | + | .on_hover_text("Add someone's shared classifier as a removable layer") | |
| 826 | + | .clicked() | |
| 827 | + | && let Some(path) = rfd::FileDialog::new() | |
| 828 | + | .add_filter("AF classifier", &["afcl"]) | |
| 829 | + | .pick_file() | |
| 830 | + | { | |
| 831 | + | state.classifier_import_afcl(&path); | |
| 832 | + | } | |
| 833 | + | }); | |
| 834 | + | if let Some(msg) = &state.classifier.last_import_msg { | |
| 835 | + | ui.label(egui::RichText::new(msg).small().color(theme::content_secondary())); | |
| 796 | 836 | } | |
| 797 | 837 | ||
| 798 | 838 | // ── Imported layers ── | |
| @@ -811,40 +851,83 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 811 | 851 | .color(theme::content_muted()), | |
| 812 | 852 | ); | |
| 813 | 853 | ||
| 854 | + | let pending = state.classifier.pending_layer_remove.clone(); | |
| 814 | 855 | let mut toggle: Option<(String, bool)> = None; | |
| 815 | 856 | let mut weight_change: Option<(String, f64)> = None; | |
| 816 | 857 | let mut remove: Option<String> = None; | |
| 858 | + | let mut set_pending: Option<Option<String>> = None; | |
| 817 | 859 | for layer in &layers { | |
| 818 | 860 | ui.add_space(theme::space::SM); | |
| 861 | + | let confirming = pending.as_deref() == Some(layer.id.as_str()); | |
| 862 | + | // Row A: enable + name (truncated, dimmed when disabled) + counts. | |
| 819 | 863 | ui.horizontal(|ui| { | |
| 820 | 864 | let mut enabled = layer.enabled; | |
| 821 | - | if ui.checkbox(&mut enabled, "").changed() { | |
| 865 | + | if ui | |
| 866 | + | .checkbox(&mut enabled, "") | |
| 867 | + | .on_hover_text("Use this layer when auto-tagging") | |
| 868 | + | .changed() | |
| 869 | + | { | |
| 822 | 870 | toggle = Some((layer.id.clone(), enabled)); | |
| 823 | 871 | } | |
| 824 | - | ui.label(egui::RichText::new(&layer.name).small().strong()); | |
| 872 | + | let name_color = | |
| 873 | + | if layer.enabled { theme::content() } else { theme::content_muted() }; | |
| 874 | + | ui.add( | |
| 875 | + | egui::Label::new(egui::RichText::new(&layer.name).small().strong().color(name_color)) | |
| 876 | + | .truncate(), | |
| 877 | + | ) | |
| 878 | + | .on_hover_text(&layer.name); | |
| 825 | 879 | ui.label( | |
| 826 | 880 | egui::RichText::new(format!( | |
| 827 | - | "{} exemplar{} \u{00b7} {} rule{}", | |
| 881 | + | "{} ex \u{00b7} {} rule{}", | |
| 828 | 882 | layer.exemplar_count, | |
| 829 | - | if layer.exemplar_count == 1 { "" } else { "s" }, | |
| 830 | 883 | layer.rule_count, | |
| 831 | 884 | if layer.rule_count == 1 { "" } else { "s" }, | |
| 832 | 885 | )) | |
| 833 | 886 | .small() | |
| 834 | 887 | .color(theme::content_muted()), | |
| 835 | 888 | ); | |
| 836 | - | if ui.small_button("Remove").clicked() { | |
| 837 | - | remove = Some(layer.id.clone()); | |
| 838 | - | } | |
| 839 | 889 | }); | |
| 890 | + | // Row B: weight + remove (with inline two-step confirm — destructive, no undo). | |
| 840 | 891 | ui.horizontal(|ui| { | |
| 841 | 892 | ui.label(egui::RichText::new("weight").small().color(theme::content_muted())); | |
| 842 | 893 | let mut w = layer.weight as f32; | |
| 843 | - | let r = ui.add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true)); | |
| 894 | + | let r = ui | |
| 895 | + | .add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true)) | |
| 896 | + | .on_hover_text( | |
| 897 | + | "How much this layer counts when matching. Your own tags are always \ | |
| 898 | + | 1.0; lower means weaker.", | |
| 899 | + | ); | |
| 844 | 900 | if r.drag_stopped() || (r.changed() && !r.dragged()) { | |
| 845 | 901 | weight_change = Some((layer.id.clone(), w as f64)); | |
| 846 | 902 | } | |
| 903 | + | if confirming { | |
| 904 | + | if widgets::danger_small_button(ui, "Remove") | |
| 905 | + | .on_hover_text("Permanently delete this layer and its imported rules") | |
| 906 | + | .clicked() | |
| 907 | + | { | |
| 908 | + | remove = Some(layer.id.clone()); | |
| 909 | + | set_pending = Some(None); | |
| 910 | + | } | |
| 911 | + | if ui.small_button("Cancel").clicked() { | |
| 912 | + | set_pending = Some(None); | |
| 913 | + | } | |
| 914 | + | } else if widgets::danger_small_button(ui, "Remove").clicked() { | |
| 915 | + | set_pending = Some(Some(layer.id.clone())); | |
| 916 | + | } | |
| 847 | 917 | }); | |
| 918 | + | if confirming { | |
| 919 | + | ui.label( | |
| 920 | + | egui::RichText::new( | |
| 921 | + | "Removing deletes this layer's exemplars and imported rules. You'll \ | |
| 922 | + | need the .afcl file to add it again.", | |
| 923 | + | ) | |
| 924 | + | .small() | |
| 925 | + | .color(theme::danger()), | |
| 926 | + | ); | |
| 927 | + | } | |
| 928 | + | } | |
| 929 | + | if let Some(p) = set_pending { | |
| 930 | + | state.classifier.pending_layer_remove = p; | |
| 848 | 931 | } | |
| 849 | 932 | if let Some((id, enabled)) = toggle { | |
| 850 | 933 | state.classifier_set_layer_enabled(&id, enabled); |
| @@ -16,7 +16,7 @@ use tracing::instrument; | |||
| 16 | 16 | use super::classify::{FEATURE_VERSION, NUM_FEATURES}; | |
| 17 | 17 | ||
| 18 | 18 | /// One cluster: a representative (medoid) sample plus its members. | |
| 19 | - | #[derive(Debug, Clone)] | |
| 19 | + | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | |
| 20 | 20 | pub struct Cluster { | |
| 21 | 21 | pub id: usize, | |
| 22 | 22 | /// Member nearest the centroid — a playable representative for naming. |