Skip to main content

max / audiofiles

26.5 KB · 796 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 /// Actions the MIDI setup UI can request from the app layer.
412 pub enum MidiAction {
413 /// Connect to the MIDI input port at this index.
414 Connect(usize),
415 /// Disconnect the current MIDI input.
416 Disconnect,
417 /// Re-enumerate available ports.
418 RefreshPorts,
419 }
420
421 /// A recent MIDI note event for the activity display.
422 pub struct MidiNoteEvent {
423 pub note: u8,
424 pub velocity: u8,
425 pub note_name: String,
426 pub timestamp: Instant,
427 }
428
429 /// GUI-side state for the MIDI device picker and activity display.
430 #[derive(Default)]
431 pub struct MidiUiState {
432 pub available_ports: Vec<String>,
433 pub connected_port: Option<usize>,
434 pub connected_port_name: Option<String>,
435 pub recent_notes: Vec<MidiNoteEvent>,
436 }
437
438 /// A top-level imported folder with a user-editable tag input.
439 #[derive(Clone)]
440 pub struct FolderTagEntry {
441 pub folder: ImportedFolder,
442 pub tag_input: String,
443 }
444
445 /// Which long-running operation was cancelled. Drives the acknowledgement
446 /// screen's copy (file-vs-sample noun, destination-folder line, etc.).
447 #[derive(Clone, Copy, PartialEq, Eq)]
448 pub enum CancelKind {
449 Import,
450 Analysis,
451 Export,
452 }
453
454 /// Rolling progress samples for a long-running operation. Used by the
455 /// import / analysis / export progress screens to compute and display a
456 /// throughput rate and estimated time remaining (M-11). Samples are
457 /// deduplicated by `completed` so per-frame repaints don't grow the buffer.
458 pub struct OperationProgress {
459 pub started_at: std::time::Instant,
460 samples: Vec<(std::time::Instant, usize)>,
461 }
462
463 impl OperationProgress {
464 pub fn new() -> Self {
465 Self {
466 started_at: std::time::Instant::now(),
467 samples: Vec::new(),
468 }
469 }
470
471 /// Record the latest `completed` count. No-op if the count hasn't moved.
472 pub fn record(&mut self, completed: usize) {
473 let now = std::time::Instant::now();
474 let should_push = self
475 .samples
476 .last()
477 .map(|(_, c)| *c != completed)
478 .unwrap_or(true);
479 if should_push {
480 self.samples.push((now, completed));
481 }
482 // Keep the last ~10 seconds of samples; older entries drag the rate
483 // away from the present and stale the ETA.
484 self.samples
485 .retain(|(t, _)| now.duration_since(*t).as_secs_f64() < 10.0);
486 }
487
488 /// Items per second over the recent window. `None` until we have enough
489 /// data to predict.
490 pub fn rate(&self) -> Option<f64> {
491 if self.samples.len() < 2 {
492 return None;
493 }
494 let (t0, c0) = *self.samples.first()?;
495 let (t1, c1) = *self.samples.last()?;
496 let dt = t1.duration_since(t0).as_secs_f64();
497 if dt < 0.5 {
498 return None;
499 }
500 let dc = c1.saturating_sub(c0) as f64;
501 if dc <= 0.0 {
502 return None;
503 }
504 Some(dc / dt)
505 }
506
507 /// Human-formatted ETA from current `completed` / `total`. Returns `None`
508 /// when the rate is unknown, the operation is effectively done, or the
509 /// projected wait is short enough that the readout would just churn.
510 pub fn eta(&self, completed: usize, total: usize) -> Option<String> {
511 let rate = self.rate()?;
512 if total <= completed {
513 return None;
514 }
515 let remaining = (total - completed) as f64;
516 let secs = (remaining / rate) as u64;
517 if secs < 5 {
518 return None;
519 }
520 let mins = secs / 60;
521 let s = secs % 60;
522 Some(if mins > 0 {
523 format!("{mins}m {s}s remaining")
524 } else {
525 format!("{s}s remaining")
526 })
527 }
528 }
529
530 impl Default for OperationProgress {
531 fn default() -> Self {
532 Self::new()
533 }
534 }
535
536 /// Sort order for the Review Suggestions sample list (p-3).
537 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
538 pub enum ReviewSort {
539 /// Original import order.
540 ImportOrder,
541 /// Alphabetical by sample name.
542 Name,
543 /// Descending total suggestion count.
544 Suggestions,
545 /// Descending accepted count.
546 Accepted,
547 }
548
549 impl ReviewSort {
550 pub const ALL: &'static [ReviewSort] = &[
551 ReviewSort::ImportOrder,
552 ReviewSort::Name,
553 ReviewSort::Suggestions,
554 ReviewSort::Accepted,
555 ];
556
557 pub fn label(self) -> &'static str {
558 match self {
559 ReviewSort::ImportOrder => "Import order",
560 ReviewSort::Name => "Name",
561 ReviewSort::Suggestions => "Suggestions",
562 ReviewSort::Accepted => "Accepted",
563 }
564 }
565 }
566
567 /// Current import/analysis workflow state.
568 pub enum ImportMode {
569 None,
570 ConfigureImport {
571 source: PathBuf,
572 source_name: String,
573 strategy: crate::import::ImportStrategy,
574 available_vfs: Vec<audiofiles_core::vfs::Vfs>,
575 selected_merge_vfs_idx: usize,
576 new_vfs_name: String,
577 /// Number of audio files found in the source folder (dry-run scan).
578 audio_file_count: usize,
579 },
580 Importing {
581 total: usize,
582 completed: usize,
583 current_name: String,
584 walking: bool,
585 /// Running file count during the walk phase (m-12). Zero once
586 /// `walking` flips to false — use `total` thereafter.
587 walking_count: usize,
588 total_bytes: u64,
589 loose_files: bool,
590 },
591 TagFolders {
592 entries: Vec<FolderTagEntry>,
593 sample_hashes: Vec<(String, String)>,
594 },
595 ConfigureAnalysis {
596 sample_hashes: Vec<(String, String)>,
597 config: audiofiles_core::analysis::config::AnalysisConfig,
598 },
599 Analyzing {
600 completed: usize,
601 total: usize,
602 current_name: String,
603 },
604 ReviewSuggestions {
605 items: Vec<ReviewItem>,
606 current_idx: usize,
607 /// p-3: how the sample list in the side panel is ordered. Doesn't
608 /// reorder `items` — the screen renders through an index map so
609 /// `current_idx` keeps pointing at the underlying item.
610 sort: ReviewSort,
611 },
612 ConfigureExport {
613 items: Vec<audiofiles_core::export::ExportItem>,
614 config: audiofiles_core::export::ExportConfig,
615 /// Available device profiles for the profile picker.
616 available_profiles: Vec<audiofiles_core::export::profile::DeviceProfileSummary>,
617 },
618 Exporting {
619 completed: usize,
620 total: usize,
621 current_name: String,
622 },
623 Cleaning {
624 completed: usize,
625 total: usize,
626 current_name: String,
627 },
628 ExportComplete {
629 total: usize,
630 errors: Vec<(String, String)>,
631 },
632 ReviewErrors,
633 /// Acknowledgement screen after the user cancels a long-running operation.
634 /// Surfaces what landed vs what was discarded so the user isn't left to
635 /// guess whether to re-run, restore, or move on. `destination` is set only
636 /// for export — names the folder where partial files may sit.
637 OperationCancelled {
638 kind: CancelKind,
639 completed: usize,
640 total: usize,
641 destination: Option<PathBuf>,
642 },
643 }
644
645 /// A sample with its analysis results and pending tag suggestions.
646 pub struct ReviewItem {
647 pub hash: audiofiles_core::SampleHash,
648 pub name: String,
649 pub result: audiofiles_core::analysis::AnalysisResult,
650 pub suggestions: Vec<SuggestionState>,
651 }
652
653 /// A tag suggestion the user can accept or reject.
654 pub struct SuggestionState {
655 pub suggestion: audiofiles_core::analysis::suggest::TagSuggestion,
656 pub accepted: bool,
657 }
658
659 // --- Sort ---
660
661 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
662 pub enum SortColumn {
663 Name,
664 Bpm,
665 Key,
666 Duration,
667 Classification,
668 }
669
670 #[derive(Debug, Clone, PartialEq, Eq)]
671 pub enum SortDirection {
672 Ascending,
673 Descending,
674 }
675
676 // --- Selection ---
677
678 /// Multi-selection state with anchor-based range selection.
679 ///
680 /// Three indices work together: `anchor` is where a shift-click range starts,
681 /// `focus` tracks the most recently interacted-with row (the cursor),
682 /// and `selected` is the full set of selected row indices. A plain click
683 /// sets all three to the same row; shift-click extends from anchor to the
684 /// clicked row; cmd-click toggles one row without moving the anchor.
685 #[derive(Debug, Clone, Default)]
686 pub struct Selection {
687 /// The anchor point for shift-click range selection.
688 pub anchor: usize,
689 /// The focused (most recently selected) item.
690 pub focus: usize,
691 /// Set of selected indices.
692 pub selected: HashSet<usize>,
693 }
694
695 impl Selection {
696 /// Create an empty selection with anchor and focus at index 0.
697 pub fn new() -> Self {
698 Self::default()
699 }
700
701 /// Clear the selection and reset anchor/focus to 0.
702 pub fn clear(&mut self) {
703 self.selected.clear();
704 self.anchor = 0;
705 self.focus = 0;
706 }
707
708 /// Single-select one item, clearing all others.
709 pub fn set_single(&mut self, idx: usize) {
710 self.selected.clear();
711 self.selected.insert(idx);
712 self.anchor = idx;
713 self.focus = idx;
714 }
715
716 /// Toggle an item in the selection (Cmd+Click).
717 pub fn toggle(&mut self, idx: usize) {
718 if self.selected.contains(&idx) {
719 self.selected.remove(&idx);
720 } else {
721 self.selected.insert(idx);
722 }
723 self.anchor = idx;
724 self.focus = idx;
725 }
726
727 /// Extend selection from anchor to target (Shift+Click).
728 /// `_max_len` is accepted for API consistency but unused — the range is
729 /// always anchor..=target regardless of list length.
730 pub fn extend_to(&mut self, target: usize, _max_len: usize) {
731 let start = self.anchor.min(target);
732 let end = self.anchor.max(target);
733 for i in start..=end {
734 self.selected.insert(i);
735 }
736 self.focus = target;
737 }
738
739 /// Extend selection down by one (Shift+Down).
740 pub fn extend_down(&mut self, max_len: usize) {
741 if max_len > 0 && self.focus < max_len - 1 {
742 self.focus += 1;
743 self.selected.insert(self.focus);
744 }
745 }
746
747 /// Extend selection up by one (Shift+Up).
748 pub fn extend_up(&mut self) {
749 if self.focus > 0 {
750 self.focus -= 1;
751 self.selected.insert(self.focus);
752 }
753 }
754
755 /// Select all items.
756 pub fn select_all(&mut self, len: usize) {
757 self.select_all_from(0, len);
758 }
759
760 /// Select every item in `start..len`. Used so Cmd+A on a list with a
761 /// ".." parent entry can skip index 0 — the parent isn't a sample and
762 /// must never become part of a bulk operation.
763 pub fn select_all_from(&mut self, start: usize, len: usize) {
764 self.selected.clear();
765 for i in start..len {
766 self.selected.insert(i);
767 }
768 if len > start {
769 self.anchor = start;
770 self.focus = len - 1;
771 }
772 }
773
774 /// Invert the selection over `0..len`. Indices currently selected become
775 /// unselected; previously unselected indices become selected.
776 pub fn invert(&mut self, len: usize) {
777 let new_selected: std::collections::HashSet<usize> =
778 (0..len).filter(|i| !self.selected.contains(i)).collect();
779 if let Some(&first) = new_selected.iter().min() {
780 self.anchor = first;
781 self.focus = first;
782 }
783 self.selected = new_selected;
784 }
785
786 /// Check whether `idx` is in the current selection.
787 pub fn contains(&self, idx: usize) -> bool {
788 self.selected.contains(&idx)
789 }
790
791 /// Number of currently selected items.
792 pub fn count(&self) -> usize {
793 self.selected.len()
794 }
795 }
796