Skip to main content

max / audiofiles

93.3 KB · 2236 lines History Blame Raw
1 //! The described screens, in windows beside the shipped ones.
2 //!
3 //! Beside rather than instead of, which is the whole arrangement: with the
4 //! feature on, opening Settings opens two windows and opening Cloud Sync opens
5 //! two, so each port can be compared by looking at it. A port that replaced a
6 //! working panel on the way in would have to be right first time.
7 //!
8 //! One described window per shipped window, each holding its own [`Runtime`],
9 //! which mirrors the app rather than inventing navigation the shipped app does
10 //! not have. A described screen *can* navigate — that is what [`Step::Call`] is
11 //! — but a link from Settings to Sync would be a screen this port made up.
12 //!
13 //! # What this module is, and what it deliberately is not
14 //!
15 //! It is the *host* half, and it is small on purpose: plumbing against described
16 //! screens that know nothing about egui. Everything it does is one of four
17 //! things, and none of them is drawing:
18 //!
19 //! 1. resolve the host facts the screens need (themes, the palette) and adapt
20 //! the app's own handles to the narrow traits the screens borrow,
21 //! 2. hold each [`Runtime`] across frames, because a frame does not outlive itself,
22 //! 3. hand a [`Step`] to the router and the answer back to the runtime,
23 //! 4. put a route failure somewhere the user can see it.
24 //!
25 //! There is no `if let Node::...` anywhere here, and there should never be one.
26 //! The moment this file starts deciding what a node looks like, the drawing has
27 //! left `quasi-immediate` and the port has become a second renderer.
28
29 use audiofiles_sync::SyncManager;
30 use quasi_immediate::{Immediate, Runtime, Step};
31 use quasi_router::{Request, Response};
32
33 use std::cell::RefCell;
34
35 use super::{
36 FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge,
37 FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromSyncManager,
38 FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
39 };
40 use crate::state::BrowserState;
41 use crate::ui::theme;
42
43 /// The described screens' own state, held across frames.
44 ///
45 /// One runtime per window. What the user typed and ticked lives inside it, which
46 /// is the half egui does not hold for a described screen: the fields are rebuilt
47 /// from the description every frame, so their buffers have to outlive one.
48 #[derive(Debug, Default)]
49 pub struct Described {
50 settings: Option<Runtime>,
51 sync: Option<Runtime>,
52 files: Option<Runtime>,
53 export: Option<Runtime>,
54 detail: Option<Runtime>,
55 shell: Option<Runtime>,
56 edit: Option<Runtime>,
57 forge: Option<Runtime>,
58 import: Option<Runtime>,
59 queue: Option<Runtime>,
60 sweep: Option<Runtime>,
61 filters: Option<Runtime>,
62 integrity: Option<Runtime>,
63 naming: Option<Runtime>,
64 bulk: Option<Runtime>,
65 preflight: Option<Runtime>,
66 /// Whether the described main window is open.
67 pub show_shell: bool,
68 /// Whether the described file list is open.
69 ///
70 /// Its own flag rather than the shipped list's, because the shipped list is
71 /// always showing: it is the app's main pane and not a window. So this is
72 /// the one described screen with no toggle to share, and it gets its own.
73 pub show_files: bool,
74 /// Whether a described screen's subject moved while it was showing.
75 ///
76 /// **Set by the frame that changed something and read by the next one.** An
77 /// intent is applied after the drawing, so the screen the router answered
78 /// during the drawing was built from state the intent had not reached. Every
79 /// port before the export flow lived with that — `files.rs`'s sort caret was
80 /// one interaction stale and nobody noticed, because a caret that is wrong
81 /// until the next click reads as a rendering quirk.
82 ///
83 /// The export flow made it unsurvivable in two ways at once: every control
84 /// on the configure screen writes through an intent, and the progress screen
85 /// moves with no intent at all. So the answer is `quasi` 0.12.0's
86 /// `Runtime::reload`, and this is the flag that says when to call it.
87 stale: bool,
88 }
89
90 /// Draw the described settings window, and act on whatever was pressed.
91 pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) {
92 let intents = RefCell::new(Vec::new());
93 let mut runtime = state.described.settings.take();
94 let stale = state.described.stale;
95 let host = Host {
96 state,
97 sync: None,
98 themes: themes(),
99 intents: &intents,
100 };
101 let closed = window(
102 ctx,
103 "Settings (described)",
104 &mut runtime,
105 &host,
106 "/settings",
107 stale,
108 );
109 state.described.settings = runtime;
110 apply(ctx, state, None, intents.into_inner());
111 if closed {
112 state.settings.show_manager = false;
113 state.described.settings = None;
114 }
115 }
116
117 /// Draw the sync window, and act on whatever was pressed.
118 ///
119 /// `sync` is `None` when the app has no manager, and it becomes
120 /// [`Unconfigured`], which says syncing is unavailable and offers nothing. The
121 /// shipped panel had a second window for that case.
122 pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
123 // Two pieces of housekeeping that came off `ui::sync_panel::draw_sync_panel`
124 // when it was deleted. Neither is describable and neither is a control: they
125 // are caches the window owns, dropped when what they were about is over.
126 //
127 // The auth URL is only meaningful while the Copy URL fallback is on screen,
128 // and keeping it would show a stale PKCE state if the panel were reopened.
129 if let Some(manager) = sync
130 && !matches!(
131 manager.status().state,
132 audiofiles_sync::SyncState::Authenticating
133 )
134 && state.sync.auth_url.is_some()
135 {
136 state.sync.auth_url = None;
137 }
138 // The per-vault storage numbers go when the panel closes, so reopening
139 // fetches fresh ones: the user may have imported or deleted since.
140 if !state.sync.show_panel {
141 state.sync.vfs_storage_fetched = false;
142 state.sync.vfs_storage_cache.clear();
143 state.sync.synced_bytes = None;
144 state.sync.cap_picker_gib = None;
145 }
146
147 let intents = RefCell::new(Vec::new());
148 let mut runtime = state.described.sync.take();
149 let stale = state.described.stale;
150 let host = Host {
151 state,
152 sync,
153 themes: themes(),
154 intents: &intents,
155 };
156 let closed = window(ctx, "Cloud Sync", &mut runtime, &host, "/sync", stale);
157 state.described.sync = runtime;
158 apply(ctx, state, sync, intents.into_inner());
159 if closed {
160 state.sync.show_panel = false;
161 state.described.sync = None;
162 }
163 }
164
165 /// Draw the described file list, and act on whatever was pressed.
166 pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
167 let intents = RefCell::new(Vec::new());
168 let mut runtime = state.described.files.take();
169 let stale = state.described.stale;
170 let host = Host {
171 state,
172 sync,
173 themes: themes(),
174 intents: &intents,
175 };
176 let closed = window(
177 ctx,
178 "Samples (described)",
179 &mut runtime,
180 &host,
181 "/files",
182 stale,
183 );
184 state.described.files = runtime;
185 apply(ctx, state, sync, intents.into_inner());
186 if closed {
187 state.described.show_files = false;
188 state.described.files = None;
189 }
190 }
191
192 /// Draw the described export flow, and act on whatever was pressed.
193 ///
194 /// **Refreshed unconditionally**, where the other three refresh only after an
195 /// intent. The progress screen's subject is a worker writing files: it moves
196 /// with nothing the user did, so there is no event to hang a refresh on and the
197 /// frame is the only clock the host has. The other phases pay one router call
198 /// per frame for it, which is a table lookup and a walk over state already in
199 /// memory — less than the shipped screen does laying out the same panel.
200 pub fn draw_export(ctx: &egui::Context, state: &mut BrowserState) {
201 let intents = RefCell::new(Vec::new());
202 let mut runtime = state.described.export.take();
203 let host = Host {
204 state,
205 sync: None,
206 themes: themes(),
207 intents: &intents,
208 };
209 let closed = window(
210 ctx,
211 "Export (described)",
212 &mut runtime,
213 &host,
214 "/export",
215 true,
216 );
217 state.described.export = runtime;
218 apply(ctx, state, None, intents.into_inner());
219 if closed {
220 state.described.export = None;
221 }
222 }
223
224 /// Draw the detail panel, and act on whatever was pressed.
225 ///
226 /// Into the app's own right pane rather than a window, because that is what the
227 /// shipped panel was.
228 ///
229 /// Refreshed unconditionally: adding a tag and accepting a suggestion both write
230 /// through an intent, so a screen that is not re-asked shows the tags from
231 /// before the last press.
232 pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
233 let intents = RefCell::new(Vec::new());
234 let mut runtime = state.described.detail.take();
235 let host = Host {
236 state,
237 sync: None,
238 themes: themes(),
239 intents: &intents,
240 };
241 inline(ui, &mut runtime, &host, "/detail", true);
242 state.described.detail = runtime;
243 apply(ui.ctx(), state, None, intents.into_inner());
244 }
245
246 /// Draw the described main window, and act on whatever was pressed.
247 ///
248 /// **Refreshed unconditionally**, and it is the third window to need that for a
249 /// third reason. Settings and Sync move when something described is pressed;
250 /// the export flow moves because a worker is writing files; this one moves
251 /// because a sample is playing. The transport's position advances at the sample
252 /// clock with nobody touching anything, which is the case `shell`'s header calls
253 /// the sharper consumer of the `Meter` finding.
254 pub fn draw_shell(ctx: &egui::Context, state: &mut BrowserState) {
255 let intents = RefCell::new(Vec::new());
256 let mut runtime = state.described.shell.take();
257 let host = Host {
258 state,
259 sync: None,
260 themes: themes(),
261 intents: &intents,
262 };
263 let closed = window(
264 ctx,
265 "audiofiles (described)",
266 &mut runtime,
267 &host,
268 "/",
269 true,
270 );
271 state.described.shell = runtime;
272 apply(ctx, state, None, intents.into_inner());
273 if closed {
274 state.described.show_shell = false;
275 state.described.shell = None;
276 }
277 }
278
279 /// Draw the described sample editor, and act on whatever was pressed.
280 ///
281 /// **Refreshed unconditionally**, the fourth window to need it and the fourth
282 /// reason: an edit runs on a worker, so the screen moves from `working` to
283 /// `asking` with nothing pressed. Same clock the export flow reads.
284 pub fn draw_edit(ctx: &egui::Context, state: &mut BrowserState) {
285 let intents = RefCell::new(Vec::new());
286 let mut runtime = state.described.edit.take();
287 let host = Host {
288 state,
289 sync: None,
290 themes: themes(),
291 intents: &intents,
292 };
293 let closed = window(ctx, "Sample Editor", &mut runtime, &host, "/edit", true);
294 state.described.edit = runtime;
295 apply(ctx, state, None, intents.into_inner());
296 if closed {
297 state.described.edit = None;
298 }
299 }
300
301 /// Draw the described forge, and act on whatever was pressed.
302 ///
303 /// **Refreshed unconditionally**, and it is the editor's reason: a chop and a
304 /// conform both run on a worker, so `busy` goes false with nothing pressed. The
305 /// slice count moves the same way — a preview is work the app did, and the
306 /// button that commits to it is gated on the result.
307 pub fn draw_forge(ctx: &egui::Context, state: &mut BrowserState) {
308 let intents = RefCell::new(Vec::new());
309 let mut runtime = state.described.forge.take();
310 let host = Host {
311 state,
312 sync: None,
313 themes: themes(),
314 intents: &intents,
315 };
316 let closed = window(ctx, "Sample Forge", &mut runtime, &host, "/forge", true);
317 state.described.forge = runtime;
318 apply(ctx, state, None, intents.into_inner());
319 if closed {
320 state.described.forge = None;
321 }
322 }
323
324 /// Draw the import flow, and act on whatever was pressed.
325 ///
326 /// One call for the whole flow. The shipped side is nine drawing functions
327 /// chosen by a `match` on `ImportMode` in two places; the described side is one
328 /// address whose answer depends on the stage, so the app asks once and the
329 /// route decides what the user is looking at.
330 ///
331 /// Into the app's own pane rather than a window, because every stage of this was
332 /// a full-screen mode.
333 ///
334 /// Refreshed unconditionally, and this is the screen that most needs it: a
335 /// worker moves the counts with nothing pressed, and the stage changes under it.
336 pub fn draw_import(ui: &mut egui::Ui, state: &mut BrowserState) {
337 let intents = RefCell::new(Vec::new());
338 let mut runtime = state.described.import.take();
339 let host = Host {
340 state,
341 sync: None,
342 themes: themes(),
343 intents: &intents,
344 };
345 inline(ui, &mut runtime, &host, "/import", true);
346 state.described.import = runtime;
347 apply(ui.ctx(), state, None, intents.into_inner());
348 }
349
350 /// Draw the tag review queue, and act on whatever was pressed.
351 ///
352 /// Into the app's own pane rather than a window, because that is what the
353 /// shipped screen was: `ImportMode::ReviewLibrary` is a full-screen mode and
354 /// the main pane is where a mode is drawn.
355 ///
356 /// Refreshed unconditionally: the classifier worker fills the queue and drains
357 /// it while the screen is up, so what it shows moves with nothing pressed.
358 pub fn draw_queue(ui: &mut egui::Ui, state: &mut BrowserState) {
359 let intents = RefCell::new(Vec::new());
360 let mut runtime = state.described.queue.take();
361 let host = Host {
362 state,
363 sync: None,
364 themes: themes(),
365 intents: &intents,
366 };
367 inline(ui, &mut runtime, &host, "/review", true);
368 state.described.queue = runtime;
369 apply(ui.ctx(), state, None, intents.into_inner());
370 }
371
372 /// Draw the filter panel, and act on whatever was pressed.
373 ///
374 /// Into the app's own left pane rather than a window, because that is what the
375 /// shipped panel was.
376 ///
377 /// Refreshed unconditionally, and this one earns it more plainly than most:
378 /// every control here writes through an intent, so a screen that is not re-asked
379 /// shows the filter state from before the last press.
380 pub fn draw_filters(ui: &mut egui::Ui, state: &mut BrowserState) {
381 let intents = RefCell::new(Vec::new());
382 let mut runtime = state.described.filters.take();
383 let host = Host {
384 state,
385 sync: None,
386 themes: themes(),
387 intents: &intents,
388 };
389 inline(ui, &mut runtime, &host, "/filters", true);
390 state.described.filters = runtime;
391 apply(ui.ctx(), state, None, intents.into_inner());
392 }
393
394 /// Draw the blob sweep, and act on whatever was pressed.
395 ///
396 /// Its own address rather than a stage of the import flow, because it is not
397 /// one: see `importing`'s header. Into the app's own pane all the same, because
398 /// `ImportMode::Cleaning` is a full-screen mode like every other stage and the
399 /// shipped `draw_cleanup_progress` took the pane over.
400 ///
401 /// Refreshed unconditionally for the plainest of the reasons: a worker is
402 /// removing rows and the count moves on its own.
403 pub fn draw_sweep(ui: &mut egui::Ui, state: &mut BrowserState) {
404 let intents = RefCell::new(Vec::new());
405 let mut runtime = state.described.sweep.take();
406 let host = Host {
407 state,
408 sync: None,
409 themes: themes(),
410 intents: &intents,
411 };
412 inline(ui, &mut runtime, &host, "/cleanup", true);
413 state.described.sweep = runtime;
414 apply(ui.ctx(), state, None, intents.into_inner());
415 }
416
417 /// Draw the loose-files warning, and act on whatever was pressed.
418 ///
419 /// The first screen to serve rather than sit beside one, 2026-08-22. What the
420 /// shipped overlay did that this does not is stop you: it drew as a modal over
421 /// a dimmed app, and a described screen cannot raise itself, so the count is
422 /// said in the status band and this is one act away from it. `integrity`'s
423 /// header argues that difference; it is recorded rather than smoothed over.
424 ///
425 /// Refreshed every frame, because the worker that re-checks the vault moves the
426 /// count with nothing pressed.
427 pub fn draw_integrity(ctx: &egui::Context, state: &mut BrowserState) {
428 let intents = RefCell::new(Vec::new());
429 let mut runtime = state.described.integrity.take();
430 let host = Host {
431 state,
432 sync: None,
433 themes: themes(),
434 intents: &intents,
435 };
436 let closed = window(
437 ctx,
438 "Loose-files mode warning",
439 &mut runtime,
440 &host,
441 "/library/loose-files",
442 true,
443 );
444 state.described.integrity = runtime;
445 apply(ctx, state, None, intents.into_inner());
446 if closed {
447 state.described.integrity = None;
448 state.dismiss_loose_files_warning();
449 }
450 }
451
452 /// Draw one of the four name modals, and act on whatever was pressed.
453 ///
454 /// One function for four screens, because they are one screen four times: a
455 /// field, a submit and a cancel, differing only in what they are called and in
456 /// what the submit does. The shipped side already knew that, and said it by
457 /// sharing `handle_name_modal_outcome` between four functions rather than by
458 /// sharing an address.
459 ///
460 /// `title` and `home` come from the caller because the app is what knows which
461 /// of the four is showing: `vfs_modal`'s two flags and two targets still decide,
462 /// and those are the host's own state rather than anything a route reads.
463 ///
464 /// Refreshed unconditionally. A refusal replaces the form region and the field
465 /// keeps what was typed, which is `naming`'s `Fragment`, and a screen that is
466 /// not re-asked shows the answer before it.
467 pub fn draw_naming(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) {
468 let intents = RefCell::new(Vec::new());
469 let mut runtime = state.described.naming.take();
470 let host = Host {
471 state,
472 sync: None,
473 themes: themes(),
474 intents: &intents,
475 };
476 let closed = window(ctx, title, &mut runtime, &host, home, true);
477 state.described.naming = runtime;
478 let finished = intents
479 .borrow()
480 .iter()
481 .any(|i| matches!(i, Intent::NamingDone));
482 apply(ctx, state, None, intents.into_inner());
483 // The window's own X is the fourth way out, beside Cancel, an empty submit
484 // and a successful one. It raises no intent, so it is answered here with
485 // what `Intent::NamingDone` would have done.
486 if closed || finished {
487 state.described.naming = None;
488 state.vfs_modal.show_vfs_create = false;
489 state.vfs_modal.show_dir_create = false;
490 state.vfs_modal.vfs_rename_target = None;
491 state.vfs_modal.dir_rename_target = None;
492 state.vfs_modal.name_modal_error = None;
493 }
494 }
495
496 /// Draw one of the three bulk modals, and act on whatever was pressed.
497 ///
498 /// `draw_naming`'s shape, for the same reason: three screens differing in what
499 /// they ask, and the app's own `bulk_modal` is what knows which is showing.
500 ///
501 /// Refreshed unconditionally. The rename preview answers as a fragment while
502 /// the pattern is typed, and a screen that is not re-asked shows the previews
503 /// from before the last keystroke.
504 pub fn draw_bulk(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) {
505 let intents = RefCell::new(Vec::new());
506 let mut runtime = state.described.bulk.take();
507 let host = Host {
508 state,
509 sync: None,
510 themes: themes(),
511 intents: &intents,
512 };
513 let closed = window(ctx, title, &mut runtime, &host, home, true);
514 state.described.bulk = runtime;
515 let finished = intents
516 .borrow()
517 .iter()
518 .any(|intent| matches!(intent, Intent::BulkDone));
519 apply(ctx, state, None, intents.into_inner());
520 // The window's own X raises no intent, so it is answered here with what
521 // `Intent::BulkDone` would have done.
522 if closed || finished {
523 state.described.bulk = None;
524 state.close_bulk_modal();
525 }
526 }
527
528 /// Draw the import preflight, and act on whatever was pressed.
529 ///
530 /// The question asked before a large import starts, and the only part of the
531 /// flow that is a modal rather than a stage: nothing has begun yet, so there is
532 /// no pane to take over. `importing`'s header calls it the first consumer of the
533 /// unprompted-overlay shape; the loose-files warning is the other.
534 pub fn draw_preflight(ctx: &egui::Context, state: &mut BrowserState) {
535 let intents = RefCell::new(Vec::new());
536 let mut runtime = state.described.preflight.take();
537 let host = Host {
538 state,
539 sync: None,
540 themes: themes(),
541 intents: &intents,
542 };
543 let closed = window(
544 ctx,
545 "Import folder",
546 &mut runtime,
547 &host,
548 "/import/preflight",
549 true,
550 );
551 state.described.preflight = runtime;
552 apply(ctx, state, None, intents.into_inner());
553 // The X is the same answer as Cancel: nothing has started, so there is
554 // nothing to leave running.
555 if closed {
556 state.described.preflight = None;
557 state.cancel_import_preflight();
558 }
559 }
560
561 /// Do what a described screen asked the app to do to itself.
562 ///
563 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
564 /// row, so it records an [`Intent`] and this runs afterwards, with the `&mut`
565 /// the app has anyway. `SettingsUiState::pending_action` is the same pattern
566 /// already in this app.
567 ///
568 /// Each arm calls what the shipped list calls, rather than reaching into the
569 /// fields itself: a described screen that set `nav.selection` by hand would be a
570 /// second implementation of selection, which is what the port is for avoiding.
571 /// `sync` is the manager, where the caller has one. Only two panels do: the sync
572 /// window, and the file list since 2026-08-17, because a cloud-only row's
573 /// Download is an act nothing but the manager can perform. Every other caller
574 /// passes `None` and no intent it can raise wants it.
575 fn apply(
576 ctx: &egui::Context,
577 state: &mut BrowserState,
578 sync: Option<&SyncManager>,
579 intents: Vec<Intent>,
580 ) {
581 // Anything applied here landed *after* the router answered, so the screen
582 // showing was built without it. The next frame reloads.
583 state.described.stale = !intents.is_empty();
584
585 for intent in intents {
586 match intent {
587 Intent::Configure(setting, value) => configure(state, setting, &value),
588 Intent::StartExport => {
589 if let crate::state::ImportMode::ConfigureExport { items, config, .. } =
590 &state.import_wf.import_mode
591 {
592 let (items, config) = (items.clone(), config.clone());
593 state.run_export(items, config);
594 }
595 }
596 Intent::CancelExport => state.cancel_export(),
597 // Whatever phase it is in, the flow is over. The shipped screens
598 // each write `ImportMode::None` at their own Done or Cancel, and
599 // this is the one place the described side does.
600 Intent::DismissExport => {
601 state.import_wf.import_mode = crate::state::ImportMode::None;
602 }
603 Intent::Open(id) => {
604 if let Some(at) = index_of(state, id) {
605 state.nav.selection.set_single(at);
606 state.refresh_selected_tags();
607 state.refresh_selected_detail();
608 }
609 }
610 Intent::Play(id) => {
611 if let Some(at) = index_of(state, id) {
612 state.nav.selection.set_single(at);
613 state.autoplay_current();
614 }
615 }
616 // The row menu, 2026-08-17. Every arm selects the row first and then
617 // calls what the shipped menu calls on the selection, which is the
618 // rule this whole function follows: `draw_context_menu` opens with
619 // `nav.selection.set_single(row_idx)` for exactly the same reason,
620 // and a described screen reaching past the app's own selection would
621 // be a second implementation of it.
622 Intent::Enter(id) => {
623 if let Some(at) = index_of(state, id) {
624 state.nav.selection.set_single(at);
625 state.enter_directory();
626 }
627 }
628 // A host act, like `CopyPath` above: a file manager is the system's
629 // and the description has no way to say so.
630 Intent::Reveal(id) => {
631 if let Some(at) = index_of(state, id) {
632 state.nav.selection.set_single(at);
633 if let Some(path) = state.selected_sample_path() {
634 crate::ui::file_list_menus::reveal(&path);
635 }
636 }
637 }
638 Intent::Instrument(id) => {
639 if let Some(at) = index_of(state, id) {
640 state.nav.selection.set_single(at);
641 if let Some(hash) = selected_hash(state) {
642 let name = state
643 .selected_node()
644 .map(|node| node.node.name.clone())
645 .unwrap_or_default();
646 state.load_chromatic_sample(&hash);
647 state.preview.instrument_visible = true;
648 state.preview.show_midi_window = true;
649 state.status = format!("Instrument: {name}");
650 }
651 }
652 }
653 // The overwrite branch is the shipped menu's, verbatim in intent: an
654 // analysis that would replace numbers already there asks first, and
655 // one filling a gap does not. `ReanalyzeOverwrite` is the app's own
656 // confirmation, so this does not use `Act::confirm` -- the question
657 // is conditional on state the description does not carry.
658 Intent::Reanalyze(id) => {
659 if let Some(at) = index_of(state, id) {
660 state.nav.selection.set_single(at);
661 let existing = state
662 .selected_node()
663 .is_some_and(|node| node.bpm.is_some() || node.musical_key.is_some());
664 if let Some(hash) = selected_hash(state)
665 && let Ok(ext) = state.backend.sample_extension(&hash)
666 {
667 let hashes = vec![(hash, ext)];
668 if existing {
669 state.overlay.pending_confirm =
670 Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
671 sample_hashes: hashes,
672 overwrite_count: 1,
673 });
674 } else {
675 state.start_analysis_flow(hashes);
676 }
677 }
678 }
679 }
680 // The act carried `Act::confirm`, so the user has already agreed by
681 // the time this runs. `confirm_delete_selected` raises the app's own
682 // dialog on top of that, which is one question too many and is the
683 // shipped behaviour: the menu entry asks nothing itself and this is
684 // where the asking lives. Left as the app's, because the two dialogs
685 // are not equivalent -- the app's counts what it is about to remove.
686 Intent::Delete(id) => {
687 if let Some(at) = index_of(state, id) {
688 state.nav.selection.set_single(at);
689 state.confirm_delete_selected();
690 }
691 }
692 Intent::Download(id) => {
693 if let Some(at) = index_of(state, id) {
694 state.nav.selection.set_single(at);
695 let name = state
696 .selected_node()
697 .map(|node| node.node.name.clone())
698 .unwrap_or_default();
699 // Without a manager there is nothing to ask, which is the
700 // case the described screen already withholds the entry for.
701 // Said again here because an intent can arrive from a
702 // hand-typed request, and the shipped menu's own fallback is
703 // a status line rather than a silence.
704 match (sync, selected_hash(state)) {
705 (Some(manager), Some(hash)) if manager.download_sample(&hash) => {
706 state.status = format!("Downloading {name}...");
707 }
708 _ => {
709 "Sync not ready, open the Sync panel first"
710 .clone_into(&mut state.status);
711 }
712 }
713 }
714 }
715 Intent::RemoveFromCollection(id) => {
716 if let Some(active) = state.collections_ui.active_collection
717 && let Some(at) = index_of(state, id)
718 {
719 state.nav.selection.set_single(at);
720 if let Some(hash) = selected_hash(state) {
721 let _ = state.backend.remove_from_collection(active, &hash);
722 state.refresh_collections();
723 state.activate_collection(active);
724 }
725 }
726 }
727 Intent::AddToCollection(id, collection) => {
728 // The description carries an `i64` because a described id is a
729 // number, so the newtype is put back here -- the same round trip
730 // `OpenCollection` makes, and for the same reason: the
731 // collection is found by comparing against the typed id rather
732 // than by constructing one out of an unchecked number.
733 let named = state
734 .collections_ui
735 .collections
736 .iter()
737 .find(|it| it.id.as_i64() == collection)
738 .map(|it| (it.id, it.name.clone()));
739 if let Some((collection, name)) = named
740 && let Some(at) = index_of(state, id)
741 {
742 state.nav.selection.set_single(at);
743 if let Some(hash) = selected_hash(state) {
744 let _ = state.backend.add_to_collection(collection, &hash);
745 state.refresh_collections();
746 state.status = format!("Added to {name}");
747 }
748 }
749 }
750 Intent::SortBy(column) => {
751 let key = match column.as_str() {
752 "Name" => crate::state::SortColumn::Name,
753 "BPM" => crate::state::SortColumn::Bpm,
754 "Key" => crate::state::SortColumn::Key,
755 "Duration" => crate::state::SortColumn::Duration,
756 // The route already refused anything else, so this is a
757 // column the app grew and the description has not learned.
758 _ => continue,
759 };
760 state.toggle_sort(key);
761 }
762 Intent::AddTag(tag) => add_tag(state, &tag),
763 Intent::RemoveTag(tag) => remove_tag(state, &tag),
764 Intent::Suggest => state.suggest_ml_for_selected(),
765 Intent::AcceptSuggestion(tag) => state.accept_ml_suggestion(&tag),
766 // The one intent the app does not perform on itself. See
767 // `detail`'s header: a clipboard is the system's and the
768 // description has no way to say so, so it arrives here as an
769 // ordinary intent and the host does what only a host can.
770 Intent::CopyPath => {
771 if let Some(path) = state.selected_sample_path() {
772 state.status = format!("Copied: {path}");
773 ctx.copy_text(path);
774 }
775 }
776 Intent::Edit => {
777 if let Some(hash) = selected_hash(state) {
778 state.open_edit_window(&hash);
779 }
780 }
781 Intent::Forge => {
782 if let Some(hash) = selected_hash(state) {
783 state.open_forge_window(&hash);
784 }
785 }
786 Intent::FindSimilar => {
787 if let Some(hash) = selected_hash(state) {
788 state.find_similar(&hash);
789 }
790 }
791 Intent::FindDuplicates => {
792 if let Some(hash) = selected_hash(state) {
793 state.find_near_duplicates(&hash);
794 }
795 }
796 Intent::SpreadTag(tag) => {
797 let targets = across(state, |node| !node.tags.contains(&tag));
798 state.apply_tag_to_hashes(&tag, &targets);
799 }
800 Intent::StripTag(tag) => {
801 let targets = across(state, |node| node.tags.contains(&tag));
802 state.remove_tag_from_hashes(&tag, &targets);
803 }
804 // The three bulk operations, each the same shape: the described
805 // modal held what was typed, so the host opens the app's own modal
806 // to derive the arguments from the selection, puts the typed value
807 // where the executor reads it, and runs the executor.
808 //
809 // Three lines rather than a second implementation of bulk tagging.
810 // `execute_bulk_*` owns the undo entry, the status line and the
811 // partial-failure counting, and none of that should exist twice.
812 // See `Bulk`'s header on why the description does not carry
813 // `BulkModal` even though the commit path does.
814 // The toolbar.
815 Intent::Search(query) => {
816 state.search.search_query = query;
817 // Applied at once rather than debounced: an intent already
818 // arrived because the host decided to fire, so the settling is
819 // behind us. See `toolbar`'s header on what the description
820 // cannot yet say about that.
821 state.search.search_debounce_at = None;
822 state.apply_search();
823 }
824 Intent::Scope(everywhere) => {
825 state.search.search_filter.scope = if everywhere {
826 audiofiles_core::search::SearchScope::Global
827 } else {
828 audiofiles_core::search::SearchScope::CurrentFolder
829 };
830 state.apply_search();
831 }
832 Intent::SaveCollection(name) => state.save_dynamic_collection(&name),
833 Intent::Undo => state.undo(),
834 Intent::TogglePanel(panel) => match panel {
835 super::Panel::Sidebar => state.toggle_sidebar(),
836 super::Panel::Detail => state.toggle_detail(),
837 super::Panel::Edit => crate::ui::toolbar::toggle_edit_window(state),
838 super::Panel::Instrument => {
839 state.preview.show_midi_window = !state.preview.show_midi_window;
840 }
841 super::Panel::Loop => state.toggle_loop(),
842 super::Panel::Filters => state.toggle_filter_panel(),
843 },
844 Intent::GoRoot => {
845 state.nav.current_dir = None;
846 state.nav.breadcrumb.clear();
847 state.nav.selection.clear();
848 state.refresh_contents();
849 }
850 Intent::GoTo(id, depth) => {
851 if state
852 .nav
853 .breadcrumb
854 .get(depth.saturating_sub(1))
855 .is_some_and(|crumb| crumb.id.as_i64() == id)
856 {
857 state.nav.current_dir = Some(audiofiles_core::NodeId::from(id));
858 state.nav.breadcrumb.truncate(depth);
859 state.nav.selection.clear();
860 state.refresh_contents();
861 }
862 }
863 // One control for two modes, because leaving either means the same
864 // thing to the user: go back to browsing. Which one is showing is
865 // what `Where` already says.
866 Intent::Leave => {
867 if state.search.similarity_search_hash.is_some() {
868 state.clear_similarity_search();
869 } else {
870 state.deactivate_collection();
871 }
872 }
873 // The sidebar. Two of these hand an already-agreed decision to the
874 // app's own executor: the described control asked with
875 // `Act::confirm`, the runtime answered `Step::Ask`, the user said
876 // yes, and `execute_confirmed_action` is what knows how to do it.
877 // `ConfirmAction` becomes an argument carrier rather than a
878 // question, which is `library`'s header made concrete.
879 Intent::OpenVault(id) => {
880 if let Some(at) = state
881 .nav
882 .vfs_list
883 .iter()
884 .position(|vfs| vfs.id.as_i64() == id)
885 && at != state.nav.current_vfs_idx
886 {
887 state.select_vfs(at);
888 }
889 }
890 Intent::DeleteVault(id) => {
891 if let Some(vfs) = state.nav.vfs_list.iter().find(|vfs| vfs.id.as_i64() == id) {
892 state.overlay.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs {
893 vfs_id: vfs.id,
894 vfs_name: vfs.name.clone(),
895 });
896 state.execute_confirmed_action();
897 }
898 }
899 Intent::ToggleTag(path) => {
900 let wanted = &mut state.search.search_filter.required_tags;
901 if let Some(at) = wanted.iter().position(|held| *held == path) {
902 wanted.remove(at);
903 } else {
904 wanted.push(path);
905 }
906 state.apply_search();
907 }
908 Intent::RemoveTagEverywhere(tag) => {
909 state.overlay.pending_confirm =
910 Some(crate::state::ConfirmAction::RemoveTagGlobally { tag });
911 state.execute_confirmed_action();
912 }
913 Intent::OpenCollection(id) => {
914 if let Some(collection) = state
915 .collections_ui
916 .collections
917 .iter()
918 .find(|collection| collection.id.as_i64() == id)
919 {
920 let (id, filter) = (collection.id, collection.filter.clone());
921 match filter {
922 Some(filter) => state.activate_dynamic_collection(id, &filter),
923 None => state.activate_collection(id),
924 }
925 }
926 }
927 Intent::CloseCollection => state.deactivate_collection(),
928 Intent::DeleteCollection(id) => {
929 if let Some(collection) = state
930 .collections_ui
931 .collections
932 .iter()
933 .find(|collection| collection.id.as_i64() == id)
934 {
935 state.overlay.pending_confirm =
936 Some(crate::state::ConfirmAction::DeleteCollection {
937 coll_id: collection.id,
938 coll_name: collection.name.clone(),
939 });
940 state.execute_confirmed_action();
941 }
942 }
943 Intent::StopPlayback => state.stop_preview(),
944 Intent::DismissHint => state.dismiss_first_launch_hint(),
945 Intent::BulkTag(typed, adding) => {
946 state.open_bulk_tag_modal();
947 if let Some(crate::state::BulkModal::Tag {
948 tag_input,
949 adding: mode,
950 ..
951 }) = &mut state.bulk_modal
952 {
953 *tag_input = typed;
954 *mode = adding;
955 }
956 state.execute_bulk_tag();
957 state.close_bulk_modal();
958 }
959 Intent::BulkMove(folder) => {
960 state.open_bulk_move_modal();
961 if let Some(crate::state::BulkModal::Move {
962 directories,
963 selected_idx,
964 ..
965 }) = &mut state.bulk_modal
966 {
967 // Back from the id the address named to the index the
968 // executor reads. The description addresses a folder by its
969 // own id, for the reason the file list addresses a row by
970 // one: an index is a fact about a list that was built once.
971 *selected_idx = folder
972 .and_then(|id| directories.iter().position(|(at, _)| at.as_i64() == id));
973 }
974 state.execute_bulk_move();
975 state.close_bulk_modal();
976 }
977 Intent::BulkRename(pattern) => {
978 state.open_bulk_rename_modal();
979 if let Some(crate::state::BulkModal::Rename { pattern_input, .. }) =
980 &mut state.bulk_modal
981 {
982 *pattern_input = pattern;
983 }
984 state.execute_bulk_rename();
985 state.close_bulk_modal();
986 }
987 // The name modals. The write already happened in the route, which
988 // is the one place this port does that and `naming`'s header is
989 // why: the store's refusal has to reach the field it was typed
990 // into. What is left is the half a route cannot do.
991 // Whichever of the four was up, and the error with it: the modal is
992 // finished with, so a refusal it was showing is finished with too.
993 Intent::NamingDone => {
994 state.vfs_modal.show_vfs_create = false;
995 state.vfs_modal.show_dir_create = false;
996 state.vfs_modal.vfs_rename_target = None;
997 state.vfs_modal.dir_rename_target = None;
998 state.vfs_modal.name_modal_error = None;
999 }
1000 // Whichever of the three was up.
1001 Intent::BulkDone => state.close_bulk_modal(),
1002 Intent::VaultsChanged(say) => {
1003 state.refresh_vfs_list();
1004 state.status = say;
1005 }
1006 Intent::ContentsChanged(say) => {
1007 state.refresh_contents();
1008 state.status = say;
1009 }
1010 Intent::AcceptImport { again } => {
1011 if !again {
1012 if let Err(error) = state
1013 .backend
1014 .set_config(crate::backend::ConfigKey::ImportPreflightDisabled, "1")
1015 {
1016 tracing::warn!("Failed to persist preflight dismissal: {error}");
1017 }
1018 // The in-memory mirror as well, so the next bypass check
1019 // sees the answer without a reload. The shipped modal writes
1020 // both for the same reason.
1021 state.import_wf.import_preflight_disabled = true;
1022 }
1023 state.accept_import_preflight();
1024 }
1025 Intent::CancelImport => state.cancel_import_preflight(),
1026 Intent::DismissLooseFiles => state.dismiss_loose_files_warning(),
1027 Intent::PurgeLooseFiles => state.purge_missing_loose_files(),
1028 // The editor. Every one of these writes the value the described
1029 // control submitted into the knob the shipped panel keeps for it,
1030 // and then calls what the shipped button calls. That the knobs
1031 // still exist is the shipped panel's business: `edit`'s header is
1032 // about what the *description* no longer carries, and while both
1033 // windows are open both need somewhere to put a number.
1034 Intent::EditTrim { start, end } => {
1035 state.edit.trim_start = start;
1036 state.edit.trim_end = end;
1037 state.apply_edit_trim();
1038 }
1039 Intent::EditGain(db) => {
1040 state.edit.gain_db = db;
1041 state.apply_edit_gain();
1042 }
1043 Intent::EditNormalize { peak, target } => {
1044 state.edit.norm_peak = peak;
1045 state.edit.norm_target = target;
1046 state.apply_edit_normalize();
1047 }
1048 Intent::EditReverse => state.apply_edit_reverse(),
1049 Intent::EditFade {
1050 fading_in,
1051 ms,
1052 curve,
1053 } => {
1054 state.edit.fade_in = fading_in;
1055 state.edit.fade_duration_ms = ms;
1056 // The route already refused anything else, so an unreadable
1057 // curve here is one the app grew and the description has not
1058 // learned.
1059 if let Some(curve) = audiofiles_core::edit::FadeCurve::from_value(&curve) {
1060 state.edit.fade_curve = curve;
1061 }
1062 state.apply_edit_fade();
1063 }
1064 Intent::EditInsertSilence { at, ms } => {
1065 state.edit.silence_position_ms = at;
1066 state.edit.silence_duration_ms = ms;
1067 state.apply_edit_insert_silence();
1068 }
1069 Intent::EditRemoveRange { from, to } => {
1070 state.edit.remove_start_ms = from;
1071 state.edit.remove_end_ms = to;
1072 state.apply_edit_remove_range();
1073 }
1074 Intent::EditCancel => state.cancel_edit_operation(),
1075 // Play and pause are one control, which is the shipped transport's
1076 // own reading: pressing it while this sample is loaded toggles the
1077 // buffer, and pressing it while another is loaded starts this one.
1078 Intent::EditPlay => {
1079 if let Some(hash) = state.edit.hash.clone() {
1080 if state.preview.previewing_hash.as_deref() == Some(hash.as_str()) {
1081 let mut playback = state.shared.preview.lock();
1082 playback.playing = !playback.playing;
1083 } else {
1084 state.trigger_preview(&hash);
1085 }
1086 }
1087 }
1088 Intent::EditRemember(mode) => {
1089 if let Some(mode) = crate::state::EditResultMode::from_value(&mode) {
1090 state.set_edit_result_mode(mode);
1091 }
1092 }
1093 Intent::EditChoose { mode, remember } => {
1094 if let Some(mode) = crate::state::EditResultMode::from_value(&mode) {
1095 state.confirm_edit_result(mode, remember);
1096 }
1097 }
1098 Intent::EditDiscard => state.discard_edit_result(),
1099 Intent::EditUndo => state.undo_last_edit(),
1100 Intent::BatchNormalize { peak, target } => {
1101 if peak {
1102 state.batch_normalize_peak(target);
1103 } else {
1104 state.batch_normalize_lufs(target);
1105 }
1106 }
1107 Intent::BatchGain(db) => state.batch_gain(db),
1108 Intent::BatchReverse => state.batch_reverse(),
1109 // The import flow. Every one of these lands on `import_wf`, which
1110 // is the app's own screen state, so the whole capability writes
1111 // through intents -- see `Importing`'s header.
1112 //
1113 // The four doors are host acts with no described step, which is
1114 // `quasi:vocabulary:host-save-location`'s fifth to eighth consumers
1115 // and the same shape `LocateLooseFiles` takes below.
1116 Intent::OpenImportFolder => {
1117 state
1118 .dialogs
1119 .pick_folder("Import folder", BrowserState::show_import_options);
1120 }
1121 Intent::OpenQuickImport => {
1122 state
1123 .dialogs
1124 .pick_folder("Quick import folder", BrowserState::quick_import_folder);
1125 }
1126 Intent::OpenImportFiles => {
1127 state.dialogs.pick_files(
1128 "Import files",
1129 &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)],
1130 |state, paths| {
1131 // Batched through the worker rather than hashed on the
1132 // GUI thread, which is the shipped menu entry's own fix.
1133 if let Some(vfs_id) = state.current_vfs_id() {
1134 let strategy = crate::import::ImportStrategy::MergeIntoVfs {
1135 vfs_id,
1136 parent_id: state.nav.current_dir,
1137 };
1138 state.start_files_import(&paths, strategy);
1139 }
1140 },
1141 );
1142 }
1143 Intent::ChangeImportSource => {
1144 state
1145 .dialogs
1146 .pick_folder("Choose source folder", |state, folder| {
1147 state.change_import_source(folder);
1148 });
1149 }
1150 Intent::Decide(decision, value) => decide(state, decision, &value),
1151 Intent::BeginImport => begin_import(state),
1152 Intent::StopImport => state.cancel_import(),
1153 Intent::RetryImport => state.retry_import(),
1154 // Whatever stage it is at, the flow is over. The shipped screens
1155 // each write `ImportMode::None` at their own Cancel or Done, and
1156 // this is the one place the described side does.
1157 Intent::DismissImport => {
1158 state.import_wf.import_mode = crate::state::ImportMode::None;
1159 }
1160 Intent::TagFolder(at, typed) => {
1161 if let crate::state::ImportMode::TagFolders { entries, .. } =
1162 &mut state.import_wf.import_mode
1163 && let Some(entry) = entries.get_mut(at)
1164 {
1165 entry.tag_input = typed;
1166 }
1167 }
1168 Intent::TagEveryFolder(typed) => {
1169 if let crate::state::ImportMode::TagFolders { entries, .. } =
1170 &mut state.import_wf.import_mode
1171 {
1172 for entry in entries.iter_mut() {
1173 entry.tag_input.clone_from(&typed);
1174 }
1175 }
1176 }
1177 Intent::ApplyFolderTags => state.apply_folder_tags(),
1178 Intent::SkipFolderTags => state.skip_folder_tags(),
1179 Intent::Measure(measure, wanted) => {
1180 if let crate::state::ImportMode::ConfigureAnalysis { config, .. } =
1181 &mut state.import_wf.import_mode
1182 {
1183 match measure {
1184 super::Measure::Loudness => config.loudness = wanted,
1185 super::Measure::Bpm => config.bpm = wanted,
1186 super::Measure::Key => config.key = wanted,
1187 super::Measure::Spectral => config.spectral = wanted,
1188 super::Measure::Loops => config.loop_detect = wanted,
1189 super::Measure::Suggestions => config.auto_suggest_tags = wanted,
1190 super::Measure::Fingerprint => config.fingerprint = wanted,
1191 super::Measure::SmartSkip => config.smart_skip = wanted,
1192 }
1193 }
1194 }
1195 Intent::StartAnalysis => {
1196 if let crate::state::ImportMode::ConfigureAnalysis {
1197 sample_hashes,
1198 config,
1199 } = &state.import_wf.import_mode
1200 {
1201 let (hashes, config) = (sample_hashes.clone(), config.clone());
1202 state.run_analysis(hashes, config);
1203 }
1204 }
1205 Intent::BackToTagging => state.back_to_tag_folders(),
1206 Intent::SkipAnalysis => {
1207 state.import_wf.import_mode = crate::state::ImportMode::None;
1208 "Imported. Run analysis from the sidebar when ready.".clone_into(&mut state.status);
1209 }
1210 Intent::StopAnalysis => state.cancel_analysis(),
1211 Intent::RetryAnalysis => state.retry_analysis(),
1212 Intent::OrderReview(order) => {
1213 if let crate::state::ImportMode::ReviewSuggestions { sort, .. } =
1214 &mut state.import_wf.import_mode
1215 {
1216 *sort = match order {
1217 super::Order::Arrival => crate::state::ReviewSort::ImportOrder,
1218 super::Order::Name => crate::state::ReviewSort::Name,
1219 super::Order::Suggestions => crate::state::ReviewSort::Suggestions,
1220 super::Order::Accepted => crate::state::ReviewSort::Accepted,
1221 };
1222 }
1223 }
1224 Intent::ReadReviewed(at) => {
1225 if let crate::state::ImportMode::ReviewSuggestions {
1226 items, current_idx, ..
1227 } = &mut state.import_wf.import_mode
1228 && at < items.len()
1229 {
1230 *current_idx = at;
1231 }
1232 }
1233 // By tag rather than by position, which is the route's own reason:
1234 // the description sorts its copy by confidence and the app does not,
1235 // so an index agreed on one side is not the same row on the other.
1236 Intent::Judge { at, tag, accepted } => {
1237 if let crate::state::ImportMode::ReviewSuggestions { items, .. } =
1238 &mut state.import_wf.import_mode
1239 && let Some(item) = items.get_mut(at)
1240 && let Some(held) = item
1241 .suggestions
1242 .iter_mut()
1243 .find(|held| held.suggestion.tag == tag)
1244 {
1245 held.accepted = accepted;
1246 }
1247 }
1248 Intent::JudgeAll(accepted) => {
1249 if let crate::state::ImportMode::ReviewSuggestions { items, .. } =
1250 &mut state.import_wf.import_mode
1251 {
1252 for item in items.iter_mut() {
1253 for held in &mut item.suggestions {
1254 held.accepted = accepted;
1255 }
1256 }
1257 }
1258 }
1259 Intent::ApplySuggestions => state.apply_accepted_suggestions(),
1260 Intent::DiscardSuggestions => {
1261 state.import_wf.import_mode = crate::state::ImportMode::None;
1262 "Suggestions discarded".clone_into(&mut state.status);
1263 }
1264 Intent::KeepFailed => state.dismiss_import_errors(),
1265 // The described control asked with `Act::confirm` and the runtime
1266 // already had the answer, so `ConfirmAction` arrives here as an
1267 // argument carrier rather than as a question -- `library`'s header
1268 // made concrete for the third time.
1269 Intent::PurgeFailed(at) => {
1270 let name = at.and_then(|at| {
1271 state
1272 .import_wf
1273 .analysis_errors
1274 .get(at)
1275 .map(|failure| failure.name.clone())
1276 });
1277 let count = match at {
1278 Some(_) => 1,
1279 None => state.import_wf.analysis_errors.len(),
1280 };
1281 state.overlay.pending_confirm =
1282 Some(crate::state::ConfirmAction::RemoveFailedSamples {
1283 single_index: at,
1284 count,
1285 name,
1286 });
1287 state.execute_confirmed_action();
1288 }
1289 Intent::StopSweep => state.cancel_cleanup(),
1290 // The strip. Cleared here rather than on the worker's terminal
1291 // event, which is the shipped button's own note: the click has to
1292 // feel like it did something, and the Complete event that follows
1293 // would clear this anyway.
1294 Intent::PauseMigration => {
1295 if let Err(error) = state.backend.cancel_layout_migration() {
1296 tracing::warn!("failed to cancel layout migration: {error}");
1297 }
1298 state.layout_migration = None;
1299 }
1300 // The forge. Every one of these writes what the described control
1301 // submitted into the knob the shipped window keeps for it and then
1302 // calls what the shipped button calls, which is `edit`'s
1303 // arrangement: that the knobs still exist is the shipped window's
1304 // business while both are open.
1305 //
1306 // Changing any chop parameter clears the marks, which is what
1307 // re-arms the preview gate. Done here and not in the route for the
1308 // reason every write is here: it is `&mut`.
1309 Intent::SliceBy(how) => {
1310 state.forge.chop_mode = match how {
1311 super::Chop::Transient => crate::state::ChopMode::Transient,
1312 super::Chop::Equal => crate::state::ChopMode::Equal,
1313 super::Chop::Bpm => crate::state::ChopMode::Bpm,
1314 };
1315 state.forge.slice_marks.clear();
1316 }
1317 Intent::Turn(knob, value) => turn(state, knob, &value),
1318 Intent::PreviewSlices => state.forge_preview_slices(),
1319 Intent::Chop => state.forge_apply_chop(),
1320 Intent::ChooseDevice(name) => {
1321 state.forge.conform_device = (!name.is_empty()).then_some(name);
1322 }
1323 Intent::Conform => {
1324 if let Some(device) = state.forge.conform_device.clone() {
1325 state.forge_conform_device(&device);
1326 }
1327 }
1328 // The filter panel. Every one of these lands in
1329 // `state.search.search_filter`, which is the app's own UI state, and
1330 // `apply_search` is what turns a changed filter into a new result
1331 // set -- the shipped panel's own `if changed` at the end of the
1332 // draw, said once here instead of per control.
1333 Intent::Narrow(key, lower, upper) => {
1334 let f = &mut state.search.search_filter;
1335 let ends: [(&str, &mut Option<f64>, &mut Option<f64>); 6] = [
1336 ("bpm", &mut f.bpm_min, &mut f.bpm_max),
1337 ("duration", &mut f.duration_min, &mut f.duration_max),
1338 ("loudness", &mut f.peak_db_min, &mut f.peak_db_max),
1339 ("brightness", &mut f.centroid_min, &mut f.centroid_max),
1340 ("noisiness", &mut f.flatness_min, &mut f.flatness_max),
1341 ("attack", &mut f.attack_min, &mut f.attack_max),
1342 ];
1343 for (named, low, high) in ends {
1344 if named == key {
1345 *low = lower;
1346 *high = upper;
1347 break;
1348 }
1349 }
1350 state.apply_search();
1351 }
1352 Intent::KeyMode(compatible) => {
1353 use audiofiles_core::search::KeyFilterMode;
1354 state.search.search_filter.key_mode = if compatible {
1355 KeyFilterMode::Compatible
1356 } else {
1357 KeyFilterMode::Exact
1358 };
1359 state.apply_search();
1360 }
1361 Intent::ToggleKey(key) => {
1362 let keys = &mut state.search.search_filter.keys;
1363 if let Some(at) = keys.iter().position(|held| *held == key) {
1364 keys.remove(at);
1365 } else {
1366 keys.push(key);
1367 }
1368 state.apply_search();
1369 }
1370 Intent::ClearKeys => {
1371 state.search.search_filter.keys.clear();
1372 state.apply_search();
1373 }
1374 // The one filter intent that does not re-run the search: what is in
1375 // the tag box is not a filter until it is added.
1376 Intent::TypingTag(text) => state.search.filter_tag_input = text,
1377 Intent::RequireTag(tag) => {
1378 state.search.search_filter.required_tags.push(tag);
1379 state.apply_search();
1380 }
1381 Intent::UnrequireTag(tag) => {
1382 state
1383 .search
1384 .search_filter
1385 .required_tags
1386 .retain(|held| *held != tag);
1387 state.apply_search();
1388 }
1389 Intent::ClearTags => {
1390 state.search.search_filter.required_tags.clear();
1391 state.apply_search();
1392 }
1393 Intent::ClearFilters => {
1394 state.search.search_filter.clear();
1395 state.search.search_query.clear();
1396 state.apply_search();
1397 }
1398 Intent::TrimSilence => {
1399 let threshold = state.forge.trim_threshold_db;
1400 state.batch_trim_silence(threshold);
1401 }
1402 // The tag queue. Opening a tag also resolves the names of the rows
1403 // that will be drawn for it, which is a backend call per row and so
1404 // is the host's: the shipped screen does it from inside the drawing
1405 // and a route holding `&S` could not.
1406 Intent::ReadGroup(at) => {
1407 state.set_review_selected(at);
1408 state.ensure_review_names(at, crate::quasi::queue::RENDER_ROWS);
1409 }
1410 Intent::TickCandidate(at) => {
1411 let selected = state.review_selected();
1412 if let Some(candidate) = state
1413 .classifier
1414 .review
1415 .as_mut()
1416 .and_then(|queue| queue.groups.get_mut(selected))
1417 .and_then(|group| group.candidates.get_mut(at))
1418 {
1419 candidate.accepted = !candidate.accepted;
1420 }
1421 }
1422 // Only the drawn rows, which is the shipped button's own bound: see
1423 // `queue`'s note on why "Accept checked" must not quietly become
1424 // "accept everything".
1425 Intent::TickShown(ticked) => {
1426 let selected = state.review_selected();
1427 if let Some(group) = state
1428 .classifier
1429 .review
1430 .as_mut()
1431 .and_then(|queue| queue.groups.get_mut(selected))
1432 {
1433 for candidate in group
1434 .candidates
1435 .iter_mut()
1436 .take(crate::quasi::queue::RENDER_ROWS)
1437 {
1438 candidate.accepted = ticked;
1439 }
1440 }
1441 }
1442 Intent::AcceptGroup(scope) => {
1443 let selected = state.review_selected();
1444 state.accept_review(
1445 selected,
1446 match scope {
1447 super::Scope::All => crate::state::ReviewSelection::All,
1448 super::Scope::Confident => crate::state::ReviewSelection::Confident,
1449 super::Scope::Checked => crate::state::ReviewSelection::Checked,
1450 },
1451 );
1452 }
1453 Intent::AcceptConfident => state.accept_all_confident(),
1454 Intent::DismissGroup => {
1455 let selected = state.review_selected();
1456 state.dismiss_review_group(selected);
1457 }
1458 Intent::Rescan => state.classifier_review_library(),
1459 Intent::CloseReview => state.close_review_screen(),
1460 Intent::BeginExport => state.start_export_flow(None),
1461 // The host act with no described step. See `integrity`'s header:
1462 // fourth consumer of `quasi:vocabulary:host-save-location`.
1463 Intent::LocateLooseFiles => {
1464 state
1465 .dialogs
1466 .pick_folder("Locate missing sample files", |state, folder| {
1467 state.locate_missing_loose_files(&folder);
1468 });
1469 }
1470 }
1471 }
1472 }
1473
1474 /// Put a tag on the selected sample, the way the shipped panel does.
1475 ///
1476 /// Validated here rather than in the route, because validation is the app's:
1477 /// `audiofiles_core::tags::validate_tag` is what the shipped panel calls and a
1478 /// described screen that carried a second copy of the rule would be a second
1479 /// implementation of what a tag may be.
1480 fn add_tag(state: &mut BrowserState, tag: &str) {
1481 let Some(hash) = selected_hash(state) else {
1482 return;
1483 };
1484 if audiofiles_core::tags::validate_tag(tag).is_err() {
1485 state.status = format!("Invalid tag: {tag}");
1486 return;
1487 }
1488 let _ = state.backend.add_tag(&hash, tag);
1489 state.detail.tag_input.clear();
1490 state.refresh_selected_tags();
1491 }
1492
1493 /// Take a tag off the selected sample, undo entry and all.
1494 ///
1495 /// **The undo is why this is here and not in the route.** `Backend::remove_tag`
1496 /// is `&self` and a handler could call it; what it could not do is push the
1497 /// `UndoOp::TagRemove` that makes Cmd+Z put the tag back, because that is
1498 /// `&mut BrowserState`. A described screen that called the backend directly
1499 /// would remove the tag and silently lose the undo. See `Detail`'s header.
1500 fn remove_tag(state: &mut BrowserState, tag: &str) {
1501 let Some(hash) = selected_hash(state) else {
1502 return;
1503 };
1504 if state.backend.remove_tag(&hash, tag).is_ok() {
1505 state.push_undo(crate::state::UndoOp::TagRemove {
1506 hash: hash.clone(),
1507 tag: tag.to_owned(),
1508 });
1509 state.status = format!("Removed tag \"{tag}\"");
1510 state.refresh_selected_tags();
1511 }
1512 }
1513
1514 /// The hash of whatever is selected, where it is a sample.
1515 fn selected_hash(state: &BrowserState) -> Option<String> {
1516 state
1517 .selected_node()
1518 .and_then(|node| node.node.sample_hash.as_ref().map(ToString::to_string))
1519 }
1520
1521 /// The chosen samples this tag operation applies to.
1522 ///
1523 /// The filtering is the shipped panel's: applying a tag touches only the samples
1524 /// that lack it and removing one touches only those that carry it, so the counts
1525 /// the described row shows are the counts the operation acts on.
1526 fn across(
1527 state: &BrowserState,
1528 wanted: impl Fn(&audiofiles_core::vfs::VfsNodeWithAnalysis) -> bool,
1529 ) -> Vec<String> {
1530 state
1531 .selected_nodes()
1532 .into_iter()
1533 .filter(|node| node.node.sample_hash.is_some() && wanted(node))
1534 .filter_map(|node| node.node.sample_hash.as_ref().map(ToString::to_string))
1535 .collect()
1536 }
1537
1538 /// Write one described number back into the forge's own knobs.
1539 ///
1540 /// Anything unparseable is dropped rather than defaulted, which is the opposite
1541 /// of `configure`'s reading and right for the opposite reason: an export setting
1542 /// has a "keep the original" answer that a bad value can honestly fall back to,
1543 /// and a sensitivity does not. Leaving the previous number standing is what the
1544 /// described control then reads back on the next frame.
1545 ///
1546 /// Every chop parameter clears the slice marks, which re-arms the preview gate.
1547 fn turn(state: &mut BrowserState, knob: super::Knob, value: &str) {
1548 match knob {
1549 super::Knob::Sensitivity => {
1550 if let Ok(sensitivity) = value.parse::<f32>() {
1551 state.forge.sensitivity = sensitivity.clamp(0.0, 1.0);
1552 state.forge.slice_marks.clear();
1553 }
1554 }
1555 super::Knob::Divisions => {
1556 if let Ok(divisions) = value.parse::<usize>() {
1557 state.forge.divisions = divisions;
1558 state.forge.slice_marks.clear();
1559 }
1560 }
1561 super::Knob::Bpm => {
1562 if let Ok(bpm) = value.parse::<f64>() {
1563 state.forge.bpm = bpm.clamp(20.0, 300.0);
1564 state.forge.slice_marks.clear();
1565 }
1566 }
1567 super::Knob::Subdivisions => {
1568 if let Ok(subdivisions) = value.parse::<u32>() {
1569 state.forge.subdivisions = subdivisions;
1570 state.forge.slice_marks.clear();
1571 }
1572 }
1573 // Not a chop parameter, so it leaves the marks alone: the batch section
1574 // is about the selection rather than about this sample.
1575 super::Knob::Threshold => {
1576 if let Ok(threshold) = value.parse::<f64>() {
1577 state.forge.trim_threshold_db = threshold.clamp(-96.0, -20.0);
1578 }
1579 }
1580 }
1581 }
1582
1583 /// Write one described answer back into the import being configured.
1584 ///
1585 /// **The strategy is re-derived from all three answers on every write**, never
1586 /// patched in place, and that is `ui::import_screens::configure`'s own
1587 /// arrangement rather than a choice made here. The comment it carries names the
1588 /// bug it fixed: the strategy is a function of the three answers, so a site that
1589 /// changes one of them and forgets to rebuild it leaves a strategy that
1590 /// disagrees with the controls, and the vault-name edit was the only site that
1591 /// remembered.
1592 ///
1593 /// A strategy the app cannot form yet — flat with no vault open, merge with
1594 /// nothing to merge into — leaves the previous one standing, which is what the
1595 /// described radio then reads back from. That too is the shipped screen's.
1596 fn decide(state: &mut BrowserState, decision: super::Decision, value: &str) {
1597 use crate::import::ImportStrategy;
1598
1599 let current_vfs_id = state.current_vfs_id();
1600 let current_dir = state.nav.current_dir;
1601 let crate::state::ImportMode::ConfigureImport {
1602 strategy,
1603 new_vfs_name,
1604 available_vfs,
1605 selected_merge_vfs_idx,
1606 ..
1607 } = &mut state.import_wf.import_mode
1608 else {
1609 // The flow moved on between the frame that drew the control and the one
1610 // that applies it. Dropping the write is right for `configure`'s reason:
1611 // there is no longer a configuration for it to land in.
1612 return;
1613 };
1614
1615 let mut chosen = match strategy {
1616 ImportStrategy::Flat { .. } => super::Strategy::Flat,
1617 ImportStrategy::NewVfs { .. } => super::Strategy::NewVault,
1618 ImportStrategy::MergeIntoVfs { .. } => super::Strategy::Merge,
1619 };
1620
1621 match decision {
1622 // The route already refused anything else, so an unreadable strategy
1623 // here is one the app grew and the description has not learned.
1624 super::Decision::Strategy => chosen = super::Strategy::from_key(value).unwrap_or(chosen),
1625 super::Decision::VaultName => {
1626 new_vfs_name.clear();
1627 new_vfs_name.push_str(value);
1628 }
1629 super::Decision::MergeVault => {
1630 if let Ok(at) = value.parse::<usize>() {
1631 *selected_merge_vfs_idx = at;
1632 }
1633 }
1634 }
1635
1636 let next = match chosen {
1637 super::Strategy::Flat => current_vfs_id.map(|vfs_id| ImportStrategy::Flat {
1638 vfs_id,
1639 parent_id: current_dir,
1640 }),
1641 super::Strategy::NewVault => Some(ImportStrategy::NewVfs {
1642 vfs_name: new_vfs_name.clone(),
1643 }),
1644 super::Strategy::Merge => {
1645 available_vfs
1646 .get(*selected_merge_vfs_idx)
1647 .map(|vfs| ImportStrategy::MergeIntoVfs {
1648 vfs_id: vfs.id,
1649 parent_id: None,
1650 })
1651 }
1652 };
1653 if let Some(next) = next {
1654 *strategy = next;
1655 }
1656 }
1657
1658 /// Start the import that is configured, the way the shipped button does.
1659 ///
1660 /// The strategy is rebuilt from the three answers one last time rather than
1661 /// taken as it stands, which is again the shipped button's own code: the vault
1662 /// name and the merge index are what the user typed and picked, and the
1663 /// strategy is only ever their derivative.
1664 fn begin_import(state: &mut BrowserState) {
1665 use crate::import::ImportStrategy;
1666
1667 let crate::state::ImportMode::ConfigureImport {
1668 source,
1669 strategy,
1670 new_vfs_name,
1671 available_vfs,
1672 selected_merge_vfs_idx,
1673 ..
1674 } = &state.import_wf.import_mode
1675 else {
1676 return;
1677 };
1678
1679 let source = source.clone();
1680 let strategy = match strategy {
1681 ImportStrategy::Flat { vfs_id, parent_id } => Some(ImportStrategy::Flat {
1682 vfs_id: *vfs_id,
1683 parent_id: *parent_id,
1684 }),
1685 ImportStrategy::NewVfs { .. } => Some(ImportStrategy::NewVfs {
1686 vfs_name: new_vfs_name.clone(),
1687 }),
1688 ImportStrategy::MergeIntoVfs { .. } => {
1689 available_vfs
1690 .get(*selected_merge_vfs_idx)
1691 .map(|vfs| ImportStrategy::MergeIntoVfs {
1692 vfs_id: vfs.id,
1693 parent_id: None,
1694 })
1695 }
1696 };
1697
1698 if let Some(strategy) = strategy {
1699 state.start_folder_import(source, strategy);
1700 }
1701 }
1702
1703 /// Write one described setting back into the app's own export config.
1704 ///
1705 /// The described value is a string because that is what a control submits, and
1706 /// this is where it stops being one. Anything unparseable falls back to the
1707 /// setting's "keep the original" reading rather than being ignored: a rate the
1708 /// description does not know is not a reason to keep the old one, which would be
1709 /// a control that silently does nothing.
1710 ///
1711 /// Choosing a device profile clears the three fields the profile owns, which is
1712 /// what `ui::export_screens` does at the same control and for the same reason:
1713 /// they are derived from the profile, so a stale set of them would outlive the
1714 /// profile that produced it.
1715 fn configure(state: &mut BrowserState, setting: Setting, value: &str) {
1716 use audiofiles_core::export::{ExportChannels, ExportFormat};
1717
1718 let crate::state::ImportMode::ConfigureExport { config, .. } = &mut state.import_wf.import_mode
1719 else {
1720 // The flow moved on between the frame that drew the control and the one
1721 // that applies it. Dropping the write is right: there is no longer a
1722 // configuration for it to land in.
1723 return;
1724 };
1725
1726 match setting {
1727 Setting::Format => {
1728 config.format = match value {
1729 "wav" => ExportFormat::Wav,
1730 "aiff" => ExportFormat::Aiff,
1731 _ => ExportFormat::Original,
1732 };
1733 }
1734 Setting::SampleRate => config.sample_rate = value.parse().ok(),
1735 Setting::BitDepth => config.bit_depth = value.parse().ok(),
1736 Setting::Channels => {
1737 config.channels = match value {
1738 "mono" => ExportChannels::Mono,
1739 "stereo" => ExportChannels::Stereo,
1740 _ => ExportChannels::Original,
1741 };
1742 }
1743 Setting::Flatten => config.flatten = !value.is_empty(),
1744 Setting::Sidecar => config.metadata_sidecar = !value.is_empty(),
1745 Setting::NamingPattern => {
1746 config.naming_pattern = (!value.is_empty()).then(|| value.to_owned());
1747 }
1748 Setting::DeviceProfile => {
1749 config.device_profile = (!value.is_empty()).then(|| value.to_owned());
1750 config.naming_rules = None;
1751 config.max_file_size_bytes = None;
1752 config.name_overrides = None;
1753 }
1754 }
1755 }
1756
1757 /// Where a sample sits in what is on screen.
1758 ///
1759 /// The described screen addresses a row by its own id and the app selects by
1760 /// index, so one of them has to translate. Here rather than in the description:
1761 /// an index is a fact about the current filter and sort, which is exactly the
1762 /// kind of thing an address should not be.
1763 fn index_of(state: &BrowserState, id: i64) -> Option<usize> {
1764 state
1765 .nav
1766 .contents
1767 .iter()
1768 .position(|node| node.node.id.as_i64() == id)
1769 }
1770
1771 /// Everything a described screen is answered out of.
1772 ///
1773 /// The four travel together through every function below, so they are one thing
1774 /// rather than four parameters repeated three times. What they have in common is
1775 /// the reason: each is a handle the *host* holds and the description does not —
1776 /// the app's state, its sync manager, the themes it resolved at startup, and the
1777 /// place a route leaves what it could not do itself.
1778 struct Host<'a> {
1779 state: &'a BrowserState,
1780 sync: Option<&'a SyncManager>,
1781 themes: Vec<ThemeChoice>,
1782 intents: &'a RefCell<Vec<Intent>>,
1783 }
1784
1785 /// Drive one described screen into a `Ui`: draw it, and act on what was pressed.
1786 ///
1787 /// The whole of a described screen's frame, with no opinion about where it is.
1788 /// [`window`] puts a window round it and [`inline`] does not, which is the only
1789 /// difference between a described modal and a described full-screen mode: the
1790 /// app's own arrangement, not the screen's.
1791 fn drive(
1792 ui: &mut egui::Ui,
1793 runtime: &mut Option<Runtime>,
1794 host: &Host<'_>,
1795 home: &str,
1796 refresh: bool,
1797 ) {
1798 let immediate = Immediate::new(theme::palette());
1799
1800 // The first frame has no screen yet, so it asks for one. Everything
1801 // after it is the loop below.
1802 let runtime = match runtime {
1803 Some(runtime) => runtime,
1804 none => match answer(host, Request::get(home)) {
1805 Ok(response) => match response.outcome {
1806 // The app's keys, bound to the one table `help::chrome`
1807 // holds and the help overlay lists. Every described
1808 // window gets them, which is what "works from every
1809 // screen" means for an app that has several.
1810 // `Over` as well as `Screen`, because a screen that is
1811 // an overlay everywhere else is the whole of this window
1812 // when the window is what the app opened for it. "Over"
1813 // says what a screen is drawn on top of, and a modal
1814 // given a window of its own is drawn on top of the app.
1815 //
1816 // Missing until 2026-08-22, and it did not matter while
1817 // every described window was a `Screen`: the first flip
1818 // pointed a window at `/library/loose-files`, which is
1819 // an `Over`, and the window drew the outcome's `Debug`
1820 // rendering instead of the screen.
1821 quasi_router::Outcome::Screen(screen) | quasi_router::Outcome::Over(screen) => {
1822 none.insert(Runtime::new(screen).with_chrome(super::help::chrome()))
1823 }
1824 other => {
1825 ui.label(format!("the home address answered {other:?}"));
1826 return;
1827 }
1828 },
1829 Err(message) => {
1830 ui.label(message);
1831 return;
1832 }
1833 },
1834 };
1835
1836 // Before the drawing, so the frame draws what is true now rather
1837 // than showing the previous answer for one more frame. `reload`
1838 // re-asks the address the screen came from, and the runtime keeps
1839 // what the user has typed and ticked across it.
1840 //
1841 // **Never while an overlay is open.** `reload` re-asks the address
1842 // the screen came from, and an overlay is not a place, so that
1843 // address is the screen *underneath* -- which answers
1844 // `Outcome::Screen`, which clears the layer stack. An unconditional
1845 // refresh would take the modal down on the frame after it opened.
1846 // See `bulk`'s header, finding 2.
1847 if refresh && !runtime.overlaid() {
1848 let step = runtime.reload();
1849 perform(runtime, ui, host, step);
1850 }
1851
1852 // A screen that says it is live is re-asked on the renderer's own
1853 // cadence, which is `quasi_immediate::CADENCE` and is paced by the
1854 // runtime rather than by anything here. This is the whole of what
1855 // the sync panel's finding asked for: its state moves when an OAuth
1856 // callback lands in another process, and until now nothing but an
1857 // intent or a per-frame reload would notice.
1858 //
1859 // Under the same overlay guard as the refresh above, and for the
1860 // same reason: a live screen's address is the screen underneath an
1861 // open modal, and asking for it would take the modal down.
1862 if !runtime.overlaid() {
1863 for request in runtime.refreshes() {
1864 call(runtime, host, request);
1865 }
1866 }
1867
1868 let step = runtime.show(ui, &immediate);
1869 perform(runtime, ui, host, step);
1870
1871 // One drain for every described window, rather than one per
1872 // `draw_*`: a file is produced by a route and a route is reachable
1873 // from all of them, so the host answer belongs where the runtime is
1874 // driven. Last in the frame because `perform` above is what may have
1875 // just produced one.
1876 hand_over(runtime, host.state);
1877 }
1878
1879 /// One described window: draw it, act on it, and say whether it was closed.
1880 fn window(
1881 ctx: &egui::Context,
1882 title: &str,
1883 runtime: &mut Option<Runtime>,
1884 host: &Host<'_>,
1885 home: &str,
1886 refresh: bool,
1887 ) -> bool {
1888 let mut open = true;
1889
1890 egui::Window::new(title)
1891 .open(&mut open)
1892 .default_width(420.0)
1893 .show(ctx, |ui| drive(ui, runtime, host, home, refresh));
1894
1895 !open
1896 }
1897
1898 /// One described screen, filling whatever it is given.
1899 ///
1900 /// The full-screen modes take this rather than [`window`]: the review queue, the
1901 /// import flow and the export flow are drawn into the app's own pane and have no
1902 /// frame of their own to close.
1903 fn inline(
1904 ui: &mut egui::Ui,
1905 runtime: &mut Option<Runtime>,
1906 host: &Host<'_>,
1907 home: &str,
1908 refresh: bool,
1909 ) {
1910 drive(ui, runtime, host, home, refresh);
1911 }
1912
1913 /// Do what the runtime asked for.
1914 fn perform(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, step: Step) {
1915 match step {
1916 Step::Idle => {}
1917 Step::Call(request) => call(runtime, host, request),
1918 // A described control asked before acting. Drawn where it is asked
1919 // rather than in a second window, since it is about the control.
1920 Step::Ask(question) => {
1921 ui.label(&question);
1922 ui.horizontal(|ui| {
1923 if ui.button("Yes").clicked() {
1924 let next = runtime.answer(true);
1925 perform(runtime, ui, host, next);
1926 }
1927 if ui.button("No").clicked() {
1928 runtime.answer(false);
1929 }
1930 });
1931 }
1932 // Somewhere outside the app, which is a one-way handoff.
1933 Step::Open(address) => open_externally(&address),
1934 // A mount of its own, which here would be a second `Runtime` in a
1935 // second `egui::Window`. Nothing this app describes asks for one yet,
1936 // so rather than hold a runtime nothing fills, the call is made where
1937 // it stands -- the documented answer for a host with nowhere to put a
1938 // second mount, and the same thing a terminal does with the mark.
1939 //
1940 // Not an empty arm. That is the `by_host` failure one line up in
1941 // quasi-tui: a control drawn, reachable, and doing nothing when
1942 // pressed. When a screen here wants a second window, `window` above is
1943 // most of it.
1944 Step::Mount(request) => call(runtime, host, request),
1945 }
1946 }
1947
1948 /// Ask the router and put the whole answer on the screen.
1949 ///
1950 /// **The whole answer, which it was not until 2026-08-16.** This used to
1951 /// flatten the response to its `Screen` and rebuild a fresh `Response` around
1952 /// it, which silently dropped two things every route can say: the `notice`, so
1953 /// every `toast` in `settings`, `sync` and `detail` was written and never shown,
1954 /// and any outcome that is not a screen, so `Outcome::Over` could not have
1955 /// worked at all. `Runtime::apply` takes a `Response` because a response is
1956 /// what it is for.
1957 ///
1958 /// The loop is `Goto`: the runtime answers a redirect with the request to make
1959 /// next rather than making it, since asking is the host's. Bounded, because a
1960 /// route that redirects to itself is a bug and a loop here would be a hang
1961 /// inside a frame.
1962 fn call(runtime: &mut Runtime, host: &Host<'_>, request: Request) {
1963 let mut request = request;
1964 for _ in 0..REDIRECTS {
1965 let response = match answer(host, request.clone()) {
1966 Ok(response) => response,
1967 Err(message) => {
1968 runtime.say(message);
1969 return;
1970 }
1971 };
1972 // Somewhere outside the app is the host's to perform, and nothing comes
1973 // back from it. The runtime would answer `None` here and the handoff
1974 // would never happen.
1975 if let quasi_router::Outcome::Goto(action) = &response.outcome
1976 && action.destination.route().is_none()
1977 {
1978 open_externally(action.destination.as_str());
1979 return;
1980 }
1981 match runtime.apply(&request, response) {
1982 Some(next) => request = next,
1983 None => return,
1984 }
1985 }
1986 runtime.say("that address kept redirecting");
1987 }
1988
1989 /// How many `Goto`s one press may chain before the host calls it a loop.
1990 const REDIRECTS: usize = 8;
1991
1992 /// Put a file a route answered with wherever the user says.
1993 ///
1994 /// The host half of `Outcome::File`, which quasi ruled and shipped in 0.50.0
1995 /// (`67881a88`, Max: the route answers with the file and the host puts it
1996 /// somewhere) and which nothing on this side had ever drained. A member with no
1997 /// consumer reads as a member that does not work, and a described control that
1998 /// produces a file was unbuildable here until this existed.
1999 ///
2000 /// **The description never names a path**, which is the whole point of the
2001 /// ruling: `name` is a suggestion and `kind` is what sort of file it is. What
2002 /// audiofiles does about that is open a save dialog, because it has one --
2003 /// `ui::dialog`, the subsystem that draws nothing and asks the operating system
2004 /// a question. A terminal would write to the working directory and a browser
2005 /// would download; one description, three hosts, three answers.
2006 ///
2007 /// Drained after `apply` rather than inside it: `Runtime::handed` is a one-slot
2008 /// mailbox, so a second file replaces the first, and the frame that produced one
2009 /// is the frame that should hand it over.
2010 fn hand_over(runtime: &mut Runtime, state: &BrowserState) {
2011 let Some(handed) = runtime.handed() else {
2012 return;
2013 };
2014 let quasi_immediate::Handed { name, kind, bytes } = handed;
2015 // The dialog wants a filter, and `Accepted` is the same type the upload half
2016 // uses rather than a second way to name a file kind. Only a suffix is a
2017 // filter a native dialog can take; a family or a media type is a fact about
2018 // the file rather than a list of extensions, so those offer no filter and
2019 // the user picks freely.
2020 let suffix = match &kind {
2021 quasi_router::Accepted::Suffix(suffix) => Some(suffix.trim_start_matches('.').to_owned()),
2022 // `Accepted` is `#[non_exhaustive]`, so a kind added later lands here
2023 // and the user picks freely rather than the build breaking.
2024 _ => None,
2025 };
2026 let filters: Vec<(String, Vec<String>)> = suffix
2027 .into_iter()
2028 .map(|suffix| (suffix.to_uppercase(), vec![suffix]))
2029 .collect();
2030 let borrowed: Vec<(&str, Vec<&str>)> = filters
2031 .iter()
2032 .map(|(label, suffixes)| {
2033 (
2034 label.as_str(),
2035 suffixes.iter().map(String::as_str).collect::<Vec<_>>(),
2036 )
2037 })
2038 .collect();
2039 let borrowed: Vec<(&str, &[&str])> = borrowed
2040 .iter()
2041 .map(|(label, suffixes)| (*label, suffixes.as_slice()))
2042 .collect();
2043
2044 state.dialogs.save_file(
2045 "Save",
2046 name,
2047 &borrowed,
2048 move |s, path| match std::fs::write(&path, &bytes) {
2049 Ok(()) => s.status = format!("Saved to {}", path.display()),
2050 Err(error) => {
2051 tracing::error!("failed to write {}: {error}", path.display());
2052 s.status = format!("Could not save: {error}");
2053 }
2054 },
2055 );
2056 }
2057
2058 /// Hand an address to the desktop.
2059 ///
2060 /// The one piece of platform knowledge in the port, and it is the host's by
2061 /// definition. **This is what the description deletes from the shipped panel**:
2062 /// `ui::sync_panel::draw_disconnected` carries the same three branches inside a
2063 /// drawing function, chosen by `#[cfg(target_os)]`, because that is where the
2064 /// auth URL happened to be. Here the description says "external" and the host
2065 /// answers once, for every control that ever goes outside.
2066 fn open_externally(address: &str) {
2067 #[cfg(target_os = "macos")]
2068 let (program, leading) = ("open", Vec::<&str>::new());
2069 #[cfg(target_os = "linux")]
2070 let (program, leading) = ("xdg-open", Vec::<&str>::new());
2071 #[cfg(target_os = "windows")]
2072 let (program, leading) = ("cmd", vec!["/c", "start"]);
2073
2074 let _ = std::process::Command::new(program)
2075 .args(leading)
2076 .arg(address)
2077 .spawn();
2078 }
2079
2080 /// Ask the router, and flatten a refusal into something a user can read.
2081 fn answer(host: &Host<'_>, request: Request) -> Result<Response, String> {
2082 let Host {
2083 state,
2084 sync,
2085 themes,
2086 intents,
2087 } = host;
2088 let config = FromBackend(&*state.backend);
2089 let manager = sync.map(|manager| FromSyncManager {
2090 manager,
2091 backend: &*state.backend,
2092 });
2093 let unconfigured = Unconfigured;
2094 let sync: &dyn Sync = match &manager {
2095 Some(manager) => manager,
2096 None => &unconfigured,
2097 };
2098
2099 let files = FromContents { state, intents };
2100 let export = FromExport { state, intents };
2101 let detail = FromSelection { state, intents };
2102 let bulk = FromBulk { state, intents };
2103 let shell = FromWindow { state, intents };
2104 let library = FromLibrary { state, intents };
2105 let bar = FromBar { state, intents };
2106 let naming = FromNaming { state, intents };
2107 let importing = FromImport { state, intents };
2108 let integrity = FromIntegrity { state, intents };
2109 let editor = FromEditor { state, intents };
2110 let forge = FromForge { state, intents };
2111 let queue = FromQueue { state, intents };
2112 let filters = FromFilters { state, intents };
2113 let panels = Panels {
2114 config: &config,
2115 sync,
2116 files: &files,
2117 export: &export,
2118 detail: &detail,
2119 bulk: &bulk,
2120 shell: &shell,
2121 library: &library,
2122 bar: &bar,
2123 naming: &naming,
2124 importing: &importing,
2125 integrity: &integrity,
2126 editor: &editor,
2127 forge: &forge,
2128 queue: &queue,
2129 filters: &filters,
2130 themes,
2131 };
2132 super::router()
2133 .handle(&panels, request)
2134 .map_err(|error| error.message.clone())
2135 }
2136
2137 /// The themes the host has resolved, as the description names them.
2138 fn themes() -> Vec<ThemeChoice> {
2139 theme::list_themes()
2140 .into_iter()
2141 .map(|meta| ThemeChoice {
2142 source: theme::export_theme_content(&meta.id),
2143 id: meta.id,
2144 name: meta.name,
2145 variant: meta.variant,
2146 })
2147 .collect()
2148 }
2149
2150 /// The screen the app's own adapters answer at `address`, for the parity tests.
2151 ///
2152 /// The parity harness compares a described screen against the shipped panel it
2153 /// replaces, and both have to read one fixture or the comparison proves
2154 /// nothing. This is that seam: the same `answer` the window loop calls, with
2155 /// the same `Host`, against a real [`BrowserState`]. A test that built its own
2156 /// `Panels` out of fakes would be comparing the shipped panel against a fixture
2157 /// rather than against the description the app actually serves.
2158 ///
2159 /// Intents are collected and dropped. A parity read presses nothing.
2160 #[cfg(test)]
2161 pub(super) fn described_screen(state: &BrowserState, address: &str) -> quasi_router::Screen {
2162 use quasi_router::Outcome;
2163
2164 let intents = RefCell::new(Vec::new());
2165 let host = Host {
2166 state,
2167 sync: None,
2168 themes: themes(),
2169 intents: &intents,
2170 };
2171 match answer(&host, Request::get(address)) {
2172 Ok(response) => match response.outcome {
2173 Outcome::Screen(screen) | Outcome::Over(screen) => screen,
2174 other => panic!("{address} answered with {other:?} rather than a screen"),
2175 },
2176 Err(message) => panic!("{address} was refused: {message}"),
2177 }
2178 }
2179
2180 #[cfg(test)]
2181 mod tests {
2182 use super::*;
2183
2184 /// A real app with one sample chosen, which is what a tag act needs.
2185 fn chosen() -> (BrowserState, tempfile::TempDir) {
2186 use std::sync::Arc;
2187
2188 let dir = tempfile::TempDir::new().unwrap();
2189 let shared = Arc::new(crate::state::SharedState::new());
2190 let mut state = BrowserState::new(dir.path(), shared, 44_100.0, "Vault").unwrap();
2191
2192 let vfs = state.current_vfs_id().unwrap();
2193 let parent = state.nav.current_dir;
2194 let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap();
2195 db.conn()
2196 .execute(
2197 "INSERT OR IGNORE INTO samples \
2198 (hash, original_name, file_extension, file_size, import_date, last_modified) \
2199 VALUES ('aaa111', 'aaa111.wav', 'wav', 100, 0, 0)",
2200 [],
2201 )
2202 .unwrap();
2203 state
2204 .backend
2205 .create_sample_link(vfs, parent, "kick.wav", "aaa111")
2206 .unwrap();
2207 state.refresh_contents();
2208 state.nav.selection.set_single(0);
2209 (state, dir)
2210 }
2211
2212 #[test]
2213 fn removing_a_tag_from_the_described_panel_is_undoable() {
2214 // `162b99a3` states the bar this has to clear: "a flip that loses Cmd+Z
2215 // has failed." It is the reason the detail panel's tag writes are
2216 // intents rather than handle calls -- a route can remove the tag and
2217 // cannot push the undo entry that makes it recoverable, so what the app
2218 // does *around* a write is what decides where the write goes.
2219 let (mut state, _dir) = chosen();
2220 add_tag(&mut state, "drums");
2221 assert_eq!(state.detail.selected_tags.len(), 1);
2222
2223 remove_tag(&mut state, "drums");
2224 assert!(state.detail.selected_tags.is_empty(), "the tag is gone");
2225
2226 assert!(state.can_undo(), "and taking it off is undoable");
2227 state.undo();
2228 assert_eq!(
2229 state.detail.selected_tags.len(),
2230 1,
2231 "undo puts it back: {:?}",
2232 state.detail.selected_tags
2233 );
2234 }
2235 }
2236