//! UI state: selection, sort, and UI-specific type definitions. use std::collections::HashSet; use std::path::PathBuf; use std::time::Instant; use audiofiles_core::edit::{EditOperation, FadeCurve}; use audiofiles_core::vfs::VfsNode; use audiofiles_core::{NodeId, VfsId}; use crate::import::ImportedFolder; /// User preference for how to handle edit results. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditResultMode { /// Replace the VFS node in-place with the edited sample. Replace, /// Create a sibling node next to the original. Sibling, } /// Data from a completed edit, pending user choice (replace vs sibling). pub struct PendingEditResult { pub source_hash: String, pub result_path: PathBuf, pub operation: EditOperation, } /// Snapshot of a just-applied edit, used by `BrowserState::undo_last_edit` /// to reverse the VFS-level work after `finalize_edit` (C-1 part 2). /// /// We don't need to snapshot the audio data itself — the content store is /// content-addressed, so the original `source_hash` blob is preserved when /// Replace mode re-points VFS nodes. Undo only needs to walk the VFS work /// back, plus drop the matching `edit_history` row. pub struct EditUndoEntry { /// Display name of the operation (e.g. "Trim", "Reverse"). pub op_name: String, pub source_hash: String, pub result_hash: String, pub mode: EditResultMode, /// VFS the edit was applied in. pub vfs_id: VfsId, /// Pre-edit node positions for Replace mode: `(parent_id, name)`. After /// undo we recreate one sample link per entry pointing back at /// `source_hash`. Empty for Sibling mode. pub replace_targets: Vec<(Option, String)>, /// For Sibling mode: the result-side node id to delete. None for Replace. pub sibling_node_id: Option, /// When the edit landed — drives the inline affordance's fade-out. pub created_at: Instant, } /// Pending destructive action awaiting user confirmation. pub enum ConfirmAction { DeleteNode { node_id: NodeId, node_name: String }, DeleteVfs { vfs_id: VfsId, vfs_name: String }, DeleteMultiple { node_ids: Vec, count: usize }, DeleteCollection { coll_id: audiofiles_core::CollectionId, coll_name: String }, /// Remove a tag from every sample that carries it. RemoveTagGlobally { tag: String }, /// Switch to a different library while in-flight work would be interrupted. /// Non-destructive: confirm label is "Switch", no danger styling. SwitchLibrary { path: PathBuf, library_name: String }, /// Re-analyze selected samples when one or more already has computed values /// (BPM / Key / classification). Confirming opens the ConfigureAnalysis /// wizard; cancelling drops the operation. ReanalyzeOverwrite { sample_hashes: Vec<(String, String)>, overwrite_count: usize, }, /// Disconnect from cloud sync. Destructive because reconnecting requires /// re-entering the encryption password — a typo there would leave the cloud /// blob unreadable. `pending_changes` is surfaced in the detail line so the /// user knows whether unsynced work is at stake. DisconnectSync { pending_changes: i64 }, /// Permanently remove analysis-failed samples from the content store. The /// post-import error review surfaces these with per-row and bulk delete /// buttons; both gate through this variant so a stray click can't purge /// recoverable files (codec missing, transient read error, etc.). /// `single_index` distinguishes the per-row case (specific name in `name`) /// from the bulk "Remove All Failed" case (None). RemoveFailedSamples { single_index: Option, count: usize, name: Option, }, /// Batch Reverse on a large selection. Single-sample Reverse is its own /// undo (click again); batch Reverse on N samples is easy to fire by /// accident and tedious to walk back. Gated at the call site when the /// selection size exceeds the threshold (10). ReverseSamples { count: usize }, } /// An undoable bulk operation. pub enum UndoOp { BulkDelete { nodes: Vec, tags: Vec<(String, Vec)>, }, BulkMove { moves: Vec<(NodeId, Option)>, }, BulkRename { renames: Vec<(NodeId, String)>, }, BulkTagAdd { tag: String, hashes: Vec, }, BulkTagRemove { tag: String, hashes: Vec, }, /// Single-sample inline tag removal (from the detail panel's tag chip "x"). /// Restoring re-adds the tag to the same sample. TagRemove { hash: String, tag: String, }, } /// Active bulk operation modal. pub enum BulkModal { /// Bulk add or remove a tag from selected samples. Tag { /// The tag string being entered by the user. tag_input: String, /// `true` for add, `false` for remove. adding: bool, /// Content-addressed hashes of the targeted samples. hashes: Vec, /// Display names of the targeted samples. names: Vec, }, /// Bulk move selected nodes to a different directory. Move { /// IDs of the VFS nodes being moved. node_ids: Vec, /// Display names of the nodes being moved. names: Vec, /// All available target directories as `(id, full_path)` pairs. directories: Vec<(NodeId, String)>, /// Index into `directories` the user has chosen, or `None` for root. selected_idx: Option, }, /// Bulk rename selected nodes using a pattern template. Rename { /// The rename pattern string (e.g. `"{name}_{bpm}"`). pattern_input: String, /// The nodes targeted for rename, with their analysis context. targets: Vec, /// Live preview pairs of `(old_name, new_name)`. previews: Vec<(String, String)>, /// Validation error, if any (empty names, duplicates, bad pattern). error: Option, }, } /// A rename target with its context for preview. pub struct RenameTarget { pub node_id: NodeId, pub context: audiofiles_core::rename::RenameContext, } /// Column visibility configuration. #[derive(serde::Serialize, serde::Deserialize)] pub struct ColumnConfig { pub show_classification: bool, pub show_bpm: bool, pub show_key: bool, pub show_duration: bool, pub show_peak_db: bool, pub show_tags: bool, } impl Default for ColumnConfig { fn default() -> Self { Self { show_classification: true, show_bpm: true, show_key: true, show_duration: true, show_peak_db: false, show_tags: false, } } } /// A file that failed during the import phase (before entering the store). pub struct ImportFileError { pub path: String, pub error: String, } /// A file that entered the store but failed during analysis. pub struct AnalysisFileError { pub hash: String, pub name: String, pub error: String, } /// Status of the sync API key test flow. pub enum SyncSetupStatus { /// No test in progress. Idle, /// Validation request in flight. Testing, /// Key is valid; server returned the app name. Valid { app_name: String }, /// Key is invalid or server unreachable. Failed { error: String }, } /// Actions the sync setup UI can request from the app layer. pub enum SyncSetupAction { /// Validate this API key against the server. TestKey(String), /// Save this API key and create a SyncManager. SaveKey(String), } /// Actions the vault picker UI can request from the app layer. pub enum VaultAction { /// Switch to a different vault. SwitchVault(PathBuf), /// Create a new vault and switch to it. CreateVault { name: String, path: PathBuf, loose_files: bool }, /// Add an existing vault directory to the registry. AddExistingVault { name: String, path: PathBuf }, /// Remove a vault from the registry (no file deletion). RemoveVault(PathBuf), /// Rename a vault in the registry. RenameVault { path: PathBuf, new_name: String }, /// Repoint an offline vault's registry entry to a new directory. RelocateVault { old_path: PathBuf, new_path: PathBuf }, /// Scan storage stats for the active vault. ScanStorage, /// Deactivate the license key and return to activation screen. DeactivateLicense, } /// GUI state for the consolidated Settings window. #[derive(Default)] pub struct SettingsUiState { /// Display name of the active vault. pub name: String, /// (name, path, reachable) for each known vault. pub list: Vec<(String, PathBuf, bool)>, /// Set by the UI, consumed by the app layer each frame. pub pending_action: Option, /// Whether the Settings window is open. pub show_manager: bool, /// Name input for creating/adding a vault. pub create_name: String, /// Path input for creating/adding a vault. pub create_path: Option, /// Inline rename: (path, new_name_buffer). pub rename_target: Option<(PathBuf, String)>, /// Loose-files mode checkbox state for vault creation. pub create_loose_files: bool, /// Whether the active vault has loose-files mode enabled (read from DB on vault load). pub is_loose_files: bool, /// Cached storage statistics from the last scan. pub storage_cache: Option, /// Unix timestamp (seconds) of the last successful storage scan, used to /// render a "last scanned N minutes ago" label so stale numbers are visible. pub storage_cache_at: Option, /// Whether a storage scan is in progress. pub storage_scanning: bool, /// Masked license key for display. pub license_key_masked: Option, /// Machine ID for display. pub machine_id: Option, /// Trial days remaining (None if not in trial mode). pub trial_days_remaining: Option, } /// GUI state for the sync setup and panel. pub struct SyncUiState { /// Whether the sync panel overlay is open. pub show_panel: bool, pub encryption_input: String, /// Confirm-password field, only used during first-time encryption setup /// (`!has_server_key`). Gates the Set Password button until it matches /// `encryption_input` — there is no recovery path if the user mistypes the /// password they're about to lock their cloud blob under. pub encryption_confirm_input: String, pub auth_code_input: String, /// API key input for initial setup. pub api_key_input: String, /// Status of the API key test flow. pub setup_status: SyncSetupStatus, /// Set by the UI, consumed by the app layer each frame. pub pending_action: Option, /// Whether a subscription fetch is in progress. pub subscription_loading: bool, /// When the in-flight subscription fetch was kicked off. Used to time the /// spinner out if the request never resolves — without this, a network /// failure leaves the panel pretending to be busy forever. pub subscription_loading_at: Option, /// Whether a checkout request is in progress. pub checkout_loading: bool, /// When the in-flight checkout was kicked off. Same role as /// `subscription_loading_at` — a closed browser tab or declined card would /// otherwise leave every Subscribe / Change-tier button disabled forever. pub checkout_loading_at: Option, /// Set true by `execute_confirmed_action` when the user confirms a /// DisconnectSync. The sync panel consumes the flag next frame and calls /// `sync.disconnect()`. Decouples the confirm dispatch (which lives in /// `bulk_ops.rs` and has no SyncManager handle) from the sync action. pub pending_disconnect: bool, /// Last URL returned by `sync.start_auth()`, cached so the Authenticating /// screen can offer a Copy URL fallback when the user's browser didn't open. pub auth_url: Option, /// Per-VFS storage stats cache: `(sample_count, total_bytes)` keyed by the /// raw VfsId. Populated when the sync panel opens (on the assumption that /// vault contents don't change while the panel is showing) and rendered as /// a muted sub-label beside each Sync-audio-files checkbox. pub vfs_storage_cache: std::collections::HashMap, /// True once we've populated `vfs_storage_cache` for this panel-open. Reset /// each time `show_panel` transitions to false so reopening the panel gets /// fresh numbers. pub vfs_storage_fetched: bool, /// User's working cap selection on the cap-picker slider, in GiB. /// Persisted across frames so dragging the slider doesn't reset. Defaults /// to 100 GiB the first time the panel renders. pub cap_picker_gib: i64, } impl Default for SyncUiState { fn default() -> Self { Self { show_panel: false, encryption_input: String::new(), encryption_confirm_input: String::new(), auth_code_input: String::new(), api_key_input: String::new(), setup_status: SyncSetupStatus::Idle, pending_action: None, subscription_loading: false, subscription_loading_at: None, checkout_loading: false, checkout_loading_at: None, pending_disconnect: false, auth_url: None, vfs_storage_cache: std::collections::HashMap::new(), vfs_storage_fetched: false, cap_picker_gib: 100, } } } /// GUI state for the floating sample editor window. pub struct EditUiState { pub show_window: bool, pub hash: Option, pub in_progress: bool, pub result_prompt: bool, pub pending_result: Option, pub trim_start: f32, pub trim_end: f32, pub total_frames: usize, pub gain_db: f64, pub norm_peak: bool, pub norm_target: f64, pub fade_in: bool, pub fade_duration_ms: f64, pub fade_curve: FadeCurve, pub result_mode: Option, pub silence_position_ms: f64, pub silence_duration_ms: f64, pub remove_start_ms: f64, pub remove_end_ms: f64, /// C-1 part 2: the most-recent finished edit, while still reversible from /// the panel. Cleared when the affordance times out (>10s) or another /// edit overwrites it. None at startup and after an undo. pub last_undo: Option, } impl Default for EditUiState { fn default() -> Self { Self { show_window: false, hash: None, in_progress: false, result_prompt: false, pending_result: None, trim_start: 0.0, trim_end: 1.0, total_frames: 0, gain_db: 0.0, norm_peak: true, norm_target: -0.1, fade_in: true, fade_duration_ms: 100.0, fade_curve: FadeCurve::Linear, result_mode: None, silence_position_ms: 0.0, silence_duration_ms: 100.0, remove_start_ms: 0.0, remove_end_ms: 100.0, last_undo: None, } } } /// Which chop method the forge UI is configured for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChopMode { /// Slice at detected transients. Transient, /// Slice into N equal divisions. Equal, /// Slice on a BPM grid. Bpm, } /// GUI-side state for the Sample Forge window (chop / conform / batch). pub struct ForgeUiState { pub show_window: bool, /// Hash of the sample being forged. pub hash: Option, /// Extension of the source sample (for decode path resolution). pub ext: String, /// Display name of the source (used to name slices / conform output). pub name: String, /// Source sample rate, for device conform target selection. pub source_rate: u32, /// Currently selected chop method. pub chop_mode: ChopMode, /// Transient sensitivity, 0..1. pub sensitivity: f32, /// Equal-divisions slice count. pub divisions: usize, /// BPM for grid chop (seeded from analysis when available). pub bpm: f64, /// Subdivisions per beat for grid chop (1 = beats, 2 = eighths, 4 = sixteenths). pub subdivisions: u32, /// Waveform of the sample being forged, captured at open time so the display /// stays bound to `hash` even if the file-list selection changes underneath. pub waveform: Option, /// Normalized slice-boundary fractions (0..1) for the waveform overlay; set /// by Preview, cleared when parameters change. pub slice_marks: Vec, /// True while a chop/conform run is in flight (disables controls). pub busy: bool, /// Selected device profile name for conform (None = no device chosen). pub conform_device: Option, /// Cached device list `(name, format_summary)` for the conform picker, /// populated when the window opens. pub devices: Vec<(String, String)>, /// Threshold (dBFS) for batch trim-silence. pub trim_threshold_db: f64, } impl Default for ForgeUiState { fn default() -> Self { Self { show_window: false, hash: None, ext: "wav".to_string(), name: String::new(), source_rate: 44100, chop_mode: ChopMode::Equal, sensitivity: 0.5, divisions: 8, bpm: 120.0, subdivisions: 1, waveform: None, slice_marks: Vec::new(), busy: false, conform_device: None, devices: Vec::new(), trim_threshold_db: -60.0, } } } /// Actions the MIDI setup UI can request from the app layer. pub enum MidiAction { /// Connect to the MIDI input port at this index. Connect(usize), /// Disconnect the current MIDI input. Disconnect, /// Re-enumerate available ports. RefreshPorts, } /// A recent MIDI note event for the activity display. pub struct MidiNoteEvent { pub note: u8, pub velocity: u8, pub note_name: String, pub timestamp: Instant, } /// GUI-side state for the MIDI device picker and activity display. #[derive(Default)] pub struct MidiUiState { pub available_ports: Vec, pub connected_port: Option, pub connected_port_name: Option, pub recent_notes: Vec, } /// A top-level imported folder with a user-editable tag input. #[derive(Clone)] pub struct FolderTagEntry { pub folder: ImportedFolder, pub tag_input: String, } /// Which long-running operation was cancelled. Drives the acknowledgement /// screen's copy (file-vs-sample noun, destination-folder line, etc.). #[derive(Clone, Copy, PartialEq, Eq)] pub enum CancelKind { Import, Analysis, Export, } /// Rolling progress samples for a long-running operation. Used by the /// import / analysis / export progress screens to compute and display a /// throughput rate and estimated time remaining (M-11). Samples are /// deduplicated by `completed` so per-frame repaints don't grow the buffer. pub struct OperationProgress { pub started_at: std::time::Instant, samples: Vec<(std::time::Instant, usize)>, } impl OperationProgress { pub fn new() -> Self { Self { started_at: std::time::Instant::now(), samples: Vec::new(), } } /// Record the latest `completed` count. No-op if the count hasn't moved. pub fn record(&mut self, completed: usize) { let now = std::time::Instant::now(); let should_push = self .samples .last() .map(|(_, c)| *c != completed) .unwrap_or(true); if should_push { self.samples.push((now, completed)); } // Keep the last ~10 seconds of samples; older entries drag the rate // away from the present and stale the ETA. self.samples .retain(|(t, _)| now.duration_since(*t).as_secs_f64() < 10.0); } /// Items per second over the recent window. `None` until we have enough /// data to predict. pub fn rate(&self) -> Option { if self.samples.len() < 2 { return None; } let (t0, c0) = *self.samples.first()?; let (t1, c1) = *self.samples.last()?; let dt = t1.duration_since(t0).as_secs_f64(); if dt < 0.5 { return None; } let dc = c1.saturating_sub(c0) as f64; if dc <= 0.0 { return None; } Some(dc / dt) } /// Human-formatted ETA from current `completed` / `total`. Returns `None` /// when the rate is unknown, the operation is effectively done, or the /// projected wait is short enough that the readout would just churn. pub fn eta(&self, completed: usize, total: usize) -> Option { let rate = self.rate()?; if total <= completed { return None; } let remaining = (total - completed) as f64; let secs = (remaining / rate) as u64; if secs < 5 { return None; } let mins = secs / 60; let s = secs % 60; Some(if mins > 0 { format!("{mins}m {s}s remaining") } else { format!("{s}s remaining") }) } } impl Default for OperationProgress { fn default() -> Self { Self::new() } } /// Sort order for the Review Suggestions sample list (p-3). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReviewSort { /// Original import order. ImportOrder, /// Alphabetical by sample name. Name, /// Descending total suggestion count. Suggestions, /// Descending accepted count. Accepted, } impl ReviewSort { pub const ALL: &'static [ReviewSort] = &[ ReviewSort::ImportOrder, ReviewSort::Name, ReviewSort::Suggestions, ReviewSort::Accepted, ]; pub fn label(self) -> &'static str { match self { ReviewSort::ImportOrder => "Import order", ReviewSort::Name => "Name", ReviewSort::Suggestions => "Suggestions", ReviewSort::Accepted => "Accepted", } } } /// Current import/analysis workflow state. pub enum ImportMode { None, ConfigureImport { source: PathBuf, source_name: String, strategy: crate::import::ImportStrategy, available_vfs: Vec, selected_merge_vfs_idx: usize, new_vfs_name: String, /// Number of audio files found in the source folder (dry-run scan). audio_file_count: usize, }, Importing { total: usize, completed: usize, current_name: String, walking: bool, /// Running file count during the walk phase (m-12). Zero once /// `walking` flips to false — use `total` thereafter. walking_count: usize, total_bytes: u64, loose_files: bool, }, TagFolders { entries: Vec, sample_hashes: Vec<(String, String)>, }, ConfigureAnalysis { sample_hashes: Vec<(String, String)>, config: audiofiles_core::analysis::config::AnalysisConfig, }, Analyzing { completed: usize, total: usize, current_name: String, }, ReviewSuggestions { items: Vec, current_idx: usize, /// p-3: how the sample list in the side panel is ordered. Doesn't /// reorder `items` — the screen renders through an index map so /// `current_idx` keeps pointing at the underlying item. sort: ReviewSort, }, ConfigureExport { items: Vec, config: audiofiles_core::export::ExportConfig, /// Available device profiles for the profile picker. available_profiles: Vec, }, Exporting { completed: usize, total: usize, current_name: String, }, Cleaning { completed: usize, total: usize, current_name: String, }, ExportComplete { total: usize, errors: Vec<(String, String)>, }, ReviewErrors, /// Acknowledgement screen after the user cancels a long-running operation. /// Surfaces what landed vs what was discarded so the user isn't left to /// guess whether to re-run, restore, or move on. `destination` is set only /// for export — names the folder where partial files may sit. OperationCancelled { kind: CancelKind, completed: usize, total: usize, destination: Option, }, } /// A sample with its analysis results and pending tag suggestions. pub struct ReviewItem { pub hash: audiofiles_core::SampleHash, pub name: String, pub result: audiofiles_core::analysis::AnalysisResult, pub suggestions: Vec, } /// A tag suggestion the user can accept or reject. pub struct SuggestionState { pub suggestion: audiofiles_core::analysis::suggest::TagSuggestion, pub accepted: bool, } // --- Sort --- #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortColumn { Name, Bpm, Key, Duration, Classification, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SortDirection { Ascending, Descending, } // --- Selection --- /// Multi-selection state with anchor-based range selection. /// /// Three indices work together: `anchor` is where a shift-click range starts, /// `focus` tracks the most recently interacted-with row (the cursor), /// and `selected` is the full set of selected row indices. A plain click /// sets all three to the same row; shift-click extends from anchor to the /// clicked row; cmd-click toggles one row without moving the anchor. #[derive(Debug, Clone, Default)] pub struct Selection { /// The anchor point for shift-click range selection. pub anchor: usize, /// The focused (most recently selected) item. pub focus: usize, /// Set of selected indices. pub selected: HashSet, } impl Selection { /// Create an empty selection with anchor and focus at index 0. pub fn new() -> Self { Self::default() } /// Clear the selection and reset anchor/focus to 0. pub fn clear(&mut self) { self.selected.clear(); self.anchor = 0; self.focus = 0; } /// Single-select one item, clearing all others. pub fn set_single(&mut self, idx: usize) { self.selected.clear(); self.selected.insert(idx); self.anchor = idx; self.focus = idx; } /// Toggle an item in the selection (Cmd+Click). pub fn toggle(&mut self, idx: usize) { if self.selected.contains(&idx) { self.selected.remove(&idx); } else { self.selected.insert(idx); } self.anchor = idx; self.focus = idx; } /// Extend selection from anchor to target (Shift+Click). /// `_max_len` is accepted for API consistency but unused — the range is /// always anchor..=target regardless of list length. pub fn extend_to(&mut self, target: usize, _max_len: usize) { let start = self.anchor.min(target); let end = self.anchor.max(target); for i in start..=end { self.selected.insert(i); } self.focus = target; } /// Extend selection down by one (Shift+Down). pub fn extend_down(&mut self, max_len: usize) { if max_len > 0 && self.focus < max_len - 1 { self.focus += 1; self.selected.insert(self.focus); } } /// Extend selection up by one (Shift+Up). pub fn extend_up(&mut self) { if self.focus > 0 { self.focus -= 1; self.selected.insert(self.focus); } } /// Select all items. pub fn select_all(&mut self, len: usize) { self.select_all_from(0, len); } /// Select every item in `start..len`. Used so Cmd+A on a list with a /// ".." parent entry can skip index 0 — the parent isn't a sample and /// must never become part of a bulk operation. pub fn select_all_from(&mut self, start: usize, len: usize) { self.selected.clear(); for i in start..len { self.selected.insert(i); } if len > start { self.anchor = start; self.focus = len - 1; } } /// Invert the selection over `0..len`. Indices currently selected become /// unselected; previously unselected indices become selected. pub fn invert(&mut self, len: usize) { let new_selected: std::collections::HashSet = (0..len).filter(|i| !self.selected.contains(i)).collect(); if let Some(&first) = new_selected.iter().min() { self.anchor = first; self.focus = first; } self.selected = new_selected; } /// Check whether `idx` is in the current selection. pub fn contains(&self, idx: usize) -> bool { self.selected.contains(&idx) } /// Number of currently selected items. pub fn count(&self) -> usize { self.selected.len() } }