Skip to main content

max / audiofiles

29.0 KB · 868 lines History Blame Raw
1 //! UI state: selection, sort, and UI-specific type definitions.
2
3 use std::collections::HashSet;
4 use std::path::PathBuf;
5 use std::time::Instant;
6
7 use audiofiles_core::edit::{EditOperation, FadeCurve};
8 use audiofiles_core::vfs::VfsNode;
9 use audiofiles_core::{NodeId, VfsId};
10
11 use crate::import::ImportedFolder;
12
13 /// User preference for how to handle edit results.
14 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 pub enum EditResultMode {
16 /// Replace the VFS node in-place with the edited sample.
17 Replace,
18 /// Create a sibling node next to the original.
19 Sibling,
20 }
21
22 /// Data from a completed edit, pending user choice (replace vs sibling).
23 pub struct PendingEditResult {
24 pub source_hash: String,
25 pub result_path: PathBuf,
26 pub operation: EditOperation,
27 }
28
29 /// Snapshot of a just-applied edit, used by `BrowserState::undo_last_edit`
30 /// to reverse the VFS-level work after `finalize_edit` (C-1 part 2).
31 ///
32 /// We don't need to snapshot the audio data itself — the content store is
33 /// content-addressed, so the original `source_hash` blob is preserved when
34 /// Replace mode re-points VFS nodes. Undo only needs to walk the VFS work
35 /// back, plus drop the matching `edit_history` row.
36 pub struct EditUndoEntry {
37 /// Display name of the operation (e.g. "Trim", "Reverse").
38 pub op_name: String,
39 pub source_hash: String,
40 pub result_hash: String,
41 pub mode: EditResultMode,
42 /// VFS the edit was applied in.
43 pub vfs_id: VfsId,
44 /// Pre-edit node positions for Replace mode: `(parent_id, name)`. After
45 /// undo we recreate one sample link per entry pointing back at
46 /// `source_hash`. Empty for Sibling mode.
47 pub replace_targets: Vec<(Option<NodeId>, String)>,
48 /// For Sibling mode: the result-side node id to delete. None for Replace.
49 pub sibling_node_id: Option<NodeId>,
50 /// When the edit landed — drives the inline affordance's fade-out.
51 pub created_at: Instant,
52 }
53
54 /// Pending destructive action awaiting user confirmation.
55 pub enum ConfirmAction {
56 DeleteNode { node_id: NodeId, node_name: String },
57 DeleteVfs { vfs_id: VfsId, vfs_name: String },
58 DeleteMultiple { node_ids: Vec<NodeId>, count: usize },
59 DeleteCollection { coll_id: audiofiles_core::CollectionId, coll_name: String },
60 /// Remove a tag from every sample that carries it.
61 RemoveTagGlobally { tag: String },
62 /// Switch to a different library while in-flight work would be interrupted.
63 /// Non-destructive: confirm label is "Switch", no danger styling.
64 SwitchLibrary { path: PathBuf, library_name: String },
65 /// Re-analyze selected samples when one or more already has computed values
66 /// (BPM / Key / classification). Confirming opens the ConfigureAnalysis
67 /// wizard; cancelling drops the operation.
68 ReanalyzeOverwrite {
69 sample_hashes: Vec<(String, String)>,
70 overwrite_count: usize,
71 },
72 /// Disconnect from cloud sync. Destructive because reconnecting requires
73 /// re-entering the encryption password — a typo there would leave the cloud
74 /// blob unreadable. `pending_changes` is surfaced in the detail line so the
75 /// user knows whether unsynced work is at stake.
76 DisconnectSync { pending_changes: i64 },
77 /// Permanently remove analysis-failed samples from the content store. The
78 /// post-import error review surfaces these with per-row and bulk delete
79 /// buttons; both gate through this variant so a stray click can't purge
80 /// recoverable files (codec missing, transient read error, etc.).
81 /// `single_index` distinguishes the per-row case (specific name in `name`)
82 /// from the bulk "Remove All Failed" case (None).
83 RemoveFailedSamples {
84 single_index: Option<usize>,
85 count: usize,
86 name: Option<String>,
87 },
88 /// Batch Reverse on a large selection. Single-sample Reverse is its own
89 /// undo (click again); batch Reverse on N samples is easy to fire by
90 /// accident and tedious to walk back. Gated at the call site when the
91 /// selection size exceeds the threshold (10).
92 ReverseSamples { count: usize },
93 }
94
95 /// An undoable bulk operation.
96 pub enum UndoOp {
97 BulkDelete {
98 nodes: Vec<VfsNode>,
99 tags: Vec<(String, Vec<String>)>,
100 },
101 BulkMove {
102 moves: Vec<(NodeId, Option<NodeId>)>,
103 },
104 BulkRename {
105 renames: Vec<(NodeId, String)>,
106 },
107 BulkTagAdd {
108 tag: String,
109 hashes: Vec<String>,
110 },
111 BulkTagRemove {
112 tag: String,
113 hashes: Vec<String>,
114 },
115 /// Single-sample inline tag removal (from the detail panel's tag chip "x").
116 /// Restoring re-adds the tag to the same sample.
117 TagRemove {
118 hash: String,
119 tag: String,
120 },
121 }
122
123 /// Active bulk operation modal.
124 pub enum BulkModal {
125 /// Bulk add or remove a tag from selected samples.
126 Tag {
127 /// The tag string being entered by the user.
128 tag_input: String,
129 /// `true` for add, `false` for remove.
130 adding: bool,
131 /// Content-addressed hashes of the targeted samples.
132 hashes: Vec<String>,
133 /// Display names of the targeted samples.
134 names: Vec<String>,
135 },
136 /// Bulk move selected nodes to a different directory.
137 Move {
138 /// IDs of the VFS nodes being moved.
139 node_ids: Vec<NodeId>,
140 /// Display names of the nodes being moved.
141 names: Vec<String>,
142 /// All available target directories as `(id, full_path)` pairs.
143 directories: Vec<(NodeId, String)>,
144 /// Index into `directories` the user has chosen, or `None` for root.
145 selected_idx: Option<usize>,
146 },
147 /// Bulk rename selected nodes using a pattern template.
148 Rename {
149 /// The rename pattern string (e.g. `"{name}_{bpm}"`).
150 pattern_input: String,
151 /// The nodes targeted for rename, with their analysis context.
152 targets: Vec<RenameTarget>,
153 /// Live preview pairs of `(old_name, new_name)`.
154 previews: Vec<(String, String)>,
155 /// Validation error, if any (empty names, duplicates, bad pattern).
156 error: Option<String>,
157 },
158 }
159
160 /// A rename target with its context for preview.
161 pub struct RenameTarget {
162 pub node_id: NodeId,
163 pub context: audiofiles_core::rename::RenameContext,
164 }
165
166 /// Column visibility configuration.
167 #[derive(serde::Serialize, serde::Deserialize)]
168 pub struct ColumnConfig {
169 pub show_classification: bool,
170 pub show_bpm: bool,
171 pub show_key: bool,
172 pub show_duration: bool,
173 pub show_peak_db: bool,
174 pub show_tags: bool,
175 }
176
177 impl Default for ColumnConfig {
178 fn default() -> Self {
179 Self {
180 show_classification: true,
181 show_bpm: true,
182 show_key: true,
183 show_duration: true,
184 show_peak_db: false,
185 show_tags: false,
186 }
187 }
188 }
189
190 /// A file that failed during the import phase (before entering the store).
191 pub struct ImportFileError {
192 pub path: String,
193 pub error: String,
194 }
195
196 /// A file that entered the store but failed during analysis.
197 pub struct AnalysisFileError {
198 pub hash: String,
199 pub name: String,
200 pub error: String,
201 }
202
203 /// Status of the sync API key test flow.
204 pub enum SyncSetupStatus {
205 /// No test in progress.
206 Idle,
207 /// Validation request in flight.
208 Testing,
209 /// Key is valid; server returned the app name.
210 Valid { app_name: String },
211 /// Key is invalid or server unreachable.
212 Failed { error: String },
213 }
214
215 /// Actions the sync setup UI can request from the app layer.
216 pub enum SyncSetupAction {
217 /// Validate this API key against the server.
218 TestKey(String),
219 /// Save this API key and create a SyncManager.
220 SaveKey(String),
221 }
222
223 /// Actions the vault picker UI can request from the app layer.
224 pub enum VaultAction {
225 /// Switch to a different vault.
226 SwitchVault(PathBuf),
227 /// Create a new vault and switch to it.
228 CreateVault { name: String, path: PathBuf, loose_files: bool },
229 /// Add an existing vault directory to the registry.
230 AddExistingVault { name: String, path: PathBuf },
231 /// Remove a vault from the registry (no file deletion).
232 RemoveVault(PathBuf),
233 /// Rename a vault in the registry.
234 RenameVault { path: PathBuf, new_name: String },
235 /// Repoint an offline vault's registry entry to a new directory.
236 RelocateVault { old_path: PathBuf, new_path: PathBuf },
237 /// Scan storage stats for the active vault.
238 ScanStorage,
239 /// Deactivate the license key and return to activation screen.
240 DeactivateLicense,
241 }
242
243 /// GUI state for the consolidated Settings window.
244 #[derive(Default)]
245 pub struct SettingsUiState {
246 /// Display name of the active vault.
247 pub name: String,
248 /// (name, path, reachable) for each known vault.
249 pub list: Vec<(String, PathBuf, bool)>,
250 /// Set by the UI, consumed by the app layer each frame.
251 pub pending_action: Option<VaultAction>,
252 /// Whether the Settings window is open.
253 pub show_manager: bool,
254 /// Name input for creating/adding a vault.
255 pub create_name: String,
256 /// Path input for creating/adding a vault.
257 pub create_path: Option<PathBuf>,
258 /// Inline rename: (path, new_name_buffer).
259 pub rename_target: Option<(PathBuf, String)>,
260
261 /// Loose-files mode checkbox state for vault creation.
262 pub create_loose_files: bool,
263
264 /// Whether the active vault has loose-files mode enabled (read from DB on vault load).
265 pub is_loose_files: bool,
266
267 /// Cached storage statistics from the last scan.
268 pub storage_cache: Option<crate::backend::StorageStats>,
269 /// Unix timestamp (seconds) of the last successful storage scan, used to
270 /// render a "last scanned N minutes ago" label so stale numbers are visible.
271 pub storage_cache_at: Option<i64>,
272 /// Whether a storage scan is in progress.
273 pub storage_scanning: bool,
274 /// Masked license key for display.
275 pub license_key_masked: Option<String>,
276 /// Machine ID for display.
277 pub machine_id: Option<String>,
278 /// Trial days remaining (None if not in trial mode).
279 pub trial_days_remaining: Option<i64>,
280 }
281
282 /// GUI state for the sync setup and panel.
283 pub struct SyncUiState {
284 /// Whether the sync panel overlay is open.
285 pub show_panel: bool,
286 pub encryption_input: String,
287 /// Confirm-password field, only used during first-time encryption setup
288 /// (`!has_server_key`). Gates the Set Password button until it matches
289 /// `encryption_input` — there is no recovery path if the user mistypes the
290 /// password they're about to lock their cloud blob under.
291 pub encryption_confirm_input: String,
292 pub auth_code_input: String,
293 /// API key input for initial setup.
294 pub api_key_input: String,
295 /// Status of the API key test flow.
296 pub setup_status: SyncSetupStatus,
297 /// Set by the UI, consumed by the app layer each frame.
298 pub pending_action: Option<SyncSetupAction>,
299 /// Whether a subscription fetch is in progress.
300 pub subscription_loading: bool,
301 /// When the in-flight subscription fetch was kicked off. Used to time the
302 /// spinner out if the request never resolves — without this, a network
303 /// failure leaves the panel pretending to be busy forever.
304 pub subscription_loading_at: Option<std::time::Instant>,
305 /// Whether a checkout request is in progress.
306 pub checkout_loading: bool,
307 /// When the in-flight checkout was kicked off. Same role as
308 /// `subscription_loading_at` — a closed browser tab or declined card would
309 /// otherwise leave every Subscribe / Change-tier button disabled forever.
310 pub checkout_loading_at: Option<std::time::Instant>,
311 /// Set true by `execute_confirmed_action` when the user confirms a
312 /// DisconnectSync. The sync panel consumes the flag next frame and calls
313 /// `sync.disconnect()`. Decouples the confirm dispatch (which lives in
314 /// `bulk_ops.rs` and has no SyncManager handle) from the sync action.
315 pub pending_disconnect: bool,
316 /// Last URL returned by `sync.start_auth()`, cached so the Authenticating
317 /// screen can offer a Copy URL fallback when the user's browser didn't open.
318 pub auth_url: Option<String>,
319 /// Per-VFS storage stats cache: `(sample_count, total_bytes)` keyed by the
320 /// raw VfsId. Populated when the sync panel opens (on the assumption that
321 /// vault contents don't change while the panel is showing) and rendered as
322 /// a muted sub-label beside each Sync-audio-files checkbox.
323 pub vfs_storage_cache: std::collections::HashMap<i64, (u64, u64)>,
324 /// True once we've populated `vfs_storage_cache` for this panel-open. Reset
325 /// each time `show_panel` transitions to false so reopening the panel gets
326 /// fresh numbers.
327 pub vfs_storage_fetched: bool,
328 /// User's working cap selection on the cap-picker slider, in GiB.
329 /// Persisted across frames so dragging the slider doesn't reset. Defaults
330 /// to 100 GiB the first time the panel renders.
331 pub cap_picker_gib: i64,
332 }
333
334 impl Default for SyncUiState {
335 fn default() -> Self {
336 Self {
337 show_panel: false,
338 encryption_input: String::new(),
339 encryption_confirm_input: String::new(),
340 auth_code_input: String::new(),
341 api_key_input: String::new(),
342 setup_status: SyncSetupStatus::Idle,
343 pending_action: None,
344 subscription_loading: false,
345 subscription_loading_at: None,
346 checkout_loading: false,
347 checkout_loading_at: None,
348 pending_disconnect: false,
349 auth_url: None,
350 vfs_storage_cache: std::collections::HashMap::new(),
351 vfs_storage_fetched: false,
352 cap_picker_gib: 100,
353 }
354 }
355 }
356
357 /// GUI state for the floating sample editor window.
358 pub struct EditUiState {
359 pub show_window: bool,
360 pub hash: Option<String>,
361 pub in_progress: bool,
362 pub result_prompt: bool,
363 pub pending_result: Option<PendingEditResult>,
364 pub trim_start: f32,
365 pub trim_end: f32,
366 pub total_frames: usize,
367 pub gain_db: f64,
368 pub norm_peak: bool,
369 pub norm_target: f64,
370 pub fade_in: bool,
371 pub fade_duration_ms: f64,
372 pub fade_curve: FadeCurve,
373 pub result_mode: Option<EditResultMode>,
374 pub silence_position_ms: f64,
375 pub silence_duration_ms: f64,
376 pub remove_start_ms: f64,
377 pub remove_end_ms: f64,
378 /// C-1 part 2: the most-recent finished edit, while still reversible from
379 /// the panel. Cleared when the affordance times out (>10s) or another
380 /// edit overwrites it. None at startup and after an undo.
381 pub last_undo: Option<EditUndoEntry>,
382 }
383
384 impl Default for EditUiState {
385 fn default() -> Self {
386 Self {
387 show_window: false,
388 hash: None,
389 in_progress: false,
390 result_prompt: false,
391 pending_result: None,
392 trim_start: 0.0,
393 trim_end: 1.0,
394 total_frames: 0,
395 gain_db: 0.0,
396 norm_peak: true,
397 norm_target: -0.1,
398 fade_in: true,
399 fade_duration_ms: 100.0,
400 fade_curve: FadeCurve::Linear,
401 result_mode: None,
402 silence_position_ms: 0.0,
403 silence_duration_ms: 100.0,
404 remove_start_ms: 0.0,
405 remove_end_ms: 100.0,
406 last_undo: None,
407 }
408 }
409 }
410
411 /// Which chop method the forge UI is configured for.
412 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
413 pub enum ChopMode {
414 /// Slice at detected transients.
415 Transient,
416 /// Slice into N equal divisions.
417 Equal,
418 /// Slice on a BPM grid.
419 Bpm,
420 }
421
422 /// GUI-side state for the Sample Forge window (chop / conform / batch).
423 pub struct ForgeUiState {
424 pub show_window: bool,
425 /// Hash of the sample being forged.
426 pub hash: Option<String>,
427 /// Extension of the source sample (for decode path resolution).
428 pub ext: String,
429 /// Display name of the source (used to name slices / conform output).
430 pub name: String,
431 /// Source sample rate, for device conform target selection.
432 pub source_rate: u32,
433 /// Currently selected chop method.
434 pub chop_mode: ChopMode,
435 /// Transient sensitivity, 0..1.
436 pub sensitivity: f32,
437 /// Equal-divisions slice count.
438 pub divisions: usize,
439 /// BPM for grid chop (seeded from analysis when available).
440 pub bpm: f64,
441 /// Subdivisions per beat for grid chop (1 = beats, 2 = eighths, 4 = sixteenths).
442 pub subdivisions: u32,
443 /// Waveform of the sample being forged, captured at open time so the display
444 /// stays bound to `hash` even if the file-list selection changes underneath.
445 pub waveform: Option<audiofiles_core::analysis::waveform::WaveformData>,
446 /// Normalized slice-boundary fractions (0..1) for the waveform overlay; set
447 /// by Preview, cleared when parameters change.
448 pub slice_marks: Vec<f32>,
449 /// True while a chop/conform run is in flight (disables controls).
450 pub busy: bool,
451 /// Selected device profile name for conform (None = no device chosen).
452 pub conform_device: Option<String>,
453 /// Cached device list `(name, format_summary)` for the conform picker,
454 /// populated when the window opens.
455 pub devices: Vec<(String, String)>,
456 /// Threshold (dBFS) for batch trim-silence.
457 pub trim_threshold_db: f64,
458 }
459
460 impl Default for ForgeUiState {
461 fn default() -> Self {
462 Self {
463 show_window: false,
464 hash: None,
465 ext: "wav".to_string(),
466 name: String::new(),
467 source_rate: 44100,
468 chop_mode: ChopMode::Equal,
469 sensitivity: 0.5,
470 divisions: 8,
471 bpm: 120.0,
472 subdivisions: 1,
473 waveform: None,
474 slice_marks: Vec::new(),
475 busy: false,
476 conform_device: None,
477 devices: Vec::new(),
478 trim_threshold_db: -60.0,
479 }
480 }
481 }
482
483 /// Actions the MIDI setup UI can request from the app layer.
484 pub enum MidiAction {
485 /// Connect to the MIDI input port at this index.
486 Connect(usize),
487 /// Disconnect the current MIDI input.
488 Disconnect,
489 /// Re-enumerate available ports.
490 RefreshPorts,
491 }
492
493 /// A recent MIDI note event for the activity display.
494 pub struct MidiNoteEvent {
495 pub note: u8,
496 pub velocity: u8,
497 pub note_name: String,
498 pub timestamp: Instant,
499 }
500
501 /// GUI-side state for the MIDI device picker and activity display.
502 #[derive(Default)]
503 pub struct MidiUiState {
504 pub available_ports: Vec<String>,
505 pub connected_port: Option<usize>,
506 pub connected_port_name: Option<String>,
507 pub recent_notes: Vec<MidiNoteEvent>,
508 }
509
510 /// A top-level imported folder with a user-editable tag input.
511 #[derive(Clone)]
512 pub struct FolderTagEntry {
513 pub folder: ImportedFolder,
514 pub tag_input: String,
515 }
516
517 /// Which long-running operation was cancelled. Drives the acknowledgement
518 /// screen's copy (file-vs-sample noun, destination-folder line, etc.).
519 #[derive(Clone, Copy, PartialEq, Eq)]
520 pub enum CancelKind {
521 Import,
522 Analysis,
523 Export,
524 }
525
526 /// Rolling progress samples for a long-running operation. Used by the
527 /// import / analysis / export progress screens to compute and display a
528 /// throughput rate and estimated time remaining (M-11). Samples are
529 /// deduplicated by `completed` so per-frame repaints don't grow the buffer.
530 pub struct OperationProgress {
531 pub started_at: std::time::Instant,
532 samples: Vec<(std::time::Instant, usize)>,
533 }
534
535 impl OperationProgress {
536 pub fn new() -> Self {
537 Self {
538 started_at: std::time::Instant::now(),
539 samples: Vec::new(),
540 }
541 }
542
543 /// Record the latest `completed` count. No-op if the count hasn't moved.
544 pub fn record(&mut self, completed: usize) {
545 let now = std::time::Instant::now();
546 let should_push = self
547 .samples
548 .last()
549 .map(|(_, c)| *c != completed)
550 .unwrap_or(true);
551 if should_push {
552 self.samples.push((now, completed));
553 }
554 // Keep the last ~10 seconds of samples; older entries drag the rate
555 // away from the present and stale the ETA.
556 self.samples
557 .retain(|(t, _)| now.duration_since(*t).as_secs_f64() < 10.0);
558 }
559
560 /// Items per second over the recent window. `None` until we have enough
561 /// data to predict.
562 pub fn rate(&self) -> Option<f64> {
563 if self.samples.len() < 2 {
564 return None;
565 }
566 let (t0, c0) = *self.samples.first()?;
567 let (t1, c1) = *self.samples.last()?;
568 let dt = t1.duration_since(t0).as_secs_f64();
569 if dt < 0.5 {
570 return None;
571 }
572 let dc = c1.saturating_sub(c0) as f64;
573 if dc <= 0.0 {
574 return None;
575 }
576 Some(dc / dt)
577 }
578
579 /// Human-formatted ETA from current `completed` / `total`. Returns `None`
580 /// when the rate is unknown, the operation is effectively done, or the
581 /// projected wait is short enough that the readout would just churn.
582 pub fn eta(&self, completed: usize, total: usize) -> Option<String> {
583 let rate = self.rate()?;
584 if total <= completed {
585 return None;
586 }
587 let remaining = (total - completed) as f64;
588 let secs = (remaining / rate) as u64;
589 if secs < 5 {
590 return None;
591 }
592 let mins = secs / 60;
593 let s = secs % 60;
594 Some(if mins > 0 {
595 format!("{mins}m {s}s remaining")
596 } else {
597 format!("{s}s remaining")
598 })
599 }
600 }
601
602 impl Default for OperationProgress {
603 fn default() -> Self {
604 Self::new()
605 }
606 }
607
608 /// Sort order for the Review Suggestions sample list (p-3).
609 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
610 pub enum ReviewSort {
611 /// Original import order.
612 ImportOrder,
613 /// Alphabetical by sample name.
614 Name,
615 /// Descending total suggestion count.
616 Suggestions,
617 /// Descending accepted count.
618 Accepted,
619 }
620
621 impl ReviewSort {
622 pub const ALL: &'static [ReviewSort] = &[
623 ReviewSort::ImportOrder,
624 ReviewSort::Name,
625 ReviewSort::Suggestions,
626 ReviewSort::Accepted,
627 ];
628
629 pub fn label(self) -> &'static str {
630 match self {
631 ReviewSort::ImportOrder => "Import order",
632 ReviewSort::Name => "Name",
633 ReviewSort::Suggestions => "Suggestions",
634 ReviewSort::Accepted => "Accepted",
635 }
636 }
637 }
638
639 /// Current import/analysis workflow state.
640 pub enum ImportMode {
641 None,
642 ConfigureImport {
643 source: PathBuf,
644 source_name: String,
645 strategy: crate::import::ImportStrategy,
646 available_vfs: Vec<audiofiles_core::vfs::Vfs>,
647 selected_merge_vfs_idx: usize,
648 new_vfs_name: String,
649 /// Number of audio files found in the source folder (dry-run scan).
650 audio_file_count: usize,
651 },
652 Importing {
653 total: usize,
654 completed: usize,
655 current_name: String,
656 walking: bool,
657 /// Running file count during the walk phase (m-12). Zero once
658 /// `walking` flips to false — use `total` thereafter.
659 walking_count: usize,
660 total_bytes: u64,
661 loose_files: bool,
662 },
663 TagFolders {
664 entries: Vec<FolderTagEntry>,
665 sample_hashes: Vec<(String, String)>,
666 },
667 ConfigureAnalysis {
668 sample_hashes: Vec<(String, String)>,
669 config: audiofiles_core::analysis::config::AnalysisConfig,
670 },
671 Analyzing {
672 completed: usize,
673 total: usize,
674 current_name: String,
675 },
676 ReviewSuggestions {
677 items: Vec<ReviewItem>,
678 current_idx: usize,
679 /// p-3: how the sample list in the side panel is ordered. Doesn't
680 /// reorder `items` — the screen renders through an index map so
681 /// `current_idx` keeps pointing at the underlying item.
682 sort: ReviewSort,
683 },
684 ConfigureExport {
685 items: Vec<audiofiles_core::export::ExportItem>,
686 config: audiofiles_core::export::ExportConfig,
687 /// Available device profiles for the profile picker.
688 available_profiles: Vec<audiofiles_core::export::profile::DeviceProfileSummary>,
689 },
690 Exporting {
691 completed: usize,
692 total: usize,
693 current_name: String,
694 },
695 Cleaning {
696 completed: usize,
697 total: usize,
698 current_name: String,
699 },
700 ExportComplete {
701 total: usize,
702 errors: Vec<(String, String)>,
703 },
704 ReviewErrors,
705 /// Acknowledgement screen after the user cancels a long-running operation.
706 /// Surfaces what landed vs what was discarded so the user isn't left to
707 /// guess whether to re-run, restore, or move on. `destination` is set only
708 /// for export — names the folder where partial files may sit.
709 OperationCancelled {
710 kind: CancelKind,
711 completed: usize,
712 total: usize,
713 destination: Option<PathBuf>,
714 },
715 }
716
717 /// A sample with its analysis results and pending tag suggestions.
718 pub struct ReviewItem {
719 pub hash: audiofiles_core::SampleHash,
720 pub name: String,
721 pub result: audiofiles_core::analysis::AnalysisResult,
722 pub suggestions: Vec<SuggestionState>,
723 }
724
725 /// A tag suggestion the user can accept or reject.
726 pub struct SuggestionState {
727 pub suggestion: audiofiles_core::analysis::suggest::TagSuggestion,
728 pub accepted: bool,
729 }
730
731 // --- Sort ---
732
733 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
734 pub enum SortColumn {
735 Name,
736 Bpm,
737 Key,
738 Duration,
739 Classification,
740 }
741
742 #[derive(Debug, Clone, PartialEq, Eq)]
743 pub enum SortDirection {
744 Ascending,
745 Descending,
746 }
747
748 // --- Selection ---
749
750 /// Multi-selection state with anchor-based range selection.
751 ///
752 /// Three indices work together: `anchor` is where a shift-click range starts,
753 /// `focus` tracks the most recently interacted-with row (the cursor),
754 /// and `selected` is the full set of selected row indices. A plain click
755 /// sets all three to the same row; shift-click extends from anchor to the
756 /// clicked row; cmd-click toggles one row without moving the anchor.
757 #[derive(Debug, Clone, Default)]
758 pub struct Selection {
759 /// The anchor point for shift-click range selection.
760 pub anchor: usize,
761 /// The focused (most recently selected) item.
762 pub focus: usize,
763 /// Set of selected indices.
764 pub selected: HashSet<usize>,
765 }
766
767 impl Selection {
768 /// Create an empty selection with anchor and focus at index 0.
769 pub fn new() -> Self {
770 Self::default()
771 }
772
773 /// Clear the selection and reset anchor/focus to 0.
774 pub fn clear(&mut self) {
775 self.selected.clear();
776 self.anchor = 0;
777 self.focus = 0;
778 }
779
780 /// Single-select one item, clearing all others.
781 pub fn set_single(&mut self, idx: usize) {
782 self.selected.clear();
783 self.selected.insert(idx);
784 self.anchor = idx;
785 self.focus = idx;
786 }
787
788 /// Toggle an item in the selection (Cmd+Click).
789 pub fn toggle(&mut self, idx: usize) {
790 if self.selected.contains(&idx) {
791 self.selected.remove(&idx);
792 } else {
793 self.selected.insert(idx);
794 }
795 self.anchor = idx;
796 self.focus = idx;
797 }
798
799 /// Extend selection from anchor to target (Shift+Click).
800 /// `_max_len` is accepted for API consistency but unused — the range is
801 /// always anchor..=target regardless of list length.
802 pub fn extend_to(&mut self, target: usize, _max_len: usize) {
803 let start = self.anchor.min(target);
804 let end = self.anchor.max(target);
805 for i in start..=end {
806 self.selected.insert(i);
807 }
808 self.focus = target;
809 }
810
811 /// Extend selection down by one (Shift+Down).
812 pub fn extend_down(&mut self, max_len: usize) {
813 if max_len > 0 && self.focus < max_len - 1 {
814 self.focus += 1;
815 self.selected.insert(self.focus);
816 }
817 }
818
819 /// Extend selection up by one (Shift+Up).
820 pub fn extend_up(&mut self) {
821 if self.focus > 0 {
822 self.focus -= 1;
823 self.selected.insert(self.focus);
824 }
825 }
826
827 /// Select all items.
828 pub fn select_all(&mut self, len: usize) {
829 self.select_all_from(0, len);
830 }
831
832 /// Select every item in `start..len`. Used so Cmd+A on a list with a
833 /// ".." parent entry can skip index 0 — the parent isn't a sample and
834 /// must never become part of a bulk operation.
835 pub fn select_all_from(&mut self, start: usize, len: usize) {
836 self.selected.clear();
837 for i in start..len {
838 self.selected.insert(i);
839 }
840 if len > start {
841 self.anchor = start;
842 self.focus = len - 1;
843 }
844 }
845
846 /// Invert the selection over `0..len`. Indices currently selected become
847 /// unselected; previously unselected indices become selected.
848 pub fn invert(&mut self, len: usize) {
849 let new_selected: std::collections::HashSet<usize> =
850 (0..len).filter(|i| !self.selected.contains(i)).collect();
851 if let Some(&first) = new_selected.iter().min() {
852 self.anchor = first;
853 self.focus = first;
854 }
855 self.selected = new_selected;
856 }
857
858 /// Check whether `idx` is in the current selection.
859 pub fn contains(&self, idx: usize) -> bool {
860 self.selected.contains(&idx)
861 }
862
863 /// Number of currently selected items.
864 pub fn count(&self) -> usize {
865 self.selected.len()
866 }
867 }
868