//! 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;