//! audiofiles' screens, described. //! //! Behind the off-by-default `quasi` feature, so the shipped egui panels in //! [`crate::ui`] are exactly what they were while the described versions are //! proved beside them. That is goingson's arrangement, and the reason is the //! same: a port that replaces a working screen on the way in has to be right //! first time, and a port that sits beside one can be wrong cheaply. //! //! # What the router's state is, and why it is this small //! //! A handler is `fn(&S, Request)`: sync, holding only what the app put in `S`. //! [`Panels`] is therefore the *narrowest* thing the described screens need //! rather than the whole of [`BrowserState`](crate::state::BrowserState). One //! capability per screen plus one host fact, and each screen's type says which //! of them it may touch: //! //! | Capability | Screen | Written through | //! |---|---|---| //! | [`Config`] | [`settings`] | the backend's own `&self` methods | //! | [`Sync`] | [`sync`] | the sync manager's own `&self` methods | //! | [`Files`] | [`files`] | an [`Intent`], applied after the frame | //! | [`Export`] | [`export`] | an [`Intent`], applied after the frame | //! | [`Detail`] | [`detail`] | an [`Intent`], applied after the frame | //! | [`Bulk`] | [`bulk`] | an [`Intent`], applied after the frame | //! | [`Shell`] | [`shell`] | an [`Intent`], applied after the frame | //! | [`Library`] | [`library`] | an [`Intent`], applied after the frame | //! | [`Bar`] | [`toolbar`] | an [`Intent`], applied after the frame | //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame | //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host | //! //! The themes are the settled rule from goingson's settings port applied first //! time out: *a host fact readable at startup goes in `S`*, resolved where the //! app still has a handle to ask. The alternative — a capability surface on //! quasi — was refused on 2026-08-09 and nothing here reopens it. //! //! Notably absent is anything `&mut`, and the right-hand column is why it can //! be. Two of the six write through a handle that already takes `&self`; the //! other four write to the app's own UI state, which a route cannot hold, so //! they record an [`Intent`] and the panel applies it with the `&mut` the app //! has anyway. See [`files`]'s header for the rule and [`export`]'s for what it //! costs — an intent lands after the answer was built, which is what //! `Runtime::reload` exists to correct. //! //! [`Detail`] sharpened that rule rather than following it. Two of its writes — //! adding a tag, removing one — go to the app's *data* through a `&self` method //! the backend already has, so by the sentence above they should have been //! handle calls. They are intents, because what the app does *around* the write //! is `&mut`: removing a tag pushes an undo entry the description could not //! have pushed, and a route calling the backend directly would have removed the //! tag and silently lost Cmd+Z. So the rule is not "reads through a handle, //! writes through an intent" but **what the app does about a write decides //! where the write goes**. [`Detail`]'s header has the long form. //! //! # The filter panel waited on the vocabulary, and that is why it is last //! //! [`filters`] is the sixteenth port and the only one held up by something the //! description could not say. Its centre is six min/max pairs, and until //! makeover-layout 0.34.0 nothing said two values were one question with two //! ends: described as `Number` pairs they are twelve fields with no //! relationship, the crossing rule is app-side per pair, and an error can only //! be attached to one side of a fault that belongs to both. //! //! `FieldKind::Interval` was ruled on 2026-08-21 with this screen named as its //! first consumer, and the port is what `range_filter_section`'s 55 lines were //! standing in for. See [`filters`]'s header for what the member deleted and //! what stayed behind: the sibling snap is a write and lives in the route, and //! the per-axis disclosure is still unsayable. //! //! # What is not a capability, because it is not a screen //! //! `ui::overlays::draw_confirm_dialog` is a ten-variant `ConfirmAction` enum, a //! `pending_confirm` field, an `execute_confirmed_action` dispatcher and a //! 140-line `match` producing a title, a prompt, a detail line, a button label //! and a danger flag. None of it is ported, and nothing replaces it, because //! all of it is [`Act::confirm`](quasi_router::Act::confirm) and //! [`Act::tone`](quasi_router::Act::tone) — which quasi has had since //! `524a63fe`, and whose header names this exact shape: "Destructiveness is a //! property of the action, known where the action is described, and until this //! existed every app expressed it by calling a JS helper at the call site." //! //! A described control that destroys something says `confirm` on itself, the //! runtime answers `Step::Ask`, and the host draws whatever asking looks like //! for it. [`sync`]'s Disconnect has done that since it landed. **Ten variants //! replaced by two builder methods**, and the port's contribution is counting //! them rather than writing anything. //! //! # `ui::dialog` is not a screen either, and it is the evidence for a finding //! //! 267 lines, four picker kinds — pick a folder, pick a file, pick several, save //! — a worker thread, a `Send` handler applied on the GUI thread a frame or more //! later, and a note about macOS run loops. **It draws nothing.** There is no //! screen here to describe and no capability to narrow: it is the mechanism a //! host uses to ask the operating system a question, which is the definition of //! a host concern. //! //! Recorded rather than skipped, because it is the sharpest measurement this //! layer has of `quasi:vocabulary:host-save-location`. The gap has eight //! consumers across the app — the export destination, Export Theme, Locate //! missing files, and the import flow's four doors — and every one of them //! reaches this file. What the count says is that a native picker is not an //! oversight in one screen but a whole subsystem the description cannot name, //! and that `FieldKind::File` covering "pick a file to submit" answers the one //! shape of it nobody here uses. //! //! There is a second half, filed with the import flow: a route that hands off to //! the host has no [`Outcome`](quasi_router::Outcome) meaning "nothing here //! changed". `dialog.rs` is why — the answer arrives frames later, on a thread, //! through a closure, and the route that asked is long finished. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's rather than // a choice made here. #![allow(clippy::needless_pass_by_value)] pub mod bulk; pub mod detail; pub mod edit; pub mod export; pub mod files; pub mod filters; pub mod forge; pub mod help; pub mod importing; pub mod integrity; pub mod library; pub mod naming; pub mod panel; pub mod queue; pub mod settings; pub mod shell; pub mod sync; pub mod toolbar; use audiofiles_core::config_key::ConfigKey; use quasi_router::Router; use crate::backend::Backend; /// The config store, as much of it as a described screen needs. /// /// Two methods against `Backend`'s several dozen, and the narrowing is the /// point rather than tidiness. Three things fall out of it: /// /// - **`Panels` is honestly the narrowest thing the screens need.** Borrowing /// `&dyn Backend` would have said "this screen may do anything the app can do" /// in its own type, which is exactly what a description layer is for not /// saying. /// - **The screens are testable with no app.** A fixture implements two methods /// rather than a trait tree covering vfs, tags, search and the rest. That is /// what makes the tests below run without a `BrowserState` or a window. /// - **The error stops being the backend's.** A route answers `RouteError`, so /// the store's failure is flattened to a string here and classified there. /// /// Adapted from the app's own handle by [`FromBackend`], which is one named /// conversion rather than a blanket impl: `&dyn Backend` and `&dyn Config` are /// unrelated trait objects and Rust upcasts between neither, so the adaptation /// has to be spelled somewhere. Spelling it as a type says where. pub trait Config { /// What is stored under this key, if anything is. /// /// # Errors /// Whatever the store said, as text. fn get(&self, key: ConfigKey) -> Result, String>; /// Store this value under this key. /// /// # Errors /// Whatever the store said, as text. fn set(&self, key: ConfigKey, value: &str) -> Result<(), String>; } /// The app's backend, as the narrow thing a described screen borrows. /// /// The whole of the adaptation, and the only place `Backend` is named on this /// side of the boundary. pub struct FromBackend<'a>(pub &'a dyn Backend); impl Config for FromBackend<'_> { fn get(&self, key: ConfigKey) -> Result, String> { self.0.get_config(key).map_err(|error| error.to_string()) } fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> { self.0 .set_config(key, value) .map_err(|error| error.to_string()) } } /// Where cloud sync has got to, as the description needs to name it. /// /// A plain snapshot rather than `audiofiles_sync::SyncStatus`, for the reason /// [`ThemeChoice`] is not `ThemeMeta`: a described screen should not depend on /// the shape of the thing it reports on, and the two fields this screen never /// names (`device_id`, `needs_refresh`) would otherwise be in its blast radius. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Status { /// Which of the four the flow is in. pub state: State, /// When the last sync finished, as the app formats it. pub last_sync_at: Option, /// How many local changes have not gone up. pub pending_changes: i64, /// What went wrong, if anything did. pub last_error: Option, /// Whether the scheduler is running. pub auto_sync_enabled: bool, /// How often it runs, in minutes. pub sync_interval_minutes: u32, } /// The four states the sync flow has. /// /// Mirrored rather than re-exported, so the described screen names its own /// vocabulary. `NeedsEncryption` keeps its flag because it changes what the /// screen says, though not what shape it is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { /// Not connected to anything. Disconnected, /// Waiting on a browser. Authenticating, /// Connected, and the vault is not unlocked yet. NeedsEncryption { /// Whether the server already holds a key, so this is an unlock rather /// than a first password. has_server_key: bool, }, /// Connected and idle. Ready, /// Connected and working. Syncing, } /// A subscription, as the description needs to name it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Subscription { /// Whether it is paid up and running. pub active: bool, /// What was bought, in bytes. pub limit_bytes: i64, /// What is used, in bytes. pub used_bytes: i64, /// `monthly` or `annual`, as the wire spells it. pub interval: String, /// A cap change already queued for the next renewal. pub pending_limit_bytes: Option, } /// What a cap may be and what it costs. /// /// The bounds, plus the ability to price a cap. The *quote* is a method rather /// than a table because pricing is the server's and the app is not going to /// reimplement it: see [`sync`]'s header on why the price cannot follow a /// slider. pub struct Pricing { /// The smallest cap that may be bought, in bytes. pub min_bytes: i64, /// The largest, in bytes. pub max_bytes: i64, } /// Cloud sync, as much of it as a described screen needs. /// /// The same narrowing [`Config`] makes, for the same three reasons, and it lands /// harder here: `SyncManager` owns a scheduler, a client and a keyring, and a /// described screen borrowing all of that would say in its own type that it may /// start a network conversation. What it may actually do is these eight things. /// /// Every one of them is `&self` on the manager already, which is why this screen /// is describable at all — see [`sync`]'s header, where that is compared against /// goingson ruling its own sync section out. pub trait Sync { /// Whether syncing is possible here at all. /// /// Not the same as disconnected, and the flip found the difference /// (2026-08-22). Disconnected means "there is a service and you are not on /// it", which is what `Connect` answers. `false` here means there is no /// service to be on: no vault is open, or this build has no manager. The /// screen offers nothing in that state, which is what the shipped panel did /// with a whole second window, rather than offering a `Connect` that /// refuses and a `Dismiss` for an error that cannot be cleared. /// /// Defaulted, because every real manager can sync and only /// [`Unconfigured`] cannot. fn available(&self) -> bool { true } /// Where the flow has got to. fn status(&self) -> Status; /// Begin authentication, and answer where the user has to go. /// /// # Errors /// Whatever the manager said, as text. fn connect(&self) -> Result; /// Give up waiting on the browser. fn cancel(&self); /// Set or supply the password that encrypts this vault. /// /// `is_new` is the difference between choosing a password and unlocking with /// one, which the manager needs and the screen already knows from /// [`State::NeedsEncryption`]. fn set_password(&self, password: &str, is_new: bool); /// Sync now rather than on the schedule. fn sync_now(&self); /// Turn the schedule on or off. fn set_auto(&self, enabled: bool); /// How often the schedule runs, in minutes. fn set_interval(&self, minutes: u32); /// Clear whatever went wrong. fn clear_error(&self); /// Stop syncing this vault. fn disconnect(&self); /// The subscription, once it has been fetched. /// /// `None` is "not known yet" rather than "none": the manager fetches it /// asynchronously, which is what the described screen reports as /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending). fn subscription(&self) -> Option; /// What a cap may be, once pricing has been fetched. fn pricing(&self) -> Option; /// How many bytes blob sync would upload today: the union of every VFS with /// `sync_files` set, deduped by hash. /// /// The cap is chosen against this, which is why the screen can propose an /// answer rather than ask for one. `None` is "cannot look" - no backend on /// this side, or the query failed - and is what the screen falls back to /// the floor on. `Some(0)` is different and means something: nothing is set /// to sync yet. fn synced_library_bytes(&self) -> Option; /// What this cap costs at this cadence, in cents. fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64; /// Go and ask what the subscription is. fn refresh_subscription(&self); /// Buy this cap at this cadence. /// /// Answers nothing, and that is the shape rather than an omission: the /// checkout URL is fetched asynchronously and the manager opens it itself, /// so unlike [`connect`](Self::connect) there is no address to hand back. fn subscribe(&self, cap_bytes: i64, annual: bool); /// Change the cap on a running subscription, at the next renewal. fn queue_cap_change(&self, cap_bytes: i64); } /// The app's sync manager, as the narrow thing a described screen borrows. /// /// Carries the backend as well as the manager, for one fact: the cap screen has /// to know how much would upload, and that lives in the vault rather than in /// the sync service. Everything else here is the manager. pub struct FromSyncManager<'a> { pub manager: &'a audiofiles_sync::SyncManager, pub backend: &'a dyn crate::backend::Backend, } impl Sync for FromSyncManager<'_> { fn status(&self) -> Status { let status = self.manager.status(); Status { state: match status.state { audiofiles_sync::SyncState::Disconnected => State::Disconnected, audiofiles_sync::SyncState::Authenticating => State::Authenticating, audiofiles_sync::SyncState::NeedsEncryption { has_server_key } => { State::NeedsEncryption { has_server_key } } audiofiles_sync::SyncState::Ready => State::Ready, audiofiles_sync::SyncState::Syncing => State::Syncing, }, last_sync_at: status.last_sync_at, pending_changes: status.pending_changes, last_error: status.last_error, auto_sync_enabled: status.auto_sync_enabled, sync_interval_minutes: status.sync_interval_minutes, } } fn connect(&self) -> Result { self.manager.start_auth().map_err(|error| error.to_string()) } fn cancel(&self) { self.manager.cancel_auth(); } fn set_password(&self, password: &str, is_new: bool) { self.manager.setup_encryption(password.to_owned(), is_new); } fn sync_now(&self) { self.manager.sync_now(); } fn set_auto(&self, enabled: bool) { self.manager.update_settings(Some(enabled), None); } fn set_interval(&self, minutes: u32) { self.manager.update_settings(None, Some(minutes)); } fn clear_error(&self) { self.manager.clear_last_error(); } fn disconnect(&self) { self.manager.disconnect(); } fn subscription(&self) -> Option { let status = self.manager.status(); status.subscription.map(|sub| Subscription { active: sub.active, limit_bytes: sub.storage_limit_bytes.unwrap_or(0), used_bytes: sub.storage_used_bytes.unwrap_or(0), interval: sub .interval .map_or_else(|| "monthly".to_owned(), |i| i.as_str().to_owned()), pending_limit_bytes: sub.pending_storage_limit_bytes, }) } fn pricing(&self) -> Option { self.manager.status().pricing.map(|pricing| Pricing { min_bytes: pricing.min_cap_bytes, max_bytes: pricing.max_cap_bytes, }) } fn synced_library_bytes(&self) -> Option { let (_, bytes) = self.backend.synced_storage_stats().ok()?; i64::try_from(bytes).ok() } fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 { self.manager.status().pricing.map_or(0, |pricing| { pricing.quote_cents(cap_bytes, interval_of(annual)).0 }) } fn refresh_subscription(&self) { self.manager.fetch_subscription_status(); } fn subscribe(&self, cap_bytes: i64, annual: bool) { self.manager.subscribe(cap_bytes, interval_of(annual)); } fn queue_cap_change(&self, cap_bytes: i64) { self.manager.queue_cap_change(cap_bytes); } } /// A cadence as the client spells it. fn interval_of(annual: bool) -> audiofiles_sync::BillingInterval { if annual { audiofiles_sync::BillingInterval::Annual } else { audiofiles_sync::BillingInterval::Monthly } } /// Sync that is not configured on this machine. /// /// The app has a whole second window for this case /// (`ui::sync_panel::draw_sync_not_configured`), so the described side needs an /// answer too. It reports [`State::Disconnected`] and refuses to connect, which /// is the truth rather than a stub: there is nothing to connect *to*, and a /// silent no-op would look like a control that does nothing. pub struct Unconfigured; impl Sync for Unconfigured { fn available(&self) -> bool { false } fn status(&self) -> Status { Status { state: State::Disconnected, last_sync_at: None, pending_changes: 0, last_error: Some("Cloud sync is not configured for this build.".to_owned()), auto_sync_enabled: false, sync_interval_minutes: 0, } } fn connect(&self) -> Result { Err("Cloud sync is not configured for this build.".to_owned()) } fn cancel(&self) {} fn set_password(&self, _password: &str, _is_new: bool) {} fn sync_now(&self) {} fn set_auto(&self, _enabled: bool) {} fn set_interval(&self, _minutes: u32) {} fn clear_error(&self) {} fn disconnect(&self) {} fn subscription(&self) -> Option { None } fn pricing(&self) -> Option { None } fn synced_library_bytes(&self) -> Option { None } fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 { 0 } fn refresh_subscription(&self) {} fn subscribe(&self, _cap_bytes: i64, _annual: bool) {} fn queue_cap_change(&self, _cap_bytes: i64) {} } /// One sample, as the description needs to name it. #[derive(Debug, Clone, PartialEq)] pub struct Sample { /// The row's own id, which is what its addresses are built from. pub id: i64, /// What it is called. pub name: String, /// How long it runs, in seconds. pub duration: Option, /// Beats per minute, where analysis found some. pub bpm: Option, /// The musical key, where analysis found one. pub key: Option, /// Peak level in dBFS. pub peak_db: Option, /// Whatever it is tagged with. pub tags: Vec, /// Whether this row is a folder rather than a sample. /// /// The file list holds both, and until 2026-08-17 a described row could not /// tell them apart: every row got the sample columns and a Play control, /// folders included. `draw_context_menu` branches on exactly this and offers /// two different menus, so `Cells::menu` could not be described without it. pub directory: bool, /// Whether the bytes are only in the cloud. /// /// What the shipped menu reads to offer Download and to withhold the four /// acts that need the file on disk. A sample nobody has fetched can still be /// listed, named and tagged, so this is not the same fact as absence. pub cloud_only: bool, } /// Which columns the file list is showing. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ColumnsShown { /// Show the duration column. pub duration: bool, /// Show the tempo column. pub bpm: bool, /// Show the musical key column. pub key: bool, /// Show the peak level column. pub peak_db: bool, /// Show the tags column. pub tags: bool, } /// The sample list, as much of it as a described screen needs. /// /// The third narrow trait, and the first whose writes do **not** go through a /// handle the app already had: selecting a row and playing one are /// `&mut BrowserState`. See [`files`]'s header — those are recorded as intents /// and applied by the host after the frame, which is the app's own /// `pending_action` pattern rather than something invented for this. pub trait Files { /// The rows on screen now, already loaded and filtered by the app. fn samples(&self) -> Vec; /// Which columns the user has switched on. fn columns(&self) -> ColumnsShown; /// The column in force, and whether it runs up. fn sort(&self) -> (String, bool); /// The row the app is pointing at. fn current(&self) -> Option; /// Select this row. fn open(&self, id: i64); /// Preview this row. fn play(&self, id: i64); /// Order by this column. fn sort_by(&self, column: &str); /// Go into this folder. /// /// The rest of this block is the row menu, 2026-08-17. Five of its entries /// are not here and do not need to be: Copy Path, Edit, Find Similar and /// Find Duplicates are already [`Detail`]'s, acting on the sample in focus, /// so a route selects the row through [`open`](Self::open) and then calls /// the capability that exists. Export is [`Export::open`]'s the same way, and /// New Folder and Rename are addresses [`Naming`] already answers. What is /// left is what nothing else could do. fn enter(&self, id: i64); /// Show this row in the system file manager. fn reveal(&self, id: i64); /// Play this sample chromatically, as an instrument. fn as_instrument(&self, id: i64); /// Analyse this sample again, replacing what is there. fn reanalyze(&self, id: i64); /// Delete this row. /// /// The asking is the act's, not this method's: a described act carries /// [`Act::confirm`](quasi_router::Act::confirm) and every renderer raises it /// its own way, so by the time a handler calls this the user has agreed. fn delete(&self, id: i64); /// Fetch this cloud-only sample to local storage. fn download(&self, id: i64); /// Take this sample out of the collection being viewed. fn remove_from_collection(&self, id: i64); /// Put this sample into that collection. /// /// Two ids rather than one, and that is the difference from /// [`remove_from_collection`](Self::remove_from_collection): removing acts on /// the collection already being viewed, so the screen knows which one without /// being told. Adding does not, and the collection is what the act asked the /// user for. fn add_to_collection(&self, id: i64, collection: i64); } /// What a described screen asked the app to do to itself. /// /// The frame boundary, made explicit. A route cannot hold `&mut BrowserState`, /// so a screen that acts on the app's own UI state records what was asked and /// the panel applies it afterwards. `SettingsUiState::pending_action` is the /// same pattern, already in this app and documented as "set by the UI, consumed /// by the app layer each frame". /// /// `PartialEq` but not `Eq`: the editor's intents carry the numbers a control /// submitted, and a gain is an `f64`. #[derive(Debug, Clone, PartialEq)] pub enum Intent { /// Select a row. Open(i64), /// Preview a row. Play(i64), /// Go into a folder. Enter(i64), /// Show a row in the system file manager. Reveal(i64), /// Play a sample chromatically. Instrument(i64), /// Analyse a sample again. Reanalyze(i64), /// Delete a row, the asking already done. Delete(i64), /// Fetch a cloud-only sample. Download(i64), /// Take a sample out of the collection being viewed. RemoveFromCollection(i64), /// Put a sample into a collection the user named, by sample then collection. AddToCollection(i64, i64), /// Order by a column. SortBy(String), /// Change one export setting. Configure(Setting, String), /// Begin the export. StartExport, /// Give up on the running one. CancelExport, /// Put the flow away. DismissExport, /// Tag the sample in focus. AddTag(String), /// Untag the sample in focus. RemoveTag(String), /// Go and look for tags on similar samples. Suggest, /// Take one of those suggestions. AcceptSuggestion(String), /// Put the sample's path on the clipboard. CopyPath, /// Narrow one numeric axis, by its key and its two ends. Narrow(&'static str, Option, Option), /// Match only these keys, or every key compatible with them. KeyMode(bool), /// Want this key, or stop wanting it. ToggleKey(String), /// Stop wanting any key. ClearKeys, /// Remember what is being typed into the tag box. TypingTag(String), /// Require this tag of every result. RequireTag(String), /// Stop requiring it. UnrequireTag(String), /// Stop requiring any tag. ClearTags, /// Drop every filter and the query with them. ClearFilters, /// Open the sample editor. Edit, /// Open the forge. Forge, /// Look for samples that sound like this one. FindSimilar, /// Look for near-duplicates of this one. FindDuplicates, /// Tag every chosen sample that lacks this tag. SpreadTag(String), /// Untag every chosen sample that carries this tag. StripTag(String), /// Search for this. Search(String), /// Search here, or everywhere. Scope(bool), /// Save the active filters as a dynamic collection. SaveCollection(String), /// Undo the last bulk action. Undo, /// Show this panel, or stop showing it. TogglePanel(Panel), /// Go to the vault root. GoRoot, /// Go to this folder, this far along the trail. GoTo(i64, usize), /// Leave whatever mode the list is in. Leave, /// Switch to this vault. OpenVault(i64), /// Delete this vault and everything in it. DeleteVault(i64), /// Filter by this tag, or stop filtering by it. ToggleTag(String), /// Take this tag off every sample that has it. RemoveTagEverywhere(String), /// Show this collection. OpenCollection(i64), /// Stop showing whichever collection is showing. CloseCollection, /// Delete this collection. DeleteCollection(i64), /// Stop the preview that is playing. StopPlayback, /// Put the first-launch hint away. DismissHint, /// Tag or untag every chosen sample. BulkTag(String, bool), /// Move everything chosen into this folder, or to the root. BulkMove(Option), /// Rename everything chosen by this pattern. BulkRename(String), /// A name modal is finished with, whichever of the four it was. NamingDone, /// A bulk modal is finished with, whichever of the three it was. BulkDone, /// Re-read the vault list, and say this about why. VaultsChanged(String), /// Re-read the current folder, and say this about why. ContentsChanged(String), /// Go ahead with the import that is waiting, and remember the answer. AcceptImport { again: bool }, /// Drop the import that is waiting. CancelImport, /// Ask the host for a folder, then set the wizard up on it. OpenImportFolder, /// Ask the host for a folder, then index it with no questions asked. OpenQuickImport, /// Ask the host for files, then merge them into the vault that is open. OpenImportFiles, /// Ask the host for a different folder for the import being configured. ChangeImportSource, /// Answer one of the configure screen's three questions. Decide(Decision, String), /// Start copying the files in. BeginImport, /// Give up on the copy that is running. StopImport, /// Give up on it and go back to configuring. RetryImport, /// Put the import flow away, from wherever it is. DismissImport, /// Type these tags against this imported folder. TagFolder(usize, String), /// Type these tags against every imported folder. TagEveryFolder(String), /// Apply what was typed against the folders. ApplyFolderTags, /// Apply none of it and move on. SkipFolderTags, /// Turn one analysis measure on or off. Measure(Measure, bool), /// Run the analysis. StartAnalysis, /// Go back to tagging the imported folders. BackToTagging, /// Do not analyse at all. SkipAnalysis, /// Give up on the analysis that is running. StopAnalysis, /// Give up on it and start it again. RetryAnalysis, /// Order the review list this way. OrderReview(Order), /// Read this reviewed sample. ReadReviewed(usize), /// Accept or reject one suggestion against one sample. Judge { /// Which sample, as an index into the review list. at: usize, /// Which suggestion, by the tag it proposes. tag: String, /// Whether it is now accepted. accepted: bool, }, /// Accept or reject every suggestion against every sample. JudgeAll(bool), /// Apply the accepted suggestions. ApplySuggestions, /// Apply none of them. DiscardSuggestions, /// Keep every file that failed. KeepFailed, /// Delete the ones that failed analysis, or one of them. PurgeFailed(Option), /// Give up on the sweep that is running. StopSweep, /// Stop the storage-layout migration until this vault reopens. PauseMigration, /// Slice the forged sample this way. SliceBy(Chop), /// Set one of the forge's numbers. Turn(Knob, String), /// Work out where the slices would fall. PreviewSlices, /// Write them. Chop, /// Aim a conform at this device. ChooseDevice(String), /// Conform to whichever device is chosen. Conform, /// Trim silence off everything chosen. TrimSilence, /// Open this tag in the review queue. ReadGroup(usize), /// Tick or untick one candidate of the open tag. TickCandidate(usize), /// Tick or untick every candidate the screen is showing. TickShown(bool), /// Apply this much of the open tag. AcceptGroup(Scope), /// Apply every confident suggestion under every tag. AcceptConfident, /// Throw the open tag away. DismissGroup, /// Run the library pass again. Rescan, /// Put the review screen away, keeping the queue. CloseReview, /// Open the export flow on whatever is selected. BeginExport, /// Put the loose-files warning away without acting. DismissLooseFiles, /// Delete the registry entries whose files are gone. PurgeLooseFiles, /// Ask the host for a folder to look for the missing files in. LocateLooseFiles, /// Cut the edited sample down to this span. EditTrim { /// Where the kept part starts, as a fraction of the whole. start: f32, /// Where it ends. end: f32, }, /// Change the edited sample's level by this many dB. EditGain(f64), /// Normalise it to this target, by peak or by loudness. EditNormalize { /// True for peak, false for LUFS. peak: bool, /// The target, in whichever unit that is. target: f64, }, /// Play it backwards. EditReverse, /// Fade it in or out, this long, on this curve. EditFade { /// True to fade in, false to fade out. fading_in: bool, /// How long the fade runs, in milliseconds. ms: f64, /// The curve, as `FadeCurve::as_value` writes it. curve: String, }, /// Put silence in at this point. EditInsertSilence { /// Where it goes, in milliseconds. at: f64, /// How much, in milliseconds. ms: f64, }, /// Take this span out. EditRemoveRange { /// Where it starts, in milliseconds. from: f64, /// Where it ends. to: f64, }, /// Give up on the edit that is running. EditCancel, /// Audition the sample being edited, or pause it. EditPlay, /// Remember this as the standing answer to what happens to an edit. EditRemember(String), /// Answer the question a finished edit is waiting on. EditChoose { /// Replace or sibling, as `EditResultMode::as_value` writes it. mode: String, /// Whether to keep this as the standing answer. remember: bool, }, /// Throw the finished edit away. EditDiscard, /// Put the last edit back. EditUndo, /// Normalise every chosen sample. BatchNormalize { /// True for peak, false for LUFS. peak: bool, /// The target, in whichever unit that is. target: f64, }, /// Change every chosen sample's level. BatchGain(f64), /// Reverse every chosen sample. BatchReverse, } /// The app's file list, as the narrow thing a described screen borrows. /// /// Reads come off `BrowserState` directly; writes are recorded rather than /// performed, because a route holds `&BrowserState` and selecting a row is /// `&mut`. See [`files`]'s header for why that is a frame boundary rather than a /// shortcoming. pub struct FromContents<'a> { /// What the app has loaded and filtered already. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Files for FromContents<'_> { fn samples(&self) -> Vec { self.state .nav .contents .iter() .map(|node| Sample { id: node.node.id.as_i64(), name: node.node.name.clone(), duration: node.duration, bpm: node.bpm, key: node.musical_key.clone(), peak_db: node.peak_db, tags: node.tags.clone(), directory: matches!( node.node.node_type, audiofiles_core::vfs::NodeType::Directory ), cloud_only: node.cloud_only, }) .collect() } fn columns(&self) -> ColumnsShown { let shown = &self.state.column_config; ColumnsShown { duration: shown.show_duration, bpm: shown.show_bpm, key: shown.show_key, peak_db: shown.show_peak_db, tags: shown.show_tags, } } fn sort(&self) -> (String, bool) { let by = match self.state.nav.sort_column { crate::state::SortColumn::Name => "Name", crate::state::SortColumn::Bpm => "BPM", crate::state::SortColumn::Key => "Key", crate::state::SortColumn::Duration => "Duration", }; ( by.to_owned(), matches!( self.state.nav.sort_direction, crate::state::SortDirection::Ascending ), ) } fn current(&self) -> Option { self.state.selected_node().map(|node| node.node.id.as_i64()) } fn open(&self, id: i64) { self.intents.borrow_mut().push(Intent::Open(id)); } fn play(&self, id: i64) { self.intents.borrow_mut().push(Intent::Play(id)); } fn sort_by(&self, column: &str) { self.intents .borrow_mut() .push(Intent::SortBy(column.to_owned())); } fn enter(&self, id: i64) { self.intents.borrow_mut().push(Intent::Enter(id)); } fn reveal(&self, id: i64) { self.intents.borrow_mut().push(Intent::Reveal(id)); } fn as_instrument(&self, id: i64) { self.intents.borrow_mut().push(Intent::Instrument(id)); } fn reanalyze(&self, id: i64) { self.intents.borrow_mut().push(Intent::Reanalyze(id)); } fn delete(&self, id: i64) { self.intents.borrow_mut().push(Intent::Delete(id)); } fn download(&self, id: i64) { self.intents.borrow_mut().push(Intent::Download(id)); } fn remove_from_collection(&self, id: i64) { self.intents .borrow_mut() .push(Intent::RemoveFromCollection(id)); } fn add_to_collection(&self, id: i64, collection: i64) { self.intents .borrow_mut() .push(Intent::AddToCollection(id, collection)); } } /// Where the export flow has got to. /// /// The phase carries what only exists in it, which is the shape the app's own /// `ImportMode` already has: there are no items to configure while an export is /// running and no errors to read before one has finished. A flat struct with /// everything optional would have made every screen ask whether the field it /// wants is there this time. #[derive(Debug, Clone, PartialEq)] pub enum Phase { /// No export in progress and none being set up. Idle, /// Choosing what and where, before anything is written. Configuring { /// What would be exported. subjects: Vec, /// The device profiles on offer. profiles: Vec, /// The settings as they stand. settings: Settings, }, /// Files being written. Running { /// How many have been written. done: usize, /// How many there are. Zero before the worker has counted them, which /// the screen reports as pending rather than as an empty export. total: usize, /// The one being written now. current: String, }, /// Finished, with whatever went wrong on the way. Finished { /// How many were written. total: usize, /// The ones that failed, by name. errors: Vec<(String, String)>, /// Where they landed. destination: Option, }, /// Given up on partway. Cancelled { /// How many had been written when it stopped. done: usize, /// How many there would have been. total: usize, /// Where the partial files sit. destination: Option, }, } /// One sample about to be exported, as the description needs to name it. /// /// [`Sample`]'s peer for a different screen, and separate from it for the reason /// that type is separate from the app's own node: what the export screen needs /// is the rename context and the duration, and what the file list needs is the /// row. Sharing one type would put every field either screen wants in both. #[derive(Debug, Clone, PartialEq)] pub struct Subject { /// What it is called. pub name: String, /// Its extension, without the dot. pub ext: String, /// How long it runs, in seconds. pub duration: Option, /// Beats per minute, where analysis found some. pub bpm: Option, /// The musical key, where analysis found one. pub musical_key: Option, } /// A device profile, as the description needs to name it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProfileChoice { /// What the device is called, which is also what the config stores. pub name: String, /// Who makes it. pub manufacturer: String, /// What it accepts, as the registry phrases it. pub summary: Option, /// What kind of device it is. pub category: Option, /// Anything else the manifest said. pub notes: Option, /// The largest file it will take, if it says. pub max_file_size_bytes: Option, } /// The export settings, as the description names them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Settings { /// What to write. pub format: Format, /// Target sample rate, or `None` to keep each file's own. pub sample_rate: Option, /// Target bit depth, or `None` to keep each file's own. pub bit_depth: Option, /// Target channel layout. pub channels: Channels, /// Whether every file lands in one folder. pub flatten: bool, /// Whether a `.audiofiles.json` sidecar goes beside each file. pub sidecar: bool, /// How to name the output files, when flattened. pub naming_pattern: Option, /// Where they go, as the host spells the path. pub destination: String, /// The device profile in force, which locks the audio settings. pub device_profile: Option, } /// What to write. /// /// Mirrored rather than re-exported, for the reason [`State`] is: a described /// screen should not depend on the shape of the thing it reports on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Format { /// Copy each file as it is. Original, /// Decode and re-encode as WAV. Wav, /// Decode and re-encode as AIFF. Aiff, } /// The channel layout to write. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channels { /// Keep each file's own. Original, /// Mix down to one. Mono, /// Mix to two. Stereo, } /// The settings a described control may change. /// /// A closed set, which is what lets one write route serve the whole screen the /// way `ConfigKey` lets `settings.rs` have one. Without it the route would carry /// a second list of what it is willing to name, and the two would drift. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Setting { /// [`Settings::format`]. Format, /// [`Settings::sample_rate`]. SampleRate, /// [`Settings::bit_depth`]. BitDepth, /// [`Settings::channels`]. Channels, /// [`Settings::flatten`]. Flatten, /// [`Settings::sidecar`]. Sidecar, /// [`Settings::naming_pattern`]. NamingPattern, /// [`Settings::device_profile`]. DeviceProfile, } impl Setting { /// The name a described address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Format => "format", Self::SampleRate => "sample-rate", Self::BitDepth => "bit-depth", Self::Channels => "channels", Self::Flatten => "flatten", Self::Sidecar => "sidecar", Self::NamingPattern => "naming-pattern", Self::DeviceProfile => "device-profile", } } /// The setting that name means, if it means one. /// /// The refusal that makes the write route safe: an address is reachable by /// typing, so an undeclared name is a `NotFound` rather than a panic or a /// silent no-op. #[must_use] pub fn from_key(name: &str) -> Option { match name { "format" => Some(Self::Format), "sample-rate" => Some(Self::SampleRate), "bit-depth" => Some(Self::BitDepth), "channels" => Some(Self::Channels), "flatten" => Some(Self::Flatten), "sidecar" => Some(Self::Sidecar), "naming-pattern" => Some(Self::NamingPattern), "device-profile" => Some(Self::DeviceProfile), _ => None, } } } /// The export flow, as much of it as a described screen needs. /// /// The fourth narrow trait, and the first whose **reads** are UI state as well /// as its writes. `Config` and `Sync` both read through a handle that owns the /// fact; the export flow's phase lives in `BrowserState::import_wf`, which is /// the app's own screen state. So this trait reads it and records every write as /// an [`Intent`], which is `files.rs`'s rule applied whole: *a described screen /// writing to UI state records an intent.* pub trait Export { /// Where the flow has got to. fn phase(&self) -> Phase; /// Open the flow on whatever is chosen. See [`export`]'s `begin`. fn open(&self); /// Change one setting. fn configure(&self, setting: Setting, value: &str); /// Begin writing files. fn start(&self); /// Give up on the running export. fn cancel(&self); /// Put the flow away, from either end of it. fn dismiss(&self); } /// The app's export flow, as the narrow thing a described screen borrows. pub struct FromExport<'a> { /// Where the flow is, read off the app's own screen state. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Export for FromExport<'_> { fn open(&self) { self.intents.borrow_mut().push(Intent::BeginExport); } fn phase(&self) -> Phase { use crate::state::ImportMode; match &self.state.import_wf.import_mode { ImportMode::ConfigureExport { items, config, available_profiles, } => Phase::Configuring { subjects: items .iter() .map(|item| Subject { name: item.name.clone(), ext: item.ext.clone(), duration: item.duration, bpm: item.bpm, musical_key: item.musical_key.clone(), }) .collect(), profiles: available_profiles .iter() .map(|profile| ProfileChoice { name: profile.name.clone(), manufacturer: profile.manufacturer.clone(), summary: profile.format_summary.clone(), category: profile.category.clone(), notes: profile.notes.clone(), max_file_size_bytes: profile.max_file_size_bytes, }) .collect(), settings: Settings { format: match config.format { audiofiles_core::export::ExportFormat::Original => Format::Original, audiofiles_core::export::ExportFormat::Wav => Format::Wav, audiofiles_core::export::ExportFormat::Aiff => Format::Aiff, }, sample_rate: config.sample_rate, bit_depth: config.bit_depth, channels: match config.channels { audiofiles_core::export::ExportChannels::Original => Channels::Original, audiofiles_core::export::ExportChannels::Mono => Channels::Mono, audiofiles_core::export::ExportChannels::Stereo => Channels::Stereo, }, flatten: config.flatten, sidecar: config.metadata_sidecar, naming_pattern: config.naming_pattern.clone(), destination: config.destination.display().to_string(), device_profile: config.device_profile.clone(), }, }, ImportMode::Exporting { completed, total, current_name, } => Phase::Running { done: *completed, total: *total, current: current_name.clone(), }, ImportMode::ExportComplete { total, errors } => Phase::Finished { total: *total, errors: errors.clone(), destination: self.destination(), }, ImportMode::OperationCancelled { kind: crate::state::CancelKind::Export, completed, total, destination, } => Phase::Cancelled { done: *completed, total: *total, destination: destination.as_ref().map(|path| path.display().to_string()), }, _ => Phase::Idle, } } fn configure(&self, setting: Setting, value: &str) { self.intents .borrow_mut() .push(Intent::Configure(setting, value.to_owned())); } fn start(&self) { self.intents.borrow_mut().push(Intent::StartExport); } fn cancel(&self) { self.intents.borrow_mut().push(Intent::CancelExport); } fn dismiss(&self) { self.intents.borrow_mut().push(Intent::DismissExport); } } impl FromExport<'_> { /// Where the last export was told to write. fn destination(&self) -> Option { self.state .import_wf .last_export_destination .as_ref() .map(|path| path.display().to_string()) } } /// What the detail panel is about. /// /// The panel's subject is the selection, and a selection is not an address: a /// user does not navigate to "three samples are chosen", they arrive there by /// choosing three. So this is [`Phase`]'s shape for a different reason than /// [`Phase`] has it — one route answering three screens, because the state is /// something that happened rather than somewhere to go. `sync`'s four states /// settled that pattern and this is the third screen to take it. #[derive(Debug, Clone, PartialEq)] pub enum Focus { /// Nothing is chosen, or what is chosen is the parent entry. Nothing, /// One sample, with everything known about it. One(Box), /// Several, so what is describable is what they have in common. Several(Box), } /// One sample, as the detail screen needs to name it. /// /// [`Sample`]'s peer, separate from it for the reason [`Subject`] is separate /// from both: the file list needs a row, the export needs the rename context, /// and this needs everything analysis found. One shared type would put every /// field any screen wants in all three. #[derive(Debug, Clone, PartialEq)] pub struct Detailed { /// The row's own id, which is what its addresses are built from. pub id: i64, /// What it is called. pub name: String, /// Where it sits, as the host spells the path. pub path: Option, /// What analysis found, where it has run. pub analysis: Option, /// What it is tagged with, and where each tag came from. pub tags: Vec, /// Tags found on acoustically similar samples, once asked for. pub suggestions: Vec, /// Whether it is a sample rather than a folder, which is what the editing /// controls need: the shipped panel offers Edit and Forge only where there /// is a hash to open them on. pub is_sample: bool, /// Whether the spectral features Find Similar reads were computed. pub has_spectral: bool, /// Whether the fingerprint Find Duplicates reads was computed. pub has_fingerprint: bool, } /// What analysis found, as the description names it. /// /// The nine fields the panel shows, out of `AnalysisResult`'s twenty-two. The /// rest — the feature vector, the fingerprint bytes, the spectral moments — are /// inputs to the two discovery paths rather than facts a reader is shown, and /// they reach this screen as [`Detailed::has_spectral`] and /// [`Detailed::has_fingerprint`], which is the only thing it says about them. #[derive(Debug, Clone, PartialEq)] pub struct Analysis { /// How long it runs, in seconds. pub duration: f64, /// Frames per second. pub sample_rate: u32, /// How many channels. pub channels: u16, /// Beats per minute, where one was found. pub bpm: Option, /// The musical key, where one was found. pub musical_key: Option, /// Peak level in dBFS. pub peak_db: Option, /// RMS level in dBFS. pub rms_db: Option, /// Integrated loudness. pub lufs: Option, /// Whether it loops cleanly. pub is_loop: Option, } /// One tag on one sample, and where it came from. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tagged { /// The tag itself. pub name: String, /// Who put it there. pub source: Source, } /// Who put a tag on a sample. /// /// Mirrored as an enum where the app holds a string, which is the one place this /// port narrows rather than copies: the store's `source` column is open text and /// the panel already switches on four known values, so a described screen that /// carried the string would make every renderer repeat that switch. /// [`Source::Other`] keeps whatever the store said, so a source the app grows /// still reaches the reader rather than being flattened to "manual". #[derive(Debug, Clone, PartialEq, Eq)] pub enum Source { /// Typed in by hand, which is what an unrecorded source means. Manual, /// A tagging rule matched. Rule, /// The classifier proposed it and it was accepted. Suggested, /// It came out of a cluster. Cluster, /// Harvested from the folder the file was in. Folder, /// Something the app has grown since this list was written. Other(String), } impl Source { /// What the panel calls it. #[must_use] pub fn as_str(&self) -> &str { match self { Self::Manual => "manual", Self::Rule => "rule", Self::Suggested => "suggested", Self::Cluster => "cluster", Self::Folder => "folder", Self::Other(other) => other, } } /// The source that name means. fn of(name: Option<&str>) -> Self { match name { None => Self::Manual, Some("rule") => Self::Rule, Some("ml") => Self::Suggested, Some("cluster") => Self::Cluster, Some("harvest") => Self::Folder, Some(other) => Self::Other(other.to_owned()), } } } /// A tag some similar sample carries, offered for this one. #[derive(Debug, Clone, PartialEq)] pub struct Suggested { /// The tag. pub tag: String, /// How confident the classifier is, from zero to one. pub score: f64, /// How many similar samples carry it. pub neighbours: usize, } /// Several samples at once, as the description can name them. /// /// What a multi-selection has to say is what its members agree on, so every /// field here is already reduced. The reduction is the app's /// (`ui::detail::summarize`) and stays there: whether three samples share a /// tempo is a fact about them rather than a rendering decision, and a /// description that carried three tempos would make each renderer decide again /// what to do when they disagree. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Spread { /// How many samples are chosen. pub samples: usize, /// How many folders are chosen alongside them. pub folders: usize, /// The tempo they share, if they share one. pub bpm: Shared, /// The key they share, if they share one. pub musical_key: Shared, /// The length they share, if they share one. pub duration: Shared, /// Every tag any of them carries, and how many carry it. pub tags: Vec, } /// One field across a selection. /// /// Three answers rather than `Option>`, which is what the app's own /// `summarize` returns and is unreadable at the call site: `Some(Err(()))` is /// "they disagree" and nothing in the type says so. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Shared { /// Every one of them says this. Same(String), /// They do not agree. Varies, /// None of them has it at all. Absent, } /// One tag across a selection. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Coverage { /// The tag. pub name: String, /// How many of the chosen samples carry it. pub on: usize, } /// The detail panel, as much of it as a described screen needs. /// /// The fifth narrow trait, and the first whose **writes are all intents**. Every /// port before it had at least one write that went through a handle the app /// already had; here even the two that look like plain data writes — adding a /// tag, removing one — are recorded instead, and the reason is worth stating as /// the rule the next port will want: /// /// **A data write whose consequences are UI state is an intent, not a handle /// call.** `Backend::add_tag` is `&self` and a route could call it. What the /// shipped panel does around that call is not: removing a tag pushes an undo /// entry, sets the status line and re-reads `detail.selected_tags`, all /// `&mut BrowserState`. A described screen that called the backend directly /// would write the tag and lose the undo, which is a worse outcome than not /// describing the control — it would look like it worked. /// /// So the boundary is not "reads through a handle, writes through an intent". It /// is: **what the app does about a write decides where the write goes.** See /// [`files`]'s header for the first half of this rule and [`export`]'s for what /// an intent costs. pub trait Detail { /// What the panel is about. fn focus(&self) -> Focus; /// Put this tag on the sample in focus. fn add_tag(&self, tag: &str); /// Take this tag off the sample in focus. fn remove_tag(&self, tag: &str); /// Go and find tags from acoustically similar samples. fn suggest(&self); /// Take one of the suggestions. fn accept(&self, tag: &str); /// Put the sample's path on the clipboard. fn copy_path(&self); /// Open the sample editor. fn edit(&self); /// Open the forge. fn forge(&self); /// Find samples that sound like this one. fn find_similar(&self); /// Find near-duplicates of this one. fn find_duplicates(&self); /// Put this tag on every chosen sample that lacks it. fn spread_tag(&self, tag: &str); /// Take this tag off every chosen sample that carries it. fn strip_tag(&self, tag: &str); } /// The app's detail panel, as the narrow thing a described screen borrows. /// /// Reads come off `BrowserState` and the analysis the app has already loaded /// into `detail.selected_analysis`; every write is recorded. See [`Detail`]'s /// header for why even the tag writes are recorded when the backend would take /// them directly. pub struct FromSelection<'a> { /// What the app has selected and loaded already. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Detail for FromSelection<'_> { fn focus(&self) -> Focus { if self.state.nav.selection.count() > 1 { return Focus::Several(Box::new(self.spread())); } let Some(node) = self.state.selected_node() else { return Focus::Nothing; }; Focus::One(Box::new(Detailed { id: node.node.id.as_i64(), name: node.node.name.clone(), path: self.state.selected_sample_path(), analysis: self .state .detail .selected_analysis .as_ref() .map(|found| Analysis { duration: found.duration, sample_rate: found.sample_rate, channels: found.channels, bpm: found.bpm, musical_key: found.musical_key.clone(), peak_db: found.peak_db, rms_db: found.rms_db, lufs: found.lufs, is_loop: found.is_loop, }), tags: self .state .detail .selected_tags .iter() .map(|tag| Tagged { name: tag.clone(), source: Source::of( self.state .detail .selected_tag_sources .get(tag) .map(|(source, _)| source.as_str()), ), }) .collect(), suggestions: self .state .detail .selected_ml_suggestions .iter() .map(|found| Suggested { tag: found.tag.clone(), score: found.score, neighbours: found.neighbors.len(), }) .collect(), is_sample: node.node.sample_hash.is_some(), has_spectral: self .state .detail .selected_analysis .as_ref() .is_some_and(|found| { found.spectral_centroid.is_some() || found.spectral_bandwidth.is_some() }), has_fingerprint: self .state .detail .selected_analysis .as_ref() .is_some_and(|found| found.fingerprint.is_some()), })) } fn add_tag(&self, tag: &str) { self.push(Intent::AddTag(tag.to_owned())); } fn remove_tag(&self, tag: &str) { self.push(Intent::RemoveTag(tag.to_owned())); } fn suggest(&self) { self.push(Intent::Suggest); } fn accept(&self, tag: &str) { self.push(Intent::AcceptSuggestion(tag.to_owned())); } fn copy_path(&self) { self.push(Intent::CopyPath); } fn edit(&self) { self.push(Intent::Edit); } fn forge(&self) { self.push(Intent::Forge); } fn find_similar(&self) { self.push(Intent::FindSimilar); } fn find_duplicates(&self) { self.push(Intent::FindDuplicates); } fn spread_tag(&self, tag: &str) { self.push(Intent::SpreadTag(tag.to_owned())); } fn strip_tag(&self, tag: &str) { self.push(Intent::StripTag(tag.to_owned())); } } impl FromSelection<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } /// What the chosen samples have in common. /// /// The reduction the shipped panel does, called through the app's own /// helpers rather than repeated here: `selected_nodes` is what the panel /// reads and the agreement test is `ui::detail::summarize`'s, made public /// for this so the two cannot drift. fn spread(&self) -> Spread { let nodes = self.state.selected_nodes(); let samples: Vec<_> = nodes .iter() .filter(|node| node.node.sample_hash.is_some()) .collect(); let count = samples.len(); let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); for node in &samples { for tag in &node.tags { *counts.entry(tag.clone()).or_insert(0) += 1; } } let mut tags: Vec = counts .into_iter() .map(|(name, on)| Coverage { name, on }) .collect(); // Widest coverage first, then alphabetical, which is the order the // shipped panel sorts its badges into. tags.sort_by(|left, right| { right .on .cmp(&left.on) .then_with(|| left.name.cmp(&right.name)) }); Spread { samples: count, folders: nodes.len().saturating_sub(count), bpm: shared( crate::quasi::detail::summarize(&samples, |node| node.bpm), |bpm| format!("{bpm:.0}"), ), musical_key: shared( crate::quasi::detail::summarize(&samples, |node| node.musical_key.clone()), |key| key, ), duration: shared( crate::quasi::detail::summarize(&samples, |node| node.duration), |seconds| format!("{seconds:.1}s"), ), tags, } } } /// The app's three-way agreement answer, as the description names it. fn shared(summary: Option>, write: impl FnOnce(T) -> String) -> Shared { match summary { Some(Ok(value)) => Shared::Same(write(value)), Some(Err(())) => Shared::Varies, None => Shared::Absent, } } /// What a bulk operation is about. /// /// The selection, reduced to what the three modals actually name. Deliberately /// **not** the app's [`BulkModal`](crate::state::BulkModal): see [`Bulk`]'s /// header, where that absence is the finding rather than an omission. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Chosen { /// What each chosen node is called, in the order they were chosen. pub names: Vec, /// How many of them are samples rather than folders, which is what the tag /// modal acts on and the other two do not care about. pub samples: usize, } /// A folder a bulk move may target. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Folder { /// The node's own id, which the address is built from. pub id: i64, /// The whole path, as the picker shows it. pub path: String, } /// Bulk operations over the selection, as much as a described screen needs. /// /// The sixth narrow trait, and the one where the port stopped copying the /// shipped screen's state and started deleting it. /// /// # The app's `BulkModal` is not here, and that is the finding /// /// `BulkModal` is one enum with three variants holding eleven fields between /// them, and it is doing two unrelated jobs at once: /// /// - **A view buffer.** `tag_input`, `adding`, `selected_idx`, `pattern_input`, /// `previews`, `error`, and `import_wf.bulk_move_filter` beside it. Every one /// of those is what the user has typed and picked, living in app state /// because egui does not hold it for you. /// - **An argument list.** `hashes`, `node_ids`, `names`, `directories`, /// `targets`. Every one derived from the selection at the moment the modal /// opened, so `execute_bulk_tag` has something to read. /// /// A described modal needs neither. The buffer is what a `Runtime`'s `View` /// holds by definition, and the arguments are derived from the selection, which /// this trait reads. So the whole type is view-state plumbing, and the port does /// not reproduce it: nothing below opens a modal to find out what is in it. /// /// # What that costs at the commit, and why it is worth it /// /// The app's own executors read their arguments back out of `BulkModal`, so the /// host has to put them there before calling one. That is three lines in /// [`panel`] and it is the right three lines: the alternative is a second /// implementation of bulk tagging with its own undo entry, which is exactly what /// this port exists to avoid. **The description holds what was typed; the host /// hands it to the command that already exists.** /// /// # The preview is on this trait rather than in the route /// /// [`previews`](Self::previews) is a pure function of a pattern and the /// selection — `RenamePattern::parse` and `resolve_all` touch nothing — so a /// route *could* compute it. It does not, for [`Sync::quote_cents`]'s reason: /// naming what a pattern expands to is the app's, and a description that /// reimplemented it would be a second answer free to disagree with the first. pub trait Bulk { /// The modal is finished with: put it away. /// /// `naming`'s `done` in a second consumer, and for the same reason: what /// keeps one of these on screen is the host's own `bulk_modal`, so leaving /// the address is not leaving the screen. fn done(&self); /// What is chosen. fn chosen(&self) -> Chosen; /// Every tag the vault knows, for completing what is typed. fn known_tags(&self) -> Vec; /// Every folder a move may target. fn folders(&self) -> Vec; /// What this pattern would rename the chosen nodes to, old beside new. /// /// # Errors /// What is wrong with the pattern, as the app phrases it. fn previews(&self, pattern: &str) -> Result, String>; /// Put this tag on every chosen sample, or take it off every one. fn tag(&self, tag: &str, adding: bool); /// Move everything chosen into this folder, or to the root. fn move_to(&self, folder: Option); /// Rename everything chosen by this pattern. fn rename(&self, pattern: &str); } /// The app's selection, as the narrow thing the bulk screens borrow. pub struct FromBulk<'a> { /// What the app has selected. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Bulk for FromBulk<'_> { fn done(&self) { self.intents.borrow_mut().push(Intent::BulkDone); } fn chosen(&self) -> Chosen { let nodes = self.state.selected_nodes(); Chosen { samples: nodes .iter() .filter(|node| node.node.sample_hash.is_some()) .count(), names: nodes.iter().map(|node| node.node.name.clone()).collect(), } } fn known_tags(&self) -> Vec { self.state.all_tags.iter().cloned().collect() } fn folders(&self) -> Vec { let Some(vfs) = self.state.current_vfs_id() else { return Vec::new(); }; self.state .backend .list_all_directories(vfs) .unwrap_or_default() .into_iter() .map(|(id, path)| Folder { id: id.as_i64(), path, }) .collect() } fn previews(&self, pattern: &str) -> Result, String> { use audiofiles_core::rename::{RenameContext, RenamePattern}; let parsed = RenamePattern::parse(pattern).map_err(|error| error.to_string())?; let nodes = self.state.selected_nodes(); let contexts: Vec = nodes .iter() .enumerate() .map(|(index, node)| { let (name, extension) = audiofiles_core::util::split_name_ext(&node.node.name); RenameContext { name, extension, bpm: node.bpm, musical_key: node.musical_key.clone(), duration: node.duration, index, } }) .collect(); Ok(contexts .iter() .zip(parsed.resolve_all(&contexts)) .map(|(context, stem)| { ( whole(&context.name, &context.extension), whole(&stem, &context.extension), ) }) .collect()) } fn tag(&self, tag: &str, adding: bool) { self.intents .borrow_mut() .push(Intent::BulkTag(tag.to_owned(), adding)); } fn move_to(&self, folder: Option) { self.intents.borrow_mut().push(Intent::BulkMove(folder)); } fn rename(&self, pattern: &str) { self.intents .borrow_mut() .push(Intent::BulkRename(pattern.to_owned())); } } /// A stem and an extension as one filename. fn whole(stem: &str, extension: &str) -> String { if extension.is_empty() { stem.to_owned() } else { format!("{stem}.{extension}") } } /// What is playing, as the band needs to name it. /// /// Whole seconds, because that is what the transport shows and what a [`Meter`] /// takes. The frame-accurate position lives behind a mutex an audio thread is /// filling and is not a fact a screen reports. /// /// [`Meter`]: quasi_router::Meter #[derive(Debug, Clone, PartialEq, Eq)] pub struct Playing { /// What it is called. pub name: String, /// How far in, in seconds. pub position: u32, /// How long it runs, in seconds. pub total: u32, } /// How much of what is on screen analysis has got through. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Analysed { /// How many samples are on screen. pub samples: u32, /// How many of them have been analysed. pub analysed: u32, /// How many carry no tags. pub untagged: u32, } /// What kind of thing the app is saying. /// /// Two, where the shipped footer decides by matching substrings against the /// message it is about to draw (`is_error_status`: "failed", "error", "could /// not", "cannot"). The classification is the app's and is made here by calling /// that same function rather than by a second list of words; what changes is /// that it becomes a described fact instead of a colour chosen at paint time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Saying { /// Something went wrong. Failed, /// Something happened. Ordinary, } /// The main window's own band, as much as a described screen needs. /// /// The seventh narrow trait, and the one that borrows least: nine methods, none /// of which is a write to anything but the app's own playback and a dismissed /// hint. What it deliberately does not offer is a way to *seek* — see /// [`shell`]'s header on why the position is reported and not steered. pub trait Shell { /// What is playing, if anything is. fn playing(&self) -> Option; /// How many rows are chosen. fn chosen(&self) -> usize; /// How far analysis has got through what is on screen. fn analysed(&self) -> Analysed; /// What the app is saying, if it is saying anything. fn status(&self) -> Option<(String, Saying)>; /// Whether the first-launch hint is still showing. fn hinting(&self) -> bool; /// What preview plays through, if a device was found. fn device(&self) -> Option; /// The focused sample's tags. fn tags(&self) -> Vec; /// The storage-layout migration, if one is running. See [`Migrating`]. fn migrating(&self) -> Option; /// Stop the preview. fn stop(&self); /// Put the first-launch hint away. fn dismiss_hint(&self); /// Stop the migration until this vault is opened again. fn pause_migration(&self); } /// Blobs being moved from the flat store into hash-prefix shards. /// /// On [`Shell`] rather than on a capability of its own, and the shipped app's /// own placement is the argument: `draw_layout_strip` is a band of the main /// window, declared after the footer so it stacks above it, and it is a band for /// a stated reason — the migration auto-starts at vault open, the library stays /// usable while it runs because reads resolve both layouts, and seizing the /// window would be the wrong trade. /// /// So this is what the window's band says while a background job runs, which is /// what every other member of [`Shell`] already is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Migrating { /// How many blobs have moved. pub done: usize, /// How many there are. pub total: usize, } /// The app's main window, as the narrow thing the described band borrows. pub struct FromWindow<'a> { /// What the app is showing and playing. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Shell for FromWindow<'_> { fn playing(&self) -> Option { let hash = self.state.preview.previewing_hash.as_deref()?; let playback = self.state.shared.preview.lock(); if !playback.playing { return None; } let buffer = playback.buffer.as_ref()?; // Two channels interleaved, which is the shipped footer's own division // and the reason it is here rather than in the description: how a // buffer is laid out is not a fact about what is playing. let frames = buffer.data.len() / 2; let rate = f64::from(buffer.sample_rate); if frames == 0 || rate <= 0.0 { return None; } let name = self .state .nav .contents .iter() .find(|node| node.node.sample_hash.as_deref() == Some(hash)) .map_or("...", |node| node.node.name.as_str()) .to_owned(); Some(Playing { name, position: seconds(playback.position_frac / rate), total: seconds(frames as f64 / rate), }) } fn chosen(&self) -> usize { self.state.nav.selection.count() } fn analysed(&self) -> Analysed { let samples = self .state .nav .contents .iter() .filter(|node| node.node.sample_hash.is_some()); let mut seen = Analysed::default(); for node in samples { seen.samples += 1; if node.duration.is_some() { seen.analysed += 1; } if node.tags.is_empty() { seen.untagged += 1; } } seen } fn status(&self) -> Option<(String, Saying)> { if self.state.status.is_empty() { return None; } Some(( self.state.status.clone(), if crate::ui::footer::is_error_status(&self.state.status) { Saying::Failed } else { Saying::Ordinary }, )) } fn hinting(&self) -> bool { self.state.onboarding.show_first_launch_hint } fn device(&self) -> Option { self.state.shared.preview_device_name.lock().clone() } fn tags(&self) -> Vec { self.state.detail.selected_tags.as_ref().clone() } fn stop(&self) { self.intents.borrow_mut().push(Intent::StopPlayback); } fn dismiss_hint(&self) { self.intents.borrow_mut().push(Intent::DismissHint); } fn migrating(&self) -> Option { let running = self.state.layout_migration?; Some(Migrating { done: running.completed, total: running.total, }) } fn pause_migration(&self) { self.intents.borrow_mut().push(Intent::PauseMigration); } } /// A duration in seconds, as a whole number the transport can show. fn seconds(value: f64) -> u32 { #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a sample's length in seconds is small and positive" )] let whole = value.max(0.0) as u32; whole } /// One vault, as the sidebar needs to name it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Vault { /// The row's own id, which its addresses are built from. pub id: i64, /// What it is called. pub name: String, /// Whether it is the one being browsed. pub current: bool, } /// What a collection holds. /// /// The distinction the shipped row puts in its label as " (auto)" or " (12)". /// Two members rather than a string, because "this updates itself" and "this has /// twelve things in it" are different claims and only one of them is a count. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Holding { /// A saved search: whatever matches, whenever it matches. Dynamic, /// A fixed set, this big. Fixed(usize), } /// One collection, as the sidebar needs to name it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Collection { /// The row's own id. pub id: i64, /// What it is called. pub name: String, /// What it holds. pub holding: Holding, /// Whether it is the one being shown. pub active: bool, } /// One tag, and whether the list is filtered by it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Filter { /// The whole dotted path. See [`library`]'s header on why the hierarchy this /// path encodes is not described. pub path: String, /// Whether it is in force. pub on: bool, } /// The vaults, collections and tags, as much as the sidebar needs. /// /// The eighth narrow trait. Every write is an intent for the usual reason — /// selecting a vault, applying a filter and activating a collection are all /// `&mut BrowserState` — and the two deletes are as well, because each pushes a /// status line and re-reads the list it emptied. pub trait Library { /// Every vault in this library. fn vaults(&self) -> Vec; /// Every collection, manual and dynamic. fn collections(&self) -> Vec; /// Every tag the vault knows, and whether it is filtering. fn tags(&self) -> Vec; /// Browse this vault. fn open_vault(&self, id: i64); /// Delete this vault and everything in it. fn delete_vault(&self, id: i64); /// Filter by this tag, or stop. fn toggle_tag(&self, path: &str); /// Take this tag off every sample that has it. fn remove_tag(&self, path: &str); /// Show this collection. fn open_collection(&self, id: i64); /// Stop showing whichever is showing. fn close_collection(&self); /// Delete this collection. fn delete_collection(&self, id: i64); } /// The app's library, as the narrow thing the sidebar borrows. pub struct FromLibrary<'a> { /// What the app has loaded. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Library for FromLibrary<'_> { fn vaults(&self) -> Vec { self.state .nav .vfs_list .iter() .enumerate() .map(|(at, vfs)| Vault { id: vfs.id.as_i64(), name: vfs.name.clone(), current: at == self.state.nav.current_vfs_idx, }) .collect() } fn collections(&self) -> Vec { let active = self.state.collections_ui.active_collection; self.state .collections_ui .collections .iter() .map(|collection| Collection { id: collection.id.as_i64(), name: collection.name.clone(), holding: if collection.is_dynamic() { Holding::Dynamic } else { Holding::Fixed(collection.member_count) }, active: active == Some(collection.id), }) .collect() } fn tags(&self) -> Vec { let on = &self.state.search.search_filter.required_tags; self.state .all_tags .iter() .map(|path| Filter { on: on.contains(path), path: path.clone(), }) .collect() } fn open_vault(&self, id: i64) { self.push(Intent::OpenVault(id)); } fn delete_vault(&self, id: i64) { self.push(Intent::DeleteVault(id)); } fn toggle_tag(&self, path: &str) { self.push(Intent::ToggleTag(path.to_owned())); } fn remove_tag(&self, path: &str) { self.push(Intent::RemoveTagEverywhere(path.to_owned())); } fn open_collection(&self, id: i64) { self.push(Intent::OpenCollection(id)); } fn close_collection(&self) { self.push(Intent::CloseCollection); } fn delete_collection(&self, id: i64) { self.push(Intent::DeleteCollection(id)); } } impl FromLibrary<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } /// One step of the trail, as the toolbar needs to name it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Crumb { /// The folder's own id. pub id: i64, /// What it is called. pub name: String, } /// What the list is showing, as a place or as a mode. /// /// Three shapes at one region, and the same reasoning `Focus` and `Phase` are /// written with: a user does not navigate to "similar to kick.wav", they arrive /// there by asking for it. What is different is that two of the three carry a /// way *out* rather than a way back, which is what a mode is. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Where { /// A folder, and how you got to it. Folder { /// The trail from the root, nearest the root first. Empty at the root. trail: Vec, }, /// A collection's contents. Collection { /// What the collection is called. name: String, }, /// Samples that sound like one particular sample. Similar { /// What that sample is called. name: String, }, } /// What is being looked for. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Searching { /// What is typed. pub query: String, /// Whether the search covers every vault rather than this folder. pub everywhere: bool, /// Whether anything is narrowing the list at all. pub filtered: bool, /// How many rows came back. pub results: usize, /// How many filter axes are set. pub filters: usize, /// The name the app would give a collection made of these filters. /// /// `SearchFilter::describe`, resolved here rather than in the description /// for [`Sync::quote_cents`]'s reason: naming a filter set is the app's, and /// a screen that reimplemented it would be a second answer. pub describes: String, } /// A panel the toolbar shows or hides. /// /// A closed set for the reason [`Setting`] is one: it lets a single route serve /// all six, and an undeclared name is a `NotFound` rather than a silent no-op. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Panel { /// The vaults, collections and tags. Sidebar, /// The selected sample's facts. Detail, /// The sample editor. Edit, /// The instrument keyboard. Instrument, /// Whether preview loops. Loop, /// The filter axes. Filters, } impl Panel { /// Every one, in the order the toolbar puts them. pub const ALL: [Self; 6] = [ Self::Sidebar, Self::Detail, Self::Edit, Self::Instrument, Self::Loop, Self::Filters, ]; /// What the toggle is worth when the toolbar runs out of room. /// /// The declared replacement for the shipped `screen_w < 900.0`, which /// collapsed all six into a View menu at one pixel width this file had no /// say in. Ranked rather than collapsed, so a narrow window loses the /// toggles nobody reaches for and keeps the two that decide the shape of /// the window. /// /// - **Sidebar and Detail never drop.** They are the two structural panes, /// and a window narrow enough to want fewer controls is exactly the /// window where being able to close one of them matters most. /// - **Filters is Secondary.** It carries the count of what is on, so /// dropping it while a filter is applied would hide why the list is /// short. It survives everything but the narrowest class. /// - **Edit, Instrument and Loop drop first.** Each opens an inspector for /// the selected sample, which is work a phone-width window is not where /// you do. /// /// What this does not say is a width. Which class drops which rank is the /// renderer's, and it is the same rank in all three of them. #[must_use] pub const fn worth(self) -> quasi_router::layout::Priority { use quasi_router::layout::Priority; match self { Self::Sidebar | Self::Detail => Priority::Essential, Self::Filters => Priority::Secondary, Self::Edit | Self::Instrument | Self::Loop => Priority::Optional, } } /// The name an address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Sidebar => "sidebar", Self::Detail => "detail", Self::Edit => "edit", Self::Instrument => "instrument", Self::Loop => "loop", Self::Filters => "filters", } } /// What the control says. #[must_use] pub const fn label(self) -> &'static str { match self { Self::Sidebar => "Sidebar", Self::Detail => "Detail", Self::Edit => "Edit", Self::Instrument => "Instrument", Self::Loop => "Loop", Self::Filters => "Filters", } } /// The panel that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { Self::ALL.into_iter().find(|panel| panel.as_str() == name) } } /// The toolbar, as much of it as a described screen needs. /// /// The ninth narrow trait. pub trait Bar { /// Where the list is, or what mode it is in. fn place(&self) -> Where; /// What is being looked for. fn searching(&self) -> Searching; /// Which panels are showing. fn showing(&self) -> Vec; /// Whether there is anything to undo. fn undoable(&self) -> bool; /// Look for this. fn search(&self, query: &str); /// Look everywhere, or just here. fn set_scope(&self, everywhere: bool); /// Keep the active filters under this name. fn save_collection(&self, name: &str); /// Undo the last bulk action. fn undo(&self); /// Show this panel, or stop. fn toggle(&self, panel: Panel); /// Go to the vault root. fn go_root(&self); /// Go to this folder, this far along the trail. fn go_to(&self, id: i64, depth: usize); /// Leave whatever mode the list is in. fn leave(&self); } /// The app's toolbar, as the narrow thing a described screen borrows. pub struct FromBar<'a> { /// Where the app is and what it has found. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Bar for FromBar<'_> { fn place(&self) -> Where { if self.state.search.similarity_search_hash.is_some() { return Where::Similar { name: self .state .search .similarity_source_name .clone() .unwrap_or_else(|| "sample".to_owned()), }; } if let Some(active) = self.state.collections_ui.active_collection { return Where::Collection { name: self .state .collections_ui .collections .iter() .find(|collection| collection.id == active) .map_or_else(|| "Collection".to_owned(), |found| found.name.clone()), }; } Where::Folder { trail: self .state .nav .breadcrumb .iter() .map(|crumb| Crumb { id: crumb.id.as_i64(), name: crumb.name.clone(), }) .collect(), } } fn searching(&self) -> Searching { let filter = &self.state.search.search_filter; Searching { query: self.state.search.search_query.clone(), everywhere: matches!(filter.scope, audiofiles_core::search::SearchScope::Global), filtered: filter.is_active(), results: self.state.nav.contents.len(), filters: filter.active_count(), describes: filter.describe(), } } fn showing(&self) -> Vec { let mut showing = Vec::new(); if self.state.sidebar_visible { showing.push(Panel::Sidebar); } if self.state.detail.detail_visible { showing.push(Panel::Detail); } if self.state.edit.show_window { showing.push(Panel::Edit); } if self.state.preview.show_midi_window { showing.push(Panel::Instrument); } if self.state.preview.loop_enabled { showing.push(Panel::Loop); } if self.state.search.filter_panel_open { showing.push(Panel::Filters); } showing } fn undoable(&self) -> bool { self.state.can_undo() } fn search(&self, query: &str) { self.push(Intent::Search(query.to_owned())); } fn set_scope(&self, everywhere: bool) { self.push(Intent::Scope(everywhere)); } fn save_collection(&self, name: &str) { self.push(Intent::SaveCollection(name.to_owned())); } fn undo(&self) { self.push(Intent::Undo); } fn toggle(&self, panel: Panel) { self.push(Intent::TogglePanel(panel)); } fn go_root(&self) { self.push(Intent::GoRoot); } fn go_to(&self, id: i64, depth: usize) { self.push(Intent::GoTo(id, depth)); } fn leave(&self) { self.push(Intent::Leave); } } impl FromBar<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } /// A folder or vault being named, as the four name modals need it. /// /// The ninth, tenth and eleventh narrow traits are below, and this one is the /// odd member of the set: **its writes answer**. Every other capability records /// an [`Intent`] and hears nothing back, because what it asks for is /// `&mut BrowserState` and cannot happen inside a frame. Naming a vault is both /// at once — `Backend::create_vfs` is `&self` and returns whether it worked, /// `refresh_vfs_list` is `&mut` — so the write happens here and the refresh is /// the intent. /// /// That split is what makes the described modal able to keep its own error. A /// name that the store refuses has to come back to the field it was typed into, /// and an intent applied after the answer was built could not carry it. See /// [`naming`]'s header for the rule this sharpens: [`Detail`] settled that *what /// the app does about a write decides where the write goes*, and here what the /// app does about it is two things with different lifetimes. pub trait Naming { /// The modal is finished with: put it away. /// /// The host is what keeps one of these on screen, so leaving the address is /// not leaving the screen. See `naming`'s `DONE`. fn done(&self); /// What this vault is called, if it is one. fn vault(&self, id: i64) -> Option; /// What this folder is called, if it is one here. fn folder(&self, id: i64) -> Option; /// Make a vault by this name. /// /// # Errors /// Whatever the store said, as text, for the field to carry. fn create_vault(&self, name: &str) -> Result; /// Rename this vault. /// /// # Errors /// Whatever the store said, as text. fn rename_vault(&self, id: i64, name: &str) -> Result; /// Make a folder by this name, where the app is looking. /// /// # Errors /// Whatever the store said, as text. fn create_folder(&self, name: &str) -> Result; /// Rename this folder. /// /// # Errors /// Whatever the store said, as text. fn rename_folder(&self, id: i64, name: &str) -> Result; } /// The app's vaults and folders, as the narrow thing the name modals borrow. pub struct FromNaming<'a> { /// What the app has loaded. pub state: &'a crate::state::BrowserState, /// The refresh the write needs afterwards, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl FromNaming<'_> { /// The vault this id names, as the app's own id type. /// /// Looked up in the loaded list rather than built with `VfsId::from`, which /// is [`library`]'s rule: an address is reachable by typing, so an id that /// names nothing is a refusal rather than a call against the store. fn vault_id(&self, id: i64) -> Option { self.state .nav .vfs_list .iter() .find(|vfs| vfs.id.as_i64() == id) .map(|vfs| vfs.id) } /// The folder this id names, where it is a folder in the current listing. fn folder_id(&self, id: i64) -> Option { self.state .nav .contents .iter() .map(|node| &node.node) .find(|node| node.id.as_i64() == id && node.sample_hash.is_none()) .map(|node| node.id) } /// Record the refresh the write owes, and hand back what to say. fn changed(&self, intent: Intent, say: String) -> Result { self.intents.borrow_mut().push(intent); Ok(say) } } impl Naming for FromNaming<'_> { fn done(&self) { self.intents.borrow_mut().push(Intent::NamingDone); } fn vault(&self, id: i64) -> Option { self.state .nav .vfs_list .iter() .find(|vfs| vfs.id.as_i64() == id) .map(|vfs| vfs.name.clone()) } fn folder(&self, id: i64) -> Option { self.state .nav .contents .iter() .map(|node| &node.node) .find(|node| node.id.as_i64() == id && node.sample_hash.is_none()) .map(|node| node.name.clone()) } fn create_vault(&self, name: &str) -> Result { self.state .backend .create_vfs(name) .map_err(|error| error.to_string())?; let say = format!("Created vault: {name}"); self.changed(Intent::VaultsChanged(say.clone()), say) } fn rename_vault(&self, id: i64, name: &str) -> Result { let vault = self .vault_id(id) .ok_or_else(|| "No such vault".to_owned())?; self.state .backend .rename_vfs(vault, name) .map_err(|error| error.to_string())?; let say = format!("Renamed vault to: {name}"); self.changed(Intent::VaultsChanged(say.clone()), say) } fn create_folder(&self, name: &str) -> Result { // The shipped modal's own guard, kept as a guard: New Folder is only // reachable inside a vault, so this is the defensive path rather than a // real one. let vault = self .state .current_vfs_id() .ok_or_else(|| "No vault selected".to_owned())?; self.state .backend .create_directory(vault, self.state.nav.current_dir, name) .map_err(|error| error.to_string())?; let say = format!("Created folder: {name}"); self.changed(Intent::ContentsChanged(say.clone()), say) } fn rename_folder(&self, id: i64, name: &str) -> Result { let node = self .folder_id(id) .ok_or_else(|| "No such folder".to_owned())?; self.state .backend .rename_node(node, name) .map_err(|error| error.to_string())?; let say = format!("Renamed to: {name}"); self.changed(Intent::ContentsChanged(say.clone()), say) } } /// Where the import flow has got to. /// /// [`Phase`]'s peer for the other long flow, and the same argument holds for the /// same reason: which screen is showing is a fact about the app rather than /// somewhere the user chose to be, so it is one address answering several /// screens. What is different is the length — nine states against five — and the /// length is the app's, not the description's: `ImportMode` carries all nine and /// the shipped wizard draws one screen per state. /// /// **Not every state on `ImportMode` is here.** The four export states are /// [`Phase`]'s, and `Cleaning` and `ReviewLibrary` are not import at all: the /// enum is the app's full-screen router rather than an import-only state, which /// its own header says. Splitting it here is the description saying what the /// name stopped saying — see [`Sweep`] for the one that shares a *file* with /// these screens and nothing else. #[derive(Debug, Clone, PartialEq)] pub enum Stage { /// Nothing is being imported. Idle, /// Choosing what lands where, before anything is copied. Configuring { /// The folder the files are coming from. source: String, /// How many audio files the dry-run scan found in it. files: usize, /// Where they will land. strategy: Strategy, /// The name typed for a new vault. vault_name: String, /// The vaults a merge could go into. vaults: Vec, /// Which of them is chosen. merging_into: usize, }, /// Walking the folder, before the count is known. /// /// Its own state rather than [`Copying`](Self::Copying) with a flag, for /// the reason [`Phase`] is an enum: there is no total to report yet, and a /// screen holding a total that is not there is one every reader has to ask /// about. Scanning { /// How many audio files the walk has reached, or zero before the first /// event lands. found: usize, /// How much they weigh, as the app formats a size. size: Option, }, /// Files being copied in. Copying { /// How many have landed. done: usize, /// How many there are. total: usize, /// The one being copied now. current: String, /// What the whole set weighs, as the app formats a size. size: Option, /// Whether the files are referenced where they sit rather than copied. in_place: bool, /// What has gone wrong so far. failures: Vec, }, /// Naming what came in, one folder at a time. Tagging { /// Every imported folder, with whatever has been typed against it. folders: Vec, }, /// Choosing what to measure, before any of it runs. Choosing { /// How many samples would be analysed. samples: usize, /// What is ticked. measures: Measures, /// Whether the tagging step can be returned to. resumable: bool, }, /// Samples being analysed. Analysing { /// How many are done. done: usize, /// How many there are. total: usize, /// The one being analysed now. current: String, /// What has gone wrong so far, from both halves of the run. failures: Vec, }, /// Reading what the analysis suggested, before any of it is applied. Reviewing { /// Every sample with something to say about it, in the app's own order. items: Vec, /// Which one is being read, as an index into `items`. at: usize, /// How the list is ordered. order: Order, }, /// What failed, once the run is over. Summary { /// Files that never entered the library. rejected: Vec, /// Files that entered it and could not be analysed. unanalysed: Vec, }, /// Given up on partway. Stopped { /// Which half of the flow stopped. what: Halted, /// How many had been done when it stopped. done: usize, /// How many there would have been. total: usize, }, } /// Where imported files land. /// /// Mirrored rather than re-exported, which is [`Format`]'s reason: the app's /// `ImportStrategy` carries the vault and parent ids the choice resolves to, and /// those are the answer rather than the question. What the screen asks is which /// of three, and that is this. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Strategy { /// Every file into the folder that is open, structure discarded. Flat, /// A new vault, with the folder tree preserved. NewVault, /// An existing vault, merged into. Merge, } impl Strategy { /// The name a described control submits. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Flat => "flat", Self::NewVault => "new", Self::Merge => "merge", } } /// The strategy that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { match name { "flat" => Some(Self::Flat), "new" => Some(Self::NewVault), "merge" => Some(Self::Merge), _ => None, } } } /// A vault a merge could go into. /// /// The name and nothing else: the shipped picker addresses one by its index into /// the list it was built from, and that is what the app's `selected_merge_vfs_idx` /// holds. Carrying the id as well would offer the description a second way to /// name the same thing, and the app can only read one of them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VaultChoice { /// What the vault is called. pub name: String, } /// Something that went wrong, as a screen reports it. /// /// One type for both error lists, where the app has two — `ImportFileError` /// carries a path and `AnalysisFileError` carries a hash and a name. What a /// screen says of either is the same two things, so the difference is which list /// it is in rather than what shape it has. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Failure { /// What it was, as the app names it: a path before the store, a name after. pub name: String, /// Why it failed. pub error: String, } /// One imported folder waiting to be tagged. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FolderTags { /// What the folder is called. pub name: String, /// How many samples came out of it. pub samples: usize, /// What has been typed against it, comma-separated. pub typed: String, /// The typed tags this app would refuse, if any. /// /// Resolved here rather than in the route because it is the app's rule: /// `audiofiles_core::tags::validate_tag` says what a tag may be, and a /// described screen carrying a second copy of it would be the drift this /// layer exists to end. Same division as [`panel`]'s `add_tag`. pub invalid: Vec, } /// What an analysis run would measure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Measures { /// Peak, RMS and LUFS. pub loudness: bool, /// Tempo. pub bpm: bool, /// Musical key. pub key: bool, /// Centroid, flatness, rolloff and zero-crossing rate. pub spectral: bool, /// Whether the sample is a seamless loop. pub loops: bool, /// Tags suggested from the results. pub suggestions: bool, /// The envelope fingerprint near-duplicate detection reads. pub fingerprint: bool, /// Skipping tempo and key where they cannot apply. pub smart_skip: bool, } /// One thing an analysis run may be told to measure. /// /// [`Setting`]'s peer, and closed for the same reason: it is what lets one write /// route serve the whole screen without carrying a second list of the names it /// will answer to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Measure { /// [`Measures::loudness`]. Loudness, /// [`Measures::bpm`]. Bpm, /// [`Measures::key`]. Key, /// [`Measures::spectral`]. Spectral, /// [`Measures::loops`]. Loops, /// [`Measures::suggestions`]. Suggestions, /// [`Measures::fingerprint`]. Fingerprint, /// [`Measures::smart_skip`]. SmartSkip, } impl Measure { /// Every one of them, in the order the shipped screen ticks them. pub const ALL: [Self; 8] = [ Self::Loudness, Self::Bpm, Self::Key, Self::Spectral, Self::Loops, Self::Suggestions, Self::Fingerprint, Self::SmartSkip, ]; /// The name a described address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Loudness => "loudness", Self::Bpm => "bpm", Self::Key => "key", Self::Spectral => "spectral", Self::Loops => "loops", Self::Suggestions => "suggestions", Self::Fingerprint => "fingerprint", Self::SmartSkip => "smart-skip", } } /// What the control says. #[must_use] pub const fn label(self) -> &'static str { match self { Self::Loudness => "Loudness (peak, RMS, LUFS)", Self::Bpm => "BPM detection", Self::Key => "Key detection", Self::Spectral => "Spectral features", Self::Loops => "Loop detection", Self::Suggestions => "Auto-suggest tags", Self::Fingerprint => "Fingerprint (duplicate detection)", Self::SmartSkip => "Smart skip (skip BPM/key where they cannot apply)", } } /// The measure that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { Self::ALL.into_iter().find(|held| held.as_str() == name) } /// Whether this one is on. #[must_use] pub const fn read(self, measures: &Measures) -> bool { match self { Self::Loudness => measures.loudness, Self::Bpm => measures.bpm, Self::Key => measures.key, Self::Spectral => measures.spectral, Self::Loops => measures.loops, Self::Suggestions => measures.suggestions, Self::Fingerprint => measures.fingerprint, Self::SmartSkip => measures.smart_skip, } } } /// One analysed sample, and what the analysis wants to call it. #[derive(Debug, Clone, PartialEq)] pub struct Reviewed { /// What the sample is called. pub name: String, /// How long it runs, in seconds. pub duration: f64, /// What it was recorded at. pub sample_rate: u32, /// Its peak, in dBFS. pub peak_db: Option, /// Its tempo, where one was found. pub bpm: Option, /// Its key, where one was found. pub musical_key: Option, /// What the analysis suggests, best first. /// /// Sorted by the description rather than by the app, which is the one place /// this flow reorders anything: the shipped screen sorts `item.suggestions` /// in place every frame, and a route cannot do that. Sorting a copy is the /// same answer without the write. pub suggestions: Vec, } /// One tag the analysis proposes. #[derive(Debug, Clone, PartialEq)] pub struct Suggestion { /// The tag itself. pub tag: String, /// How sure the analysis is, from zero to one. pub confidence: f32, /// Why it thinks so. pub reason: String, /// Whether it is ticked to be applied. pub accepted: bool, } /// How the review list is ordered. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Order { /// The order they were imported in. Arrival, /// By name. Name, /// Most suggestions first. Suggestions, /// Most accepted first. Accepted, } impl Order { /// Every one of them, in the order the shipped picker lists them. pub const ALL: [Self; 4] = [Self::Arrival, Self::Name, Self::Suggestions, Self::Accepted]; /// The name a described control submits. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Arrival => "arrival", Self::Name => "name", Self::Suggestions => "suggestions", Self::Accepted => "accepted", } } /// What the control says. #[must_use] pub const fn label(self) -> &'static str { match self { Self::Arrival => "Import order", Self::Name => "Name", Self::Suggestions => "Suggestions", Self::Accepted => "Accepted", } } /// The order that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { Self::ALL.into_iter().find(|held| held.as_str() == name) } } /// One thing the configure screen may change. /// /// [`Setting`] and [`Measure`]'s third peer. Three answers rather than one, /// because the strategy is derived from all three and the app rebuilds it from /// them every frame — see `ui::import_screens::configure`, and the bug that /// arrangement was written to fix. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Decision { /// Which of the three strategies. Strategy, /// What to call a new vault. VaultName, /// Which existing vault to merge into. MergeVault, } impl Decision { /// The name a described address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Strategy => "strategy", Self::VaultName => "vault-name", Self::MergeVault => "merge-vault", } } /// The decision that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { match name { "strategy" => Some(Self::Strategy), "vault-name" => Some(Self::VaultName), "merge-vault" => Some(Self::MergeVault), _ => None, } } } /// Which half of the flow was given up on. /// /// The app's `CancelKind` less its export arm, which is [`Phase::Cancelled`]'s. /// One enum split across two descriptions because one screen serving three /// operations is the app's arrangement rather than a fact about any of them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Halted { /// Files were being copied in. Import, /// Samples were being analysed. Analysis, } /// Orphaned samples being swept up. /// /// **Not a stage of the import flow**, and it is here because it shares a file /// with four screens that are. `ui::import_screens::progress` draws it beside /// the import and analysis progress screens, and `ImportMode::Cleaning` sits on /// the same enum, so from the app's side it reads as part of the wizard. It is /// not: it is started by a bulk delete and by the export flow's orphan pass, and /// nothing in the wizard reaches it. So it answers its own address, which is the /// description saying what the shared file and the shared enum both blur. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Sweep { /// How many have been removed. pub done: usize, /// How many there are, or zero before the scan has counted them. pub total: usize, /// The one being removed now. pub current: String, } /// An import waiting to be agreed to, as the description needs to name it. /// /// The source is a `String` rather than a `PathBuf` for the reason [`Status`] is /// not `SyncStatus`: what a screen says about a path is the path, and a /// described screen that took the app's own type would carry the app's platform /// with it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Preflight { /// Where the files are. pub source: String, /// How many there are. pub files: usize, /// How big they are, as the app formats a size. pub size: String, } /// Importing, as much of it as a described screen needs. /// /// The tenth narrow trait, and it **grew rather than gained a sibling**. It /// landed as the preflight alone — one read and two writes — with its header /// saying "the configure, tagging, progress and summary screens are their own /// pass, and this trait is what that pass grows". This is that pass, and taking /// the note at its word is what keeps the two halves one capability: the /// preflight is a question about an import that has not started and the flow is /// every stage after it starts, which is one subject asked at two moments rather /// than two subjects. /// /// Every write is an [`Intent`], with no exceptions and for one reason: all of /// them land on `BrowserState::import_wf`, which is the app's own screen state. /// That is [`Export`]'s arrangement, and this trait is the larger consumer of /// it — nineteen writes against five. pub trait Importing { /// The import waiting to be agreed to, if one is. fn waiting(&self) -> Option; /// Go ahead with it, and remember the answer if `again` is false. fn accept(&self, again: bool); /// Do not. fn cancel(&self); /// Where the flow has got to. fn stage(&self) -> Stage; /// Orphaned samples being swept up, if any are. See [`Sweep`]. fn sweeping(&self) -> Option; // The doors. Each opens a native picker and then acts, which is a host act // with no described step: see this module's `integrity` header, and // `importing`'s for why three more consumers of it arrive at once. /// Pick a folder, then set the wizard up on it. fn open_folder(&self); /// Pick a folder, then index it with no questions asked. fn open_quickly(&self); /// Pick files, then merge them into the vault that is open. fn open_files(&self); /// Pick a different folder for the import being configured. fn change_source(&self); // Configuring. /// Answer one of the three questions the configure screen asks. fn decide(&self, decision: Decision, value: &str); /// Begin copying. fn begin(&self); /// Give up on the copy that is running. fn stop(&self); /// Give up and go back to configuring. fn retry(&self); /// Put the flow away, from wherever it is. fn dismiss(&self); // Tagging. /// Type these tags against this folder. fn tag_folder(&self, at: usize, typed: &str); /// Type these tags against every folder. fn tag_every_folder(&self, typed: &str); /// Apply what has been typed. fn apply_folder_tags(&self); /// Apply none of it and move on. fn skip_folder_tags(&self); // Analysing. /// Turn one measure on or off. fn measure(&self, measure: Measure, wanted: bool); /// Run the analysis. fn analyse(&self); /// Go back to tagging the folders. fn back_to_tagging(&self); /// Do not analyse at all. fn skip_analysis(&self); /// Give up on the analysis that is running. fn stop_analysis(&self); /// Give up on it and start it again. fn retry_analysis(&self); // Reviewing. /// Order the review list this way. fn order(&self, order: Order); /// Read this one. fn read(&self, at: usize); /// Accept or reject one suggestion against one sample. fn judge(&self, at: usize, tag: &str, accepted: bool); /// Accept or reject every suggestion against every sample. fn judge_all(&self, accepted: bool); /// Apply the accepted ones. fn apply_suggestions(&self); /// Apply none of them. fn discard_suggestions(&self); // The summary. /// Keep every file that failed. fn keep_failed(&self); /// Delete the ones that failed analysis, or one of them. fn purge_failed(&self, at: Option); // The sweep. /// Give up on the sweep that is running. fn stop_sweep(&self); } /// The app's import workflow, as the narrow thing the preflight borrows. pub struct FromImport<'a> { /// What the app is holding. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Importing for FromImport<'_> { fn waiting(&self) -> Option { let waiting = self.state.import_wf.pending_import_preflight.as_ref()?; Some(Preflight { source: waiting.source.display().to_string(), files: waiting.file_count, // Formatted by the app's own function rather than by the // description, which is the rule the footer's status tone follows: // how this app writes a size is the app's, and a second copy of it // here would drift. size: crate::ui::widgets::format_bytes(waiting.total_bytes), }) } fn accept(&self, again: bool) { self.push(Intent::AcceptImport { again }); } fn cancel(&self) { self.push(Intent::CancelImport); } fn stage(&self) -> Stage { use crate::state::ImportMode; match &self.state.import_wf.import_mode { ImportMode::ConfigureImport { source, strategy, available_vfs, selected_merge_vfs_idx, new_vfs_name, audio_file_count, .. } => Stage::Configuring { source: source.display().to_string(), files: *audio_file_count, strategy: match strategy { crate::import::ImportStrategy::Flat { .. } => Strategy::Flat, crate::import::ImportStrategy::NewVfs { .. } => Strategy::NewVault, crate::import::ImportStrategy::MergeIntoVfs { .. } => Strategy::Merge, }, vault_name: new_vfs_name.clone(), vaults: available_vfs .iter() .map(|vfs| VaultChoice { name: vfs.name.clone(), }) .collect(), merging_into: *selected_merge_vfs_idx, }, // The walk and the copy are one `ImportMode` arm with a flag, and // two stages here. See [`Stage::Scanning`]: before the walk lands // there is no total, and the shipped screen answers that with a // whole different body rather than with a zeroed bar. ImportMode::Importing { walking: true, walking_count, total_bytes, .. } => Stage::Scanning { found: *walking_count, size: Self::size(*total_bytes), }, ImportMode::Importing { total, completed, current_name, total_bytes, loose_files, .. } => Stage::Copying { done: *completed, total: *total, current: current_name.clone(), size: Self::size(*total_bytes), in_place: *loose_files, failures: self.failures(), }, ImportMode::TagFolders { entries, .. } => Stage::Tagging { folders: entries .iter() .map(|entry| FolderTags { name: entry.folder.name.clone(), samples: entry.folder.samples.len(), typed: entry.tag_input.clone(), invalid: invalid_tags(&entry.tag_input), }) .collect(), }, ImportMode::ConfigureAnalysis { sample_hashes, config, } => Stage::Choosing { samples: sample_hashes.len(), measures: Measures { loudness: config.loudness, bpm: config.bpm, key: config.key, spectral: config.spectral, loops: config.loop_detect, suggestions: config.auto_suggest_tags, fingerprint: config.fingerprint, smart_skip: config.smart_skip, }, // What the shipped Back button is enabled on, and it is a fact // about the app rather than about the screen: the tags typed on // the previous step are stashed, or the flow was entered from // somewhere that never had one. resumable: self.state.import_wf.last_folder_tags.is_some(), }, ImportMode::Analyzing { completed, total, current_name, } => Stage::Analysing { done: *completed, total: *total, current: current_name.clone(), failures: self.failures(), }, ImportMode::ReviewSuggestions { items, current_idx, sort, } => Stage::Reviewing { items: items.iter().map(reviewed).collect(), at: *current_idx, order: match sort { crate::state::ReviewSort::ImportOrder => Order::Arrival, crate::state::ReviewSort::Name => Order::Name, crate::state::ReviewSort::Suggestions => Order::Suggestions, crate::state::ReviewSort::Accepted => Order::Accepted, }, }, ImportMode::ReviewErrors => Stage::Summary { rejected: self .state .import_wf .import_file_errors .iter() .map(|failure| Failure { name: failure.path.clone(), error: failure.error.clone(), }) .collect(), unanalysed: self .state .import_wf .analysis_errors .iter() .map(|failure| Failure { name: failure.name.clone(), error: failure.error.clone(), }) .collect(), }, ImportMode::OperationCancelled { kind: kind @ (crate::state::CancelKind::Import | crate::state::CancelKind::Analysis), completed, total, .. } => Stage::Stopped { what: match kind { crate::state::CancelKind::Analysis => Halted::Analysis, _ => Halted::Import, }, done: *completed, total: *total, }, _ => Stage::Idle, } } fn sweeping(&self) -> Option { match &self.state.import_wf.import_mode { crate::state::ImportMode::Cleaning { completed, total, current_name, } => Some(Sweep { done: *completed, total: *total, current: current_name.clone(), }), _ => None, } } fn open_folder(&self) { self.push(Intent::OpenImportFolder); } fn open_quickly(&self) { self.push(Intent::OpenQuickImport); } fn open_files(&self) { self.push(Intent::OpenImportFiles); } fn change_source(&self) { self.push(Intent::ChangeImportSource); } fn decide(&self, decision: Decision, value: &str) { self.push(Intent::Decide(decision, value.to_owned())); } fn begin(&self) { self.push(Intent::BeginImport); } fn stop(&self) { self.push(Intent::StopImport); } fn retry(&self) { self.push(Intent::RetryImport); } fn dismiss(&self) { self.push(Intent::DismissImport); } fn tag_folder(&self, at: usize, typed: &str) { self.push(Intent::TagFolder(at, typed.to_owned())); } fn tag_every_folder(&self, typed: &str) { self.push(Intent::TagEveryFolder(typed.to_owned())); } fn apply_folder_tags(&self) { self.push(Intent::ApplyFolderTags); } fn skip_folder_tags(&self) { self.push(Intent::SkipFolderTags); } fn measure(&self, measure: Measure, wanted: bool) { self.push(Intent::Measure(measure, wanted)); } fn analyse(&self) { self.push(Intent::StartAnalysis); } fn back_to_tagging(&self) { self.push(Intent::BackToTagging); } fn skip_analysis(&self) { self.push(Intent::SkipAnalysis); } fn stop_analysis(&self) { self.push(Intent::StopAnalysis); } fn retry_analysis(&self) { self.push(Intent::RetryAnalysis); } fn order(&self, order: Order) { self.push(Intent::OrderReview(order)); } fn read(&self, at: usize) { self.push(Intent::ReadReviewed(at)); } fn judge(&self, at: usize, tag: &str, accepted: bool) { self.push(Intent::Judge { at, tag: tag.to_owned(), accepted, }); } fn judge_all(&self, accepted: bool) { self.push(Intent::JudgeAll(accepted)); } fn apply_suggestions(&self) { self.push(Intent::ApplySuggestions); } fn discard_suggestions(&self) { self.push(Intent::DiscardSuggestions); } fn keep_failed(&self) { self.push(Intent::KeepFailed); } fn purge_failed(&self, at: Option) { self.push(Intent::PurgeFailed(at)); } fn stop_sweep(&self) { self.push(Intent::StopSweep); } } impl FromImport<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } /// A weight the app has measured, as the app writes one. /// /// `None` at zero rather than "0 B", because zero here means the walk has /// not weighed anything yet rather than that the files are empty. The /// shipped screen draws nothing in that case and this is that, said. fn size(bytes: u64) -> Option { (bytes > 0).then(|| crate::ui::widgets::format_bytes(bytes)) } /// Everything that has gone wrong in this run, from both halves of it. /// /// One list where the app keeps two, which is what the shipped progress /// screens do as well: `draw_error_log` counts them together and draws them /// one after the other, because what a running screen reports is how much is /// going wrong rather than at which stage. fn failures(&self) -> Vec { self.state .import_wf .import_file_errors .iter() .map(|failure| Failure { name: failure.path.clone(), error: failure.error.clone(), }) .chain( self.state .import_wf .analysis_errors .iter() .map(|failure| Failure { name: failure.name.clone(), error: failure.error.clone(), }), ) .collect() } } /// The typed tags this app would refuse. /// /// Empty things are not refusals: a trailing comma is how someone types a list, /// not a mistake to report while they are still typing it. That is the shipped /// screen's own filter. fn invalid_tags(typed: &str) -> Vec { typed .split(',') .map(str::trim) .filter(|tag| !tag.is_empty() && audiofiles_core::tags::validate_tag(tag).is_err()) .map(ToOwned::to_owned) .collect() } /// One reviewed sample, with its suggestions put in the order they are read in. /// /// The sort is the shipped screen's — confidence descending — and doing it here /// is the difference between a route and a frame: `draw_review_suggestions` sorts /// `item.suggestions` in place on every pass, which a handler holding `&S` /// cannot. Sorting the copy the description is building reaches the same order /// without the write, and it is what makes the accepted count and the list agree. fn reviewed(item: &crate::state::ReviewItem) -> Reviewed { let mut suggestions: Vec = item .suggestions .iter() .map(|held| Suggestion { tag: held.suggestion.tag.clone(), confidence: held.suggestion.confidence, reason: held.suggestion.reason.clone(), accepted: held.accepted, }) .collect(); suggestions.sort_by(|a, b| { b.confidence .partial_cmp(&a.confidence) .unwrap_or(std::cmp::Ordering::Equal) }); Reviewed { name: item.name.clone(), duration: item.result.duration, sample_rate: item.result.sample_rate, peak_db: item.result.peak_db, bpm: item.result.bpm, musical_key: item.result.musical_key.clone(), suggestions, } } /// The vault's own health, as much as the warning needs. /// /// The eleventh narrow trait. Every write is an intent, and the third of them is /// the interesting one: locating the missing files opens a native folder picker, /// which is a host act with no described step. See [`integrity`]'s header — it is /// the **fourth consumer** of `quasi:vocabulary:host-save-location`. pub trait Integrity { /// How many samples have lost the file they point at. fn missing(&self) -> usize; /// Put the warning away without acting. fn dismiss(&self); /// Go looking for the files. fn locate(&self); /// Delete the entries whose files are gone. fn purge(&self); } /// The app's loose-files state, as the narrow thing the warning borrows. pub struct FromIntegrity<'a> { /// What the app has checked. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Integrity for FromIntegrity<'_> { fn missing(&self) -> usize { self.state.loose_files.loose_files_missing_count } fn dismiss(&self) { self.push(Intent::DismissLooseFiles); } fn locate(&self) { self.push(Intent::LocateLooseFiles); } fn purge(&self) { self.push(Intent::PurgeLooseFiles); } } impl FromIntegrity<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } /// The library-wide tag queue, as much of it as a described screen needs. /// /// One struct where the flow is an enum, and [`Forging`]'s reason again: the /// three sections of this screen are all live at once and nothing here is a /// state a reader arrived at. `rescanning` is a field for exactly the reason /// `busy` is one. #[derive(Debug, Clone, PartialEq)] pub struct Queued { /// Every tag with something waiting under it. pub groups: Vec, /// Which one is open, as an index into `groups`. pub at: usize, /// How many samples the pass looked at. pub considered: usize, /// How many of them it had something to say about. pub suggested: usize, /// How many suggestions across the whole queue clear their tag's threshold. pub confident: usize, /// Whether a pass is running. pub rescanning: bool, /// What the last accept did, if it has said anything. pub said: Option, /// The open group's candidates, as far as their names have been resolved. /// /// A window, and a real one rather than a renderer's: see [`Candidate`]. pub shown: Vec, } /// One tag, and how much is waiting under it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Group { /// The tag itself. pub tag: String, /// How many samples would get it. pub candidates: usize, /// How many of those clear its auto threshold. pub confident: usize, /// How many are ticked. pub checked: usize, } /// One sample that would get the open tag. /// /// Only ever the strongest few hundred, and that is a fact about the data rather /// than about the drawing. `ReviewGroup::names_loaded` resolves a display name /// per candidate through one backend call each, so a 44,000-row group is /// resolved to the window and no further — the rows past it have no name to /// carry. Every control on the screen still acts on the whole group. /// /// The distinction [`Files`] draws in the other direction: the file list holds /// every row it describes, so windowing there is a renderer's performance /// technique and `more` is `None`. #[derive(Debug, Clone, PartialEq)] pub struct Candidate { /// What the sample is called, or its hash until the name is resolved. pub name: String, /// How strongly it matches, from zero to one. pub score: f64, /// Whether it clears the tag's auto threshold. pub confident: bool, /// Whether it is ticked. pub accepted: bool, } /// How much of a group an accept applies to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scope { /// Every candidate under the tag. All, /// Only those above its auto threshold. Confident, /// Only the ticked ones. Checked, } impl Scope { /// The name a described address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::All => "all", Self::Confident => "confident", Self::Checked => "checked", } } /// The scope that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { match name { "all" => Some(Self::All), "confident" => Some(Self::Confident), "checked" => Some(Self::Checked), _ => None, } } } /// The tag queue, as much of it as a described screen needs. /// /// The fifteenth narrow trait, and the second over `classifier` rather than over /// `import_wf` — which one group is open is `ImportMode::ReviewLibrary`'s /// `selected`, and the queue itself is `classifier.review`. Every write is an /// [`Intent`] because both of those are the app's own state. pub trait Queue { /// The queue, if there is one worth reading. fn queued(&self) -> Option; /// Open this tag. fn read(&self, at: usize); /// Tick or untick one candidate of the open tag. fn tick(&self, at: usize); /// Tick or untick every candidate the screen is showing. fn tick_shown(&self, ticked: bool); /// Apply this much of the open tag. fn accept(&self, scope: Scope); /// Apply every confident suggestion under every tag. fn accept_confident(&self); /// Throw the open tag away without applying any of it. fn dismiss(&self); /// Run the pass again. fn rescan(&self); /// Put the screen away, keeping the queue. fn close(&self); } /// The app's tag queue, as the narrow thing the described screen borrows. pub struct FromQueue<'a> { /// What the last pass found. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Queue for FromQueue<'_> { fn queued(&self) -> Option { let queue = self.state.classifier.review.as_ref()?; // An empty queue is not a queue. The shipped screen leaves rather than // drawing an empty shell, on the grounds that "I finished" and "there // was never anything" should not look the same; the described side // cannot leave, so it refuses the address instead. if queue.groups.is_empty() { return None; } let at = self.state.review_selected(); Some(Queued { groups: queue .groups .iter() .map(|group| Group { tag: group.tag.clone(), candidates: group.candidates.len(), confident: group.confident(), checked: group.checked(), }) .collect(), at, considered: queue.samples_considered, suggested: queue.samples_with_suggestions, confident: self.state.review_confident_total(), rescanning: self.state.classifier.busy.is_some(), said: self.state.classifier.last_review_accept.clone(), shown: queue.groups.get(at).map_or_else(Vec::new, |group| { group .candidates .iter() .take(crate::quasi::queue::RENDER_ROWS) .map(|candidate| Candidate { // The hash stands in until the name is resolved, which // is the shipped row's own fallback. name: candidate .name .clone() .unwrap_or_else(|| candidate.hash.clone()), score: candidate.score, confident: candidate.confident, accepted: candidate.accepted, }) .collect() }), }) } fn read(&self, at: usize) { self.push(Intent::ReadGroup(at)); } fn tick(&self, at: usize) { self.push(Intent::TickCandidate(at)); } fn tick_shown(&self, ticked: bool) { self.push(Intent::TickShown(ticked)); } fn accept(&self, scope: Scope) { self.push(Intent::AcceptGroup(scope)); } fn accept_confident(&self) { self.push(Intent::AcceptConfident); } fn dismiss(&self) { self.push(Intent::DismissGroup); } fn rescan(&self) { self.push(Intent::Rescan); } fn close(&self) { self.push(Intent::CloseReview); } } /// One numeric axis of the filter panel, as a described screen needs it. /// /// The geometry is [`crate::quasi::filters::RangeAxis`], which is the shipped /// panel's own table read rather than copied: the six axes are a constant, and a /// second table here would drift the way the class filter's list and colour /// table drifted before the first one existed. /// /// The two ends are `Option` because an absent end is an answer. A minimum /// sitting on the sentinel edge stores `None` and the SQL omits that bound, /// which is what "no lower bound" is, and the description says the same thing by /// leaving the box empty. #[derive(Debug, Clone, PartialEq)] pub struct Narrowing { /// The key the axis is addressed by, and the stem both its names are built /// from. pub key: &'static str, /// The fixed geometry: the sentinel edges, the granularity, the unit. pub axis: &'static crate::quasi::filters::RangeAxis, /// The lower end wanted, if one is. pub lower: Option, /// The upper end wanted, if one is. pub upper: Option, } /// How a key filter matches. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Keys { /// The keys wanted, spelled the way the library spells them. pub wanted: Vec, /// Whether musically compatible keys count as well. pub compatible: bool, } /// The filter panel, as much of it as a described screen needs. /// /// The sixteenth narrow trait, and the last of audiofiles' real screens. It /// reads what is being filtered for and writes through [`Intent`]s, which is /// [`files`]'s arrangement and for its reason: every write here lands in /// `state.search.search_filter`, which is the app's own UI state and not /// something a route holding `&S` can reach. pub trait Filters { /// The six numeric axes, in the order the panel offers them. fn axes(&self) -> Vec; /// The keys wanted, and how they are matched. fn keys(&self) -> Keys; /// The tags every result has to carry. fn tags(&self) -> Vec; /// What is being typed into the tag box. fn typing(&self) -> String; /// How many samples match now. fn matched(&self) -> usize; /// Whether anything is filtering at all. fn active(&self) -> bool; /// What the current filters would be called, if they were saved unnamed. fn describes(&self) -> String; /// Narrow one axis to these two ends. fn narrow(&self, key: &'static str, lower: Option, upper: Option); /// Match only the chosen keys, or every key compatible with them. fn set_key_mode(&self, compatible: bool); /// Want this key, or stop wanting it. fn toggle_key(&self, key: &str); /// Stop wanting any key. fn clear_keys(&self); /// Remember what is being typed. fn typed(&self, text: &str); /// Require this tag of every result. fn require(&self, tag: &str); /// Stop requiring it. fn unrequire(&self, tag: &str); /// Stop requiring any tag. fn clear_tags(&self); /// Drop every filter, and the query with it. fn clear_all(&self); /// Keep the active filters under this name. fn save_collection(&self, name: &str); } /// The app's filters, as the narrow thing a described screen borrows. pub struct FromFilters<'a> { /// What is being filtered for, and what matched. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl FromFilters<'_> { /// The six axes and where each one's two ends are kept. /// /// One table, read twice: here for the values and in /// [`filters`](self::filters) for the description. Pairing the key with the /// geometry in one place is what stops the description and the write /// disagreeing about which axis `bpm` is. fn table(&self) -> [Narrowing; 6] { use crate::quasi::filters as axes; let f = &self.state.search.search_filter; [ Narrowing { key: "bpm", axis: &axes::BPM, lower: f.bpm_min, upper: f.bpm_max, }, Narrowing { key: "duration", axis: &axes::DURATION, lower: f.duration_min, upper: f.duration_max, }, Narrowing { key: "loudness", axis: &axes::LOUDNESS, lower: f.peak_db_min, upper: f.peak_db_max, }, Narrowing { key: "brightness", axis: &axes::BRIGHTNESS, lower: f.centroid_min, upper: f.centroid_max, }, Narrowing { key: "noisiness", axis: &axes::NOISINESS, lower: f.flatness_min, upper: f.flatness_max, }, Narrowing { key: "attack", axis: &axes::ATTACK, lower: f.attack_min, upper: f.attack_max, }, ] } /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } impl Filters for FromFilters<'_> { fn axes(&self) -> Vec { self.table().to_vec() } fn keys(&self) -> Keys { use audiofiles_core::search::KeyFilterMode; Keys { wanted: self.state.search.search_filter.keys.clone(), compatible: matches!( self.state.search.search_filter.key_mode, KeyFilterMode::Compatible ), } } fn tags(&self) -> Vec { self.state.search.search_filter.required_tags.clone() } fn typing(&self) -> String { self.state.search.filter_tag_input.clone() } fn matched(&self) -> usize { // Files only. A directory is structural and is not what a filter // targets, which is the shipped count's own rule. self.state .nav .contents .iter() .filter(|entry| entry.node.node_type != audiofiles_core::vfs::NodeType::Directory) .count() } fn active(&self) -> bool { self.state.search.search_filter.is_active() } fn describes(&self) -> String { self.state.search.search_filter.describe() } fn narrow(&self, key: &'static str, lower: Option, upper: Option) { self.push(Intent::Narrow(key, lower, upper)); } fn set_key_mode(&self, compatible: bool) { self.push(Intent::KeyMode(compatible)); } fn toggle_key(&self, key: &str) { self.push(Intent::ToggleKey(key.to_owned())); } fn clear_keys(&self) { self.push(Intent::ClearKeys); } fn typed(&self, text: &str) { self.push(Intent::TypingTag(text.to_owned())); } fn require(&self, tag: &str) { self.push(Intent::RequireTag(tag.to_owned())); } fn unrequire(&self, tag: &str) { self.push(Intent::UnrequireTag(tag.to_owned())); } fn clear_tags(&self) { self.push(Intent::ClearTags); } fn clear_all(&self) { self.push(Intent::ClearFilters); } fn save_collection(&self, name: &str) { self.push(Intent::SaveCollection(name.to_owned())); } } impl FromQueue<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } /// The sample in the forge, and everything the maker surface asks about it. /// /// One struct where [`Stage`] is an enum, and the difference is the screen: the /// forge is three sections of one window that are all live at once, so there is /// no state a reader arrives at. `busy` is a field rather than a shape for the /// same reason — the shipped window keeps drawing every control while a run is /// in flight and greys them, because the sample is still the subject. #[derive(Debug, Clone, PartialEq)] pub struct Forging { /// What the sample is called. pub name: String, /// What it was recorded at. pub rate: u32, /// Whether a chop or a conform is in flight. pub busy: bool, /// How it would be sliced. pub how: Chop, /// Transient sensitivity, from zero to one. pub sensitivity: f32, /// How many equal divisions. pub divisions: usize, /// The tempo the grid is built on. pub bpm: f64, /// Grid subdivisions per beat: one, two or four. pub subdivisions: u32, /// How many slices the last preview found, or zero for no preview. /// /// A count rather than the boundary fractions, and that is the waveform /// exclusion showing through: the marks are drawn over a rendered waveform, /// which no description reaches, and what the *controls* need of them is how /// many there are. See [`forge`]'s header. pub slices: usize, /// The devices a conform could target. pub devices: Vec, /// Which of them is chosen, if one is. pub device: Option, /// How many samples are chosen, for the batch section. pub chosen: usize, /// The level below which batch trim treats audio as silence. pub threshold_db: f64, } /// How a sample would be sliced. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Chop { /// At detected transients. Transient, /// Into equal divisions. Equal, /// On a tempo grid. Bpm, } impl Chop { /// Every one of them, in the order the shipped window offers them. pub const ALL: [Self; 3] = [Self::Transient, Self::Equal, Self::Bpm]; /// The name a described address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Transient => "transient", Self::Equal => "divisions", Self::Bpm => "bpm", } } /// What the control says. #[must_use] pub const fn label(self) -> &'static str { match self { Self::Transient => "Transient", Self::Equal => "Divisions", Self::Bpm => "BPM grid", } } /// The method that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { Self::ALL.into_iter().find(|held| held.as_str() == name) } } /// A device a conform could target. /// /// [`ProfileChoice`]'s smaller cousin, and separate from it for that type's own /// reason: the export screen needs the manufacturer, the category and the file /// size cap, and this needs the name and one line about what it takes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeviceChoice { /// What the device is called, which is also what a conform names. pub name: String, /// What it accepts, as the registry phrases it. pub summary: String, } /// One number the forge's controls may change. /// /// [`Setting`], [`Measure`] and [`Decision`]'s fourth peer, closed for the same /// reason: one write route serves five controls without a second list of the /// names it will answer to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Knob { /// [`Forging::sensitivity`]. Sensitivity, /// [`Forging::divisions`]. Divisions, /// [`Forging::bpm`]. Bpm, /// [`Forging::subdivisions`]. Subdivisions, /// [`Forging::threshold_db`]. Threshold, } impl Knob { /// The name a described address is built from. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Sensitivity => "sensitivity", Self::Divisions => "divisions", Self::Bpm => "bpm", Self::Subdivisions => "subdivisions", Self::Threshold => "threshold", } } /// The knob that name means, if it means one. #[must_use] pub fn from_key(name: &str) -> Option { match name { "sensitivity" => Some(Self::Sensitivity), "divisions" => Some(Self::Divisions), "bpm" => Some(Self::Bpm), "subdivisions" => Some(Self::Subdivisions), "threshold" => Some(Self::Threshold), _ => None, } } } /// The forge, as much of it as a described screen needs. /// /// The fourteenth narrow trait. Every write is an [`Intent`] and every one of /// them lands on `ForgeUiState`, which is the app's own screen state — the rule /// [`Files`] set and [`Export`] and [`Importing`] both follow. pub trait Forge { /// The sample in the forge, if one is. fn forging(&self) -> Option; /// Slice it this way. fn slice_by(&self, how: Chop); /// Set one of the numbers the slicing reads. fn turn(&self, knob: Knob, value: &str); /// Work out where the slices would fall. fn preview(&self); /// Write them. fn chop(&self); /// Aim a conform at this device. fn choose_device(&self, name: &str); /// Conform to whichever is chosen. fn conform(&self); /// Trim silence off everything chosen. fn trim_silence(&self); } /// The app's forge, as the narrow thing the described window borrows. pub struct FromForge<'a> { /// What the app has loaded into the forge. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Forge for FromForge<'_> { fn forging(&self) -> Option { let forge = &self.state.forge; forge.hash.as_ref()?; Some(Forging { name: forge.name.clone(), rate: forge.source_rate, busy: forge.busy, how: match forge.chop_mode { crate::state::ChopMode::Transient => Chop::Transient, crate::state::ChopMode::Equal => Chop::Equal, crate::state::ChopMode::Bpm => Chop::Bpm, }, sensitivity: forge.sensitivity, divisions: forge.divisions, bpm: forge.bpm, subdivisions: forge.subdivisions, // The marks are boundaries and the slices are the gaps between them, // which is the shipped button's own arithmetic. slices: forge.slice_marks.len().saturating_sub(1), devices: forge .devices .iter() .map(|(name, summary)| DeviceChoice { name: name.clone(), summary: summary.clone(), }) .collect(), device: forge.conform_device.clone(), chosen: self.state.selected_sample_hashes().len(), threshold_db: forge.trim_threshold_db, }) } fn slice_by(&self, how: Chop) { self.push(Intent::SliceBy(how)); } fn turn(&self, knob: Knob, value: &str) { self.push(Intent::Turn(knob, value.to_owned())); } fn preview(&self) { self.push(Intent::PreviewSlices); } fn chop(&self) { self.push(Intent::Chop); } fn choose_device(&self, name: &str) { self.push(Intent::ChooseDevice(name.to_owned())); } fn conform(&self) { self.push(Intent::Conform); } fn trim_silence(&self) { self.push(Intent::TrimSilence); } } impl FromForge<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } /// The sample being edited, as much as the editor needs to say about it. /// /// What is **not** here is the eleven knobs `EditUiState` carries — trim bounds, /// gain, normalise target and mode, fade shape and length, the two silence /// spans. See [`edit`]'s header: those are a buffer for what is being typed, /// which is a `Runtime`'s `View`, and the same deletion [`bulk`] made of /// `BulkModal`'s eleven fields. #[derive(Debug, Clone, PartialEq)] pub struct Editing { /// What the sample is called. pub name: String, /// Its sample rate, in Hz. pub sample_rate: u32, /// How long it runs, in seconds, where analysis has said. pub duration: Option, /// Its peak, in dBFS, where analysis has said. pub peak_db: Option, /// Whether this sample is the preview that is playing. pub playing: bool, /// Whether an edit is being applied right now. pub working: bool, /// Whether the app is waiting to be told what to do with a finished edit. pub asking: bool, /// The standing answer to that question, as [`EditResultMode::as_value`] /// writes it. /// /// [`EditResultMode::as_value`]: crate::state::EditResultMode::as_value pub result: Option, /// How many samples are chosen, which is what makes the batch section a /// section rather than nothing. pub chosen: usize, /// The last edit, while it is still reversible, by the name it goes under. pub undoing: Option, } /// The sample editor, as much as a described screen needs. /// /// The twelfth narrow trait and the widest, at eighteen methods, and the width /// is the screen's rather than the trait's: the shipped editor is one window /// with seven sections and every one of them dispatches its own operation. What /// it does *not* have is a way to read a knob back, which is the deletion. pub trait Edit { /// What is being edited, if anything is. fn subject(&self) -> Option; /// Cut the sample down to this span, as fractions of its length. fn trim(&self, start: f32, end: f32); /// Change its level by this many dB. fn gain(&self, db: f64); /// Normalise it to this target, by peak or by loudness. fn normalize(&self, peak: bool, target: f64); /// Play it backwards. fn reverse(&self); /// Fade it in or out, this long, on this curve. fn fade(&self, fading_in: bool, ms: f64, curve: &str); /// Put this much silence in at this point. fn insert_silence(&self, at: f64, ms: f64); /// Take this span out. fn remove_range(&self, from: f64, to: f64); /// Give up on the edit that is running. fn cancel(&self); /// Audition it, or stop auditioning it. fn play(&self); /// Stop the preview. fn stop(&self); /// Remember this as the standing answer to what happens to an edit. fn remember(&self, mode: &str); /// Answer the question a finished edit is waiting on. fn choose(&self, mode: &str, remember: bool); /// Throw the finished edit away. fn discard(&self); /// Put the last edit back. fn undo(&self); /// Normalise every chosen sample. fn batch_normalize(&self, peak: bool, target: f64); /// Change every chosen sample's level. fn batch_gain(&self, db: f64); /// Reverse every chosen sample. fn batch_reverse(&self); } /// The app's editor, as the narrow thing the described editor borrows. pub struct FromEditor<'a> { /// What the app is editing. pub state: &'a crate::state::BrowserState, /// What the described screen asked for, applied after the frame. pub intents: &'a std::cell::RefCell>, } impl Edit for FromEditor<'_> { fn subject(&self) -> Option { let hash = self.state.edit.hash.as_deref()?; let analysis = self.state.detail.selected_analysis.as_ref(); Some(Editing { name: self .state .selected_node() .map(|node| node.node.name.clone()) .unwrap_or_default(), sample_rate: analysis.map_or(44_100, |analysis| analysis.sample_rate), duration: analysis.map(|analysis| analysis.duration), peak_db: analysis.and_then(|analysis| analysis.peak_db), playing: self.state.preview.previewing_hash.as_deref() == Some(hash) && self.state.shared.preview.lock().playing, working: self.state.edit.in_progress, asking: self.state.edit.result_prompt, result: self .state .edit .result_mode .map(|mode| mode.as_value().to_owned()), chosen: self.state.selected_sample_hashes().len(), undoing: self .state .edit .last_undo .as_ref() .map(|entry| entry.op_name.clone()), }) } fn trim(&self, start: f32, end: f32) { self.push(Intent::EditTrim { start, end }); } fn gain(&self, db: f64) { self.push(Intent::EditGain(db)); } fn normalize(&self, peak: bool, target: f64) { self.push(Intent::EditNormalize { peak, target }); } fn reverse(&self) { self.push(Intent::EditReverse); } fn fade(&self, fading_in: bool, ms: f64, curve: &str) { self.push(Intent::EditFade { fading_in, ms, curve: curve.to_owned(), }); } fn insert_silence(&self, at: f64, ms: f64) { self.push(Intent::EditInsertSilence { at, ms }); } fn remove_range(&self, from: f64, to: f64) { self.push(Intent::EditRemoveRange { from, to }); } fn cancel(&self) { self.push(Intent::EditCancel); } fn play(&self) { self.push(Intent::EditPlay); } fn stop(&self) { self.push(Intent::StopPlayback); } fn remember(&self, mode: &str) { self.push(Intent::EditRemember(mode.to_owned())); } fn choose(&self, mode: &str, remember: bool) { self.push(Intent::EditChoose { mode: mode.to_owned(), remember, }); } fn discard(&self) { self.push(Intent::EditDiscard); } fn undo(&self) { self.push(Intent::EditUndo); } fn batch_normalize(&self, peak: bool, target: f64) { self.push(Intent::BatchNormalize { peak, target }); } fn batch_gain(&self, db: f64) { self.push(Intent::BatchGain(db)); } fn batch_reverse(&self) { self.push(Intent::BatchReverse); } } impl FromEditor<'_> { /// Record what the described screen asked for. fn push(&self, intent: Intent) { self.intents.borrow_mut().push(intent); } } /// A theme the host resolved, as the description needs to name it. /// /// Three strings rather than the app's own `ThemeMeta`, so the described screen /// does not depend on the shape of the theme loader: what a `Choice` needs is a /// value and something to show, and the variant is what the grouping finding is /// about. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThemeChoice { /// The id stored under `ConfigKey::Theme`. pub id: String, /// What the picker shows. pub name: String, /// `dark`, `light` or `high-contrast`. pub variant: String, /// This theme's TOML, when the host could read it. /// /// Here rather than behind a capability method for the reason the rest of /// `ThemeChoice` is: a theme's source is a host fact the app resolves, and /// the settled rule is that a host fact the app can answer goes in `S`. A /// capability that read a file would be a route touching this machine's /// disk, which is the thing the narrow traits exist to prevent. /// /// `None` for a theme whose source is not readable -- a built-in compiled /// in, or a custom file that has since moved. Export offers nothing in that /// case rather than offering an empty file. pub source: Option, } /// Everything the described screens read and write. /// /// One state for every screen rather than one per screen, because a router is /// one table: `Router` is generic over a single `S`, so the settings screen /// and the sync screen share it. Each borrows only the capability it uses, and /// the type says which. pub struct Panels<'a> { /// The config store, for the settings screen. pub config: &'a dyn Config, /// Cloud sync, for the sync screen. pub sync: &'a dyn Sync, /// The sample list, for the files screen. pub files: &'a dyn Files, /// The export flow, for the export screens. pub export: &'a dyn Export, /// The selection, for the detail screen. pub detail: &'a dyn Detail, /// The window's own band, for the main screen. pub shell: &'a dyn Shell, /// The vaults, collections and tags, for the sidebar. pub library: &'a dyn Library, /// Where you are and what you are looking for, for the toolbar. pub bar: &'a dyn Bar, /// The selection again, for the bulk screens. Two capabilities over one /// selection rather than one, because they need different things of it and /// the narrowing is the point: the detail screen may not move a file and /// the bulk screens may not read an analysis. pub bulk: &'a dyn Bulk, /// Vaults and folders again, for the four name modals. A third capability /// over ground [`Library`] already covers, and the narrowing is the same /// argument: the sidebar may delete a vault and may not name one, and the /// modal is the other way round. pub naming: &'a dyn Naming, /// The import waiting to be agreed to, for the preflight. pub importing: &'a dyn Importing, /// The vault's health, for the loose-files warning. pub integrity: &'a dyn Integrity, /// The sample being edited, for the editor. pub editor: &'a dyn Edit, /// The sample in the forge, for the maker surface. pub forge: &'a dyn Forge, /// The library-wide tag queue, for the review screen. pub queue: &'a dyn Queue, /// What is being filtered for, for the filter panel. pub filters: &'a dyn Filters, /// The themes on offer, resolved by the host at startup. pub themes: &'a [ThemeChoice], } /// Every described screen this app serves. /// /// Built per call rather than once: it is a `Vec` of function pointers, so the /// cost is nothing, and building it fresh is what lets the state borrow. #[must_use] pub fn router<'a>() -> Router> { filters::routes(queue::routes(forge::routes(edit::routes( integrity::routes(importing::routes(naming::routes(toolbar::routes( library::routes(shell::routes(help::routes(bulk::routes(detail::routes( export::routes(files::routes(sync::routes(settings::routes(Router::new())))), ))))), )))), )))) } #[cfg(test)] mod parity; #[cfg(test)] mod tests;