Skip to main content

max / audiofiles

167.8 KB · 5064 lines History Blame Raw
1 //! audiofiles' screens, described.
2 //!
3 //! Behind the off-by-default `quasi` feature, so the shipped egui panels in
4 //! [`crate::ui`] are exactly what they were while the described versions are
5 //! proved beside them. That is goingson's arrangement, and the reason is the
6 //! same: a port that replaces a working screen on the way in has to be right
7 //! first time, and a port that sits beside one can be wrong cheaply.
8 //!
9 //! # What the router's state is, and why it is this small
10 //!
11 //! A handler is `fn(&S, Request)`: sync, holding only what the app put in `S`.
12 //! [`Panels`] is therefore the *narrowest* thing the described screens need
13 //! rather than the whole of [`BrowserState`](crate::state::BrowserState). One
14 //! capability per screen plus one host fact, and each screen's type says which
15 //! of them it may touch:
16 //!
17 //! | Capability | Screen | Written through |
18 //! |---|---|---|
19 //! | [`Config`] | [`settings`] | the backend's own `&self` methods |
20 //! | [`Sync`] | [`sync`] | the sync manager's own `&self` methods |
21 //! | [`Files`] | [`files`] | an [`Intent`], applied after the frame |
22 //! | [`Export`] | [`export`] | an [`Intent`], applied after the frame |
23 //! | [`Detail`] | [`detail`] | an [`Intent`], applied after the frame |
24 //! | [`Bulk`] | [`bulk`] | an [`Intent`], applied after the frame |
25 //! | [`Shell`] | [`shell`] | an [`Intent`], applied after the frame |
26 //! | [`Library`] | [`library`] | an [`Intent`], applied after the frame |
27 //! | [`Bar`] | [`toolbar`] | an [`Intent`], applied after the frame |
28 //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame |
29 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
30 //!
31 //! The themes are the settled rule from goingson's settings port applied first
32 //! time out: *a host fact readable at startup goes in `S`*, resolved where the
33 //! app still has a handle to ask. The alternative — a capability surface on
34 //! quasi — was refused on 2026-08-09 and nothing here reopens it.
35 //!
36 //! Notably absent is anything `&mut`, and the right-hand column is why it can
37 //! be. Two of the six write through a handle that already takes `&self`; the
38 //! other four write to the app's own UI state, which a route cannot hold, so
39 //! they record an [`Intent`] and the panel applies it with the `&mut` the app
40 //! has anyway. See [`files`]'s header for the rule and [`export`]'s for what it
41 //! costs — an intent lands after the answer was built, which is what
42 //! `Runtime::reload` exists to correct.
43 //!
44 //! [`Detail`] sharpened that rule rather than following it. Two of its writes —
45 //! adding a tag, removing one — go to the app's *data* through a `&self` method
46 //! the backend already has, so by the sentence above they should have been
47 //! handle calls. They are intents, because what the app does *around* the write
48 //! is `&mut`: removing a tag pushes an undo entry the description could not
49 //! have pushed, and a route calling the backend directly would have removed the
50 //! tag and silently lost Cmd+Z. So the rule is not "reads through a handle,
51 //! writes through an intent" but **what the app does about a write decides
52 //! where the write goes**. [`Detail`]'s header has the long form.
53 //!
54 //! # The filter panel waited on the vocabulary, and that is why it is last
55 //!
56 //! [`filters`] is the sixteenth port and the only one held up by something the
57 //! description could not say. Its centre is six min/max pairs, and until
58 //! makeover-layout 0.34.0 nothing said two values were one question with two
59 //! ends: described as `Number` pairs they are twelve fields with no
60 //! relationship, the crossing rule is app-side per pair, and an error can only
61 //! be attached to one side of a fault that belongs to both.
62 //!
63 //! `FieldKind::Interval` was ruled on 2026-08-21 with this screen named as its
64 //! first consumer, and the port is what `range_filter_section`'s 55 lines were
65 //! standing in for. See [`filters`]'s header for what the member deleted and
66 //! what stayed behind: the sibling snap is a write and lives in the route, and
67 //! the per-axis disclosure is still unsayable.
68 //!
69 //! # What is not a capability, because it is not a screen
70 //!
71 //! `ui::overlays::draw_confirm_dialog` is a ten-variant `ConfirmAction` enum, a
72 //! `pending_confirm` field, an `execute_confirmed_action` dispatcher and a
73 //! 140-line `match` producing a title, a prompt, a detail line, a button label
74 //! and a danger flag. None of it is ported, and nothing replaces it, because
75 //! all of it is [`Act::confirm`](quasi_router::Act::confirm) and
76 //! [`Act::tone`](quasi_router::Act::tone) — which quasi has had since
77 //! `524a63fe`, and whose header names this exact shape: "Destructiveness is a
78 //! property of the action, known where the action is described, and until this
79 //! existed every app expressed it by calling a JS helper at the call site."
80 //!
81 //! A described control that destroys something says `confirm` on itself, the
82 //! runtime answers `Step::Ask`, and the host draws whatever asking looks like
83 //! for it. [`sync`]'s Disconnect has done that since it landed. **Ten variants
84 //! replaced by two builder methods**, and the port's contribution is counting
85 //! them rather than writing anything.
86 //!
87 //! # `ui::dialog` is not a screen either, and it is the evidence for a finding
88 //!
89 //! 267 lines, four picker kinds — pick a folder, pick a file, pick several, save
90 //! — a worker thread, a `Send` handler applied on the GUI thread a frame or more
91 //! later, and a note about macOS run loops. **It draws nothing.** There is no
92 //! screen here to describe and no capability to narrow: it is the mechanism a
93 //! host uses to ask the operating system a question, which is the definition of
94 //! a host concern.
95 //!
96 //! Recorded rather than skipped, because it is the sharpest measurement this
97 //! layer has of `quasi:vocabulary:host-save-location`. The gap has eight
98 //! consumers across the app — the export destination, Export Theme, Locate
99 //! missing files, and the import flow's four doors — and every one of them
100 //! reaches this file. What the count says is that a native picker is not an
101 //! oversight in one screen but a whole subsystem the description cannot name,
102 //! and that `FieldKind::File` covering "pick a file to submit" answers the one
103 //! shape of it nobody here uses.
104 //!
105 //! There is a second half, filed with the import flow: a route that hands off to
106 //! the host has no [`Outcome`](quasi_router::Outcome) meaning "nothing here
107 //! changed". `dialog.rs` is why — the answer arrives frames later, on a thread,
108 //! through a closure, and the route that asked is long finished.
109
110 // Handlers take their request by value because `quasi_router::Handler` is a
111 // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
112 // a choice made here.
113 #![allow(clippy::needless_pass_by_value)]
114
115 pub mod bulk;
116 pub mod detail;
117 pub mod edit;
118 pub mod export;
119 pub mod files;
120 pub mod filters;
121 pub mod forge;
122 pub mod help;
123 pub mod importing;
124 pub mod integrity;
125 pub mod library;
126 pub mod naming;
127 pub mod panel;
128 pub mod queue;
129 pub mod settings;
130 pub mod shell;
131 pub mod sync;
132 pub mod toolbar;
133
134 use audiofiles_core::config_key::ConfigKey;
135 use quasi_router::Router;
136
137 use crate::backend::Backend;
138
139 /// The config store, as much of it as a described screen needs.
140 ///
141 /// Two methods against `Backend`'s several dozen, and the narrowing is the
142 /// point rather than tidiness. Three things fall out of it:
143 ///
144 /// - **`Panels` is honestly the narrowest thing the screens need.** Borrowing
145 /// `&dyn Backend` would have said "this screen may do anything the app can do"
146 /// in its own type, which is exactly what a description layer is for not
147 /// saying.
148 /// - **The screens are testable with no app.** A fixture implements two methods
149 /// rather than a trait tree covering vfs, tags, search and the rest. That is
150 /// what makes the tests below run without a `BrowserState` or a window.
151 /// - **The error stops being the backend's.** A route answers `RouteError`, so
152 /// the store's failure is flattened to a string here and classified there.
153 ///
154 /// Adapted from the app's own handle by [`FromBackend`], which is one named
155 /// conversion rather than a blanket impl: `&dyn Backend` and `&dyn Config` are
156 /// unrelated trait objects and Rust upcasts between neither, so the adaptation
157 /// has to be spelled somewhere. Spelling it as a type says where.
158 pub trait Config {
159 /// What is stored under this key, if anything is.
160 ///
161 /// # Errors
162 /// Whatever the store said, as text.
163 fn get(&self, key: ConfigKey) -> Result<Option<String>, String>;
164
165 /// Store this value under this key.
166 ///
167 /// # Errors
168 /// Whatever the store said, as text.
169 fn set(&self, key: ConfigKey, value: &str) -> Result<(), String>;
170 }
171
172 /// The app's backend, as the narrow thing a described screen borrows.
173 ///
174 /// The whole of the adaptation, and the only place `Backend` is named on this
175 /// side of the boundary.
176 pub struct FromBackend<'a>(pub &'a dyn Backend);
177
178 impl Config for FromBackend<'_> {
179 fn get(&self, key: ConfigKey) -> Result<Option<String>, String> {
180 self.0.get_config(key).map_err(|error| error.to_string())
181 }
182
183 fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> {
184 self.0
185 .set_config(key, value)
186 .map_err(|error| error.to_string())
187 }
188 }
189
190 /// Where cloud sync has got to, as the description needs to name it.
191 ///
192 /// A plain snapshot rather than `audiofiles_sync::SyncStatus`, for the reason
193 /// [`ThemeChoice`] is not `ThemeMeta`: a described screen should not depend on
194 /// the shape of the thing it reports on, and the two fields this screen never
195 /// names (`device_id`, `needs_refresh`) would otherwise be in its blast radius.
196 #[derive(Debug, Clone, PartialEq, Eq)]
197 pub struct Status {
198 /// Which of the four the flow is in.
199 pub state: State,
200 /// When the last sync finished, as the app formats it.
201 pub last_sync_at: Option<String>,
202 /// How many local changes have not gone up.
203 pub pending_changes: i64,
204 /// What went wrong, if anything did.
205 pub last_error: Option<String>,
206 /// Whether the scheduler is running.
207 pub auto_sync_enabled: bool,
208 /// How often it runs, in minutes.
209 pub sync_interval_minutes: u32,
210 }
211
212 /// The four states the sync flow has.
213 ///
214 /// Mirrored rather than re-exported, so the described screen names its own
215 /// vocabulary. `NeedsEncryption` keeps its flag because it changes what the
216 /// screen says, though not what shape it is.
217 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
218 pub enum State {
219 /// Not connected to anything.
220 Disconnected,
221 /// Waiting on a browser.
222 Authenticating,
223 /// Connected, and the vault is not unlocked yet.
224 NeedsEncryption {
225 /// Whether the server already holds a key, so this is an unlock rather
226 /// than a first password.
227 has_server_key: bool,
228 },
229 /// Connected and idle.
230 Ready,
231 /// Connected and working.
232 Syncing,
233 }
234
235 /// A subscription, as the description needs to name it.
236 #[derive(Debug, Clone, PartialEq, Eq)]
237 pub struct Subscription {
238 /// Whether it is paid up and running.
239 pub active: bool,
240 /// What was bought, in bytes.
241 pub limit_bytes: i64,
242 /// What is used, in bytes.
243 pub used_bytes: i64,
244 /// `monthly` or `annual`, as the wire spells it.
245 pub interval: String,
246 /// A cap change already queued for the next renewal.
247 pub pending_limit_bytes: Option<i64>,
248 }
249
250 /// What a cap may be and what it costs.
251 ///
252 /// The bounds, plus the ability to price a cap. The *quote* is a method rather
253 /// than a table because pricing is the server's and the app is not going to
254 /// reimplement it: see [`sync`]'s header on why the price cannot follow a
255 /// slider.
256 pub struct Pricing {
257 /// The smallest cap that may be bought, in bytes.
258 pub min_bytes: i64,
259 /// The largest, in bytes.
260 pub max_bytes: i64,
261 }
262
263 /// Cloud sync, as much of it as a described screen needs.
264 ///
265 /// The same narrowing [`Config`] makes, for the same three reasons, and it lands
266 /// harder here: `SyncManager` owns a scheduler, a client and a keyring, and a
267 /// described screen borrowing all of that would say in its own type that it may
268 /// start a network conversation. What it may actually do is these eight things.
269 ///
270 /// Every one of them is `&self` on the manager already, which is why this screen
271 /// is describable at all — see [`sync`]'s header, where that is compared against
272 /// goingson ruling its own sync section out.
273 pub trait Sync {
274 /// Whether syncing is possible here at all.
275 ///
276 /// Not the same as disconnected, and the flip found the difference
277 /// (2026-08-22). Disconnected means "there is a service and you are not on
278 /// it", which is what `Connect` answers. `false` here means there is no
279 /// service to be on: no vault is open, or this build has no manager. The
280 /// screen offers nothing in that state, which is what the shipped panel did
281 /// with a whole second window, rather than offering a `Connect` that
282 /// refuses and a `Dismiss` for an error that cannot be cleared.
283 ///
284 /// Defaulted, because every real manager can sync and only
285 /// [`Unconfigured`] cannot.
286 fn available(&self) -> bool {
287 true
288 }
289
290 /// Where the flow has got to.
291 fn status(&self) -> Status;
292
293 /// Begin authentication, and answer where the user has to go.
294 ///
295 /// # Errors
296 /// Whatever the manager said, as text.
297 fn connect(&self) -> Result<String, String>;
298
299 /// Give up waiting on the browser.
300 fn cancel(&self);
301
302 /// Set or supply the password that encrypts this vault.
303 ///
304 /// `is_new` is the difference between choosing a password and unlocking with
305 /// one, which the manager needs and the screen already knows from
306 /// [`State::NeedsEncryption`].
307 fn set_password(&self, password: &str, is_new: bool);
308
309 /// Sync now rather than on the schedule.
310 fn sync_now(&self);
311
312 /// Turn the schedule on or off.
313 fn set_auto(&self, enabled: bool);
314
315 /// How often the schedule runs, in minutes.
316 fn set_interval(&self, minutes: u32);
317
318 /// Clear whatever went wrong.
319 fn clear_error(&self);
320
321 /// Stop syncing this vault.
322 fn disconnect(&self);
323
324 /// The subscription, once it has been fetched.
325 ///
326 /// `None` is "not known yet" rather than "none": the manager fetches it
327 /// asynchronously, which is what the described screen reports as
328 /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending).
329 fn subscription(&self) -> Option<Subscription>;
330
331 /// What a cap may be, once pricing has been fetched.
332 fn pricing(&self) -> Option<Pricing>;
333
334 /// How many bytes blob sync would upload today: the union of every VFS with
335 /// `sync_files` set, deduped by hash.
336 ///
337 /// The cap is chosen against this, which is why the screen can propose an
338 /// answer rather than ask for one. `None` is "cannot look" - no backend on
339 /// this side, or the query failed - and is what the screen falls back to
340 /// the floor on. `Some(0)` is different and means something: nothing is set
341 /// to sync yet.
342 fn synced_library_bytes(&self) -> Option<i64>;
343
344 /// What this cap costs at this cadence, in cents.
345 fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64;
346
347 /// Go and ask what the subscription is.
348 fn refresh_subscription(&self);
349
350 /// Buy this cap at this cadence.
351 ///
352 /// Answers nothing, and that is the shape rather than an omission: the
353 /// checkout URL is fetched asynchronously and the manager opens it itself,
354 /// so unlike [`connect`](Self::connect) there is no address to hand back.
355 fn subscribe(&self, cap_bytes: i64, annual: bool);
356
357 /// Change the cap on a running subscription, at the next renewal.
358 fn queue_cap_change(&self, cap_bytes: i64);
359 }
360
361 /// The app's sync manager, as the narrow thing a described screen borrows.
362 ///
363 /// Carries the backend as well as the manager, for one fact: the cap screen has
364 /// to know how much would upload, and that lives in the vault rather than in
365 /// the sync service. Everything else here is the manager.
366 pub struct FromSyncManager<'a> {
367 pub manager: &'a audiofiles_sync::SyncManager,
368 pub backend: &'a dyn crate::backend::Backend,
369 }
370
371 impl Sync for FromSyncManager<'_> {
372 fn status(&self) -> Status {
373 let status = self.manager.status();
374 Status {
375 state: match status.state {
376 audiofiles_sync::SyncState::Disconnected => State::Disconnected,
377 audiofiles_sync::SyncState::Authenticating => State::Authenticating,
378 audiofiles_sync::SyncState::NeedsEncryption { has_server_key } => {
379 State::NeedsEncryption { has_server_key }
380 }
381 audiofiles_sync::SyncState::Ready => State::Ready,
382 audiofiles_sync::SyncState::Syncing => State::Syncing,
383 },
384 last_sync_at: status.last_sync_at,
385 pending_changes: status.pending_changes,
386 last_error: status.last_error,
387 auto_sync_enabled: status.auto_sync_enabled,
388 sync_interval_minutes: status.sync_interval_minutes,
389 }
390 }
391
392 fn connect(&self) -> Result<String, String> {
393 self.manager.start_auth().map_err(|error| error.to_string())
394 }
395
396 fn cancel(&self) {
397 self.manager.cancel_auth();
398 }
399
400 fn set_password(&self, password: &str, is_new: bool) {
401 self.manager.setup_encryption(password.to_owned(), is_new);
402 }
403
404 fn sync_now(&self) {
405 self.manager.sync_now();
406 }
407
408 fn set_auto(&self, enabled: bool) {
409 self.manager.update_settings(Some(enabled), None);
410 }
411
412 fn set_interval(&self, minutes: u32) {
413 self.manager.update_settings(None, Some(minutes));
414 }
415
416 fn clear_error(&self) {
417 self.manager.clear_last_error();
418 }
419
420 fn disconnect(&self) {
421 self.manager.disconnect();
422 }
423
424 fn subscription(&self) -> Option<Subscription> {
425 let status = self.manager.status();
426 status.subscription.map(|sub| Subscription {
427 active: sub.active,
428 limit_bytes: sub.storage_limit_bytes.unwrap_or(0),
429 used_bytes: sub.storage_used_bytes.unwrap_or(0),
430 interval: sub
431 .interval
432 .map_or_else(|| "monthly".to_owned(), |i| i.as_str().to_owned()),
433 pending_limit_bytes: sub.pending_storage_limit_bytes,
434 })
435 }
436
437 fn pricing(&self) -> Option<Pricing> {
438 self.manager.status().pricing.map(|pricing| Pricing {
439 min_bytes: pricing.min_cap_bytes,
440 max_bytes: pricing.max_cap_bytes,
441 })
442 }
443
444 fn synced_library_bytes(&self) -> Option<i64> {
445 let (_, bytes) = self.backend.synced_storage_stats().ok()?;
446 i64::try_from(bytes).ok()
447 }
448
449 fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 {
450 self.manager.status().pricing.map_or(0, |pricing| {
451 pricing.quote_cents(cap_bytes, interval_of(annual)).0
452 })
453 }
454
455 fn refresh_subscription(&self) {
456 self.manager.fetch_subscription_status();
457 }
458
459 fn subscribe(&self, cap_bytes: i64, annual: bool) {
460 self.manager.subscribe(cap_bytes, interval_of(annual));
461 }
462
463 fn queue_cap_change(&self, cap_bytes: i64) {
464 self.manager.queue_cap_change(cap_bytes);
465 }
466 }
467
468 /// A cadence as the client spells it.
469 fn interval_of(annual: bool) -> audiofiles_sync::BillingInterval {
470 if annual {
471 audiofiles_sync::BillingInterval::Annual
472 } else {
473 audiofiles_sync::BillingInterval::Monthly
474 }
475 }
476
477 /// Sync that is not configured on this machine.
478 ///
479 /// The app has a whole second window for this case
480 /// (`ui::sync_panel::draw_sync_not_configured`), so the described side needs an
481 /// answer too. It reports [`State::Disconnected`] and refuses to connect, which
482 /// is the truth rather than a stub: there is nothing to connect *to*, and a
483 /// silent no-op would look like a control that does nothing.
484 pub struct Unconfigured;
485
486 impl Sync for Unconfigured {
487 fn available(&self) -> bool {
488 false
489 }
490
491 fn status(&self) -> Status {
492 Status {
493 state: State::Disconnected,
494 last_sync_at: None,
495 pending_changes: 0,
496 last_error: Some("Cloud sync is not configured for this build.".to_owned()),
497 auto_sync_enabled: false,
498 sync_interval_minutes: 0,
499 }
500 }
501
502 fn connect(&self) -> Result<String, String> {
503 Err("Cloud sync is not configured for this build.".to_owned())
504 }
505
506 fn cancel(&self) {}
507 fn set_password(&self, _password: &str, _is_new: bool) {}
508 fn sync_now(&self) {}
509 fn set_auto(&self, _enabled: bool) {}
510 fn set_interval(&self, _minutes: u32) {}
511 fn clear_error(&self) {}
512 fn disconnect(&self) {}
513 fn subscription(&self) -> Option<Subscription> {
514 None
515 }
516 fn pricing(&self) -> Option<Pricing> {
517 None
518 }
519 fn synced_library_bytes(&self) -> Option<i64> {
520 None
521 }
522 fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 {
523 0
524 }
525 fn refresh_subscription(&self) {}
526 fn subscribe(&self, _cap_bytes: i64, _annual: bool) {}
527 fn queue_cap_change(&self, _cap_bytes: i64) {}
528 }
529
530 /// One sample, as the description needs to name it.
531 #[derive(Debug, Clone, PartialEq)]
532 pub struct Sample {
533 /// The row's own id, which is what its addresses are built from.
534 pub id: i64,
535 /// What it is called.
536 pub name: String,
537 /// How long it runs, in seconds.
538 pub duration: Option<f64>,
539 /// Beats per minute, where analysis found some.
540 pub bpm: Option<f64>,
541 /// The musical key, where analysis found one.
542 pub key: Option<String>,
543 /// Peak level in dBFS.
544 pub peak_db: Option<f64>,
545 /// Whatever it is tagged with.
546 pub tags: Vec<String>,
547 /// Whether this row is a folder rather than a sample.
548 ///
549 /// The file list holds both, and until 2026-08-17 a described row could not
550 /// tell them apart: every row got the sample columns and a Play control,
551 /// folders included. `draw_context_menu` branches on exactly this and offers
552 /// two different menus, so `Cells::menu` could not be described without it.
553 pub directory: bool,
554 /// Whether the bytes are only in the cloud.
555 ///
556 /// What the shipped menu reads to offer Download and to withhold the four
557 /// acts that need the file on disk. A sample nobody has fetched can still be
558 /// listed, named and tagged, so this is not the same fact as absence.
559 pub cloud_only: bool,
560 }
561
562 /// Which columns the file list is showing.
563 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
564 pub struct ColumnsShown {
565 /// Show the duration column.
566 pub duration: bool,
567 /// Show the tempo column.
568 pub bpm: bool,
569 /// Show the musical key column.
570 pub key: bool,
571 /// Show the peak level column.
572 pub peak_db: bool,
573 /// Show the tags column.
574 pub tags: bool,
575 }
576
577 /// The sample list, as much of it as a described screen needs.
578 ///
579 /// The third narrow trait, and the first whose writes do **not** go through a
580 /// handle the app already had: selecting a row and playing one are
581 /// `&mut BrowserState`. See [`files`]'s header — those are recorded as intents
582 /// and applied by the host after the frame, which is the app's own
583 /// `pending_action` pattern rather than something invented for this.
584 pub trait Files {
585 /// The rows on screen now, already loaded and filtered by the app.
586 fn samples(&self) -> Vec<Sample>;
587
588 /// Which columns the user has switched on.
589 fn columns(&self) -> ColumnsShown;
590
591 /// The column in force, and whether it runs up.
592 fn sort(&self) -> (String, bool);
593
594 /// The row the app is pointing at.
595 fn current(&self) -> Option<i64>;
596
597 /// Select this row.
598 fn open(&self, id: i64);
599
600 /// Preview this row.
601 fn play(&self, id: i64);
602
603 /// Order by this column.
604 fn sort_by(&self, column: &str);
605
606 /// Go into this folder.
607 ///
608 /// The rest of this block is the row menu, 2026-08-17. Five of its entries
609 /// are not here and do not need to be: Copy Path, Edit, Find Similar and
610 /// Find Duplicates are already [`Detail`]'s, acting on the sample in focus,
611 /// so a route selects the row through [`open`](Self::open) and then calls
612 /// the capability that exists. Export is [`Export::open`]'s the same way, and
613 /// New Folder and Rename are addresses [`Naming`] already answers. What is
614 /// left is what nothing else could do.
615 fn enter(&self, id: i64);
616
617 /// Show this row in the system file manager.
618 fn reveal(&self, id: i64);
619
620 /// Play this sample chromatically, as an instrument.
621 fn as_instrument(&self, id: i64);
622
623 /// Analyse this sample again, replacing what is there.
624 fn reanalyze(&self, id: i64);
625
626 /// Delete this row.
627 ///
628 /// The asking is the act's, not this method's: a described act carries
629 /// [`Act::confirm`](quasi_router::Act::confirm) and every renderer raises it
630 /// its own way, so by the time a handler calls this the user has agreed.
631 fn delete(&self, id: i64);
632
633 /// Fetch this cloud-only sample to local storage.
634 fn download(&self, id: i64);
635
636 /// Take this sample out of the collection being viewed.
637 fn remove_from_collection(&self, id: i64);
638
639 /// Put this sample into that collection.
640 ///
641 /// Two ids rather than one, and that is the difference from
642 /// [`remove_from_collection`](Self::remove_from_collection): removing acts on
643 /// the collection already being viewed, so the screen knows which one without
644 /// being told. Adding does not, and the collection is what the act asked the
645 /// user for.
646 fn add_to_collection(&self, id: i64, collection: i64);
647 }
648
649 /// What a described screen asked the app to do to itself.
650 ///
651 /// The frame boundary, made explicit. A route cannot hold `&mut BrowserState`,
652 /// so a screen that acts on the app's own UI state records what was asked and
653 /// the panel applies it afterwards. `SettingsUiState::pending_action` is the
654 /// same pattern, already in this app and documented as "set by the UI, consumed
655 /// by the app layer each frame".
656 ///
657 /// `PartialEq` but not `Eq`: the editor's intents carry the numbers a control
658 /// submitted, and a gain is an `f64`.
659 #[derive(Debug, Clone, PartialEq)]
660 pub enum Intent {
661 /// Select a row.
662 Open(i64),
663 /// Preview a row.
664 Play(i64),
665 /// Go into a folder.
666 Enter(i64),
667 /// Show a row in the system file manager.
668 Reveal(i64),
669 /// Play a sample chromatically.
670 Instrument(i64),
671 /// Analyse a sample again.
672 Reanalyze(i64),
673 /// Delete a row, the asking already done.
674 Delete(i64),
675 /// Fetch a cloud-only sample.
676 Download(i64),
677 /// Take a sample out of the collection being viewed.
678 RemoveFromCollection(i64),
679 /// Put a sample into a collection the user named, by sample then collection.
680 AddToCollection(i64, i64),
681 /// Order by a column.
682 SortBy(String),
683 /// Change one export setting.
684 Configure(Setting, String),
685 /// Begin the export.
686 StartExport,
687 /// Give up on the running one.
688 CancelExport,
689 /// Put the flow away.
690 DismissExport,
691 /// Tag the sample in focus.
692 AddTag(String),
693 /// Untag the sample in focus.
694 RemoveTag(String),
695 /// Go and look for tags on similar samples.
696 Suggest,
697 /// Take one of those suggestions.
698 AcceptSuggestion(String),
699 /// Put the sample's path on the clipboard.
700 CopyPath,
701 /// Narrow one numeric axis, by its key and its two ends.
702 Narrow(&'static str, Option<f64>, Option<f64>),
703 /// Match only these keys, or every key compatible with them.
704 KeyMode(bool),
705 /// Want this key, or stop wanting it.
706 ToggleKey(String),
707 /// Stop wanting any key.
708 ClearKeys,
709 /// Remember what is being typed into the tag box.
710 TypingTag(String),
711 /// Require this tag of every result.
712 RequireTag(String),
713 /// Stop requiring it.
714 UnrequireTag(String),
715 /// Stop requiring any tag.
716 ClearTags,
717 /// Drop every filter and the query with them.
718 ClearFilters,
719 /// Open the sample editor.
720 Edit,
721 /// Open the forge.
722 Forge,
723 /// Look for samples that sound like this one.
724 FindSimilar,
725 /// Look for near-duplicates of this one.
726 FindDuplicates,
727 /// Tag every chosen sample that lacks this tag.
728 SpreadTag(String),
729 /// Untag every chosen sample that carries this tag.
730 StripTag(String),
731 /// Search for this.
732 Search(String),
733 /// Search here, or everywhere.
734 Scope(bool),
735 /// Save the active filters as a dynamic collection.
736 SaveCollection(String),
737 /// Undo the last bulk action.
738 Undo,
739 /// Show this panel, or stop showing it.
740 TogglePanel(Panel),
741 /// Go to the vault root.
742 GoRoot,
743 /// Go to this folder, this far along the trail.
744 GoTo(i64, usize),
745 /// Leave whatever mode the list is in.
746 Leave,
747 /// Switch to this vault.
748 OpenVault(i64),
749 /// Delete this vault and everything in it.
750 DeleteVault(i64),
751 /// Filter by this tag, or stop filtering by it.
752 ToggleTag(String),
753 /// Take this tag off every sample that has it.
754 RemoveTagEverywhere(String),
755 /// Show this collection.
756 OpenCollection(i64),
757 /// Stop showing whichever collection is showing.
758 CloseCollection,
759 /// Delete this collection.
760 DeleteCollection(i64),
761 /// Stop the preview that is playing.
762 StopPlayback,
763 /// Put the first-launch hint away.
764 DismissHint,
765 /// Tag or untag every chosen sample.
766 BulkTag(String, bool),
767 /// Move everything chosen into this folder, or to the root.
768 BulkMove(Option<i64>),
769 /// Rename everything chosen by this pattern.
770 BulkRename(String),
771 /// A name modal is finished with, whichever of the four it was.
772 NamingDone,
773 /// A bulk modal is finished with, whichever of the three it was.
774 BulkDone,
775 /// Re-read the vault list, and say this about why.
776 VaultsChanged(String),
777 /// Re-read the current folder, and say this about why.
778 ContentsChanged(String),
779 /// Go ahead with the import that is waiting, and remember the answer.
780 AcceptImport { again: bool },
781 /// Drop the import that is waiting.
782 CancelImport,
783 /// Ask the host for a folder, then set the wizard up on it.
784 OpenImportFolder,
785 /// Ask the host for a folder, then index it with no questions asked.
786 OpenQuickImport,
787 /// Ask the host for files, then merge them into the vault that is open.
788 OpenImportFiles,
789 /// Ask the host for a different folder for the import being configured.
790 ChangeImportSource,
791 /// Answer one of the configure screen's three questions.
792 Decide(Decision, String),
793 /// Start copying the files in.
794 BeginImport,
795 /// Give up on the copy that is running.
796 StopImport,
797 /// Give up on it and go back to configuring.
798 RetryImport,
799 /// Put the import flow away, from wherever it is.
800 DismissImport,
801 /// Type these tags against this imported folder.
802 TagFolder(usize, String),
803 /// Type these tags against every imported folder.
804 TagEveryFolder(String),
805 /// Apply what was typed against the folders.
806 ApplyFolderTags,
807 /// Apply none of it and move on.
808 SkipFolderTags,
809 /// Turn one analysis measure on or off.
810 Measure(Measure, bool),
811 /// Run the analysis.
812 StartAnalysis,
813 /// Go back to tagging the imported folders.
814 BackToTagging,
815 /// Do not analyse at all.
816 SkipAnalysis,
817 /// Give up on the analysis that is running.
818 StopAnalysis,
819 /// Give up on it and start it again.
820 RetryAnalysis,
821 /// Order the review list this way.
822 OrderReview(Order),
823 /// Read this reviewed sample.
824 ReadReviewed(usize),
825 /// Accept or reject one suggestion against one sample.
826 Judge {
827 /// Which sample, as an index into the review list.
828 at: usize,
829 /// Which suggestion, by the tag it proposes.
830 tag: String,
831 /// Whether it is now accepted.
832 accepted: bool,
833 },
834 /// Accept or reject every suggestion against every sample.
835 JudgeAll(bool),
836 /// Apply the accepted suggestions.
837 ApplySuggestions,
838 /// Apply none of them.
839 DiscardSuggestions,
840 /// Keep every file that failed.
841 KeepFailed,
842 /// Delete the ones that failed analysis, or one of them.
843 PurgeFailed(Option<usize>),
844 /// Give up on the sweep that is running.
845 StopSweep,
846 /// Stop the storage-layout migration until this vault reopens.
847 PauseMigration,
848 /// Slice the forged sample this way.
849 SliceBy(Chop),
850 /// Set one of the forge's numbers.
851 Turn(Knob, String),
852 /// Work out where the slices would fall.
853 PreviewSlices,
854 /// Write them.
855 Chop,
856 /// Aim a conform at this device.
857 ChooseDevice(String),
858 /// Conform to whichever device is chosen.
859 Conform,
860 /// Trim silence off everything chosen.
861 TrimSilence,
862 /// Open this tag in the review queue.
863 ReadGroup(usize),
864 /// Tick or untick one candidate of the open tag.
865 TickCandidate(usize),
866 /// Tick or untick every candidate the screen is showing.
867 TickShown(bool),
868 /// Apply this much of the open tag.
869 AcceptGroup(Scope),
870 /// Apply every confident suggestion under every tag.
871 AcceptConfident,
872 /// Throw the open tag away.
873 DismissGroup,
874 /// Run the library pass again.
875 Rescan,
876 /// Put the review screen away, keeping the queue.
877 CloseReview,
878 /// Open the export flow on whatever is selected.
879 BeginExport,
880 /// Put the loose-files warning away without acting.
881 DismissLooseFiles,
882 /// Delete the registry entries whose files are gone.
883 PurgeLooseFiles,
884 /// Ask the host for a folder to look for the missing files in.
885 LocateLooseFiles,
886 /// Cut the edited sample down to this span.
887 EditTrim {
888 /// Where the kept part starts, as a fraction of the whole.
889 start: f32,
890 /// Where it ends.
891 end: f32,
892 },
893 /// Change the edited sample's level by this many dB.
894 EditGain(f64),
895 /// Normalise it to this target, by peak or by loudness.
896 EditNormalize {
897 /// True for peak, false for LUFS.
898 peak: bool,
899 /// The target, in whichever unit that is.
900 target: f64,
901 },
902 /// Play it backwards.
903 EditReverse,
904 /// Fade it in or out, this long, on this curve.
905 EditFade {
906 /// True to fade in, false to fade out.
907 fading_in: bool,
908 /// How long the fade runs, in milliseconds.
909 ms: f64,
910 /// The curve, as `FadeCurve::as_value` writes it.
911 curve: String,
912 },
913 /// Put silence in at this point.
914 EditInsertSilence {
915 /// Where it goes, in milliseconds.
916 at: f64,
917 /// How much, in milliseconds.
918 ms: f64,
919 },
920 /// Take this span out.
921 EditRemoveRange {
922 /// Where it starts, in milliseconds.
923 from: f64,
924 /// Where it ends.
925 to: f64,
926 },
927 /// Give up on the edit that is running.
928 EditCancel,
929 /// Audition the sample being edited, or pause it.
930 EditPlay,
931 /// Remember this as the standing answer to what happens to an edit.
932 EditRemember(String),
933 /// Answer the question a finished edit is waiting on.
934 EditChoose {
935 /// Replace or sibling, as `EditResultMode::as_value` writes it.
936 mode: String,
937 /// Whether to keep this as the standing answer.
938 remember: bool,
939 },
940 /// Throw the finished edit away.
941 EditDiscard,
942 /// Put the last edit back.
943 EditUndo,
944 /// Normalise every chosen sample.
945 BatchNormalize {
946 /// True for peak, false for LUFS.
947 peak: bool,
948 /// The target, in whichever unit that is.
949 target: f64,
950 },
951 /// Change every chosen sample's level.
952 BatchGain(f64),
953 /// Reverse every chosen sample.
954 BatchReverse,
955 }
956
957 /// The app's file list, as the narrow thing a described screen borrows.
958 ///
959 /// Reads come off `BrowserState` directly; writes are recorded rather than
960 /// performed, because a route holds `&BrowserState` and selecting a row is
961 /// `&mut`. See [`files`]'s header for why that is a frame boundary rather than a
962 /// shortcoming.
963 pub struct FromContents<'a> {
964 /// What the app has loaded and filtered already.
965 pub state: &'a crate::state::BrowserState,
966 /// What the described screen asked for, applied after the frame.
967 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
968 }
969
970 impl Files for FromContents<'_> {
971 fn samples(&self) -> Vec<Sample> {
972 self.state
973 .nav
974 .contents
975 .iter()
976 .map(|node| Sample {
977 id: node.node.id.as_i64(),
978 name: node.node.name.clone(),
979 duration: node.duration,
980 bpm: node.bpm,
981 key: node.musical_key.clone(),
982 peak_db: node.peak_db,
983 tags: node.tags.clone(),
984 directory: matches!(
985 node.node.node_type,
986 audiofiles_core::vfs::NodeType::Directory
987 ),
988 cloud_only: node.cloud_only,
989 })
990 .collect()
991 }
992
993 fn columns(&self) -> ColumnsShown {
994 let shown = &self.state.column_config;
995 ColumnsShown {
996 duration: shown.show_duration,
997 bpm: shown.show_bpm,
998 key: shown.show_key,
999 peak_db: shown.show_peak_db,
1000 tags: shown.show_tags,
1001 }
1002 }
1003
1004 fn sort(&self) -> (String, bool) {
1005 let by = match self.state.nav.sort_column {
1006 crate::state::SortColumn::Name => "Name",
1007 crate::state::SortColumn::Bpm => "BPM",
1008 crate::state::SortColumn::Key => "Key",
1009 crate::state::SortColumn::Duration => "Duration",
1010 };
1011 (
1012 by.to_owned(),
1013 matches!(
1014 self.state.nav.sort_direction,
1015 crate::state::SortDirection::Ascending
1016 ),
1017 )
1018 }
1019
1020 fn current(&self) -> Option<i64> {
1021 self.state.selected_node().map(|node| node.node.id.as_i64())
1022 }
1023
1024 fn open(&self, id: i64) {
1025 self.intents.borrow_mut().push(Intent::Open(id));
1026 }
1027
1028 fn play(&self, id: i64) {
1029 self.intents.borrow_mut().push(Intent::Play(id));
1030 }
1031
1032 fn sort_by(&self, column: &str) {
1033 self.intents
1034 .borrow_mut()
1035 .push(Intent::SortBy(column.to_owned()));
1036 }
1037
1038 fn enter(&self, id: i64) {
1039 self.intents.borrow_mut().push(Intent::Enter(id));
1040 }
1041
1042 fn reveal(&self, id: i64) {
1043 self.intents.borrow_mut().push(Intent::Reveal(id));
1044 }
1045
1046 fn as_instrument(&self, id: i64) {
1047 self.intents.borrow_mut().push(Intent::Instrument(id));
1048 }
1049
1050 fn reanalyze(&self, id: i64) {
1051 self.intents.borrow_mut().push(Intent::Reanalyze(id));
1052 }
1053
1054 fn delete(&self, id: i64) {
1055 self.intents.borrow_mut().push(Intent::Delete(id));
1056 }
1057
1058 fn download(&self, id: i64) {
1059 self.intents.borrow_mut().push(Intent::Download(id));
1060 }
1061
1062 fn remove_from_collection(&self, id: i64) {
1063 self.intents
1064 .borrow_mut()
1065 .push(Intent::RemoveFromCollection(id));
1066 }
1067
1068 fn add_to_collection(&self, id: i64, collection: i64) {
1069 self.intents
1070 .borrow_mut()
1071 .push(Intent::AddToCollection(id, collection));
1072 }
1073 }
1074
1075 /// Where the export flow has got to.
1076 ///
1077 /// The phase carries what only exists in it, which is the shape the app's own
1078 /// `ImportMode` already has: there are no items to configure while an export is
1079 /// running and no errors to read before one has finished. A flat struct with
1080 /// everything optional would have made every screen ask whether the field it
1081 /// wants is there this time.
1082 #[derive(Debug, Clone, PartialEq)]
1083 pub enum Phase {
1084 /// No export in progress and none being set up.
1085 Idle,
1086 /// Choosing what and where, before anything is written.
1087 Configuring {
1088 /// What would be exported.
1089 subjects: Vec<Subject>,
1090 /// The device profiles on offer.
1091 profiles: Vec<ProfileChoice>,
1092 /// The settings as they stand.
1093 settings: Settings,
1094 },
1095 /// Files being written.
1096 Running {
1097 /// How many have been written.
1098 done: usize,
1099 /// How many there are. Zero before the worker has counted them, which
1100 /// the screen reports as pending rather than as an empty export.
1101 total: usize,
1102 /// The one being written now.
1103 current: String,
1104 },
1105 /// Finished, with whatever went wrong on the way.
1106 Finished {
1107 /// How many were written.
1108 total: usize,
1109 /// The ones that failed, by name.
1110 errors: Vec<(String, String)>,
1111 /// Where they landed.
1112 destination: Option<String>,
1113 },
1114 /// Given up on partway.
1115 Cancelled {
1116 /// How many had been written when it stopped.
1117 done: usize,
1118 /// How many there would have been.
1119 total: usize,
1120 /// Where the partial files sit.
1121 destination: Option<String>,
1122 },
1123 }
1124
1125 /// One sample about to be exported, as the description needs to name it.
1126 ///
1127 /// [`Sample`]'s peer for a different screen, and separate from it for the reason
1128 /// that type is separate from the app's own node: what the export screen needs
1129 /// is the rename context and the duration, and what the file list needs is the
1130 /// row. Sharing one type would put every field either screen wants in both.
1131 #[derive(Debug, Clone, PartialEq)]
1132 pub struct Subject {
1133 /// What it is called.
1134 pub name: String,
1135 /// Its extension, without the dot.
1136 pub ext: String,
1137 /// How long it runs, in seconds.
1138 pub duration: Option<f64>,
1139 /// Beats per minute, where analysis found some.
1140 pub bpm: Option<f64>,
1141 /// The musical key, where analysis found one.
1142 pub musical_key: Option<String>,
1143 }
1144
1145 /// A device profile, as the description needs to name it.
1146 #[derive(Debug, Clone, PartialEq, Eq)]
1147 pub struct ProfileChoice {
1148 /// What the device is called, which is also what the config stores.
1149 pub name: String,
1150 /// Who makes it.
1151 pub manufacturer: String,
1152 /// What it accepts, as the registry phrases it.
1153 pub summary: Option<String>,
1154 /// What kind of device it is.
1155 pub category: Option<String>,
1156 /// Anything else the manifest said.
1157 pub notes: Option<String>,
1158 /// The largest file it will take, if it says.
1159 pub max_file_size_bytes: Option<u64>,
1160 }
1161
1162 /// The export settings, as the description names them.
1163 #[derive(Debug, Clone, PartialEq, Eq)]
1164 pub struct Settings {
1165 /// What to write.
1166 pub format: Format,
1167 /// Target sample rate, or `None` to keep each file's own.
1168 pub sample_rate: Option<u32>,
1169 /// Target bit depth, or `None` to keep each file's own.
1170 pub bit_depth: Option<u16>,
1171 /// Target channel layout.
1172 pub channels: Channels,
1173 /// Whether every file lands in one folder.
1174 pub flatten: bool,
1175 /// Whether a `.audiofiles.json` sidecar goes beside each file.
1176 pub sidecar: bool,
1177 /// How to name the output files, when flattened.
1178 pub naming_pattern: Option<String>,
1179 /// Where they go, as the host spells the path.
1180 pub destination: String,
1181 /// The device profile in force, which locks the audio settings.
1182 pub device_profile: Option<String>,
1183 }
1184
1185 /// What to write.
1186 ///
1187 /// Mirrored rather than re-exported, for the reason [`State`] is: a described
1188 /// screen should not depend on the shape of the thing it reports on.
1189 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1190 pub enum Format {
1191 /// Copy each file as it is.
1192 Original,
1193 /// Decode and re-encode as WAV.
1194 Wav,
1195 /// Decode and re-encode as AIFF.
1196 Aiff,
1197 }
1198
1199 /// The channel layout to write.
1200 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1201 pub enum Channels {
1202 /// Keep each file's own.
1203 Original,
1204 /// Mix down to one.
1205 Mono,
1206 /// Mix to two.
1207 Stereo,
1208 }
1209
1210 /// The settings a described control may change.
1211 ///
1212 /// A closed set, which is what lets one write route serve the whole screen the
1213 /// way `ConfigKey` lets `settings.rs` have one. Without it the route would carry
1214 /// a second list of what it is willing to name, and the two would drift.
1215 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1216 pub enum Setting {
1217 /// [`Settings::format`].
1218 Format,
1219 /// [`Settings::sample_rate`].
1220 SampleRate,
1221 /// [`Settings::bit_depth`].
1222 BitDepth,
1223 /// [`Settings::channels`].
1224 Channels,
1225 /// [`Settings::flatten`].
1226 Flatten,
1227 /// [`Settings::sidecar`].
1228 Sidecar,
1229 /// [`Settings::naming_pattern`].
1230 NamingPattern,
1231 /// [`Settings::device_profile`].
1232 DeviceProfile,
1233 }
1234
1235 impl Setting {
1236 /// The name a described address is built from.
1237 #[must_use]
1238 pub const fn as_str(self) -> &'static str {
1239 match self {
1240 Self::Format => "format",
1241 Self::SampleRate => "sample-rate",
1242 Self::BitDepth => "bit-depth",
1243 Self::Channels => "channels",
1244 Self::Flatten => "flatten",
1245 Self::Sidecar => "sidecar",
1246 Self::NamingPattern => "naming-pattern",
1247 Self::DeviceProfile => "device-profile",
1248 }
1249 }
1250
1251 /// The setting that name means, if it means one.
1252 ///
1253 /// The refusal that makes the write route safe: an address is reachable by
1254 /// typing, so an undeclared name is a `NotFound` rather than a panic or a
1255 /// silent no-op.
1256 #[must_use]
1257 pub fn from_key(name: &str) -> Option<Self> {
1258 match name {
1259 "format" => Some(Self::Format),
1260 "sample-rate" => Some(Self::SampleRate),
1261 "bit-depth" => Some(Self::BitDepth),
1262 "channels" => Some(Self::Channels),
1263 "flatten" => Some(Self::Flatten),
1264 "sidecar" => Some(Self::Sidecar),
1265 "naming-pattern" => Some(Self::NamingPattern),
1266 "device-profile" => Some(Self::DeviceProfile),
1267 _ => None,
1268 }
1269 }
1270 }
1271
1272 /// The export flow, as much of it as a described screen needs.
1273 ///
1274 /// The fourth narrow trait, and the first whose **reads** are UI state as well
1275 /// as its writes. `Config` and `Sync` both read through a handle that owns the
1276 /// fact; the export flow's phase lives in `BrowserState::import_wf`, which is
1277 /// the app's own screen state. So this trait reads it and records every write as
1278 /// an [`Intent`], which is `files.rs`'s rule applied whole: *a described screen
1279 /// writing to UI state records an intent.*
1280 pub trait Export {
1281 /// Where the flow has got to.
1282 fn phase(&self) -> Phase;
1283
1284 /// Open the flow on whatever is chosen. See [`export`]'s `begin`.
1285 fn open(&self);
1286
1287 /// Change one setting.
1288 fn configure(&self, setting: Setting, value: &str);
1289
1290 /// Begin writing files.
1291 fn start(&self);
1292
1293 /// Give up on the running export.
1294 fn cancel(&self);
1295
1296 /// Put the flow away, from either end of it.
1297 fn dismiss(&self);
1298 }
1299
1300 /// The app's export flow, as the narrow thing a described screen borrows.
1301 pub struct FromExport<'a> {
1302 /// Where the flow is, read off the app's own screen state.
1303 pub state: &'a crate::state::BrowserState,
1304 /// What the described screen asked for, applied after the frame.
1305 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1306 }
1307
1308 impl Export for FromExport<'_> {
1309 fn open(&self) {
1310 self.intents.borrow_mut().push(Intent::BeginExport);
1311 }
1312
1313 fn phase(&self) -> Phase {
1314 use crate::state::ImportMode;
1315
1316 match &self.state.import_wf.import_mode {
1317 ImportMode::ConfigureExport {
1318 items,
1319 config,
1320 available_profiles,
1321 } => Phase::Configuring {
1322 subjects: items
1323 .iter()
1324 .map(|item| Subject {
1325 name: item.name.clone(),
1326 ext: item.ext.clone(),
1327 duration: item.duration,
1328 bpm: item.bpm,
1329 musical_key: item.musical_key.clone(),
1330 })
1331 .collect(),
1332 profiles: available_profiles
1333 .iter()
1334 .map(|profile| ProfileChoice {
1335 name: profile.name.clone(),
1336 manufacturer: profile.manufacturer.clone(),
1337 summary: profile.format_summary.clone(),
1338 category: profile.category.clone(),
1339 notes: profile.notes.clone(),
1340 max_file_size_bytes: profile.max_file_size_bytes,
1341 })
1342 .collect(),
1343 settings: Settings {
1344 format: match config.format {
1345 audiofiles_core::export::ExportFormat::Original => Format::Original,
1346 audiofiles_core::export::ExportFormat::Wav => Format::Wav,
1347 audiofiles_core::export::ExportFormat::Aiff => Format::Aiff,
1348 },
1349 sample_rate: config.sample_rate,
1350 bit_depth: config.bit_depth,
1351 channels: match config.channels {
1352 audiofiles_core::export::ExportChannels::Original => Channels::Original,
1353 audiofiles_core::export::ExportChannels::Mono => Channels::Mono,
1354 audiofiles_core::export::ExportChannels::Stereo => Channels::Stereo,
1355 },
1356 flatten: config.flatten,
1357 sidecar: config.metadata_sidecar,
1358 naming_pattern: config.naming_pattern.clone(),
1359 destination: config.destination.display().to_string(),
1360 device_profile: config.device_profile.clone(),
1361 },
1362 },
1363 ImportMode::Exporting {
1364 completed,
1365 total,
1366 current_name,
1367 } => Phase::Running {
1368 done: *completed,
1369 total: *total,
1370 current: current_name.clone(),
1371 },
1372 ImportMode::ExportComplete { total, errors } => Phase::Finished {
1373 total: *total,
1374 errors: errors.clone(),
1375 destination: self.destination(),
1376 },
1377 ImportMode::OperationCancelled {
1378 kind: crate::state::CancelKind::Export,
1379 completed,
1380 total,
1381 destination,
1382 } => Phase::Cancelled {
1383 done: *completed,
1384 total: *total,
1385 destination: destination.as_ref().map(|path| path.display().to_string()),
1386 },
1387 _ => Phase::Idle,
1388 }
1389 }
1390
1391 fn configure(&self, setting: Setting, value: &str) {
1392 self.intents
1393 .borrow_mut()
1394 .push(Intent::Configure(setting, value.to_owned()));
1395 }
1396
1397 fn start(&self) {
1398 self.intents.borrow_mut().push(Intent::StartExport);
1399 }
1400
1401 fn cancel(&self) {
1402 self.intents.borrow_mut().push(Intent::CancelExport);
1403 }
1404
1405 fn dismiss(&self) {
1406 self.intents.borrow_mut().push(Intent::DismissExport);
1407 }
1408 }
1409
1410 impl FromExport<'_> {
1411 /// Where the last export was told to write.
1412 fn destination(&self) -> Option<String> {
1413 self.state
1414 .import_wf
1415 .last_export_destination
1416 .as_ref()
1417 .map(|path| path.display().to_string())
1418 }
1419 }
1420
1421 /// What the detail panel is about.
1422 ///
1423 /// The panel's subject is the selection, and a selection is not an address: a
1424 /// user does not navigate to "three samples are chosen", they arrive there by
1425 /// choosing three. So this is [`Phase`]'s shape for a different reason than
1426 /// [`Phase`] has it — one route answering three screens, because the state is
1427 /// something that happened rather than somewhere to go. `sync`'s four states
1428 /// settled that pattern and this is the third screen to take it.
1429 #[derive(Debug, Clone, PartialEq)]
1430 pub enum Focus {
1431 /// Nothing is chosen, or what is chosen is the parent entry.
1432 Nothing,
1433 /// One sample, with everything known about it.
1434 One(Box<Detailed>),
1435 /// Several, so what is describable is what they have in common.
1436 Several(Box<Spread>),
1437 }
1438
1439 /// One sample, as the detail screen needs to name it.
1440 ///
1441 /// [`Sample`]'s peer, separate from it for the reason [`Subject`] is separate
1442 /// from both: the file list needs a row, the export needs the rename context,
1443 /// and this needs everything analysis found. One shared type would put every
1444 /// field any screen wants in all three.
1445 #[derive(Debug, Clone, PartialEq)]
1446 pub struct Detailed {
1447 /// The row's own id, which is what its addresses are built from.
1448 pub id: i64,
1449 /// What it is called.
1450 pub name: String,
1451 /// Where it sits, as the host spells the path.
1452 pub path: Option<String>,
1453 /// What analysis found, where it has run.
1454 pub analysis: Option<Analysis>,
1455 /// What it is tagged with, and where each tag came from.
1456 pub tags: Vec<Tagged>,
1457 /// Tags found on acoustically similar samples, once asked for.
1458 pub suggestions: Vec<Suggested>,
1459 /// Whether it is a sample rather than a folder, which is what the editing
1460 /// controls need: the shipped panel offers Edit and Forge only where there
1461 /// is a hash to open them on.
1462 pub is_sample: bool,
1463 /// Whether the spectral features Find Similar reads were computed.
1464 pub has_spectral: bool,
1465 /// Whether the fingerprint Find Duplicates reads was computed.
1466 pub has_fingerprint: bool,
1467 }
1468
1469 /// What analysis found, as the description names it.
1470 ///
1471 /// The nine fields the panel shows, out of `AnalysisResult`'s twenty-two. The
1472 /// rest — the feature vector, the fingerprint bytes, the spectral moments — are
1473 /// inputs to the two discovery paths rather than facts a reader is shown, and
1474 /// they reach this screen as [`Detailed::has_spectral`] and
1475 /// [`Detailed::has_fingerprint`], which is the only thing it says about them.
1476 #[derive(Debug, Clone, PartialEq)]
1477 pub struct Analysis {
1478 /// How long it runs, in seconds.
1479 pub duration: f64,
1480 /// Frames per second.
1481 pub sample_rate: u32,
1482 /// How many channels.
1483 pub channels: u16,
1484 /// Beats per minute, where one was found.
1485 pub bpm: Option<f64>,
1486 /// The musical key, where one was found.
1487 pub musical_key: Option<String>,
1488 /// Peak level in dBFS.
1489 pub peak_db: Option<f64>,
1490 /// RMS level in dBFS.
1491 pub rms_db: Option<f64>,
1492 /// Integrated loudness.
1493 pub lufs: Option<f64>,
1494 /// Whether it loops cleanly.
1495 pub is_loop: Option<bool>,
1496 }
1497
1498 /// One tag on one sample, and where it came from.
1499 #[derive(Debug, Clone, PartialEq, Eq)]
1500 pub struct Tagged {
1501 /// The tag itself.
1502 pub name: String,
1503 /// Who put it there.
1504 pub source: Source,
1505 }
1506
1507 /// Who put a tag on a sample.
1508 ///
1509 /// Mirrored as an enum where the app holds a string, which is the one place this
1510 /// port narrows rather than copies: the store's `source` column is open text and
1511 /// the panel already switches on four known values, so a described screen that
1512 /// carried the string would make every renderer repeat that switch.
1513 /// [`Source::Other`] keeps whatever the store said, so a source the app grows
1514 /// still reaches the reader rather than being flattened to "manual".
1515 #[derive(Debug, Clone, PartialEq, Eq)]
1516 pub enum Source {
1517 /// Typed in by hand, which is what an unrecorded source means.
1518 Manual,
1519 /// A tagging rule matched.
1520 Rule,
1521 /// The classifier proposed it and it was accepted.
1522 Suggested,
1523 /// It came out of a cluster.
1524 Cluster,
1525 /// Harvested from the folder the file was in.
1526 Folder,
1527 /// Something the app has grown since this list was written.
1528 Other(String),
1529 }
1530
1531 impl Source {
1532 /// What the panel calls it.
1533 #[must_use]
1534 pub fn as_str(&self) -> &str {
1535 match self {
1536 Self::Manual => "manual",
1537 Self::Rule => "rule",
1538 Self::Suggested => "suggested",
1539 Self::Cluster => "cluster",
1540 Self::Folder => "folder",
1541 Self::Other(other) => other,
1542 }
1543 }
1544
1545 /// The source that name means.
1546 fn of(name: Option<&str>) -> Self {
1547 match name {
1548 None => Self::Manual,
1549 Some("rule") => Self::Rule,
1550 Some("ml") => Self::Suggested,
1551 Some("cluster") => Self::Cluster,
1552 Some("harvest") => Self::Folder,
1553 Some(other) => Self::Other(other.to_owned()),
1554 }
1555 }
1556 }
1557
1558 /// A tag some similar sample carries, offered for this one.
1559 #[derive(Debug, Clone, PartialEq)]
1560 pub struct Suggested {
1561 /// The tag.
1562 pub tag: String,
1563 /// How confident the classifier is, from zero to one.
1564 pub score: f64,
1565 /// How many similar samples carry it.
1566 pub neighbours: usize,
1567 }
1568
1569 /// Several samples at once, as the description can name them.
1570 ///
1571 /// What a multi-selection has to say is what its members agree on, so every
1572 /// field here is already reduced. The reduction is the app's
1573 /// (`ui::detail::summarize`) and stays there: whether three samples share a
1574 /// tempo is a fact about them rather than a rendering decision, and a
1575 /// description that carried three tempos would make each renderer decide again
1576 /// what to do when they disagree.
1577 #[derive(Debug, Clone, PartialEq, Eq)]
1578 pub struct Spread {
1579 /// How many samples are chosen.
1580 pub samples: usize,
1581 /// How many folders are chosen alongside them.
1582 pub folders: usize,
1583 /// The tempo they share, if they share one.
1584 pub bpm: Shared,
1585 /// The key they share, if they share one.
1586 pub musical_key: Shared,
1587 /// The length they share, if they share one.
1588 pub duration: Shared,
1589 /// Every tag any of them carries, and how many carry it.
1590 pub tags: Vec<Coverage>,
1591 }
1592
1593 /// One field across a selection.
1594 ///
1595 /// Three answers rather than `Option<Option<T>>`, which is what the app's own
1596 /// `summarize` returns and is unreadable at the call site: `Some(Err(()))` is
1597 /// "they disagree" and nothing in the type says so.
1598 #[derive(Debug, Clone, PartialEq, Eq)]
1599 pub enum Shared {
1600 /// Every one of them says this.
1601 Same(String),
1602 /// They do not agree.
1603 Varies,
1604 /// None of them has it at all.
1605 Absent,
1606 }
1607
1608 /// One tag across a selection.
1609 #[derive(Debug, Clone, PartialEq, Eq)]
1610 pub struct Coverage {
1611 /// The tag.
1612 pub name: String,
1613 /// How many of the chosen samples carry it.
1614 pub on: usize,
1615 }
1616
1617 /// The detail panel, as much of it as a described screen needs.
1618 ///
1619 /// The fifth narrow trait, and the first whose **writes are all intents**. Every
1620 /// port before it had at least one write that went through a handle the app
1621 /// already had; here even the two that look like plain data writes — adding a
1622 /// tag, removing one — are recorded instead, and the reason is worth stating as
1623 /// the rule the next port will want:
1624 ///
1625 /// **A data write whose consequences are UI state is an intent, not a handle
1626 /// call.** `Backend::add_tag` is `&self` and a route could call it. What the
1627 /// shipped panel does around that call is not: removing a tag pushes an undo
1628 /// entry, sets the status line and re-reads `detail.selected_tags`, all
1629 /// `&mut BrowserState`. A described screen that called the backend directly
1630 /// would write the tag and lose the undo, which is a worse outcome than not
1631 /// describing the control — it would look like it worked.
1632 ///
1633 /// So the boundary is not "reads through a handle, writes through an intent". It
1634 /// is: **what the app does about a write decides where the write goes.** See
1635 /// [`files`]'s header for the first half of this rule and [`export`]'s for what
1636 /// an intent costs.
1637 pub trait Detail {
1638 /// What the panel is about.
1639 fn focus(&self) -> Focus;
1640
1641 /// Put this tag on the sample in focus.
1642 fn add_tag(&self, tag: &str);
1643
1644 /// Take this tag off the sample in focus.
1645 fn remove_tag(&self, tag: &str);
1646
1647 /// Go and find tags from acoustically similar samples.
1648 fn suggest(&self);
1649
1650 /// Take one of the suggestions.
1651 fn accept(&self, tag: &str);
1652
1653 /// Put the sample's path on the clipboard.
1654 fn copy_path(&self);
1655
1656 /// Open the sample editor.
1657 fn edit(&self);
1658
1659 /// Open the forge.
1660 fn forge(&self);
1661
1662 /// Find samples that sound like this one.
1663 fn find_similar(&self);
1664
1665 /// Find near-duplicates of this one.
1666 fn find_duplicates(&self);
1667
1668 /// Put this tag on every chosen sample that lacks it.
1669 fn spread_tag(&self, tag: &str);
1670
1671 /// Take this tag off every chosen sample that carries it.
1672 fn strip_tag(&self, tag: &str);
1673 }
1674
1675 /// The app's detail panel, as the narrow thing a described screen borrows.
1676 ///
1677 /// Reads come off `BrowserState` and the analysis the app has already loaded
1678 /// into `detail.selected_analysis`; every write is recorded. See [`Detail`]'s
1679 /// header for why even the tag writes are recorded when the backend would take
1680 /// them directly.
1681 pub struct FromSelection<'a> {
1682 /// What the app has selected and loaded already.
1683 pub state: &'a crate::state::BrowserState,
1684 /// What the described screen asked for, applied after the frame.
1685 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1686 }
1687
1688 impl Detail for FromSelection<'_> {
1689 fn focus(&self) -> Focus {
1690 if self.state.nav.selection.count() > 1 {
1691 return Focus::Several(Box::new(self.spread()));
1692 }
1693 let Some(node) = self.state.selected_node() else {
1694 return Focus::Nothing;
1695 };
1696 Focus::One(Box::new(Detailed {
1697 id: node.node.id.as_i64(),
1698 name: node.node.name.clone(),
1699 path: self.state.selected_sample_path(),
1700 analysis: self
1701 .state
1702 .detail
1703 .selected_analysis
1704 .as_ref()
1705 .map(|found| Analysis {
1706 duration: found.duration,
1707 sample_rate: found.sample_rate,
1708 channels: found.channels,
1709 bpm: found.bpm,
1710 musical_key: found.musical_key.clone(),
1711 peak_db: found.peak_db,
1712 rms_db: found.rms_db,
1713 lufs: found.lufs,
1714 is_loop: found.is_loop,
1715 }),
1716 tags: self
1717 .state
1718 .detail
1719 .selected_tags
1720 .iter()
1721 .map(|tag| Tagged {
1722 name: tag.clone(),
1723 source: Source::of(
1724 self.state
1725 .detail
1726 .selected_tag_sources
1727 .get(tag)
1728 .map(|(source, _)| source.as_str()),
1729 ),
1730 })
1731 .collect(),
1732 suggestions: self
1733 .state
1734 .detail
1735 .selected_ml_suggestions
1736 .iter()
1737 .map(|found| Suggested {
1738 tag: found.tag.clone(),
1739 score: found.score,
1740 neighbours: found.neighbors.len(),
1741 })
1742 .collect(),
1743 is_sample: node.node.sample_hash.is_some(),
1744 has_spectral: self
1745 .state
1746 .detail
1747 .selected_analysis
1748 .as_ref()
1749 .is_some_and(|found| {
1750 found.spectral_centroid.is_some() || found.spectral_bandwidth.is_some()
1751 }),
1752 has_fingerprint: self
1753 .state
1754 .detail
1755 .selected_analysis
1756 .as_ref()
1757 .is_some_and(|found| found.fingerprint.is_some()),
1758 }))
1759 }
1760
1761 fn add_tag(&self, tag: &str) {
1762 self.push(Intent::AddTag(tag.to_owned()));
1763 }
1764
1765 fn remove_tag(&self, tag: &str) {
1766 self.push(Intent::RemoveTag(tag.to_owned()));
1767 }
1768
1769 fn suggest(&self) {
1770 self.push(Intent::Suggest);
1771 }
1772
1773 fn accept(&self, tag: &str) {
1774 self.push(Intent::AcceptSuggestion(tag.to_owned()));
1775 }
1776
1777 fn copy_path(&self) {
1778 self.push(Intent::CopyPath);
1779 }
1780
1781 fn edit(&self) {
1782 self.push(Intent::Edit);
1783 }
1784
1785 fn forge(&self) {
1786 self.push(Intent::Forge);
1787 }
1788
1789 fn find_similar(&self) {
1790 self.push(Intent::FindSimilar);
1791 }
1792
1793 fn find_duplicates(&self) {
1794 self.push(Intent::FindDuplicates);
1795 }
1796
1797 fn spread_tag(&self, tag: &str) {
1798 self.push(Intent::SpreadTag(tag.to_owned()));
1799 }
1800
1801 fn strip_tag(&self, tag: &str) {
1802 self.push(Intent::StripTag(tag.to_owned()));
1803 }
1804 }
1805
1806 impl FromSelection<'_> {
1807 /// Record what the described screen asked for.
1808 fn push(&self, intent: Intent) {
1809 self.intents.borrow_mut().push(intent);
1810 }
1811
1812 /// What the chosen samples have in common.
1813 ///
1814 /// The reduction the shipped panel does, called through the app's own
1815 /// helpers rather than repeated here: `selected_nodes` is what the panel
1816 /// reads and the agreement test is `ui::detail::summarize`'s, made public
1817 /// for this so the two cannot drift.
1818 fn spread(&self) -> Spread {
1819 let nodes = self.state.selected_nodes();
1820 let samples: Vec<_> = nodes
1821 .iter()
1822 .filter(|node| node.node.sample_hash.is_some())
1823 .collect();
1824 let count = samples.len();
1825
1826 let mut counts: std::collections::BTreeMap<String, usize> =
1827 std::collections::BTreeMap::new();
1828 for node in &samples {
1829 for tag in &node.tags {
1830 *counts.entry(tag.clone()).or_insert(0) += 1;
1831 }
1832 }
1833 let mut tags: Vec<Coverage> = counts
1834 .into_iter()
1835 .map(|(name, on)| Coverage { name, on })
1836 .collect();
1837 // Widest coverage first, then alphabetical, which is the order the
1838 // shipped panel sorts its badges into.
1839 tags.sort_by(|left, right| {
1840 right
1841 .on
1842 .cmp(&left.on)
1843 .then_with(|| left.name.cmp(&right.name))
1844 });
1845
1846 Spread {
1847 samples: count,
1848 folders: nodes.len().saturating_sub(count),
1849 bpm: shared(
1850 crate::quasi::detail::summarize(&samples, |node| node.bpm),
1851 |bpm| format!("{bpm:.0}"),
1852 ),
1853 musical_key: shared(
1854 crate::quasi::detail::summarize(&samples, |node| node.musical_key.clone()),
1855 |key| key,
1856 ),
1857 duration: shared(
1858 crate::quasi::detail::summarize(&samples, |node| node.duration),
1859 |seconds| format!("{seconds:.1}s"),
1860 ),
1861 tags,
1862 }
1863 }
1864 }
1865
1866 /// The app's three-way agreement answer, as the description names it.
1867 fn shared<T>(summary: Option<Result<T, ()>>, write: impl FnOnce(T) -> String) -> Shared {
1868 match summary {
1869 Some(Ok(value)) => Shared::Same(write(value)),
1870 Some(Err(())) => Shared::Varies,
1871 None => Shared::Absent,
1872 }
1873 }
1874
1875 /// What a bulk operation is about.
1876 ///
1877 /// The selection, reduced to what the three modals actually name. Deliberately
1878 /// **not** the app's [`BulkModal`](crate::state::BulkModal): see [`Bulk`]'s
1879 /// header, where that absence is the finding rather than an omission.
1880 #[derive(Debug, Clone, PartialEq, Eq)]
1881 pub struct Chosen {
1882 /// What each chosen node is called, in the order they were chosen.
1883 pub names: Vec<String>,
1884 /// How many of them are samples rather than folders, which is what the tag
1885 /// modal acts on and the other two do not care about.
1886 pub samples: usize,
1887 }
1888
1889 /// A folder a bulk move may target.
1890 #[derive(Debug, Clone, PartialEq, Eq)]
1891 pub struct Folder {
1892 /// The node's own id, which the address is built from.
1893 pub id: i64,
1894 /// The whole path, as the picker shows it.
1895 pub path: String,
1896 }
1897
1898 /// Bulk operations over the selection, as much as a described screen needs.
1899 ///
1900 /// The sixth narrow trait, and the one where the port stopped copying the
1901 /// shipped screen's state and started deleting it.
1902 ///
1903 /// # The app's `BulkModal` is not here, and that is the finding
1904 ///
1905 /// `BulkModal` is one enum with three variants holding eleven fields between
1906 /// them, and it is doing two unrelated jobs at once:
1907 ///
1908 /// - **A view buffer.** `tag_input`, `adding`, `selected_idx`, `pattern_input`,
1909 /// `previews`, `error`, and `import_wf.bulk_move_filter` beside it. Every one
1910 /// of those is what the user has typed and picked, living in app state
1911 /// because egui does not hold it for you.
1912 /// - **An argument list.** `hashes`, `node_ids`, `names`, `directories`,
1913 /// `targets`. Every one derived from the selection at the moment the modal
1914 /// opened, so `execute_bulk_tag` has something to read.
1915 ///
1916 /// A described modal needs neither. The buffer is what a `Runtime`'s `View`
1917 /// holds by definition, and the arguments are derived from the selection, which
1918 /// this trait reads. So the whole type is view-state plumbing, and the port does
1919 /// not reproduce it: nothing below opens a modal to find out what is in it.
1920 ///
1921 /// # What that costs at the commit, and why it is worth it
1922 ///
1923 /// The app's own executors read their arguments back out of `BulkModal`, so the
1924 /// host has to put them there before calling one. That is three lines in
1925 /// [`panel`] and it is the right three lines: the alternative is a second
1926 /// implementation of bulk tagging with its own undo entry, which is exactly what
1927 /// this port exists to avoid. **The description holds what was typed; the host
1928 /// hands it to the command that already exists.**
1929 ///
1930 /// # The preview is on this trait rather than in the route
1931 ///
1932 /// [`previews`](Self::previews) is a pure function of a pattern and the
1933 /// selection — `RenamePattern::parse` and `resolve_all` touch nothing — so a
1934 /// route *could* compute it. It does not, for [`Sync::quote_cents`]'s reason:
1935 /// naming what a pattern expands to is the app's, and a description that
1936 /// reimplemented it would be a second answer free to disagree with the first.
1937 pub trait Bulk {
1938 /// The modal is finished with: put it away.
1939 ///
1940 /// `naming`'s `done` in a second consumer, and for the same reason: what
1941 /// keeps one of these on screen is the host's own `bulk_modal`, so leaving
1942 /// the address is not leaving the screen.
1943 fn done(&self);
1944
1945 /// What is chosen.
1946 fn chosen(&self) -> Chosen;
1947
1948 /// Every tag the vault knows, for completing what is typed.
1949 fn known_tags(&self) -> Vec<String>;
1950
1951 /// Every folder a move may target.
1952 fn folders(&self) -> Vec<Folder>;
1953
1954 /// What this pattern would rename the chosen nodes to, old beside new.
1955 ///
1956 /// # Errors
1957 /// What is wrong with the pattern, as the app phrases it.
1958 fn previews(&self, pattern: &str) -> Result<Vec<(String, String)>, String>;
1959
1960 /// Put this tag on every chosen sample, or take it off every one.
1961 fn tag(&self, tag: &str, adding: bool);
1962
1963 /// Move everything chosen into this folder, or to the root.
1964 fn move_to(&self, folder: Option<i64>);
1965
1966 /// Rename everything chosen by this pattern.
1967 fn rename(&self, pattern: &str);
1968 }
1969
1970 /// The app's selection, as the narrow thing the bulk screens borrow.
1971 pub struct FromBulk<'a> {
1972 /// What the app has selected.
1973 pub state: &'a crate::state::BrowserState,
1974 /// What the described screen asked for, applied after the frame.
1975 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1976 }
1977
1978 impl Bulk for FromBulk<'_> {
1979 fn done(&self) {
1980 self.intents.borrow_mut().push(Intent::BulkDone);
1981 }
1982
1983 fn chosen(&self) -> Chosen {
1984 let nodes = self.state.selected_nodes();
1985 Chosen {
1986 samples: nodes
1987 .iter()
1988 .filter(|node| node.node.sample_hash.is_some())
1989 .count(),
1990 names: nodes.iter().map(|node| node.node.name.clone()).collect(),
1991 }
1992 }
1993
1994 fn known_tags(&self) -> Vec<String> {
1995 self.state.all_tags.iter().cloned().collect()
1996 }
1997
1998 fn folders(&self) -> Vec<Folder> {
1999 let Some(vfs) = self.state.current_vfs_id() else {
2000 return Vec::new();
2001 };
2002 self.state
2003 .backend
2004 .list_all_directories(vfs)
2005 .unwrap_or_default()
2006 .into_iter()
2007 .map(|(id, path)| Folder {
2008 id: id.as_i64(),
2009 path,
2010 })
2011 .collect()
2012 }
2013
2014 fn previews(&self, pattern: &str) -> Result<Vec<(String, String)>, String> {
2015 use audiofiles_core::rename::{RenameContext, RenamePattern};
2016
2017 let parsed = RenamePattern::parse(pattern).map_err(|error| error.to_string())?;
2018 let nodes = self.state.selected_nodes();
2019 let contexts: Vec<RenameContext> = nodes
2020 .iter()
2021 .enumerate()
2022 .map(|(index, node)| {
2023 let (name, extension) = audiofiles_core::util::split_name_ext(&node.node.name);
2024 RenameContext {
2025 name,
2026 extension,
2027 bpm: node.bpm,
2028 musical_key: node.musical_key.clone(),
2029 duration: node.duration,
2030 index,
2031 }
2032 })
2033 .collect();
2034 Ok(contexts
2035 .iter()
2036 .zip(parsed.resolve_all(&contexts))
2037 .map(|(context, stem)| {
2038 (
2039 whole(&context.name, &context.extension),
2040 whole(&stem, &context.extension),
2041 )
2042 })
2043 .collect())
2044 }
2045
2046 fn tag(&self, tag: &str, adding: bool) {
2047 self.intents
2048 .borrow_mut()
2049 .push(Intent::BulkTag(tag.to_owned(), adding));
2050 }
2051
2052 fn move_to(&self, folder: Option<i64>) {
2053 self.intents.borrow_mut().push(Intent::BulkMove(folder));
2054 }
2055
2056 fn rename(&self, pattern: &str) {
2057 self.intents
2058 .borrow_mut()
2059 .push(Intent::BulkRename(pattern.to_owned()));
2060 }
2061 }
2062
2063 /// A stem and an extension as one filename.
2064 fn whole(stem: &str, extension: &str) -> String {
2065 if extension.is_empty() {
2066 stem.to_owned()
2067 } else {
2068 format!("{stem}.{extension}")
2069 }
2070 }
2071
2072 /// What is playing, as the band needs to name it.
2073 ///
2074 /// Whole seconds, because that is what the transport shows and what a [`Meter`]
2075 /// takes. The frame-accurate position lives behind a mutex an audio thread is
2076 /// filling and is not a fact a screen reports.
2077 ///
2078 /// [`Meter`]: quasi_router::Meter
2079 #[derive(Debug, Clone, PartialEq, Eq)]
2080 pub struct Playing {
2081 /// What it is called.
2082 pub name: String,
2083 /// How far in, in seconds.
2084 pub position: u32,
2085 /// How long it runs, in seconds.
2086 pub total: u32,
2087 }
2088
2089 /// How much of what is on screen analysis has got through.
2090 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2091 pub struct Analysed {
2092 /// How many samples are on screen.
2093 pub samples: u32,
2094 /// How many of them have been analysed.
2095 pub analysed: u32,
2096 /// How many carry no tags.
2097 pub untagged: u32,
2098 }
2099
2100 /// What kind of thing the app is saying.
2101 ///
2102 /// Two, where the shipped footer decides by matching substrings against the
2103 /// message it is about to draw (`is_error_status`: "failed", "error", "could
2104 /// not", "cannot"). The classification is the app's and is made here by calling
2105 /// that same function rather than by a second list of words; what changes is
2106 /// that it becomes a described fact instead of a colour chosen at paint time.
2107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2108 pub enum Saying {
2109 /// Something went wrong.
2110 Failed,
2111 /// Something happened.
2112 Ordinary,
2113 }
2114
2115 /// The main window's own band, as much as a described screen needs.
2116 ///
2117 /// The seventh narrow trait, and the one that borrows least: nine methods, none
2118 /// of which is a write to anything but the app's own playback and a dismissed
2119 /// hint. What it deliberately does not offer is a way to *seek* — see
2120 /// [`shell`]'s header on why the position is reported and not steered.
2121 pub trait Shell {
2122 /// What is playing, if anything is.
2123 fn playing(&self) -> Option<Playing>;
2124
2125 /// How many rows are chosen.
2126 fn chosen(&self) -> usize;
2127
2128 /// How far analysis has got through what is on screen.
2129 fn analysed(&self) -> Analysed;
2130
2131 /// What the app is saying, if it is saying anything.
2132 fn status(&self) -> Option<(String, Saying)>;
2133
2134 /// Whether the first-launch hint is still showing.
2135 fn hinting(&self) -> bool;
2136
2137 /// What preview plays through, if a device was found.
2138 fn device(&self) -> Option<String>;
2139
2140 /// The focused sample's tags.
2141 fn tags(&self) -> Vec<String>;
2142
2143 /// The storage-layout migration, if one is running. See [`Migrating`].
2144 fn migrating(&self) -> Option<Migrating>;
2145
2146 /// Stop the preview.
2147 fn stop(&self);
2148
2149 /// Put the first-launch hint away.
2150 fn dismiss_hint(&self);
2151
2152 /// Stop the migration until this vault is opened again.
2153 fn pause_migration(&self);
2154 }
2155
2156 /// Blobs being moved from the flat store into hash-prefix shards.
2157 ///
2158 /// On [`Shell`] rather than on a capability of its own, and the shipped app's
2159 /// own placement is the argument: `draw_layout_strip` is a band of the main
2160 /// window, declared after the footer so it stacks above it, and it is a band for
2161 /// a stated reason — the migration auto-starts at vault open, the library stays
2162 /// usable while it runs because reads resolve both layouts, and seizing the
2163 /// window would be the wrong trade.
2164 ///
2165 /// So this is what the window's band says while a background job runs, which is
2166 /// what every other member of [`Shell`] already is.
2167 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2168 pub struct Migrating {
2169 /// How many blobs have moved.
2170 pub done: usize,
2171 /// How many there are.
2172 pub total: usize,
2173 }
2174
2175 /// The app's main window, as the narrow thing the described band borrows.
2176 pub struct FromWindow<'a> {
2177 /// What the app is showing and playing.
2178 pub state: &'a crate::state::BrowserState,
2179 /// What the described screen asked for, applied after the frame.
2180 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2181 }
2182
2183 impl Shell for FromWindow<'_> {
2184 fn playing(&self) -> Option<Playing> {
2185 let hash = self.state.preview.previewing_hash.as_deref()?;
2186 let playback = self.state.shared.preview.lock();
2187 if !playback.playing {
2188 return None;
2189 }
2190 let buffer = playback.buffer.as_ref()?;
2191 // Two channels interleaved, which is the shipped footer's own division
2192 // and the reason it is here rather than in the description: how a
2193 // buffer is laid out is not a fact about what is playing.
2194 let frames = buffer.data.len() / 2;
2195 let rate = f64::from(buffer.sample_rate);
2196 if frames == 0 || rate <= 0.0 {
2197 return None;
2198 }
2199 let name = self
2200 .state
2201 .nav
2202 .contents
2203 .iter()
2204 .find(|node| node.node.sample_hash.as_deref() == Some(hash))
2205 .map_or("...", |node| node.node.name.as_str())
2206 .to_owned();
2207 Some(Playing {
2208 name,
2209 position: seconds(playback.position_frac / rate),
2210 total: seconds(frames as f64 / rate),
2211 })
2212 }
2213
2214 fn chosen(&self) -> usize {
2215 self.state.nav.selection.count()
2216 }
2217
2218 fn analysed(&self) -> Analysed {
2219 let samples = self
2220 .state
2221 .nav
2222 .contents
2223 .iter()
2224 .filter(|node| node.node.sample_hash.is_some());
2225 let mut seen = Analysed::default();
2226 for node in samples {
2227 seen.samples += 1;
2228 if node.duration.is_some() {
2229 seen.analysed += 1;
2230 }
2231 if node.tags.is_empty() {
2232 seen.untagged += 1;
2233 }
2234 }
2235 seen
2236 }
2237
2238 fn status(&self) -> Option<(String, Saying)> {
2239 if self.state.status.is_empty() {
2240 return None;
2241 }
2242 Some((
2243 self.state.status.clone(),
2244 if crate::ui::footer::is_error_status(&self.state.status) {
2245 Saying::Failed
2246 } else {
2247 Saying::Ordinary
2248 },
2249 ))
2250 }
2251
2252 fn hinting(&self) -> bool {
2253 self.state.onboarding.show_first_launch_hint
2254 }
2255
2256 fn device(&self) -> Option<String> {
2257 self.state.shared.preview_device_name.lock().clone()
2258 }
2259
2260 fn tags(&self) -> Vec<String> {
2261 self.state.detail.selected_tags.as_ref().clone()
2262 }
2263
2264 fn stop(&self) {
2265 self.intents.borrow_mut().push(Intent::StopPlayback);
2266 }
2267
2268 fn dismiss_hint(&self) {
2269 self.intents.borrow_mut().push(Intent::DismissHint);
2270 }
2271
2272 fn migrating(&self) -> Option<Migrating> {
2273 let running = self.state.layout_migration?;
2274 Some(Migrating {
2275 done: running.completed,
2276 total: running.total,
2277 })
2278 }
2279
2280 fn pause_migration(&self) {
2281 self.intents.borrow_mut().push(Intent::PauseMigration);
2282 }
2283 }
2284
2285 /// A duration in seconds, as a whole number the transport can show.
2286 fn seconds(value: f64) -> u32 {
2287 #[expect(
2288 clippy::cast_possible_truncation,
2289 clippy::cast_sign_loss,
2290 reason = "a sample's length in seconds is small and positive"
2291 )]
2292 let whole = value.max(0.0) as u32;
2293 whole
2294 }
2295
2296 /// One vault, as the sidebar needs to name it.
2297 #[derive(Debug, Clone, PartialEq, Eq)]
2298 pub struct Vault {
2299 /// The row's own id, which its addresses are built from.
2300 pub id: i64,
2301 /// What it is called.
2302 pub name: String,
2303 /// Whether it is the one being browsed.
2304 pub current: bool,
2305 }
2306
2307 /// What a collection holds.
2308 ///
2309 /// The distinction the shipped row puts in its label as " (auto)" or " (12)".
2310 /// Two members rather than a string, because "this updates itself" and "this has
2311 /// twelve things in it" are different claims and only one of them is a count.
2312 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2313 pub enum Holding {
2314 /// A saved search: whatever matches, whenever it matches.
2315 Dynamic,
2316 /// A fixed set, this big.
2317 Fixed(usize),
2318 }
2319
2320 /// One collection, as the sidebar needs to name it.
2321 #[derive(Debug, Clone, PartialEq, Eq)]
2322 pub struct Collection {
2323 /// The row's own id.
2324 pub id: i64,
2325 /// What it is called.
2326 pub name: String,
2327 /// What it holds.
2328 pub holding: Holding,
2329 /// Whether it is the one being shown.
2330 pub active: bool,
2331 }
2332
2333 /// One tag, and whether the list is filtered by it.
2334 #[derive(Debug, Clone, PartialEq, Eq)]
2335 pub struct Filter {
2336 /// The whole dotted path. See [`library`]'s header on why the hierarchy this
2337 /// path encodes is not described.
2338 pub path: String,
2339 /// Whether it is in force.
2340 pub on: bool,
2341 }
2342
2343 /// The vaults, collections and tags, as much as the sidebar needs.
2344 ///
2345 /// The eighth narrow trait. Every write is an intent for the usual reason —
2346 /// selecting a vault, applying a filter and activating a collection are all
2347 /// `&mut BrowserState` — and the two deletes are as well, because each pushes a
2348 /// status line and re-reads the list it emptied.
2349 pub trait Library {
2350 /// Every vault in this library.
2351 fn vaults(&self) -> Vec<Vault>;
2352
2353 /// Every collection, manual and dynamic.
2354 fn collections(&self) -> Vec<Collection>;
2355
2356 /// Every tag the vault knows, and whether it is filtering.
2357 fn tags(&self) -> Vec<Filter>;
2358
2359 /// Browse this vault.
2360 fn open_vault(&self, id: i64);
2361
2362 /// Delete this vault and everything in it.
2363 fn delete_vault(&self, id: i64);
2364
2365 /// Filter by this tag, or stop.
2366 fn toggle_tag(&self, path: &str);
2367
2368 /// Take this tag off every sample that has it.
2369 fn remove_tag(&self, path: &str);
2370
2371 /// Show this collection.
2372 fn open_collection(&self, id: i64);
2373
2374 /// Stop showing whichever is showing.
2375 fn close_collection(&self);
2376
2377 /// Delete this collection.
2378 fn delete_collection(&self, id: i64);
2379 }
2380
2381 /// The app's library, as the narrow thing the sidebar borrows.
2382 pub struct FromLibrary<'a> {
2383 /// What the app has loaded.
2384 pub state: &'a crate::state::BrowserState,
2385 /// What the described screen asked for, applied after the frame.
2386 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2387 }
2388
2389 impl Library for FromLibrary<'_> {
2390 fn vaults(&self) -> Vec<Vault> {
2391 self.state
2392 .nav
2393 .vfs_list
2394 .iter()
2395 .enumerate()
2396 .map(|(at, vfs)| Vault {
2397 id: vfs.id.as_i64(),
2398 name: vfs.name.clone(),
2399 current: at == self.state.nav.current_vfs_idx,
2400 })
2401 .collect()
2402 }
2403
2404 fn collections(&self) -> Vec<Collection> {
2405 let active = self.state.collections_ui.active_collection;
2406 self.state
2407 .collections_ui
2408 .collections
2409 .iter()
2410 .map(|collection| Collection {
2411 id: collection.id.as_i64(),
2412 name: collection.name.clone(),
2413 holding: if collection.is_dynamic() {
2414 Holding::Dynamic
2415 } else {
2416 Holding::Fixed(collection.member_count)
2417 },
2418 active: active == Some(collection.id),
2419 })
2420 .collect()
2421 }
2422
2423 fn tags(&self) -> Vec<Filter> {
2424 let on = &self.state.search.search_filter.required_tags;
2425 self.state
2426 .all_tags
2427 .iter()
2428 .map(|path| Filter {
2429 on: on.contains(path),
2430 path: path.clone(),
2431 })
2432 .collect()
2433 }
2434
2435 fn open_vault(&self, id: i64) {
2436 self.push(Intent::OpenVault(id));
2437 }
2438
2439 fn delete_vault(&self, id: i64) {
2440 self.push(Intent::DeleteVault(id));
2441 }
2442
2443 fn toggle_tag(&self, path: &str) {
2444 self.push(Intent::ToggleTag(path.to_owned()));
2445 }
2446
2447 fn remove_tag(&self, path: &str) {
2448 self.push(Intent::RemoveTagEverywhere(path.to_owned()));
2449 }
2450
2451 fn open_collection(&self, id: i64) {
2452 self.push(Intent::OpenCollection(id));
2453 }
2454
2455 fn close_collection(&self) {
2456 self.push(Intent::CloseCollection);
2457 }
2458
2459 fn delete_collection(&self, id: i64) {
2460 self.push(Intent::DeleteCollection(id));
2461 }
2462 }
2463
2464 impl FromLibrary<'_> {
2465 /// Record what the described screen asked for.
2466 fn push(&self, intent: Intent) {
2467 self.intents.borrow_mut().push(intent);
2468 }
2469 }
2470
2471 /// One step of the trail, as the toolbar needs to name it.
2472 #[derive(Debug, Clone, PartialEq, Eq)]
2473 pub struct Crumb {
2474 /// The folder's own id.
2475 pub id: i64,
2476 /// What it is called.
2477 pub name: String,
2478 }
2479
2480 /// What the list is showing, as a place or as a mode.
2481 ///
2482 /// Three shapes at one region, and the same reasoning `Focus` and `Phase` are
2483 /// written with: a user does not navigate to "similar to kick.wav", they arrive
2484 /// there by asking for it. What is different is that two of the three carry a
2485 /// way *out* rather than a way back, which is what a mode is.
2486 #[derive(Debug, Clone, PartialEq, Eq)]
2487 pub enum Where {
2488 /// A folder, and how you got to it.
2489 Folder {
2490 /// The trail from the root, nearest the root first. Empty at the root.
2491 trail: Vec<Crumb>,
2492 },
2493 /// A collection's contents.
2494 Collection {
2495 /// What the collection is called.
2496 name: String,
2497 },
2498 /// Samples that sound like one particular sample.
2499 Similar {
2500 /// What that sample is called.
2501 name: String,
2502 },
2503 }
2504
2505 /// What is being looked for.
2506 #[derive(Debug, Clone, PartialEq, Eq)]
2507 pub struct Searching {
2508 /// What is typed.
2509 pub query: String,
2510 /// Whether the search covers every vault rather than this folder.
2511 pub everywhere: bool,
2512 /// Whether anything is narrowing the list at all.
2513 pub filtered: bool,
2514 /// How many rows came back.
2515 pub results: usize,
2516 /// How many filter axes are set.
2517 pub filters: usize,
2518 /// The name the app would give a collection made of these filters.
2519 ///
2520 /// `SearchFilter::describe`, resolved here rather than in the description
2521 /// for [`Sync::quote_cents`]'s reason: naming a filter set is the app's, and
2522 /// a screen that reimplemented it would be a second answer.
2523 pub describes: String,
2524 }
2525
2526 /// A panel the toolbar shows or hides.
2527 ///
2528 /// A closed set for the reason [`Setting`] is one: it lets a single route serve
2529 /// all six, and an undeclared name is a `NotFound` rather than a silent no-op.
2530 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2531 pub enum Panel {
2532 /// The vaults, collections and tags.
2533 Sidebar,
2534 /// The selected sample's facts.
2535 Detail,
2536 /// The sample editor.
2537 Edit,
2538 /// The instrument keyboard.
2539 Instrument,
2540 /// Whether preview loops.
2541 Loop,
2542 /// The filter axes.
2543 Filters,
2544 }
2545
2546 impl Panel {
2547 /// Every one, in the order the toolbar puts them.
2548 pub const ALL: [Self; 6] = [
2549 Self::Sidebar,
2550 Self::Detail,
2551 Self::Edit,
2552 Self::Instrument,
2553 Self::Loop,
2554 Self::Filters,
2555 ];
2556
2557 /// What the toggle is worth when the toolbar runs out of room.
2558 ///
2559 /// The declared replacement for the shipped `screen_w < 900.0`, which
2560 /// collapsed all six into a View menu at one pixel width this file had no
2561 /// say in. Ranked rather than collapsed, so a narrow window loses the
2562 /// toggles nobody reaches for and keeps the two that decide the shape of
2563 /// the window.
2564 ///
2565 /// - **Sidebar and Detail never drop.** They are the two structural panes,
2566 /// and a window narrow enough to want fewer controls is exactly the
2567 /// window where being able to close one of them matters most.
2568 /// - **Filters is Secondary.** It carries the count of what is on, so
2569 /// dropping it while a filter is applied would hide why the list is
2570 /// short. It survives everything but the narrowest class.
2571 /// - **Edit, Instrument and Loop drop first.** Each opens an inspector for
2572 /// the selected sample, which is work a phone-width window is not where
2573 /// you do.
2574 ///
2575 /// What this does not say is a width. Which class drops which rank is the
2576 /// renderer's, and it is the same rank in all three of them.
2577 #[must_use]
2578 pub const fn worth(self) -> quasi_router::layout::Priority {
2579 use quasi_router::layout::Priority;
2580 match self {
2581 Self::Sidebar | Self::Detail => Priority::Essential,
2582 Self::Filters => Priority::Secondary,
2583 Self::Edit | Self::Instrument | Self::Loop => Priority::Optional,
2584 }
2585 }
2586
2587 /// The name an address is built from.
2588 #[must_use]
2589 pub const fn as_str(self) -> &'static str {
2590 match self {
2591 Self::Sidebar => "sidebar",
2592 Self::Detail => "detail",
2593 Self::Edit => "edit",
2594 Self::Instrument => "instrument",
2595 Self::Loop => "loop",
2596 Self::Filters => "filters",
2597 }
2598 }
2599
2600 /// What the control says.
2601 #[must_use]
2602 pub const fn label(self) -> &'static str {
2603 match self {
2604 Self::Sidebar => "Sidebar",
2605 Self::Detail => "Detail",
2606 Self::Edit => "Edit",
2607 Self::Instrument => "Instrument",
2608 Self::Loop => "Loop",
2609 Self::Filters => "Filters",
2610 }
2611 }
2612
2613 /// The panel that name means, if it means one.
2614 #[must_use]
2615 pub fn from_key(name: &str) -> Option<Self> {
2616 Self::ALL.into_iter().find(|panel| panel.as_str() == name)
2617 }
2618 }
2619
2620 /// The toolbar, as much of it as a described screen needs.
2621 ///
2622 /// The ninth narrow trait.
2623 pub trait Bar {
2624 /// Where the list is, or what mode it is in.
2625 fn place(&self) -> Where;
2626
2627 /// What is being looked for.
2628 fn searching(&self) -> Searching;
2629
2630 /// Which panels are showing.
2631 fn showing(&self) -> Vec<Panel>;
2632
2633 /// Whether there is anything to undo.
2634 fn undoable(&self) -> bool;
2635
2636 /// Look for this.
2637 fn search(&self, query: &str);
2638
2639 /// Look everywhere, or just here.
2640 fn set_scope(&self, everywhere: bool);
2641
2642 /// Keep the active filters under this name.
2643 fn save_collection(&self, name: &str);
2644
2645 /// Undo the last bulk action.
2646 fn undo(&self);
2647
2648 /// Show this panel, or stop.
2649 fn toggle(&self, panel: Panel);
2650
2651 /// Go to the vault root.
2652 fn go_root(&self);
2653
2654 /// Go to this folder, this far along the trail.
2655 fn go_to(&self, id: i64, depth: usize);
2656
2657 /// Leave whatever mode the list is in.
2658 fn leave(&self);
2659 }
2660
2661 /// The app's toolbar, as the narrow thing a described screen borrows.
2662 pub struct FromBar<'a> {
2663 /// Where the app is and what it has found.
2664 pub state: &'a crate::state::BrowserState,
2665 /// What the described screen asked for, applied after the frame.
2666 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2667 }
2668
2669 impl Bar for FromBar<'_> {
2670 fn place(&self) -> Where {
2671 if self.state.search.similarity_search_hash.is_some() {
2672 return Where::Similar {
2673 name: self
2674 .state
2675 .search
2676 .similarity_source_name
2677 .clone()
2678 .unwrap_or_else(|| "sample".to_owned()),
2679 };
2680 }
2681 if let Some(active) = self.state.collections_ui.active_collection {
2682 return Where::Collection {
2683 name: self
2684 .state
2685 .collections_ui
2686 .collections
2687 .iter()
2688 .find(|collection| collection.id == active)
2689 .map_or_else(|| "Collection".to_owned(), |found| found.name.clone()),
2690 };
2691 }
2692 Where::Folder {
2693 trail: self
2694 .state
2695 .nav
2696 .breadcrumb
2697 .iter()
2698 .map(|crumb| Crumb {
2699 id: crumb.id.as_i64(),
2700 name: crumb.name.clone(),
2701 })
2702 .collect(),
2703 }
2704 }
2705
2706 fn searching(&self) -> Searching {
2707 let filter = &self.state.search.search_filter;
2708 Searching {
2709 query: self.state.search.search_query.clone(),
2710 everywhere: matches!(filter.scope, audiofiles_core::search::SearchScope::Global),
2711 filtered: filter.is_active(),
2712 results: self.state.nav.contents.len(),
2713 filters: filter.active_count(),
2714 describes: filter.describe(),
2715 }
2716 }
2717
2718 fn showing(&self) -> Vec<Panel> {
2719 let mut showing = Vec::new();
2720 if self.state.sidebar_visible {
2721 showing.push(Panel::Sidebar);
2722 }
2723 if self.state.detail.detail_visible {
2724 showing.push(Panel::Detail);
2725 }
2726 if self.state.edit.show_window {
2727 showing.push(Panel::Edit);
2728 }
2729 if self.state.preview.show_midi_window {
2730 showing.push(Panel::Instrument);
2731 }
2732 if self.state.preview.loop_enabled {
2733 showing.push(Panel::Loop);
2734 }
2735 if self.state.search.filter_panel_open {
2736 showing.push(Panel::Filters);
2737 }
2738 showing
2739 }
2740
2741 fn undoable(&self) -> bool {
2742 self.state.can_undo()
2743 }
2744
2745 fn search(&self, query: &str) {
2746 self.push(Intent::Search(query.to_owned()));
2747 }
2748
2749 fn set_scope(&self, everywhere: bool) {
2750 self.push(Intent::Scope(everywhere));
2751 }
2752
2753 fn save_collection(&self, name: &str) {
2754 self.push(Intent::SaveCollection(name.to_owned()));
2755 }
2756
2757 fn undo(&self) {
2758 self.push(Intent::Undo);
2759 }
2760
2761 fn toggle(&self, panel: Panel) {
2762 self.push(Intent::TogglePanel(panel));
2763 }
2764
2765 fn go_root(&self) {
2766 self.push(Intent::GoRoot);
2767 }
2768
2769 fn go_to(&self, id: i64, depth: usize) {
2770 self.push(Intent::GoTo(id, depth));
2771 }
2772
2773 fn leave(&self) {
2774 self.push(Intent::Leave);
2775 }
2776 }
2777
2778 impl FromBar<'_> {
2779 /// Record what the described screen asked for.
2780 fn push(&self, intent: Intent) {
2781 self.intents.borrow_mut().push(intent);
2782 }
2783 }
2784
2785 /// A folder or vault being named, as the four name modals need it.
2786 ///
2787 /// The ninth, tenth and eleventh narrow traits are below, and this one is the
2788 /// odd member of the set: **its writes answer**. Every other capability records
2789 /// an [`Intent`] and hears nothing back, because what it asks for is
2790 /// `&mut BrowserState` and cannot happen inside a frame. Naming a vault is both
2791 /// at once — `Backend::create_vfs` is `&self` and returns whether it worked,
2792 /// `refresh_vfs_list` is `&mut` — so the write happens here and the refresh is
2793 /// the intent.
2794 ///
2795 /// That split is what makes the described modal able to keep its own error. A
2796 /// name that the store refuses has to come back to the field it was typed into,
2797 /// and an intent applied after the answer was built could not carry it. See
2798 /// [`naming`]'s header for the rule this sharpens: [`Detail`] settled that *what
2799 /// the app does about a write decides where the write goes*, and here what the
2800 /// app does about it is two things with different lifetimes.
2801 pub trait Naming {
2802 /// The modal is finished with: put it away.
2803 ///
2804 /// The host is what keeps one of these on screen, so leaving the address is
2805 /// not leaving the screen. See `naming`'s `DONE`.
2806 fn done(&self);
2807
2808 /// What this vault is called, if it is one.
2809 fn vault(&self, id: i64) -> Option<String>;
2810
2811 /// What this folder is called, if it is one here.
2812 fn folder(&self, id: i64) -> Option<String>;
2813
2814 /// Make a vault by this name.
2815 ///
2816 /// # Errors
2817 /// Whatever the store said, as text, for the field to carry.
2818 fn create_vault(&self, name: &str) -> Result<String, String>;
2819
2820 /// Rename this vault.
2821 ///
2822 /// # Errors
2823 /// Whatever the store said, as text.
2824 fn rename_vault(&self, id: i64, name: &str) -> Result<String, String>;
2825
2826 /// Make a folder by this name, where the app is looking.
2827 ///
2828 /// # Errors
2829 /// Whatever the store said, as text.
2830 fn create_folder(&self, name: &str) -> Result<String, String>;
2831
2832 /// Rename this folder.
2833 ///
2834 /// # Errors
2835 /// Whatever the store said, as text.
2836 fn rename_folder(&self, id: i64, name: &str) -> Result<String, String>;
2837 }
2838
2839 /// The app's vaults and folders, as the narrow thing the name modals borrow.
2840 pub struct FromNaming<'a> {
2841 /// What the app has loaded.
2842 pub state: &'a crate::state::BrowserState,
2843 /// The refresh the write needs afterwards, applied after the frame.
2844 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2845 }
2846
2847 impl FromNaming<'_> {
2848 /// The vault this id names, as the app's own id type.
2849 ///
2850 /// Looked up in the loaded list rather than built with `VfsId::from`, which
2851 /// is [`library`]'s rule: an address is reachable by typing, so an id that
2852 /// names nothing is a refusal rather than a call against the store.
2853 fn vault_id(&self, id: i64) -> Option<audiofiles_core::id_types::VfsId> {
2854 self.state
2855 .nav
2856 .vfs_list
2857 .iter()
2858 .find(|vfs| vfs.id.as_i64() == id)
2859 .map(|vfs| vfs.id)
2860 }
2861
2862 /// The folder this id names, where it is a folder in the current listing.
2863 fn folder_id(&self, id: i64) -> Option<audiofiles_core::id_types::NodeId> {
2864 self.state
2865 .nav
2866 .contents
2867 .iter()
2868 .map(|node| &node.node)
2869 .find(|node| node.id.as_i64() == id && node.sample_hash.is_none())
2870 .map(|node| node.id)
2871 }
2872
2873 /// Record the refresh the write owes, and hand back what to say.
2874 fn changed(&self, intent: Intent, say: String) -> Result<String, String> {
2875 self.intents.borrow_mut().push(intent);
2876 Ok(say)
2877 }
2878 }
2879
2880 impl Naming for FromNaming<'_> {
2881 fn done(&self) {
2882 self.intents.borrow_mut().push(Intent::NamingDone);
2883 }
2884
2885 fn vault(&self, id: i64) -> Option<String> {
2886 self.state
2887 .nav
2888 .vfs_list
2889 .iter()
2890 .find(|vfs| vfs.id.as_i64() == id)
2891 .map(|vfs| vfs.name.clone())
2892 }
2893
2894 fn folder(&self, id: i64) -> Option<String> {
2895 self.state
2896 .nav
2897 .contents
2898 .iter()
2899 .map(|node| &node.node)
2900 .find(|node| node.id.as_i64() == id && node.sample_hash.is_none())
2901 .map(|node| node.name.clone())
2902 }
2903
2904 fn create_vault(&self, name: &str) -> Result<String, String> {
2905 self.state
2906 .backend
2907 .create_vfs(name)
2908 .map_err(|error| error.to_string())?;
2909 let say = format!("Created vault: {name}");
2910 self.changed(Intent::VaultsChanged(say.clone()), say)
2911 }
2912
2913 fn rename_vault(&self, id: i64, name: &str) -> Result<String, String> {
2914 let vault = self
2915 .vault_id(id)
2916 .ok_or_else(|| "No such vault".to_owned())?;
2917 self.state
2918 .backend
2919 .rename_vfs(vault, name)
2920 .map_err(|error| error.to_string())?;
2921 let say = format!("Renamed vault to: {name}");
2922 self.changed(Intent::VaultsChanged(say.clone()), say)
2923 }
2924
2925 fn create_folder(&self, name: &str) -> Result<String, String> {
2926 // The shipped modal's own guard, kept as a guard: New Folder is only
2927 // reachable inside a vault, so this is the defensive path rather than a
2928 // real one.
2929 let vault = self
2930 .state
2931 .current_vfs_id()
2932 .ok_or_else(|| "No vault selected".to_owned())?;
2933 self.state
2934 .backend
2935 .create_directory(vault, self.state.nav.current_dir, name)
2936 .map_err(|error| error.to_string())?;
2937 let say = format!("Created folder: {name}");
2938 self.changed(Intent::ContentsChanged(say.clone()), say)
2939 }
2940
2941 fn rename_folder(&self, id: i64, name: &str) -> Result<String, String> {
2942 let node = self
2943 .folder_id(id)
2944 .ok_or_else(|| "No such folder".to_owned())?;
2945 self.state
2946 .backend
2947 .rename_node(node, name)
2948 .map_err(|error| error.to_string())?;
2949 let say = format!("Renamed to: {name}");
2950 self.changed(Intent::ContentsChanged(say.clone()), say)
2951 }
2952 }
2953
2954 /// Where the import flow has got to.
2955 ///
2956 /// [`Phase`]'s peer for the other long flow, and the same argument holds for the
2957 /// same reason: which screen is showing is a fact about the app rather than
2958 /// somewhere the user chose to be, so it is one address answering several
2959 /// screens. What is different is the length — nine states against five — and the
2960 /// length is the app's, not the description's: `ImportMode` carries all nine and
2961 /// the shipped wizard draws one screen per state.
2962 ///
2963 /// **Not every state on `ImportMode` is here.** The four export states are
2964 /// [`Phase`]'s, and `Cleaning` and `ReviewLibrary` are not import at all: the
2965 /// enum is the app's full-screen router rather than an import-only state, which
2966 /// its own header says. Splitting it here is the description saying what the
2967 /// name stopped saying — see [`Sweep`] for the one that shares a *file* with
2968 /// these screens and nothing else.
2969 #[derive(Debug, Clone, PartialEq)]
2970 pub enum Stage {
2971 /// Nothing is being imported.
2972 Idle,
2973 /// Choosing what lands where, before anything is copied.
2974 Configuring {
2975 /// The folder the files are coming from.
2976 source: String,
2977 /// How many audio files the dry-run scan found in it.
2978 files: usize,
2979 /// Where they will land.
2980 strategy: Strategy,
2981 /// The name typed for a new vault.
2982 vault_name: String,
2983 /// The vaults a merge could go into.
2984 vaults: Vec<VaultChoice>,
2985 /// Which of them is chosen.
2986 merging_into: usize,
2987 },
2988 /// Walking the folder, before the count is known.
2989 ///
2990 /// Its own state rather than [`Copying`](Self::Copying) with a flag, for
2991 /// the reason [`Phase`] is an enum: there is no total to report yet, and a
2992 /// screen holding a total that is not there is one every reader has to ask
2993 /// about.
2994 Scanning {
2995 /// How many audio files the walk has reached, or zero before the first
2996 /// event lands.
2997 found: usize,
2998 /// How much they weigh, as the app formats a size.
2999 size: Option<String>,
3000 },
3001 /// Files being copied in.
3002 Copying {
3003 /// How many have landed.
3004 done: usize,
3005 /// How many there are.
3006 total: usize,
3007 /// The one being copied now.
3008 current: String,
3009 /// What the whole set weighs, as the app formats a size.
3010 size: Option<String>,
3011 /// Whether the files are referenced where they sit rather than copied.
3012 in_place: bool,
3013 /// What has gone wrong so far.
3014 failures: Vec<Failure>,
3015 },
3016 /// Naming what came in, one folder at a time.
3017 Tagging {
3018 /// Every imported folder, with whatever has been typed against it.
3019 folders: Vec<FolderTags>,
3020 },
3021 /// Choosing what to measure, before any of it runs.
3022 Choosing {
3023 /// How many samples would be analysed.
3024 samples: usize,
3025 /// What is ticked.
3026 measures: Measures,
3027 /// Whether the tagging step can be returned to.
3028 resumable: bool,
3029 },
3030 /// Samples being analysed.
3031 Analysing {
3032 /// How many are done.
3033 done: usize,
3034 /// How many there are.
3035 total: usize,
3036 /// The one being analysed now.
3037 current: String,
3038 /// What has gone wrong so far, from both halves of the run.
3039 failures: Vec<Failure>,
3040 },
3041 /// Reading what the analysis suggested, before any of it is applied.
3042 Reviewing {
3043 /// Every sample with something to say about it, in the app's own order.
3044 items: Vec<Reviewed>,
3045 /// Which one is being read, as an index into `items`.
3046 at: usize,
3047 /// How the list is ordered.
3048 order: Order,
3049 },
3050 /// What failed, once the run is over.
3051 Summary {
3052 /// Files that never entered the library.
3053 rejected: Vec<Failure>,
3054 /// Files that entered it and could not be analysed.
3055 unanalysed: Vec<Failure>,
3056 },
3057 /// Given up on partway.
3058 Stopped {
3059 /// Which half of the flow stopped.
3060 what: Halted,
3061 /// How many had been done when it stopped.
3062 done: usize,
3063 /// How many there would have been.
3064 total: usize,
3065 },
3066 }
3067
3068 /// Where imported files land.
3069 ///
3070 /// Mirrored rather than re-exported, which is [`Format`]'s reason: the app's
3071 /// `ImportStrategy` carries the vault and parent ids the choice resolves to, and
3072 /// those are the answer rather than the question. What the screen asks is which
3073 /// of three, and that is this.
3074 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3075 pub enum Strategy {
3076 /// Every file into the folder that is open, structure discarded.
3077 Flat,
3078 /// A new vault, with the folder tree preserved.
3079 NewVault,
3080 /// An existing vault, merged into.
3081 Merge,
3082 }
3083
3084 impl Strategy {
3085 /// The name a described control submits.
3086 #[must_use]
3087 pub const fn as_str(self) -> &'static str {
3088 match self {
3089 Self::Flat => "flat",
3090 Self::NewVault => "new",
3091 Self::Merge => "merge",
3092 }
3093 }
3094
3095 /// The strategy that name means, if it means one.
3096 #[must_use]
3097 pub fn from_key(name: &str) -> Option<Self> {
3098 match name {
3099 "flat" => Some(Self::Flat),
3100 "new" => Some(Self::NewVault),
3101 "merge" => Some(Self::Merge),
3102 _ => None,
3103 }
3104 }
3105 }
3106
3107 /// A vault a merge could go into.
3108 ///
3109 /// The name and nothing else: the shipped picker addresses one by its index into
3110 /// the list it was built from, and that is what the app's `selected_merge_vfs_idx`
3111 /// holds. Carrying the id as well would offer the description a second way to
3112 /// name the same thing, and the app can only read one of them.
3113 #[derive(Debug, Clone, PartialEq, Eq)]
3114 pub struct VaultChoice {
3115 /// What the vault is called.
3116 pub name: String,
3117 }
3118
3119 /// Something that went wrong, as a screen reports it.
3120 ///
3121 /// One type for both error lists, where the app has two — `ImportFileError`
3122 /// carries a path and `AnalysisFileError` carries a hash and a name. What a
3123 /// screen says of either is the same two things, so the difference is which list
3124 /// it is in rather than what shape it has.
3125 #[derive(Debug, Clone, PartialEq, Eq)]
3126 pub struct Failure {
3127 /// What it was, as the app names it: a path before the store, a name after.
3128 pub name: String,
3129 /// Why it failed.
3130 pub error: String,
3131 }
3132
3133 /// One imported folder waiting to be tagged.
3134 #[derive(Debug, Clone, PartialEq, Eq)]
3135 pub struct FolderTags {
3136 /// What the folder is called.
3137 pub name: String,
3138 /// How many samples came out of it.
3139 pub samples: usize,
3140 /// What has been typed against it, comma-separated.
3141 pub typed: String,
3142 /// The typed tags this app would refuse, if any.
3143 ///
3144 /// Resolved here rather than in the route because it is the app's rule:
3145 /// `audiofiles_core::tags::validate_tag` says what a tag may be, and a
3146 /// described screen carrying a second copy of it would be the drift this
3147 /// layer exists to end. Same division as [`panel`]'s `add_tag`.
3148 pub invalid: Vec<String>,
3149 }
3150
3151 /// What an analysis run would measure.
3152 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3153 pub struct Measures {
3154 /// Peak, RMS and LUFS.
3155 pub loudness: bool,
3156 /// Tempo.
3157 pub bpm: bool,
3158 /// Musical key.
3159 pub key: bool,
3160 /// Centroid, flatness, rolloff and zero-crossing rate.
3161 pub spectral: bool,
3162 /// Whether the sample is a seamless loop.
3163 pub loops: bool,
3164 /// Tags suggested from the results.
3165 pub suggestions: bool,
3166 /// The envelope fingerprint near-duplicate detection reads.
3167 pub fingerprint: bool,
3168 /// Skipping tempo and key where they cannot apply.
3169 pub smart_skip: bool,
3170 }
3171
3172 /// One thing an analysis run may be told to measure.
3173 ///
3174 /// [`Setting`]'s peer, and closed for the same reason: it is what lets one write
3175 /// route serve the whole screen without carrying a second list of the names it
3176 /// will answer to.
3177 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3178 pub enum Measure {
3179 /// [`Measures::loudness`].
3180 Loudness,
3181 /// [`Measures::bpm`].
3182 Bpm,
3183 /// [`Measures::key`].
3184 Key,
3185 /// [`Measures::spectral`].
3186 Spectral,
3187 /// [`Measures::loops`].
3188 Loops,
3189 /// [`Measures::suggestions`].
3190 Suggestions,
3191 /// [`Measures::fingerprint`].
3192 Fingerprint,
3193 /// [`Measures::smart_skip`].
3194 SmartSkip,
3195 }
3196
3197 impl Measure {
3198 /// Every one of them, in the order the shipped screen ticks them.
3199 pub const ALL: [Self; 8] = [
3200 Self::Loudness,
3201 Self::Bpm,
3202 Self::Key,
3203 Self::Spectral,
3204 Self::Loops,
3205 Self::Suggestions,
3206 Self::Fingerprint,
3207 Self::SmartSkip,
3208 ];
3209
3210 /// The name a described address is built from.
3211 #[must_use]
3212 pub const fn as_str(self) -> &'static str {
3213 match self {
3214 Self::Loudness => "loudness",
3215 Self::Bpm => "bpm",
3216 Self::Key => "key",
3217 Self::Spectral => "spectral",
3218 Self::Loops => "loops",
3219 Self::Suggestions => "suggestions",
3220 Self::Fingerprint => "fingerprint",
3221 Self::SmartSkip => "smart-skip",
3222 }
3223 }
3224
3225 /// What the control says.
3226 #[must_use]
3227 pub const fn label(self) -> &'static str {
3228 match self {
3229 Self::Loudness => "Loudness (peak, RMS, LUFS)",
3230 Self::Bpm => "BPM detection",
3231 Self::Key => "Key detection",
3232 Self::Spectral => "Spectral features",
3233 Self::Loops => "Loop detection",
3234 Self::Suggestions => "Auto-suggest tags",
3235 Self::Fingerprint => "Fingerprint (duplicate detection)",
3236 Self::SmartSkip => "Smart skip (skip BPM/key where they cannot apply)",
3237 }
3238 }
3239
3240 /// The measure that name means, if it means one.
3241 #[must_use]
3242 pub fn from_key(name: &str) -> Option<Self> {
3243 Self::ALL.into_iter().find(|held| held.as_str() == name)
3244 }
3245
3246 /// Whether this one is on.
3247 #[must_use]
3248 pub const fn read(self, measures: &Measures) -> bool {
3249 match self {
3250 Self::Loudness => measures.loudness,
3251 Self::Bpm => measures.bpm,
3252 Self::Key => measures.key,
3253 Self::Spectral => measures.spectral,
3254 Self::Loops => measures.loops,
3255 Self::Suggestions => measures.suggestions,
3256 Self::Fingerprint => measures.fingerprint,
3257 Self::SmartSkip => measures.smart_skip,
3258 }
3259 }
3260 }
3261
3262 /// One analysed sample, and what the analysis wants to call it.
3263 #[derive(Debug, Clone, PartialEq)]
3264 pub struct Reviewed {
3265 /// What the sample is called.
3266 pub name: String,
3267 /// How long it runs, in seconds.
3268 pub duration: f64,
3269 /// What it was recorded at.
3270 pub sample_rate: u32,
3271 /// Its peak, in dBFS.
3272 pub peak_db: Option<f64>,
3273 /// Its tempo, where one was found.
3274 pub bpm: Option<f64>,
3275 /// Its key, where one was found.
3276 pub musical_key: Option<String>,
3277 /// What the analysis suggests, best first.
3278 ///
3279 /// Sorted by the description rather than by the app, which is the one place
3280 /// this flow reorders anything: the shipped screen sorts `item.suggestions`
3281 /// in place every frame, and a route cannot do that. Sorting a copy is the
3282 /// same answer without the write.
3283 pub suggestions: Vec<Suggestion>,
3284 }
3285
3286 /// One tag the analysis proposes.
3287 #[derive(Debug, Clone, PartialEq)]
3288 pub struct Suggestion {
3289 /// The tag itself.
3290 pub tag: String,
3291 /// How sure the analysis is, from zero to one.
3292 pub confidence: f32,
3293 /// Why it thinks so.
3294 pub reason: String,
3295 /// Whether it is ticked to be applied.
3296 pub accepted: bool,
3297 }
3298
3299 /// How the review list is ordered.
3300 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3301 pub enum Order {
3302 /// The order they were imported in.
3303 Arrival,
3304 /// By name.
3305 Name,
3306 /// Most suggestions first.
3307 Suggestions,
3308 /// Most accepted first.
3309 Accepted,
3310 }
3311
3312 impl Order {
3313 /// Every one of them, in the order the shipped picker lists them.
3314 pub const ALL: [Self; 4] = [Self::Arrival, Self::Name, Self::Suggestions, Self::Accepted];
3315
3316 /// The name a described control submits.
3317 #[must_use]
3318 pub const fn as_str(self) -> &'static str {
3319 match self {
3320 Self::Arrival => "arrival",
3321 Self::Name => "name",
3322 Self::Suggestions => "suggestions",
3323 Self::Accepted => "accepted",
3324 }
3325 }
3326
3327 /// What the control says.
3328 #[must_use]
3329 pub const fn label(self) -> &'static str {
3330 match self {
3331 Self::Arrival => "Import order",
3332 Self::Name => "Name",
3333 Self::Suggestions => "Suggestions",
3334 Self::Accepted => "Accepted",
3335 }
3336 }
3337
3338 /// The order that name means, if it means one.
3339 #[must_use]
3340 pub fn from_key(name: &str) -> Option<Self> {
3341 Self::ALL.into_iter().find(|held| held.as_str() == name)
3342 }
3343 }
3344
3345 /// One thing the configure screen may change.
3346 ///
3347 /// [`Setting`] and [`Measure`]'s third peer. Three answers rather than one,
3348 /// because the strategy is derived from all three and the app rebuilds it from
3349 /// them every frame — see `ui::import_screens::configure`, and the bug that
3350 /// arrangement was written to fix.
3351 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3352 pub enum Decision {
3353 /// Which of the three strategies.
3354 Strategy,
3355 /// What to call a new vault.
3356 VaultName,
3357 /// Which existing vault to merge into.
3358 MergeVault,
3359 }
3360
3361 impl Decision {
3362 /// The name a described address is built from.
3363 #[must_use]
3364 pub const fn as_str(self) -> &'static str {
3365 match self {
3366 Self::Strategy => "strategy",
3367 Self::VaultName => "vault-name",
3368 Self::MergeVault => "merge-vault",
3369 }
3370 }
3371
3372 /// The decision that name means, if it means one.
3373 #[must_use]
3374 pub fn from_key(name: &str) -> Option<Self> {
3375 match name {
3376 "strategy" => Some(Self::Strategy),
3377 "vault-name" => Some(Self::VaultName),
3378 "merge-vault" => Some(Self::MergeVault),
3379 _ => None,
3380 }
3381 }
3382 }
3383
3384 /// Which half of the flow was given up on.
3385 ///
3386 /// The app's `CancelKind` less its export arm, which is [`Phase::Cancelled`]'s.
3387 /// One enum split across two descriptions because one screen serving three
3388 /// operations is the app's arrangement rather than a fact about any of them.
3389 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3390 pub enum Halted {
3391 /// Files were being copied in.
3392 Import,
3393 /// Samples were being analysed.
3394 Analysis,
3395 }
3396
3397 /// Orphaned samples being swept up.
3398 ///
3399 /// **Not a stage of the import flow**, and it is here because it shares a file
3400 /// with four screens that are. `ui::import_screens::progress` draws it beside
3401 /// the import and analysis progress screens, and `ImportMode::Cleaning` sits on
3402 /// the same enum, so from the app's side it reads as part of the wizard. It is
3403 /// not: it is started by a bulk delete and by the export flow's orphan pass, and
3404 /// nothing in the wizard reaches it. So it answers its own address, which is the
3405 /// description saying what the shared file and the shared enum both blur.
3406 #[derive(Debug, Clone, PartialEq, Eq)]
3407 pub struct Sweep {
3408 /// How many have been removed.
3409 pub done: usize,
3410 /// How many there are, or zero before the scan has counted them.
3411 pub total: usize,
3412 /// The one being removed now.
3413 pub current: String,
3414 }
3415
3416 /// An import waiting to be agreed to, as the description needs to name it.
3417 ///
3418 /// The source is a `String` rather than a `PathBuf` for the reason [`Status`] is
3419 /// not `SyncStatus`: what a screen says about a path is the path, and a
3420 /// described screen that took the app's own type would carry the app's platform
3421 /// with it.
3422 #[derive(Debug, Clone, PartialEq, Eq)]
3423 pub struct Preflight {
3424 /// Where the files are.
3425 pub source: String,
3426 /// How many there are.
3427 pub files: usize,
3428 /// How big they are, as the app formats a size.
3429 pub size: String,
3430 }
3431
3432 /// Importing, as much of it as a described screen needs.
3433 ///
3434 /// The tenth narrow trait, and it **grew rather than gained a sibling**. It
3435 /// landed as the preflight alone — one read and two writes — with its header
3436 /// saying "the configure, tagging, progress and summary screens are their own
3437 /// pass, and this trait is what that pass grows". This is that pass, and taking
3438 /// the note at its word is what keeps the two halves one capability: the
3439 /// preflight is a question about an import that has not started and the flow is
3440 /// every stage after it starts, which is one subject asked at two moments rather
3441 /// than two subjects.
3442 ///
3443 /// Every write is an [`Intent`], with no exceptions and for one reason: all of
3444 /// them land on `BrowserState::import_wf`, which is the app's own screen state.
3445 /// That is [`Export`]'s arrangement, and this trait is the larger consumer of
3446 /// it — nineteen writes against five.
3447 pub trait Importing {
3448 /// The import waiting to be agreed to, if one is.
3449 fn waiting(&self) -> Option<Preflight>;
3450
3451 /// Go ahead with it, and remember the answer if `again` is false.
3452 fn accept(&self, again: bool);
3453
3454 /// Do not.
3455 fn cancel(&self);
3456
3457 /// Where the flow has got to.
3458 fn stage(&self) -> Stage;
3459
3460 /// Orphaned samples being swept up, if any are. See [`Sweep`].
3461 fn sweeping(&self) -> Option<Sweep>;
3462
3463 // The doors. Each opens a native picker and then acts, which is a host act
3464 // with no described step: see this module's `integrity` header, and
3465 // `importing`'s for why three more consumers of it arrive at once.
3466
3467 /// Pick a folder, then set the wizard up on it.
3468 fn open_folder(&self);
3469
3470 /// Pick a folder, then index it with no questions asked.
3471 fn open_quickly(&self);
3472
3473 /// Pick files, then merge them into the vault that is open.
3474 fn open_files(&self);
3475
3476 /// Pick a different folder for the import being configured.
3477 fn change_source(&self);
3478
3479 // Configuring.
3480
3481 /// Answer one of the three questions the configure screen asks.
3482 fn decide(&self, decision: Decision, value: &str);
3483
3484 /// Begin copying.
3485 fn begin(&self);
3486
3487 /// Give up on the copy that is running.
3488 fn stop(&self);
3489
3490 /// Give up and go back to configuring.
3491 fn retry(&self);
3492
3493 /// Put the flow away, from wherever it is.
3494 fn dismiss(&self);
3495
3496 // Tagging.
3497
3498 /// Type these tags against this folder.
3499 fn tag_folder(&self, at: usize, typed: &str);
3500
3501 /// Type these tags against every folder.
3502 fn tag_every_folder(&self, typed: &str);
3503
3504 /// Apply what has been typed.
3505 fn apply_folder_tags(&self);
3506
3507 /// Apply none of it and move on.
3508 fn skip_folder_tags(&self);
3509
3510 // Analysing.
3511
3512 /// Turn one measure on or off.
3513 fn measure(&self, measure: Measure, wanted: bool);
3514
3515 /// Run the analysis.
3516 fn analyse(&self);
3517
3518 /// Go back to tagging the folders.
3519 fn back_to_tagging(&self);
3520
3521 /// Do not analyse at all.
3522 fn skip_analysis(&self);
3523
3524 /// Give up on the analysis that is running.
3525 fn stop_analysis(&self);
3526
3527 /// Give up on it and start it again.
3528 fn retry_analysis(&self);
3529
3530 // Reviewing.
3531
3532 /// Order the review list this way.
3533 fn order(&self, order: Order);
3534
3535 /// Read this one.
3536 fn read(&self, at: usize);
3537
3538 /// Accept or reject one suggestion against one sample.
3539 fn judge(&self, at: usize, tag: &str, accepted: bool);
3540
3541 /// Accept or reject every suggestion against every sample.
3542 fn judge_all(&self, accepted: bool);
3543
3544 /// Apply the accepted ones.
3545 fn apply_suggestions(&self);
3546
3547 /// Apply none of them.
3548 fn discard_suggestions(&self);
3549
3550 // The summary.
3551
3552 /// Keep every file that failed.
3553 fn keep_failed(&self);
3554
3555 /// Delete the ones that failed analysis, or one of them.
3556 fn purge_failed(&self, at: Option<usize>);
3557
3558 // The sweep.
3559
3560 /// Give up on the sweep that is running.
3561 fn stop_sweep(&self);
3562 }
3563
3564 /// The app's import workflow, as the narrow thing the preflight borrows.
3565 pub struct FromImport<'a> {
3566 /// What the app is holding.
3567 pub state: &'a crate::state::BrowserState,
3568 /// What the described screen asked for, applied after the frame.
3569 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
3570 }
3571
3572 impl Importing for FromImport<'_> {
3573 fn waiting(&self) -> Option<Preflight> {
3574 let waiting = self.state.import_wf.pending_import_preflight.as_ref()?;
3575 Some(Preflight {
3576 source: waiting.source.display().to_string(),
3577 files: waiting.file_count,
3578 // Formatted by the app's own function rather than by the
3579 // description, which is the rule the footer's status tone follows:
3580 // how this app writes a size is the app's, and a second copy of it
3581 // here would drift.
3582 size: crate::ui::widgets::format_bytes(waiting.total_bytes),
3583 })
3584 }
3585
3586 fn accept(&self, again: bool) {
3587 self.push(Intent::AcceptImport { again });
3588 }
3589
3590 fn cancel(&self) {
3591 self.push(Intent::CancelImport);
3592 }
3593
3594 fn stage(&self) -> Stage {
3595 use crate::state::ImportMode;
3596
3597 match &self.state.import_wf.import_mode {
3598 ImportMode::ConfigureImport {
3599 source,
3600 strategy,
3601 available_vfs,
3602 selected_merge_vfs_idx,
3603 new_vfs_name,
3604 audio_file_count,
3605 ..
3606 } => Stage::Configuring {
3607 source: source.display().to_string(),
3608 files: *audio_file_count,
3609 strategy: match strategy {
3610 crate::import::ImportStrategy::Flat { .. } => Strategy::Flat,
3611 crate::import::ImportStrategy::NewVfs { .. } => Strategy::NewVault,
3612 crate::import::ImportStrategy::MergeIntoVfs { .. } => Strategy::Merge,
3613 },
3614 vault_name: new_vfs_name.clone(),
3615 vaults: available_vfs
3616 .iter()
3617 .map(|vfs| VaultChoice {
3618 name: vfs.name.clone(),
3619 })
3620 .collect(),
3621 merging_into: *selected_merge_vfs_idx,
3622 },
3623 // The walk and the copy are one `ImportMode` arm with a flag, and
3624 // two stages here. See [`Stage::Scanning`]: before the walk lands
3625 // there is no total, and the shipped screen answers that with a
3626 // whole different body rather than with a zeroed bar.
3627 ImportMode::Importing {
3628 walking: true,
3629 walking_count,
3630 total_bytes,
3631 ..
3632 } => Stage::Scanning {
3633 found: *walking_count,
3634 size: Self::size(*total_bytes),
3635 },
3636 ImportMode::Importing {
3637 total,
3638 completed,
3639 current_name,
3640 total_bytes,
3641 loose_files,
3642 ..
3643 } => Stage::Copying {
3644 done: *completed,
3645 total: *total,
3646 current: current_name.clone(),
3647 size: Self::size(*total_bytes),
3648 in_place: *loose_files,
3649 failures: self.failures(),
3650 },
3651 ImportMode::TagFolders { entries, .. } => Stage::Tagging {
3652 folders: entries
3653 .iter()
3654 .map(|entry| FolderTags {
3655 name: entry.folder.name.clone(),
3656 samples: entry.folder.samples.len(),
3657 typed: entry.tag_input.clone(),
3658 invalid: invalid_tags(&entry.tag_input),
3659 })
3660 .collect(),
3661 },
3662 ImportMode::ConfigureAnalysis {
3663 sample_hashes,
3664 config,
3665 } => Stage::Choosing {
3666 samples: sample_hashes.len(),
3667 measures: Measures {
3668 loudness: config.loudness,
3669 bpm: config.bpm,
3670 key: config.key,
3671 spectral: config.spectral,
3672 loops: config.loop_detect,
3673 suggestions: config.auto_suggest_tags,
3674 fingerprint: config.fingerprint,
3675 smart_skip: config.smart_skip,
3676 },
3677 // What the shipped Back button is enabled on, and it is a fact
3678 // about the app rather than about the screen: the tags typed on
3679 // the previous step are stashed, or the flow was entered from
3680 // somewhere that never had one.
3681 resumable: self.state.import_wf.last_folder_tags.is_some(),
3682 },
3683 ImportMode::Analyzing {
3684 completed,
3685 total,
3686 current_name,
3687 } => Stage::Analysing {
3688 done: *completed,
3689 total: *total,
3690 current: current_name.clone(),
3691 failures: self.failures(),
3692 },
3693 ImportMode::ReviewSuggestions {
3694 items,
3695 current_idx,
3696 sort,
3697 } => Stage::Reviewing {
3698 items: items.iter().map(reviewed).collect(),
3699 at: *current_idx,
3700 order: match sort {
3701 crate::state::ReviewSort::ImportOrder => Order::Arrival,
3702 crate::state::ReviewSort::Name => Order::Name,
3703 crate::state::ReviewSort::Suggestions => Order::Suggestions,
3704 crate::state::ReviewSort::Accepted => Order::Accepted,
3705 },
3706 },
3707 ImportMode::ReviewErrors => Stage::Summary {
3708 rejected: self
3709 .state
3710 .import_wf
3711 .import_file_errors
3712 .iter()
3713 .map(|failure| Failure {
3714 name: failure.path.clone(),
3715 error: failure.error.clone(),
3716 })
3717 .collect(),
3718 unanalysed: self
3719 .state
3720 .import_wf
3721 .analysis_errors
3722 .iter()
3723 .map(|failure| Failure {
3724 name: failure.name.clone(),
3725 error: failure.error.clone(),
3726 })
3727 .collect(),
3728 },
3729 ImportMode::OperationCancelled {
3730 kind: kind @ (crate::state::CancelKind::Import | crate::state::CancelKind::Analysis),
3731 completed,
3732 total,
3733 ..
3734 } => Stage::Stopped {
3735 what: match kind {
3736 crate::state::CancelKind::Analysis => Halted::Analysis,
3737 _ => Halted::Import,
3738 },
3739 done: *completed,
3740 total: *total,
3741 },
3742 _ => Stage::Idle,
3743 }
3744 }
3745
3746 fn sweeping(&self) -> Option<Sweep> {
3747 match &self.state.import_wf.import_mode {
3748 crate::state::ImportMode::Cleaning {
3749 completed,
3750 total,
3751 current_name,
3752 } => Some(Sweep {
3753 done: *completed,
3754 total: *total,
3755 current: current_name.clone(),
3756 }),
3757 _ => None,
3758 }
3759 }
3760
3761 fn open_folder(&self) {
3762 self.push(Intent::OpenImportFolder);
3763 }
3764
3765 fn open_quickly(&self) {
3766 self.push(Intent::OpenQuickImport);
3767 }
3768
3769 fn open_files(&self) {
3770 self.push(Intent::OpenImportFiles);
3771 }
3772
3773 fn change_source(&self) {
3774 self.push(Intent::ChangeImportSource);
3775 }
3776
3777 fn decide(&self, decision: Decision, value: &str) {
3778 self.push(Intent::Decide(decision, value.to_owned()));
3779 }
3780
3781 fn begin(&self) {
3782 self.push(Intent::BeginImport);
3783 }
3784
3785 fn stop(&self) {
3786 self.push(Intent::StopImport);
3787 }
3788
3789 fn retry(&self) {
3790 self.push(Intent::RetryImport);
3791 }
3792
3793 fn dismiss(&self) {
3794 self.push(Intent::DismissImport);
3795 }
3796
3797 fn tag_folder(&self, at: usize, typed: &str) {
3798 self.push(Intent::TagFolder(at, typed.to_owned()));
3799 }
3800
3801 fn tag_every_folder(&self, typed: &str) {
3802 self.push(Intent::TagEveryFolder(typed.to_owned()));
3803 }
3804
3805 fn apply_folder_tags(&self) {
3806 self.push(Intent::ApplyFolderTags);
3807 }
3808
3809 fn skip_folder_tags(&self) {
3810 self.push(Intent::SkipFolderTags);
3811 }
3812
3813 fn measure(&self, measure: Measure, wanted: bool) {
3814 self.push(Intent::Measure(measure, wanted));
3815 }
3816
3817 fn analyse(&self) {
3818 self.push(Intent::StartAnalysis);
3819 }
3820
3821 fn back_to_tagging(&self) {
3822 self.push(Intent::BackToTagging);
3823 }
3824
3825 fn skip_analysis(&self) {
3826 self.push(Intent::SkipAnalysis);
3827 }
3828
3829 fn stop_analysis(&self) {
3830 self.push(Intent::StopAnalysis);
3831 }
3832
3833 fn retry_analysis(&self) {
3834 self.push(Intent::RetryAnalysis);
3835 }
3836
3837 fn order(&self, order: Order) {
3838 self.push(Intent::OrderReview(order));
3839 }
3840
3841 fn read(&self, at: usize) {
3842 self.push(Intent::ReadReviewed(at));
3843 }
3844
3845 fn judge(&self, at: usize, tag: &str, accepted: bool) {
3846 self.push(Intent::Judge {
3847 at,
3848 tag: tag.to_owned(),
3849 accepted,
3850 });
3851 }
3852
3853 fn judge_all(&self, accepted: bool) {
3854 self.push(Intent::JudgeAll(accepted));
3855 }
3856
3857 fn apply_suggestions(&self) {
3858 self.push(Intent::ApplySuggestions);
3859 }
3860
3861 fn discard_suggestions(&self) {
3862 self.push(Intent::DiscardSuggestions);
3863 }
3864
3865 fn keep_failed(&self) {
3866 self.push(Intent::KeepFailed);
3867 }
3868
3869 fn purge_failed(&self, at: Option<usize>) {
3870 self.push(Intent::PurgeFailed(at));
3871 }
3872
3873 fn stop_sweep(&self) {
3874 self.push(Intent::StopSweep);
3875 }
3876 }
3877
3878 impl FromImport<'_> {
3879 /// Record what the described screen asked for.
3880 fn push(&self, intent: Intent) {
3881 self.intents.borrow_mut().push(intent);
3882 }
3883
3884 /// A weight the app has measured, as the app writes one.
3885 ///
3886 /// `None` at zero rather than "0 B", because zero here means the walk has
3887 /// not weighed anything yet rather than that the files are empty. The
3888 /// shipped screen draws nothing in that case and this is that, said.
3889 fn size(bytes: u64) -> Option<String> {
3890 (bytes > 0).then(|| crate::ui::widgets::format_bytes(bytes))
3891 }
3892
3893 /// Everything that has gone wrong in this run, from both halves of it.
3894 ///
3895 /// One list where the app keeps two, which is what the shipped progress
3896 /// screens do as well: `draw_error_log` counts them together and draws them
3897 /// one after the other, because what a running screen reports is how much is
3898 /// going wrong rather than at which stage.
3899 fn failures(&self) -> Vec<Failure> {
3900 self.state
3901 .import_wf
3902 .import_file_errors
3903 .iter()
3904 .map(|failure| Failure {
3905 name: failure.path.clone(),
3906 error: failure.error.clone(),
3907 })
3908 .chain(
3909 self.state
3910 .import_wf
3911 .analysis_errors
3912 .iter()
3913 .map(|failure| Failure {
3914 name: failure.name.clone(),
3915 error: failure.error.clone(),
3916 }),
3917 )
3918 .collect()
3919 }
3920 }
3921
3922 /// The typed tags this app would refuse.
3923 ///
3924 /// Empty things are not refusals: a trailing comma is how someone types a list,
3925 /// not a mistake to report while they are still typing it. That is the shipped
3926 /// screen's own filter.
3927 fn invalid_tags(typed: &str) -> Vec<String> {
3928 typed
3929 .split(',')
3930 .map(str::trim)
3931 .filter(|tag| !tag.is_empty() && audiofiles_core::tags::validate_tag(tag).is_err())
3932 .map(ToOwned::to_owned)
3933 .collect()
3934 }
3935
3936 /// One reviewed sample, with its suggestions put in the order they are read in.
3937 ///
3938 /// The sort is the shipped screen's — confidence descending — and doing it here
3939 /// is the difference between a route and a frame: `draw_review_suggestions` sorts
3940 /// `item.suggestions` in place on every pass, which a handler holding `&S`
3941 /// cannot. Sorting the copy the description is building reaches the same order
3942 /// without the write, and it is what makes the accepted count and the list agree.
3943 fn reviewed(item: &crate::state::ReviewItem) -> Reviewed {
3944 let mut suggestions: Vec<Suggestion> = item
3945 .suggestions
3946 .iter()
3947 .map(|held| Suggestion {
3948 tag: held.suggestion.tag.clone(),
3949 confidence: held.suggestion.confidence,
3950 reason: held.suggestion.reason.clone(),
3951 accepted: held.accepted,
3952 })
3953 .collect();
3954 suggestions.sort_by(|a, b| {
3955 b.confidence
3956 .partial_cmp(&a.confidence)
3957 .unwrap_or(std::cmp::Ordering::Equal)
3958 });
3959
3960 Reviewed {
3961 name: item.name.clone(),
3962 duration: item.result.duration,
3963 sample_rate: item.result.sample_rate,
3964 peak_db: item.result.peak_db,
3965 bpm: item.result.bpm,
3966 musical_key: item.result.musical_key.clone(),
3967 suggestions,
3968 }
3969 }
3970
3971 /// The vault's own health, as much as the warning needs.
3972 ///
3973 /// The eleventh narrow trait. Every write is an intent, and the third of them is
3974 /// the interesting one: locating the missing files opens a native folder picker,
3975 /// which is a host act with no described step. See [`integrity`]'s header — it is
3976 /// the **fourth consumer** of `quasi:vocabulary:host-save-location`.
3977 pub trait Integrity {
3978 /// How many samples have lost the file they point at.
3979 fn missing(&self) -> usize;
3980
3981 /// Put the warning away without acting.
3982 fn dismiss(&self);
3983
3984 /// Go looking for the files.
3985 fn locate(&self);
3986
3987 /// Delete the entries whose files are gone.
3988 fn purge(&self);
3989 }
3990
3991 /// The app's loose-files state, as the narrow thing the warning borrows.
3992 pub struct FromIntegrity<'a> {
3993 /// What the app has checked.
3994 pub state: &'a crate::state::BrowserState,
3995 /// What the described screen asked for, applied after the frame.
3996 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
3997 }
3998
3999 impl Integrity for FromIntegrity<'_> {
4000 fn missing(&self) -> usize {
4001 self.state.loose_files.loose_files_missing_count
4002 }
4003
4004 fn dismiss(&self) {
4005 self.push(Intent::DismissLooseFiles);
4006 }
4007
4008 fn locate(&self) {
4009 self.push(Intent::LocateLooseFiles);
4010 }
4011
4012 fn purge(&self) {
4013 self.push(Intent::PurgeLooseFiles);
4014 }
4015 }
4016
4017 impl FromIntegrity<'_> {
4018 /// Record what the described screen asked for.
4019 fn push(&self, intent: Intent) {
4020 self.intents.borrow_mut().push(intent);
4021 }
4022 }
4023
4024 /// The library-wide tag queue, as much of it as a described screen needs.
4025 ///
4026 /// One struct where the flow is an enum, and [`Forging`]'s reason again: the
4027 /// three sections of this screen are all live at once and nothing here is a
4028 /// state a reader arrived at. `rescanning` is a field for exactly the reason
4029 /// `busy` is one.
4030 #[derive(Debug, Clone, PartialEq)]
4031 pub struct Queued {
4032 /// Every tag with something waiting under it.
4033 pub groups: Vec<Group>,
4034 /// Which one is open, as an index into `groups`.
4035 pub at: usize,
4036 /// How many samples the pass looked at.
4037 pub considered: usize,
4038 /// How many of them it had something to say about.
4039 pub suggested: usize,
4040 /// How many suggestions across the whole queue clear their tag's threshold.
4041 pub confident: usize,
4042 /// Whether a pass is running.
4043 pub rescanning: bool,
4044 /// What the last accept did, if it has said anything.
4045 pub said: Option<String>,
4046 /// The open group's candidates, as far as their names have been resolved.
4047 ///
4048 /// A window, and a real one rather than a renderer's: see [`Candidate`].
4049 pub shown: Vec<Candidate>,
4050 }
4051
4052 /// One tag, and how much is waiting under it.
4053 #[derive(Debug, Clone, PartialEq, Eq)]
4054 pub struct Group {
4055 /// The tag itself.
4056 pub tag: String,
4057 /// How many samples would get it.
4058 pub candidates: usize,
4059 /// How many of those clear its auto threshold.
4060 pub confident: usize,
4061 /// How many are ticked.
4062 pub checked: usize,
4063 }
4064
4065 /// One sample that would get the open tag.
4066 ///
4067 /// Only ever the strongest few hundred, and that is a fact about the data rather
4068 /// than about the drawing. `ReviewGroup::names_loaded` resolves a display name
4069 /// per candidate through one backend call each, so a 44,000-row group is
4070 /// resolved to the window and no further — the rows past it have no name to
4071 /// carry. Every control on the screen still acts on the whole group.
4072 ///
4073 /// The distinction [`Files`] draws in the other direction: the file list holds
4074 /// every row it describes, so windowing there is a renderer's performance
4075 /// technique and `more` is `None`.
4076 #[derive(Debug, Clone, PartialEq)]
4077 pub struct Candidate {
4078 /// What the sample is called, or its hash until the name is resolved.
4079 pub name: String,
4080 /// How strongly it matches, from zero to one.
4081 pub score: f64,
4082 /// Whether it clears the tag's auto threshold.
4083 pub confident: bool,
4084 /// Whether it is ticked.
4085 pub accepted: bool,
4086 }
4087
4088 /// How much of a group an accept applies to.
4089 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4090 pub enum Scope {
4091 /// Every candidate under the tag.
4092 All,
4093 /// Only those above its auto threshold.
4094 Confident,
4095 /// Only the ticked ones.
4096 Checked,
4097 }
4098
4099 impl Scope {
4100 /// The name a described address is built from.
4101 #[must_use]
4102 pub const fn as_str(self) -> &'static str {
4103 match self {
4104 Self::All => "all",
4105 Self::Confident => "confident",
4106 Self::Checked => "checked",
4107 }
4108 }
4109
4110 /// The scope that name means, if it means one.
4111 #[must_use]
4112 pub fn from_key(name: &str) -> Option<Self> {
4113 match name {
4114 "all" => Some(Self::All),
4115 "confident" => Some(Self::Confident),
4116 "checked" => Some(Self::Checked),
4117 _ => None,
4118 }
4119 }
4120 }
4121
4122 /// The tag queue, as much of it as a described screen needs.
4123 ///
4124 /// The fifteenth narrow trait, and the second over `classifier` rather than over
4125 /// `import_wf` — which one group is open is `ImportMode::ReviewLibrary`'s
4126 /// `selected`, and the queue itself is `classifier.review`. Every write is an
4127 /// [`Intent`] because both of those are the app's own state.
4128 pub trait Queue {
4129 /// The queue, if there is one worth reading.
4130 fn queued(&self) -> Option<Queued>;
4131
4132 /// Open this tag.
4133 fn read(&self, at: usize);
4134
4135 /// Tick or untick one candidate of the open tag.
4136 fn tick(&self, at: usize);
4137
4138 /// Tick or untick every candidate the screen is showing.
4139 fn tick_shown(&self, ticked: bool);
4140
4141 /// Apply this much of the open tag.
4142 fn accept(&self, scope: Scope);
4143
4144 /// Apply every confident suggestion under every tag.
4145 fn accept_confident(&self);
4146
4147 /// Throw the open tag away without applying any of it.
4148 fn dismiss(&self);
4149
4150 /// Run the pass again.
4151 fn rescan(&self);
4152
4153 /// Put the screen away, keeping the queue.
4154 fn close(&self);
4155 }
4156
4157 /// The app's tag queue, as the narrow thing the described screen borrows.
4158 pub struct FromQueue<'a> {
4159 /// What the last pass found.
4160 pub state: &'a crate::state::BrowserState,
4161 /// What the described screen asked for, applied after the frame.
4162 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4163 }
4164
4165 impl Queue for FromQueue<'_> {
4166 fn queued(&self) -> Option<Queued> {
4167 let queue = self.state.classifier.review.as_ref()?;
4168 // An empty queue is not a queue. The shipped screen leaves rather than
4169 // drawing an empty shell, on the grounds that "I finished" and "there
4170 // was never anything" should not look the same; the described side
4171 // cannot leave, so it refuses the address instead.
4172 if queue.groups.is_empty() {
4173 return None;
4174 }
4175
4176 let at = self.state.review_selected();
4177 Some(Queued {
4178 groups: queue
4179 .groups
4180 .iter()
4181 .map(|group| Group {
4182 tag: group.tag.clone(),
4183 candidates: group.candidates.len(),
4184 confident: group.confident(),
4185 checked: group.checked(),
4186 })
4187 .collect(),
4188 at,
4189 considered: queue.samples_considered,
4190 suggested: queue.samples_with_suggestions,
4191 confident: self.state.review_confident_total(),
4192 rescanning: self.state.classifier.busy.is_some(),
4193 said: self.state.classifier.last_review_accept.clone(),
4194 shown: queue.groups.get(at).map_or_else(Vec::new, |group| {
4195 group
4196 .candidates
4197 .iter()
4198 .take(crate::quasi::queue::RENDER_ROWS)
4199 .map(|candidate| Candidate {
4200 // The hash stands in until the name is resolved, which
4201 // is the shipped row's own fallback.
4202 name: candidate
4203 .name
4204 .clone()
4205 .unwrap_or_else(|| candidate.hash.clone()),
4206 score: candidate.score,
4207 confident: candidate.confident,
4208 accepted: candidate.accepted,
4209 })
4210 .collect()
4211 }),
4212 })
4213 }
4214
4215 fn read(&self, at: usize) {
4216 self.push(Intent::ReadGroup(at));
4217 }
4218
4219 fn tick(&self, at: usize) {
4220 self.push(Intent::TickCandidate(at));
4221 }
4222
4223 fn tick_shown(&self, ticked: bool) {
4224 self.push(Intent::TickShown(ticked));
4225 }
4226
4227 fn accept(&self, scope: Scope) {
4228 self.push(Intent::AcceptGroup(scope));
4229 }
4230
4231 fn accept_confident(&self) {
4232 self.push(Intent::AcceptConfident);
4233 }
4234
4235 fn dismiss(&self) {
4236 self.push(Intent::DismissGroup);
4237 }
4238
4239 fn rescan(&self) {
4240 self.push(Intent::Rescan);
4241 }
4242
4243 fn close(&self) {
4244 self.push(Intent::CloseReview);
4245 }
4246 }
4247
4248 /// One numeric axis of the filter panel, as a described screen needs it.
4249 ///
4250 /// The geometry is [`crate::quasi::filters::RangeAxis`], which is the shipped
4251 /// panel's own table read rather than copied: the six axes are a constant, and a
4252 /// second table here would drift the way the class filter's list and colour
4253 /// table drifted before the first one existed.
4254 ///
4255 /// The two ends are `Option` because an absent end is an answer. A minimum
4256 /// sitting on the sentinel edge stores `None` and the SQL omits that bound,
4257 /// which is what "no lower bound" is, and the description says the same thing by
4258 /// leaving the box empty.
4259 #[derive(Debug, Clone, PartialEq)]
4260 pub struct Narrowing {
4261 /// The key the axis is addressed by, and the stem both its names are built
4262 /// from.
4263 pub key: &'static str,
4264 /// The fixed geometry: the sentinel edges, the granularity, the unit.
4265 pub axis: &'static crate::quasi::filters::RangeAxis,
4266 /// The lower end wanted, if one is.
4267 pub lower: Option<f64>,
4268 /// The upper end wanted, if one is.
4269 pub upper: Option<f64>,
4270 }
4271
4272 /// How a key filter matches.
4273 #[derive(Debug, Clone, PartialEq, Eq)]
4274 pub struct Keys {
4275 /// The keys wanted, spelled the way the library spells them.
4276 pub wanted: Vec<String>,
4277 /// Whether musically compatible keys count as well.
4278 pub compatible: bool,
4279 }
4280
4281 /// The filter panel, as much of it as a described screen needs.
4282 ///
4283 /// The sixteenth narrow trait, and the last of audiofiles' real screens. It
4284 /// reads what is being filtered for and writes through [`Intent`]s, which is
4285 /// [`files`]'s arrangement and for its reason: every write here lands in
4286 /// `state.search.search_filter`, which is the app's own UI state and not
4287 /// something a route holding `&S` can reach.
4288 pub trait Filters {
4289 /// The six numeric axes, in the order the panel offers them.
4290 fn axes(&self) -> Vec<Narrowing>;
4291
4292 /// The keys wanted, and how they are matched.
4293 fn keys(&self) -> Keys;
4294
4295 /// The tags every result has to carry.
4296 fn tags(&self) -> Vec<String>;
4297
4298 /// What is being typed into the tag box.
4299 fn typing(&self) -> String;
4300
4301 /// How many samples match now.
4302 fn matched(&self) -> usize;
4303
4304 /// Whether anything is filtering at all.
4305 fn active(&self) -> bool;
4306
4307 /// What the current filters would be called, if they were saved unnamed.
4308 fn describes(&self) -> String;
4309
4310 /// Narrow one axis to these two ends.
4311 fn narrow(&self, key: &'static str, lower: Option<f64>, upper: Option<f64>);
4312
4313 /// Match only the chosen keys, or every key compatible with them.
4314 fn set_key_mode(&self, compatible: bool);
4315
4316 /// Want this key, or stop wanting it.
4317 fn toggle_key(&self, key: &str);
4318
4319 /// Stop wanting any key.
4320 fn clear_keys(&self);
4321
4322 /// Remember what is being typed.
4323 fn typed(&self, text: &str);
4324
4325 /// Require this tag of every result.
4326 fn require(&self, tag: &str);
4327
4328 /// Stop requiring it.
4329 fn unrequire(&self, tag: &str);
4330
4331 /// Stop requiring any tag.
4332 fn clear_tags(&self);
4333
4334 /// Drop every filter, and the query with it.
4335 fn clear_all(&self);
4336
4337 /// Keep the active filters under this name.
4338 fn save_collection(&self, name: &str);
4339 }
4340
4341 /// The app's filters, as the narrow thing a described screen borrows.
4342 pub struct FromFilters<'a> {
4343 /// What is being filtered for, and what matched.
4344 pub state: &'a crate::state::BrowserState,
4345 /// What the described screen asked for, applied after the frame.
4346 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4347 }
4348
4349 impl FromFilters<'_> {
4350 /// The six axes and where each one's two ends are kept.
4351 ///
4352 /// One table, read twice: here for the values and in
4353 /// [`filters`](self::filters) for the description. Pairing the key with the
4354 /// geometry in one place is what stops the description and the write
4355 /// disagreeing about which axis `bpm` is.
4356 fn table(&self) -> [Narrowing; 6] {
4357 use crate::quasi::filters as axes;
4358 let f = &self.state.search.search_filter;
4359 [
4360 Narrowing {
4361 key: "bpm",
4362 axis: &axes::BPM,
4363 lower: f.bpm_min,
4364 upper: f.bpm_max,
4365 },
4366 Narrowing {
4367 key: "duration",
4368 axis: &axes::DURATION,
4369 lower: f.duration_min,
4370 upper: f.duration_max,
4371 },
4372 Narrowing {
4373 key: "loudness",
4374 axis: &axes::LOUDNESS,
4375 lower: f.peak_db_min,
4376 upper: f.peak_db_max,
4377 },
4378 Narrowing {
4379 key: "brightness",
4380 axis: &axes::BRIGHTNESS,
4381 lower: f.centroid_min,
4382 upper: f.centroid_max,
4383 },
4384 Narrowing {
4385 key: "noisiness",
4386 axis: &axes::NOISINESS,
4387 lower: f.flatness_min,
4388 upper: f.flatness_max,
4389 },
4390 Narrowing {
4391 key: "attack",
4392 axis: &axes::ATTACK,
4393 lower: f.attack_min,
4394 upper: f.attack_max,
4395 },
4396 ]
4397 }
4398
4399 /// Record what the described screen asked for.
4400 fn push(&self, intent: Intent) {
4401 self.intents.borrow_mut().push(intent);
4402 }
4403 }
4404
4405 impl Filters for FromFilters<'_> {
4406 fn axes(&self) -> Vec<Narrowing> {
4407 self.table().to_vec()
4408 }
4409
4410 fn keys(&self) -> Keys {
4411 use audiofiles_core::search::KeyFilterMode;
4412 Keys {
4413 wanted: self.state.search.search_filter.keys.clone(),
4414 compatible: matches!(
4415 self.state.search.search_filter.key_mode,
4416 KeyFilterMode::Compatible
4417 ),
4418 }
4419 }
4420
4421 fn tags(&self) -> Vec<String> {
4422 self.state.search.search_filter.required_tags.clone()
4423 }
4424
4425 fn typing(&self) -> String {
4426 self.state.search.filter_tag_input.clone()
4427 }
4428
4429 fn matched(&self) -> usize {
4430 // Files only. A directory is structural and is not what a filter
4431 // targets, which is the shipped count's own rule.
4432 self.state
4433 .nav
4434 .contents
4435 .iter()
4436 .filter(|entry| entry.node.node_type != audiofiles_core::vfs::NodeType::Directory)
4437 .count()
4438 }
4439
4440 fn active(&self) -> bool {
4441 self.state.search.search_filter.is_active()
4442 }
4443
4444 fn describes(&self) -> String {
4445 self.state.search.search_filter.describe()
4446 }
4447
4448 fn narrow(&self, key: &'static str, lower: Option<f64>, upper: Option<f64>) {
4449 self.push(Intent::Narrow(key, lower, upper));
4450 }
4451
4452 fn set_key_mode(&self, compatible: bool) {
4453 self.push(Intent::KeyMode(compatible));
4454 }
4455
4456 fn toggle_key(&self, key: &str) {
4457 self.push(Intent::ToggleKey(key.to_owned()));
4458 }
4459
4460 fn clear_keys(&self) {
4461 self.push(Intent::ClearKeys);
4462 }
4463
4464 fn typed(&self, text: &str) {
4465 self.push(Intent::TypingTag(text.to_owned()));
4466 }
4467
4468 fn require(&self, tag: &str) {
4469 self.push(Intent::RequireTag(tag.to_owned()));
4470 }
4471
4472 fn unrequire(&self, tag: &str) {
4473 self.push(Intent::UnrequireTag(tag.to_owned()));
4474 }
4475
4476 fn clear_tags(&self) {
4477 self.push(Intent::ClearTags);
4478 }
4479
4480 fn clear_all(&self) {
4481 self.push(Intent::ClearFilters);
4482 }
4483
4484 fn save_collection(&self, name: &str) {
4485 self.push(Intent::SaveCollection(name.to_owned()));
4486 }
4487 }
4488
4489 impl FromQueue<'_> {
4490 /// Record what the described screen asked for.
4491 fn push(&self, intent: Intent) {
4492 self.intents.borrow_mut().push(intent);
4493 }
4494 }
4495
4496 /// The sample in the forge, and everything the maker surface asks about it.
4497 ///
4498 /// One struct where [`Stage`] is an enum, and the difference is the screen: the
4499 /// forge is three sections of one window that are all live at once, so there is
4500 /// no state a reader arrives at. `busy` is a field rather than a shape for the
4501 /// same reason — the shipped window keeps drawing every control while a run is
4502 /// in flight and greys them, because the sample is still the subject.
4503 #[derive(Debug, Clone, PartialEq)]
4504 pub struct Forging {
4505 /// What the sample is called.
4506 pub name: String,
4507 /// What it was recorded at.
4508 pub rate: u32,
4509 /// Whether a chop or a conform is in flight.
4510 pub busy: bool,
4511 /// How it would be sliced.
4512 pub how: Chop,
4513 /// Transient sensitivity, from zero to one.
4514 pub sensitivity: f32,
4515 /// How many equal divisions.
4516 pub divisions: usize,
4517 /// The tempo the grid is built on.
4518 pub bpm: f64,
4519 /// Grid subdivisions per beat: one, two or four.
4520 pub subdivisions: u32,
4521 /// How many slices the last preview found, or zero for no preview.
4522 ///
4523 /// A count rather than the boundary fractions, and that is the waveform
4524 /// exclusion showing through: the marks are drawn over a rendered waveform,
4525 /// which no description reaches, and what the *controls* need of them is how
4526 /// many there are. See [`forge`]'s header.
4527 pub slices: usize,
4528 /// The devices a conform could target.
4529 pub devices: Vec<DeviceChoice>,
4530 /// Which of them is chosen, if one is.
4531 pub device: Option<String>,
4532 /// How many samples are chosen, for the batch section.
4533 pub chosen: usize,
4534 /// The level below which batch trim treats audio as silence.
4535 pub threshold_db: f64,
4536 }
4537
4538 /// How a sample would be sliced.
4539 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4540 pub enum Chop {
4541 /// At detected transients.
4542 Transient,
4543 /// Into equal divisions.
4544 Equal,
4545 /// On a tempo grid.
4546 Bpm,
4547 }
4548
4549 impl Chop {
4550 /// Every one of them, in the order the shipped window offers them.
4551 pub const ALL: [Self; 3] = [Self::Transient, Self::Equal, Self::Bpm];
4552
4553 /// The name a described address is built from.
4554 #[must_use]
4555 pub const fn as_str(self) -> &'static str {
4556 match self {
4557 Self::Transient => "transient",
4558 Self::Equal => "divisions",
4559 Self::Bpm => "bpm",
4560 }
4561 }
4562
4563 /// What the control says.
4564 #[must_use]
4565 pub const fn label(self) -> &'static str {
4566 match self {
4567 Self::Transient => "Transient",
4568 Self::Equal => "Divisions",
4569 Self::Bpm => "BPM grid",
4570 }
4571 }
4572
4573 /// The method that name means, if it means one.
4574 #[must_use]
4575 pub fn from_key(name: &str) -> Option<Self> {
4576 Self::ALL.into_iter().find(|held| held.as_str() == name)
4577 }
4578 }
4579
4580 /// A device a conform could target.
4581 ///
4582 /// [`ProfileChoice`]'s smaller cousin, and separate from it for that type's own
4583 /// reason: the export screen needs the manufacturer, the category and the file
4584 /// size cap, and this needs the name and one line about what it takes.
4585 #[derive(Debug, Clone, PartialEq, Eq)]
4586 pub struct DeviceChoice {
4587 /// What the device is called, which is also what a conform names.
4588 pub name: String,
4589 /// What it accepts, as the registry phrases it.
4590 pub summary: String,
4591 }
4592
4593 /// One number the forge's controls may change.
4594 ///
4595 /// [`Setting`], [`Measure`] and [`Decision`]'s fourth peer, closed for the same
4596 /// reason: one write route serves five controls without a second list of the
4597 /// names it will answer to.
4598 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4599 pub enum Knob {
4600 /// [`Forging::sensitivity`].
4601 Sensitivity,
4602 /// [`Forging::divisions`].
4603 Divisions,
4604 /// [`Forging::bpm`].
4605 Bpm,
4606 /// [`Forging::subdivisions`].
4607 Subdivisions,
4608 /// [`Forging::threshold_db`].
4609 Threshold,
4610 }
4611
4612 impl Knob {
4613 /// The name a described address is built from.
4614 #[must_use]
4615 pub const fn as_str(self) -> &'static str {
4616 match self {
4617 Self::Sensitivity => "sensitivity",
4618 Self::Divisions => "divisions",
4619 Self::Bpm => "bpm",
4620 Self::Subdivisions => "subdivisions",
4621 Self::Threshold => "threshold",
4622 }
4623 }
4624
4625 /// The knob that name means, if it means one.
4626 #[must_use]
4627 pub fn from_key(name: &str) -> Option<Self> {
4628 match name {
4629 "sensitivity" => Some(Self::Sensitivity),
4630 "divisions" => Some(Self::Divisions),
4631 "bpm" => Some(Self::Bpm),
4632 "subdivisions" => Some(Self::Subdivisions),
4633 "threshold" => Some(Self::Threshold),
4634 _ => None,
4635 }
4636 }
4637 }
4638
4639 /// The forge, as much of it as a described screen needs.
4640 ///
4641 /// The fourteenth narrow trait. Every write is an [`Intent`] and every one of
4642 /// them lands on `ForgeUiState`, which is the app's own screen state — the rule
4643 /// [`Files`] set and [`Export`] and [`Importing`] both follow.
4644 pub trait Forge {
4645 /// The sample in the forge, if one is.
4646 fn forging(&self) -> Option<Forging>;
4647
4648 /// Slice it this way.
4649 fn slice_by(&self, how: Chop);
4650
4651 /// Set one of the numbers the slicing reads.
4652 fn turn(&self, knob: Knob, value: &str);
4653
4654 /// Work out where the slices would fall.
4655 fn preview(&self);
4656
4657 /// Write them.
4658 fn chop(&self);
4659
4660 /// Aim a conform at this device.
4661 fn choose_device(&self, name: &str);
4662
4663 /// Conform to whichever is chosen.
4664 fn conform(&self);
4665
4666 /// Trim silence off everything chosen.
4667 fn trim_silence(&self);
4668 }
4669
4670 /// The app's forge, as the narrow thing the described window borrows.
4671 pub struct FromForge<'a> {
4672 /// What the app has loaded into the forge.
4673 pub state: &'a crate::state::BrowserState,
4674 /// What the described screen asked for, applied after the frame.
4675 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4676 }
4677
4678 impl Forge for FromForge<'_> {
4679 fn forging(&self) -> Option<Forging> {
4680 let forge = &self.state.forge;
4681 forge.hash.as_ref()?;
4682 Some(Forging {
4683 name: forge.name.clone(),
4684 rate: forge.source_rate,
4685 busy: forge.busy,
4686 how: match forge.chop_mode {
4687 crate::state::ChopMode::Transient => Chop::Transient,
4688 crate::state::ChopMode::Equal => Chop::Equal,
4689 crate::state::ChopMode::Bpm => Chop::Bpm,
4690 },
4691 sensitivity: forge.sensitivity,
4692 divisions: forge.divisions,
4693 bpm: forge.bpm,
4694 subdivisions: forge.subdivisions,
4695 // The marks are boundaries and the slices are the gaps between them,
4696 // which is the shipped button's own arithmetic.
4697 slices: forge.slice_marks.len().saturating_sub(1),
4698 devices: forge
4699 .devices
4700 .iter()
4701 .map(|(name, summary)| DeviceChoice {
4702 name: name.clone(),
4703 summary: summary.clone(),
4704 })
4705 .collect(),
4706 device: forge.conform_device.clone(),
4707 chosen: self.state.selected_sample_hashes().len(),
4708 threshold_db: forge.trim_threshold_db,
4709 })
4710 }
4711
4712 fn slice_by(&self, how: Chop) {
4713 self.push(Intent::SliceBy(how));
4714 }
4715
4716 fn turn(&self, knob: Knob, value: &str) {
4717 self.push(Intent::Turn(knob, value.to_owned()));
4718 }
4719
4720 fn preview(&self) {
4721 self.push(Intent::PreviewSlices);
4722 }
4723
4724 fn chop(&self) {
4725 self.push(Intent::Chop);
4726 }
4727
4728 fn choose_device(&self, name: &str) {
4729 self.push(Intent::ChooseDevice(name.to_owned()));
4730 }
4731
4732 fn conform(&self) {
4733 self.push(Intent::Conform);
4734 }
4735
4736 fn trim_silence(&self) {
4737 self.push(Intent::TrimSilence);
4738 }
4739 }
4740
4741 impl FromForge<'_> {
4742 /// Record what the described screen asked for.
4743 fn push(&self, intent: Intent) {
4744 self.intents.borrow_mut().push(intent);
4745 }
4746 }
4747
4748 /// The sample being edited, as much as the editor needs to say about it.
4749 ///
4750 /// What is **not** here is the eleven knobs `EditUiState` carries — trim bounds,
4751 /// gain, normalise target and mode, fade shape and length, the two silence
4752 /// spans. See [`edit`]'s header: those are a buffer for what is being typed,
4753 /// which is a `Runtime`'s `View`, and the same deletion [`bulk`] made of
4754 /// `BulkModal`'s eleven fields.
4755 #[derive(Debug, Clone, PartialEq)]
4756 pub struct Editing {
4757 /// What the sample is called.
4758 pub name: String,
4759 /// Its sample rate, in Hz.
4760 pub sample_rate: u32,
4761 /// How long it runs, in seconds, where analysis has said.
4762 pub duration: Option<f64>,
4763 /// Its peak, in dBFS, where analysis has said.
4764 pub peak_db: Option<f64>,
4765 /// Whether this sample is the preview that is playing.
4766 pub playing: bool,
4767 /// Whether an edit is being applied right now.
4768 pub working: bool,
4769 /// Whether the app is waiting to be told what to do with a finished edit.
4770 pub asking: bool,
4771 /// The standing answer to that question, as [`EditResultMode::as_value`]
4772 /// writes it.
4773 ///
4774 /// [`EditResultMode::as_value`]: crate::state::EditResultMode::as_value
4775 pub result: Option<String>,
4776 /// How many samples are chosen, which is what makes the batch section a
4777 /// section rather than nothing.
4778 pub chosen: usize,
4779 /// The last edit, while it is still reversible, by the name it goes under.
4780 pub undoing: Option<String>,
4781 }
4782
4783 /// The sample editor, as much as a described screen needs.
4784 ///
4785 /// The twelfth narrow trait and the widest, at eighteen methods, and the width
4786 /// is the screen's rather than the trait's: the shipped editor is one window
4787 /// with seven sections and every one of them dispatches its own operation. What
4788 /// it does *not* have is a way to read a knob back, which is the deletion.
4789 pub trait Edit {
4790 /// What is being edited, if anything is.
4791 fn subject(&self) -> Option<Editing>;
4792
4793 /// Cut the sample down to this span, as fractions of its length.
4794 fn trim(&self, start: f32, end: f32);
4795
4796 /// Change its level by this many dB.
4797 fn gain(&self, db: f64);
4798
4799 /// Normalise it to this target, by peak or by loudness.
4800 fn normalize(&self, peak: bool, target: f64);
4801
4802 /// Play it backwards.
4803 fn reverse(&self);
4804
4805 /// Fade it in or out, this long, on this curve.
4806 fn fade(&self, fading_in: bool, ms: f64, curve: &str);
4807
4808 /// Put this much silence in at this point.
4809 fn insert_silence(&self, at: f64, ms: f64);
4810
4811 /// Take this span out.
4812 fn remove_range(&self, from: f64, to: f64);
4813
4814 /// Give up on the edit that is running.
4815 fn cancel(&self);
4816
4817 /// Audition it, or stop auditioning it.
4818 fn play(&self);
4819
4820 /// Stop the preview.
4821 fn stop(&self);
4822
4823 /// Remember this as the standing answer to what happens to an edit.
4824 fn remember(&self, mode: &str);
4825
4826 /// Answer the question a finished edit is waiting on.
4827 fn choose(&self, mode: &str, remember: bool);
4828
4829 /// Throw the finished edit away.
4830 fn discard(&self);
4831
4832 /// Put the last edit back.
4833 fn undo(&self);
4834
4835 /// Normalise every chosen sample.
4836 fn batch_normalize(&self, peak: bool, target: f64);
4837
4838 /// Change every chosen sample's level.
4839 fn batch_gain(&self, db: f64);
4840
4841 /// Reverse every chosen sample.
4842 fn batch_reverse(&self);
4843 }
4844
4845 /// The app's editor, as the narrow thing the described editor borrows.
4846 pub struct FromEditor<'a> {
4847 /// What the app is editing.
4848 pub state: &'a crate::state::BrowserState,
4849 /// What the described screen asked for, applied after the frame.
4850 pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4851 }
4852
4853 impl Edit for FromEditor<'_> {
4854 fn subject(&self) -> Option<Editing> {
4855 let hash = self.state.edit.hash.as_deref()?;
4856 let analysis = self.state.detail.selected_analysis.as_ref();
4857 Some(Editing {
4858 name: self
4859 .state
4860 .selected_node()
4861 .map(|node| node.node.name.clone())
4862 .unwrap_or_default(),
4863 sample_rate: analysis.map_or(44_100, |analysis| analysis.sample_rate),
4864 duration: analysis.map(|analysis| analysis.duration),
4865 peak_db: analysis.and_then(|analysis| analysis.peak_db),
4866 playing: self.state.preview.previewing_hash.as_deref() == Some(hash)
4867 && self.state.shared.preview.lock().playing,
4868 working: self.state.edit.in_progress,
4869 asking: self.state.edit.result_prompt,
4870 result: self
4871 .state
4872 .edit
4873 .result_mode
4874 .map(|mode| mode.as_value().to_owned()),
4875 chosen: self.state.selected_sample_hashes().len(),
4876 undoing: self
4877 .state
4878 .edit
4879 .last_undo
4880 .as_ref()
4881 .map(|entry| entry.op_name.clone()),
4882 })
4883 }
4884
4885 fn trim(&self, start: f32, end: f32) {
4886 self.push(Intent::EditTrim { start, end });
4887 }
4888
4889 fn gain(&self, db: f64) {
4890 self.push(Intent::EditGain(db));
4891 }
4892
4893 fn normalize(&self, peak: bool, target: f64) {
4894 self.push(Intent::EditNormalize { peak, target });
4895 }
4896
4897 fn reverse(&self) {
4898 self.push(Intent::EditReverse);
4899 }
4900
4901 fn fade(&self, fading_in: bool, ms: f64, curve: &str) {
4902 self.push(Intent::EditFade {
4903 fading_in,
4904 ms,
4905 curve: curve.to_owned(),
4906 });
4907 }
4908
4909 fn insert_silence(&self, at: f64, ms: f64) {
4910 self.push(Intent::EditInsertSilence { at, ms });
4911 }
4912
4913 fn remove_range(&self, from: f64, to: f64) {
4914 self.push(Intent::EditRemoveRange { from, to });
4915 }
4916
4917 fn cancel(&self) {
4918 self.push(Intent::EditCancel);
4919 }
4920
4921 fn play(&self) {
4922 self.push(Intent::EditPlay);
4923 }
4924
4925 fn stop(&self) {
4926 self.push(Intent::StopPlayback);
4927 }
4928
4929 fn remember(&self, mode: &str) {
4930 self.push(Intent::EditRemember(mode.to_owned()));
4931 }
4932
4933 fn choose(&self, mode: &str, remember: bool) {
4934 self.push(Intent::EditChoose {
4935 mode: mode.to_owned(),
4936 remember,
4937 });
4938 }
4939
4940 fn discard(&self) {
4941 self.push(Intent::EditDiscard);
4942 }
4943
4944 fn undo(&self) {
4945 self.push(Intent::EditUndo);
4946 }
4947
4948 fn batch_normalize(&self, peak: bool, target: f64) {
4949 self.push(Intent::BatchNormalize { peak, target });
4950 }
4951
4952 fn batch_gain(&self, db: f64) {
4953 self.push(Intent::BatchGain(db));
4954 }
4955
4956 fn batch_reverse(&self) {
4957 self.push(Intent::BatchReverse);
4958 }
4959 }
4960
4961 impl FromEditor<'_> {
4962 /// Record what the described screen asked for.
4963 fn push(&self, intent: Intent) {
4964 self.intents.borrow_mut().push(intent);
4965 }
4966 }
4967
4968 /// A theme the host resolved, as the description needs to name it.
4969 ///
4970 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
4971 /// does not depend on the shape of the theme loader: what a `Choice` needs is a
4972 /// value and something to show, and the variant is what the grouping finding is
4973 /// about.
4974 #[derive(Debug, Clone, PartialEq, Eq)]
4975 pub struct ThemeChoice {
4976 /// The id stored under `ConfigKey::Theme`.
4977 pub id: String,
4978 /// What the picker shows.
4979 pub name: String,
4980 /// `dark`, `light` or `high-contrast`.
4981 pub variant: String,
4982 /// This theme's TOML, when the host could read it.
4983 ///
4984 /// Here rather than behind a capability method for the reason the rest of
4985 /// `ThemeChoice` is: a theme's source is a host fact the app resolves, and
4986 /// the settled rule is that a host fact the app can answer goes in `S`. A
4987 /// capability that read a file would be a route touching this machine's
4988 /// disk, which is the thing the narrow traits exist to prevent.
4989 ///
4990 /// `None` for a theme whose source is not readable -- a built-in compiled
4991 /// in, or a custom file that has since moved. Export offers nothing in that
4992 /// case rather than offering an empty file.
4993 pub source: Option<String>,
4994 }
4995
4996 /// Everything the described screens read and write.
4997 ///
4998 /// One state for every screen rather than one per screen, because a router is
4999 /// one table: `Router<S>` is generic over a single `S`, so the settings screen
5000 /// and the sync screen share it. Each borrows only the capability it uses, and
5001 /// the type says which.
5002 pub struct Panels<'a> {
5003 /// The config store, for the settings screen.
5004 pub config: &'a dyn Config,
5005 /// Cloud sync, for the sync screen.
5006 pub sync: &'a dyn Sync,
5007 /// The sample list, for the files screen.
5008 pub files: &'a dyn Files,
5009 /// The export flow, for the export screens.
5010 pub export: &'a dyn Export,
5011 /// The selection, for the detail screen.
5012 pub detail: &'a dyn Detail,
5013 /// The window's own band, for the main screen.
5014 pub shell: &'a dyn Shell,
5015 /// The vaults, collections and tags, for the sidebar.
5016 pub library: &'a dyn Library,
5017 /// Where you are and what you are looking for, for the toolbar.
5018 pub bar: &'a dyn Bar,
5019 /// The selection again, for the bulk screens. Two capabilities over one
5020 /// selection rather than one, because they need different things of it and
5021 /// the narrowing is the point: the detail screen may not move a file and
5022 /// the bulk screens may not read an analysis.
5023 pub bulk: &'a dyn Bulk,
5024 /// Vaults and folders again, for the four name modals. A third capability
5025 /// over ground [`Library`] already covers, and the narrowing is the same
5026 /// argument: the sidebar may delete a vault and may not name one, and the
5027 /// modal is the other way round.
5028 pub naming: &'a dyn Naming,
5029 /// The import waiting to be agreed to, for the preflight.
5030 pub importing: &'a dyn Importing,
5031 /// The vault's health, for the loose-files warning.
5032 pub integrity: &'a dyn Integrity,
5033 /// The sample being edited, for the editor.
5034 pub editor: &'a dyn Edit,
5035 /// The sample in the forge, for the maker surface.
5036 pub forge: &'a dyn Forge,
5037 /// The library-wide tag queue, for the review screen.
5038 pub queue: &'a dyn Queue,
5039 /// What is being filtered for, for the filter panel.
5040 pub filters: &'a dyn Filters,
5041 /// The themes on offer, resolved by the host at startup.
5042 pub themes: &'a [ThemeChoice],
5043 }
5044
5045 /// Every described screen this app serves.
5046 ///
5047 /// Built per call rather than once: it is a `Vec` of function pointers, so the
5048 /// cost is nothing, and building it fresh is what lets the state borrow.
5049 #[must_use]
5050 pub fn router<'a>() -> Router<Panels<'a>> {
5051 filters::routes(queue::routes(forge::routes(edit::routes(
5052 integrity::routes(importing::routes(naming::routes(toolbar::routes(
5053 library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
5054 export::routes(files::routes(sync::routes(settings::routes(Router::new())))),
5055 ))))),
5056 )))),
5057 ))))
5058 }
5059
5060 #[cfg(test)]
5061 mod parity;
5062 #[cfg(test)]
5063 mod tests;
5064