//! The described screens, in windows beside the shipped ones. //! //! Beside rather than instead of, which is the whole arrangement: with the //! feature on, opening Settings opens two windows and opening Cloud Sync opens //! two, so each port can be compared by looking at it. A port that replaced a //! working panel on the way in would have to be right first time. //! //! One described window per shipped window, each holding its own [`Runtime`], //! which mirrors the app rather than inventing navigation the shipped app does //! not have. A described screen *can* navigate — that is what [`Step::Call`] is //! — but a link from Settings to Sync would be a screen this port made up. //! //! # What this module is, and what it deliberately is not //! //! It is the *host* half, and it is small on purpose: plumbing against described //! screens that know nothing about egui. Everything it does is one of four //! things, and none of them is drawing: //! //! 1. resolve the host facts the screens need (themes, the palette) and adapt //! the app's own handles to the narrow traits the screens borrow, //! 2. hold each [`Runtime`] across frames, because a frame does not outlive itself, //! 3. hand a [`Step`] to the router and the answer back to the runtime, //! 4. put a route failure somewhere the user can see it. //! //! There is no `if let Node::...` anywhere here, and there should never be one. //! The moment this file starts deciding what a node looks like, the drawing has //! left `quasi-immediate` and the port has become a second renderer. use audiofiles_sync::SyncManager; use quasi_immediate::{Immediate, Runtime, Step}; use quasi_router::{Request, Response}; use std::cell::RefCell; use super::{ FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromSyncManager, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured, }; use crate::state::BrowserState; use crate::ui::theme; /// The described screens' own state, held across frames. /// /// One runtime per window. What the user typed and ticked lives inside it, which /// is the half egui does not hold for a described screen: the fields are rebuilt /// from the description every frame, so their buffers have to outlive one. #[derive(Debug, Default)] pub struct Described { settings: Option, sync: Option, files: Option, export: Option, detail: Option, shell: Option, edit: Option, forge: Option, import: Option, queue: Option, sweep: Option, filters: Option, integrity: Option, naming: Option, bulk: Option, preflight: Option, /// Whether the described main window is open. pub show_shell: bool, /// Whether the described file list is open. /// /// Its own flag rather than the shipped list's, because the shipped list is /// always showing: it is the app's main pane and not a window. So this is /// the one described screen with no toggle to share, and it gets its own. pub show_files: bool, /// Whether a described screen's subject moved while it was showing. /// /// **Set by the frame that changed something and read by the next one.** An /// intent is applied after the drawing, so the screen the router answered /// during the drawing was built from state the intent had not reached. Every /// port before the export flow lived with that — `files.rs`'s sort caret was /// one interaction stale and nobody noticed, because a caret that is wrong /// until the next click reads as a rendering quirk. /// /// The export flow made it unsurvivable in two ways at once: every control /// on the configure screen writes through an intent, and the progress screen /// moves with no intent at all. So the answer is `quasi` 0.12.0's /// `Runtime::reload`, and this is the flag that says when to call it. stale: bool, } /// Draw the described settings window, and act on whatever was pressed. pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.settings.take(); let stale = state.described.stale; let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window( ctx, "Settings (described)", &mut runtime, &host, "/settings", stale, ); state.described.settings = runtime; apply(ctx, state, None, intents.into_inner()); if closed { state.settings.show_manager = false; state.described.settings = None; } } /// Draw the sync window, and act on whatever was pressed. /// /// `sync` is `None` when the app has no manager, and it becomes /// [`Unconfigured`], which says syncing is unavailable and offers nothing. The /// shipped panel had a second window for that case. pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) { // Two pieces of housekeeping that came off `ui::sync_panel::draw_sync_panel` // when it was deleted. Neither is describable and neither is a control: they // are caches the window owns, dropped when what they were about is over. // // The auth URL is only meaningful while the Copy URL fallback is on screen, // and keeping it would show a stale PKCE state if the panel were reopened. if let Some(manager) = sync && !matches!( manager.status().state, audiofiles_sync::SyncState::Authenticating ) && state.sync.auth_url.is_some() { state.sync.auth_url = None; } // The per-vault storage numbers go when the panel closes, so reopening // fetches fresh ones: the user may have imported or deleted since. if !state.sync.show_panel { state.sync.vfs_storage_fetched = false; state.sync.vfs_storage_cache.clear(); state.sync.synced_bytes = None; state.sync.cap_picker_gib = None; } let intents = RefCell::new(Vec::new()); let mut runtime = state.described.sync.take(); let stale = state.described.stale; let host = Host { state, sync, themes: themes(), intents: &intents, }; let closed = window(ctx, "Cloud Sync", &mut runtime, &host, "/sync", stale); state.described.sync = runtime; apply(ctx, state, sync, intents.into_inner()); if closed { state.sync.show_panel = false; state.described.sync = None; } } /// Draw the described file list, and act on whatever was pressed. pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.files.take(); let stale = state.described.stale; let host = Host { state, sync, themes: themes(), intents: &intents, }; let closed = window( ctx, "Samples (described)", &mut runtime, &host, "/files", stale, ); state.described.files = runtime; apply(ctx, state, sync, intents.into_inner()); if closed { state.described.show_files = false; state.described.files = None; } } /// Draw the described export flow, and act on whatever was pressed. /// /// **Refreshed unconditionally**, where the other three refresh only after an /// intent. The progress screen's subject is a worker writing files: it moves /// with nothing the user did, so there is no event to hang a refresh on and the /// frame is the only clock the host has. The other phases pay one router call /// per frame for it, which is a table lookup and a walk over state already in /// memory — less than the shipped screen does laying out the same panel. pub fn draw_export(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.export.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window( ctx, "Export (described)", &mut runtime, &host, "/export", true, ); state.described.export = runtime; apply(ctx, state, None, intents.into_inner()); if closed { state.described.export = None; } } /// Draw the detail panel, and act on whatever was pressed. /// /// Into the app's own right pane rather than a window, because that is what the /// shipped panel was. /// /// Refreshed unconditionally: adding a tag and accepting a suggestion both write /// through an intent, so a screen that is not re-asked shows the tags from /// before the last press. pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.detail.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; inline(ui, &mut runtime, &host, "/detail", true); state.described.detail = runtime; apply(ui.ctx(), state, None, intents.into_inner()); } /// Draw the described main window, and act on whatever was pressed. /// /// **Refreshed unconditionally**, and it is the third window to need that for a /// third reason. Settings and Sync move when something described is pressed; /// the export flow moves because a worker is writing files; this one moves /// because a sample is playing. The transport's position advances at the sample /// clock with nobody touching anything, which is the case `shell`'s header calls /// the sharper consumer of the `Meter` finding. pub fn draw_shell(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.shell.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window( ctx, "audiofiles (described)", &mut runtime, &host, "/", true, ); state.described.shell = runtime; apply(ctx, state, None, intents.into_inner()); if closed { state.described.show_shell = false; state.described.shell = None; } } /// Draw the described sample editor, and act on whatever was pressed. /// /// **Refreshed unconditionally**, the fourth window to need it and the fourth /// reason: an edit runs on a worker, so the screen moves from `working` to /// `asking` with nothing pressed. Same clock the export flow reads. pub fn draw_edit(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.edit.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window(ctx, "Sample Editor", &mut runtime, &host, "/edit", true); state.described.edit = runtime; apply(ctx, state, None, intents.into_inner()); if closed { state.described.edit = None; } } /// Draw the described forge, and act on whatever was pressed. /// /// **Refreshed unconditionally**, and it is the editor's reason: a chop and a /// conform both run on a worker, so `busy` goes false with nothing pressed. The /// slice count moves the same way — a preview is work the app did, and the /// button that commits to it is gated on the result. pub fn draw_forge(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.forge.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window(ctx, "Sample Forge", &mut runtime, &host, "/forge", true); state.described.forge = runtime; apply(ctx, state, None, intents.into_inner()); if closed { state.described.forge = None; } } /// Draw the import flow, and act on whatever was pressed. /// /// One call for the whole flow. The shipped side is nine drawing functions /// chosen by a `match` on `ImportMode` in two places; the described side is one /// address whose answer depends on the stage, so the app asks once and the /// route decides what the user is looking at. /// /// Into the app's own pane rather than a window, because every stage of this was /// a full-screen mode. /// /// Refreshed unconditionally, and this is the screen that most needs it: a /// worker moves the counts with nothing pressed, and the stage changes under it. pub fn draw_import(ui: &mut egui::Ui, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.import.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; inline(ui, &mut runtime, &host, "/import", true); state.described.import = runtime; apply(ui.ctx(), state, None, intents.into_inner()); } /// Draw the tag review queue, and act on whatever was pressed. /// /// Into the app's own pane rather than a window, because that is what the /// shipped screen was: `ImportMode::ReviewLibrary` is a full-screen mode and /// the main pane is where a mode is drawn. /// /// Refreshed unconditionally: the classifier worker fills the queue and drains /// it while the screen is up, so what it shows moves with nothing pressed. pub fn draw_queue(ui: &mut egui::Ui, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.queue.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; inline(ui, &mut runtime, &host, "/review", true); state.described.queue = runtime; apply(ui.ctx(), state, None, intents.into_inner()); } /// Draw the filter panel, and act on whatever was pressed. /// /// Into the app's own left pane rather than a window, because that is what the /// shipped panel was. /// /// Refreshed unconditionally, and this one earns it more plainly than most: /// every control here writes through an intent, so a screen that is not re-asked /// shows the filter state from before the last press. pub fn draw_filters(ui: &mut egui::Ui, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.filters.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; inline(ui, &mut runtime, &host, "/filters", true); state.described.filters = runtime; apply(ui.ctx(), state, None, intents.into_inner()); } /// Draw the blob sweep, and act on whatever was pressed. /// /// Its own address rather than a stage of the import flow, because it is not /// one: see `importing`'s header. Into the app's own pane all the same, because /// `ImportMode::Cleaning` is a full-screen mode like every other stage and the /// shipped `draw_cleanup_progress` took the pane over. /// /// Refreshed unconditionally for the plainest of the reasons: a worker is /// removing rows and the count moves on its own. pub fn draw_sweep(ui: &mut egui::Ui, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.sweep.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; inline(ui, &mut runtime, &host, "/cleanup", true); state.described.sweep = runtime; apply(ui.ctx(), state, None, intents.into_inner()); } /// Draw the loose-files warning, and act on whatever was pressed. /// /// The first screen to serve rather than sit beside one, 2026-08-22. What the /// shipped overlay did that this does not is stop you: it drew as a modal over /// a dimmed app, and a described screen cannot raise itself, so the count is /// said in the status band and this is one act away from it. `integrity`'s /// header argues that difference; it is recorded rather than smoothed over. /// /// Refreshed every frame, because the worker that re-checks the vault moves the /// count with nothing pressed. pub fn draw_integrity(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.integrity.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window( ctx, "Loose-files mode warning", &mut runtime, &host, "/library/loose-files", true, ); state.described.integrity = runtime; apply(ctx, state, None, intents.into_inner()); if closed { state.described.integrity = None; state.dismiss_loose_files_warning(); } } /// Draw one of the four name modals, and act on whatever was pressed. /// /// One function for four screens, because they are one screen four times: a /// field, a submit and a cancel, differing only in what they are called and in /// what the submit does. The shipped side already knew that, and said it by /// sharing `handle_name_modal_outcome` between four functions rather than by /// sharing an address. /// /// `title` and `home` come from the caller because the app is what knows which /// of the four is showing: `vfs_modal`'s two flags and two targets still decide, /// and those are the host's own state rather than anything a route reads. /// /// Refreshed unconditionally. A refusal replaces the form region and the field /// keeps what was typed, which is `naming`'s `Fragment`, and a screen that is /// not re-asked shows the answer before it. pub fn draw_naming(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.naming.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window(ctx, title, &mut runtime, &host, home, true); state.described.naming = runtime; let finished = intents .borrow() .iter() .any(|i| matches!(i, Intent::NamingDone)); apply(ctx, state, None, intents.into_inner()); // The window's own X is the fourth way out, beside Cancel, an empty submit // and a successful one. It raises no intent, so it is answered here with // what `Intent::NamingDone` would have done. if closed || finished { state.described.naming = None; state.vfs_modal.show_vfs_create = false; state.vfs_modal.show_dir_create = false; state.vfs_modal.vfs_rename_target = None; state.vfs_modal.dir_rename_target = None; state.vfs_modal.name_modal_error = None; } } /// Draw one of the three bulk modals, and act on whatever was pressed. /// /// `draw_naming`'s shape, for the same reason: three screens differing in what /// they ask, and the app's own `bulk_modal` is what knows which is showing. /// /// Refreshed unconditionally. The rename preview answers as a fragment while /// the pattern is typed, and a screen that is not re-asked shows the previews /// from before the last keystroke. pub fn draw_bulk(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.bulk.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window(ctx, title, &mut runtime, &host, home, true); state.described.bulk = runtime; let finished = intents .borrow() .iter() .any(|intent| matches!(intent, Intent::BulkDone)); apply(ctx, state, None, intents.into_inner()); // The window's own X raises no intent, so it is answered here with what // `Intent::BulkDone` would have done. if closed || finished { state.described.bulk = None; state.close_bulk_modal(); } } /// Draw the import preflight, and act on whatever was pressed. /// /// The question asked before a large import starts, and the only part of the /// flow that is a modal rather than a stage: nothing has begun yet, so there is /// no pane to take over. `importing`'s header calls it the first consumer of the /// unprompted-overlay shape; the loose-files warning is the other. pub fn draw_preflight(ctx: &egui::Context, state: &mut BrowserState) { let intents = RefCell::new(Vec::new()); let mut runtime = state.described.preflight.take(); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; let closed = window( ctx, "Import folder", &mut runtime, &host, "/import/preflight", true, ); state.described.preflight = runtime; apply(ctx, state, None, intents.into_inner()); // The X is the same answer as Cancel: nothing has started, so there is // nothing to leave running. if closed { state.described.preflight = None; state.cancel_import_preflight(); } } /// Do what a described screen asked the app to do to itself. /// /// **The frame boundary.** A route holds `&BrowserState` and cannot select a /// row, so it records an [`Intent`] and this runs afterwards, with the `&mut` /// the app has anyway. `SettingsUiState::pending_action` is the same pattern /// already in this app. /// /// Each arm calls what the shipped list calls, rather than reaching into the /// fields itself: a described screen that set `nav.selection` by hand would be a /// second implementation of selection, which is what the port is for avoiding. /// `sync` is the manager, where the caller has one. Only two panels do: the sync /// window, and the file list since 2026-08-17, because a cloud-only row's /// Download is an act nothing but the manager can perform. Every other caller /// passes `None` and no intent it can raise wants it. fn apply( ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>, intents: Vec, ) { // Anything applied here landed *after* the router answered, so the screen // showing was built without it. The next frame reloads. state.described.stale = !intents.is_empty(); for intent in intents { match intent { Intent::Configure(setting, value) => configure(state, setting, &value), Intent::StartExport => { if let crate::state::ImportMode::ConfigureExport { items, config, .. } = &state.import_wf.import_mode { let (items, config) = (items.clone(), config.clone()); state.run_export(items, config); } } Intent::CancelExport => state.cancel_export(), // Whatever phase it is in, the flow is over. The shipped screens // each write `ImportMode::None` at their own Done or Cancel, and // this is the one place the described side does. Intent::DismissExport => { state.import_wf.import_mode = crate::state::ImportMode::None; } Intent::Open(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); state.refresh_selected_tags(); state.refresh_selected_detail(); } } Intent::Play(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); state.autoplay_current(); } } // The row menu, 2026-08-17. Every arm selects the row first and then // calls what the shipped menu calls on the selection, which is the // rule this whole function follows: `draw_context_menu` opens with // `nav.selection.set_single(row_idx)` for exactly the same reason, // and a described screen reaching past the app's own selection would // be a second implementation of it. Intent::Enter(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); state.enter_directory(); } } // A host act, like `CopyPath` above: a file manager is the system's // and the description has no way to say so. Intent::Reveal(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); if let Some(path) = state.selected_sample_path() { crate::ui::file_list_menus::reveal(&path); } } } Intent::Instrument(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); if let Some(hash) = selected_hash(state) { let name = state .selected_node() .map(|node| node.node.name.clone()) .unwrap_or_default(); state.load_chromatic_sample(&hash); state.preview.instrument_visible = true; state.preview.show_midi_window = true; state.status = format!("Instrument: {name}"); } } } // The overwrite branch is the shipped menu's, verbatim in intent: an // analysis that would replace numbers already there asks first, and // one filling a gap does not. `ReanalyzeOverwrite` is the app's own // confirmation, so this does not use `Act::confirm` -- the question // is conditional on state the description does not carry. Intent::Reanalyze(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); let existing = state .selected_node() .is_some_and(|node| node.bpm.is_some() || node.musical_key.is_some()); if let Some(hash) = selected_hash(state) && let Ok(ext) = state.backend.sample_extension(&hash) { let hashes = vec![(hash, ext)]; if existing { state.overlay.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite { sample_hashes: hashes, overwrite_count: 1, }); } else { state.start_analysis_flow(hashes); } } } } // The act carried `Act::confirm`, so the user has already agreed by // the time this runs. `confirm_delete_selected` raises the app's own // dialog on top of that, which is one question too many and is the // shipped behaviour: the menu entry asks nothing itself and this is // where the asking lives. Left as the app's, because the two dialogs // are not equivalent -- the app's counts what it is about to remove. Intent::Delete(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); state.confirm_delete_selected(); } } Intent::Download(id) => { if let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); let name = state .selected_node() .map(|node| node.node.name.clone()) .unwrap_or_default(); // Without a manager there is nothing to ask, which is the // case the described screen already withholds the entry for. // Said again here because an intent can arrive from a // hand-typed request, and the shipped menu's own fallback is // a status line rather than a silence. match (sync, selected_hash(state)) { (Some(manager), Some(hash)) if manager.download_sample(&hash) => { state.status = format!("Downloading {name}..."); } _ => { "Sync not ready, open the Sync panel first" .clone_into(&mut state.status); } } } } Intent::RemoveFromCollection(id) => { if let Some(active) = state.collections_ui.active_collection && let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); if let Some(hash) = selected_hash(state) { let _ = state.backend.remove_from_collection(active, &hash); state.refresh_collections(); state.activate_collection(active); } } } Intent::AddToCollection(id, collection) => { // The description carries an `i64` because a described id is a // number, so the newtype is put back here -- the same round trip // `OpenCollection` makes, and for the same reason: the // collection is found by comparing against the typed id rather // than by constructing one out of an unchecked number. let named = state .collections_ui .collections .iter() .find(|it| it.id.as_i64() == collection) .map(|it| (it.id, it.name.clone())); if let Some((collection, name)) = named && let Some(at) = index_of(state, id) { state.nav.selection.set_single(at); if let Some(hash) = selected_hash(state) { let _ = state.backend.add_to_collection(collection, &hash); state.refresh_collections(); state.status = format!("Added to {name}"); } } } Intent::SortBy(column) => { let key = match column.as_str() { "Name" => crate::state::SortColumn::Name, "BPM" => crate::state::SortColumn::Bpm, "Key" => crate::state::SortColumn::Key, "Duration" => crate::state::SortColumn::Duration, // The route already refused anything else, so this is a // column the app grew and the description has not learned. _ => continue, }; state.toggle_sort(key); } Intent::AddTag(tag) => add_tag(state, &tag), Intent::RemoveTag(tag) => remove_tag(state, &tag), Intent::Suggest => state.suggest_ml_for_selected(), Intent::AcceptSuggestion(tag) => state.accept_ml_suggestion(&tag), // The one intent the app does not perform on itself. See // `detail`'s header: a clipboard is the system's and the // description has no way to say so, so it arrives here as an // ordinary intent and the host does what only a host can. Intent::CopyPath => { if let Some(path) = state.selected_sample_path() { state.status = format!("Copied: {path}"); ctx.copy_text(path); } } Intent::Edit => { if let Some(hash) = selected_hash(state) { state.open_edit_window(&hash); } } Intent::Forge => { if let Some(hash) = selected_hash(state) { state.open_forge_window(&hash); } } Intent::FindSimilar => { if let Some(hash) = selected_hash(state) { state.find_similar(&hash); } } Intent::FindDuplicates => { if let Some(hash) = selected_hash(state) { state.find_near_duplicates(&hash); } } Intent::SpreadTag(tag) => { let targets = across(state, |node| !node.tags.contains(&tag)); state.apply_tag_to_hashes(&tag, &targets); } Intent::StripTag(tag) => { let targets = across(state, |node| node.tags.contains(&tag)); state.remove_tag_from_hashes(&tag, &targets); } // The three bulk operations, each the same shape: the described // modal held what was typed, so the host opens the app's own modal // to derive the arguments from the selection, puts the typed value // where the executor reads it, and runs the executor. // // Three lines rather than a second implementation of bulk tagging. // `execute_bulk_*` owns the undo entry, the status line and the // partial-failure counting, and none of that should exist twice. // See `Bulk`'s header on why the description does not carry // `BulkModal` even though the commit path does. // The toolbar. Intent::Search(query) => { state.search.search_query = query; // Applied at once rather than debounced: an intent already // arrived because the host decided to fire, so the settling is // behind us. See `toolbar`'s header on what the description // cannot yet say about that. state.search.search_debounce_at = None; state.apply_search(); } Intent::Scope(everywhere) => { state.search.search_filter.scope = if everywhere { audiofiles_core::search::SearchScope::Global } else { audiofiles_core::search::SearchScope::CurrentFolder }; state.apply_search(); } Intent::SaveCollection(name) => state.save_dynamic_collection(&name), Intent::Undo => state.undo(), Intent::TogglePanel(panel) => match panel { super::Panel::Sidebar => state.toggle_sidebar(), super::Panel::Detail => state.toggle_detail(), super::Panel::Edit => crate::ui::toolbar::toggle_edit_window(state), super::Panel::Instrument => { state.preview.show_midi_window = !state.preview.show_midi_window; } super::Panel::Loop => state.toggle_loop(), super::Panel::Filters => state.toggle_filter_panel(), }, Intent::GoRoot => { state.nav.current_dir = None; state.nav.breadcrumb.clear(); state.nav.selection.clear(); state.refresh_contents(); } Intent::GoTo(id, depth) => { if state .nav .breadcrumb .get(depth.saturating_sub(1)) .is_some_and(|crumb| crumb.id.as_i64() == id) { state.nav.current_dir = Some(audiofiles_core::NodeId::from(id)); state.nav.breadcrumb.truncate(depth); state.nav.selection.clear(); state.refresh_contents(); } } // One control for two modes, because leaving either means the same // thing to the user: go back to browsing. Which one is showing is // what `Where` already says. Intent::Leave => { if state.search.similarity_search_hash.is_some() { state.clear_similarity_search(); } else { state.deactivate_collection(); } } // The sidebar. Two of these hand an already-agreed decision to the // app's own executor: the described control asked with // `Act::confirm`, the runtime answered `Step::Ask`, the user said // yes, and `execute_confirmed_action` is what knows how to do it. // `ConfirmAction` becomes an argument carrier rather than a // question, which is `library`'s header made concrete. Intent::OpenVault(id) => { if let Some(at) = state .nav .vfs_list .iter() .position(|vfs| vfs.id.as_i64() == id) && at != state.nav.current_vfs_idx { state.select_vfs(at); } } Intent::DeleteVault(id) => { if let Some(vfs) = state.nav.vfs_list.iter().find(|vfs| vfs.id.as_i64() == id) { state.overlay.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs { vfs_id: vfs.id, vfs_name: vfs.name.clone(), }); state.execute_confirmed_action(); } } Intent::ToggleTag(path) => { let wanted = &mut state.search.search_filter.required_tags; if let Some(at) = wanted.iter().position(|held| *held == path) { wanted.remove(at); } else { wanted.push(path); } state.apply_search(); } Intent::RemoveTagEverywhere(tag) => { state.overlay.pending_confirm = Some(crate::state::ConfirmAction::RemoveTagGlobally { tag }); state.execute_confirmed_action(); } Intent::OpenCollection(id) => { if let Some(collection) = state .collections_ui .collections .iter() .find(|collection| collection.id.as_i64() == id) { let (id, filter) = (collection.id, collection.filter.clone()); match filter { Some(filter) => state.activate_dynamic_collection(id, &filter), None => state.activate_collection(id), } } } Intent::CloseCollection => state.deactivate_collection(), Intent::DeleteCollection(id) => { if let Some(collection) = state .collections_ui .collections .iter() .find(|collection| collection.id.as_i64() == id) { state.overlay.pending_confirm = Some(crate::state::ConfirmAction::DeleteCollection { coll_id: collection.id, coll_name: collection.name.clone(), }); state.execute_confirmed_action(); } } Intent::StopPlayback => state.stop_preview(), Intent::DismissHint => state.dismiss_first_launch_hint(), Intent::BulkTag(typed, adding) => { state.open_bulk_tag_modal(); if let Some(crate::state::BulkModal::Tag { tag_input, adding: mode, .. }) = &mut state.bulk_modal { *tag_input = typed; *mode = adding; } state.execute_bulk_tag(); state.close_bulk_modal(); } Intent::BulkMove(folder) => { state.open_bulk_move_modal(); if let Some(crate::state::BulkModal::Move { directories, selected_idx, .. }) = &mut state.bulk_modal { // Back from the id the address named to the index the // executor reads. The description addresses a folder by its // own id, for the reason the file list addresses a row by // one: an index is a fact about a list that was built once. *selected_idx = folder .and_then(|id| directories.iter().position(|(at, _)| at.as_i64() == id)); } state.execute_bulk_move(); state.close_bulk_modal(); } Intent::BulkRename(pattern) => { state.open_bulk_rename_modal(); if let Some(crate::state::BulkModal::Rename { pattern_input, .. }) = &mut state.bulk_modal { *pattern_input = pattern; } state.execute_bulk_rename(); state.close_bulk_modal(); } // The name modals. The write already happened in the route, which // is the one place this port does that and `naming`'s header is // why: the store's refusal has to reach the field it was typed // into. What is left is the half a route cannot do. // Whichever of the four was up, and the error with it: the modal is // finished with, so a refusal it was showing is finished with too. Intent::NamingDone => { state.vfs_modal.show_vfs_create = false; state.vfs_modal.show_dir_create = false; state.vfs_modal.vfs_rename_target = None; state.vfs_modal.dir_rename_target = None; state.vfs_modal.name_modal_error = None; } // Whichever of the three was up. Intent::BulkDone => state.close_bulk_modal(), Intent::VaultsChanged(say) => { state.refresh_vfs_list(); state.status = say; } Intent::ContentsChanged(say) => { state.refresh_contents(); state.status = say; } Intent::AcceptImport { again } => { if !again { if let Err(error) = state .backend .set_config(crate::backend::ConfigKey::ImportPreflightDisabled, "1") { tracing::warn!("Failed to persist preflight dismissal: {error}"); } // The in-memory mirror as well, so the next bypass check // sees the answer without a reload. The shipped modal writes // both for the same reason. state.import_wf.import_preflight_disabled = true; } state.accept_import_preflight(); } Intent::CancelImport => state.cancel_import_preflight(), Intent::DismissLooseFiles => state.dismiss_loose_files_warning(), Intent::PurgeLooseFiles => state.purge_missing_loose_files(), // The editor. Every one of these writes the value the described // control submitted into the knob the shipped panel keeps for it, // and then calls what the shipped button calls. That the knobs // still exist is the shipped panel's business: `edit`'s header is // about what the *description* no longer carries, and while both // windows are open both need somewhere to put a number. Intent::EditTrim { start, end } => { state.edit.trim_start = start; state.edit.trim_end = end; state.apply_edit_trim(); } Intent::EditGain(db) => { state.edit.gain_db = db; state.apply_edit_gain(); } Intent::EditNormalize { peak, target } => { state.edit.norm_peak = peak; state.edit.norm_target = target; state.apply_edit_normalize(); } Intent::EditReverse => state.apply_edit_reverse(), Intent::EditFade { fading_in, ms, curve, } => { state.edit.fade_in = fading_in; state.edit.fade_duration_ms = ms; // The route already refused anything else, so an unreadable // curve here is one the app grew and the description has not // learned. if let Some(curve) = audiofiles_core::edit::FadeCurve::from_value(&curve) { state.edit.fade_curve = curve; } state.apply_edit_fade(); } Intent::EditInsertSilence { at, ms } => { state.edit.silence_position_ms = at; state.edit.silence_duration_ms = ms; state.apply_edit_insert_silence(); } Intent::EditRemoveRange { from, to } => { state.edit.remove_start_ms = from; state.edit.remove_end_ms = to; state.apply_edit_remove_range(); } Intent::EditCancel => state.cancel_edit_operation(), // Play and pause are one control, which is the shipped transport's // own reading: pressing it while this sample is loaded toggles the // buffer, and pressing it while another is loaded starts this one. Intent::EditPlay => { if let Some(hash) = state.edit.hash.clone() { if state.preview.previewing_hash.as_deref() == Some(hash.as_str()) { let mut playback = state.shared.preview.lock(); playback.playing = !playback.playing; } else { state.trigger_preview(&hash); } } } Intent::EditRemember(mode) => { if let Some(mode) = crate::state::EditResultMode::from_value(&mode) { state.set_edit_result_mode(mode); } } Intent::EditChoose { mode, remember } => { if let Some(mode) = crate::state::EditResultMode::from_value(&mode) { state.confirm_edit_result(mode, remember); } } Intent::EditDiscard => state.discard_edit_result(), Intent::EditUndo => state.undo_last_edit(), Intent::BatchNormalize { peak, target } => { if peak { state.batch_normalize_peak(target); } else { state.batch_normalize_lufs(target); } } Intent::BatchGain(db) => state.batch_gain(db), Intent::BatchReverse => state.batch_reverse(), // The import flow. Every one of these lands on `import_wf`, which // is the app's own screen state, so the whole capability writes // through intents -- see `Importing`'s header. // // The four doors are host acts with no described step, which is // `quasi:vocabulary:host-save-location`'s fifth to eighth consumers // and the same shape `LocateLooseFiles` takes below. Intent::OpenImportFolder => { state .dialogs .pick_folder("Import folder", BrowserState::show_import_options); } Intent::OpenQuickImport => { state .dialogs .pick_folder("Quick import folder", BrowserState::quick_import_folder); } Intent::OpenImportFiles => { state.dialogs.pick_files( "Import files", &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)], |state, paths| { // Batched through the worker rather than hashed on the // GUI thread, which is the shipped menu entry's own fix. if let Some(vfs_id) = state.current_vfs_id() { let strategy = crate::import::ImportStrategy::MergeIntoVfs { vfs_id, parent_id: state.nav.current_dir, }; state.start_files_import(&paths, strategy); } }, ); } Intent::ChangeImportSource => { state .dialogs .pick_folder("Choose source folder", |state, folder| { state.change_import_source(folder); }); } Intent::Decide(decision, value) => decide(state, decision, &value), Intent::BeginImport => begin_import(state), Intent::StopImport => state.cancel_import(), Intent::RetryImport => state.retry_import(), // Whatever stage it is at, the flow is over. The shipped screens // each write `ImportMode::None` at their own Cancel or Done, and // this is the one place the described side does. Intent::DismissImport => { state.import_wf.import_mode = crate::state::ImportMode::None; } Intent::TagFolder(at, typed) => { if let crate::state::ImportMode::TagFolders { entries, .. } = &mut state.import_wf.import_mode && let Some(entry) = entries.get_mut(at) { entry.tag_input = typed; } } Intent::TagEveryFolder(typed) => { if let crate::state::ImportMode::TagFolders { entries, .. } = &mut state.import_wf.import_mode { for entry in entries.iter_mut() { entry.tag_input.clone_from(&typed); } } } Intent::ApplyFolderTags => state.apply_folder_tags(), Intent::SkipFolderTags => state.skip_folder_tags(), Intent::Measure(measure, wanted) => { if let crate::state::ImportMode::ConfigureAnalysis { config, .. } = &mut state.import_wf.import_mode { match measure { super::Measure::Loudness => config.loudness = wanted, super::Measure::Bpm => config.bpm = wanted, super::Measure::Key => config.key = wanted, super::Measure::Spectral => config.spectral = wanted, super::Measure::Loops => config.loop_detect = wanted, super::Measure::Suggestions => config.auto_suggest_tags = wanted, super::Measure::Fingerprint => config.fingerprint = wanted, super::Measure::SmartSkip => config.smart_skip = wanted, } } } Intent::StartAnalysis => { if let crate::state::ImportMode::ConfigureAnalysis { sample_hashes, config, } = &state.import_wf.import_mode { let (hashes, config) = (sample_hashes.clone(), config.clone()); state.run_analysis(hashes, config); } } Intent::BackToTagging => state.back_to_tag_folders(), Intent::SkipAnalysis => { state.import_wf.import_mode = crate::state::ImportMode::None; "Imported. Run analysis from the sidebar when ready.".clone_into(&mut state.status); } Intent::StopAnalysis => state.cancel_analysis(), Intent::RetryAnalysis => state.retry_analysis(), Intent::OrderReview(order) => { if let crate::state::ImportMode::ReviewSuggestions { sort, .. } = &mut state.import_wf.import_mode { *sort = match order { super::Order::Arrival => crate::state::ReviewSort::ImportOrder, super::Order::Name => crate::state::ReviewSort::Name, super::Order::Suggestions => crate::state::ReviewSort::Suggestions, super::Order::Accepted => crate::state::ReviewSort::Accepted, }; } } Intent::ReadReviewed(at) => { if let crate::state::ImportMode::ReviewSuggestions { items, current_idx, .. } = &mut state.import_wf.import_mode && at < items.len() { *current_idx = at; } } // By tag rather than by position, which is the route's own reason: // the description sorts its copy by confidence and the app does not, // so an index agreed on one side is not the same row on the other. Intent::Judge { at, tag, accepted } => { if let crate::state::ImportMode::ReviewSuggestions { items, .. } = &mut state.import_wf.import_mode && let Some(item) = items.get_mut(at) && let Some(held) = item .suggestions .iter_mut() .find(|held| held.suggestion.tag == tag) { held.accepted = accepted; } } Intent::JudgeAll(accepted) => { if let crate::state::ImportMode::ReviewSuggestions { items, .. } = &mut state.import_wf.import_mode { for item in items.iter_mut() { for held in &mut item.suggestions { held.accepted = accepted; } } } } Intent::ApplySuggestions => state.apply_accepted_suggestions(), Intent::DiscardSuggestions => { state.import_wf.import_mode = crate::state::ImportMode::None; "Suggestions discarded".clone_into(&mut state.status); } Intent::KeepFailed => state.dismiss_import_errors(), // The described control asked with `Act::confirm` and the runtime // already had the answer, so `ConfirmAction` arrives here as an // argument carrier rather than as a question -- `library`'s header // made concrete for the third time. Intent::PurgeFailed(at) => { let name = at.and_then(|at| { state .import_wf .analysis_errors .get(at) .map(|failure| failure.name.clone()) }); let count = match at { Some(_) => 1, None => state.import_wf.analysis_errors.len(), }; state.overlay.pending_confirm = Some(crate::state::ConfirmAction::RemoveFailedSamples { single_index: at, count, name, }); state.execute_confirmed_action(); } Intent::StopSweep => state.cancel_cleanup(), // The strip. Cleared here rather than on the worker's terminal // event, which is the shipped button's own note: the click has to // feel like it did something, and the Complete event that follows // would clear this anyway. Intent::PauseMigration => { if let Err(error) = state.backend.cancel_layout_migration() { tracing::warn!("failed to cancel layout migration: {error}"); } state.layout_migration = None; } // The forge. Every one of these writes what the described control // submitted into the knob the shipped window keeps for it and then // calls what the shipped button calls, which is `edit`'s // arrangement: that the knobs still exist is the shipped window's // business while both are open. // // Changing any chop parameter clears the marks, which is what // re-arms the preview gate. Done here and not in the route for the // reason every write is here: it is `&mut`. Intent::SliceBy(how) => { state.forge.chop_mode = match how { super::Chop::Transient => crate::state::ChopMode::Transient, super::Chop::Equal => crate::state::ChopMode::Equal, super::Chop::Bpm => crate::state::ChopMode::Bpm, }; state.forge.slice_marks.clear(); } Intent::Turn(knob, value) => turn(state, knob, &value), Intent::PreviewSlices => state.forge_preview_slices(), Intent::Chop => state.forge_apply_chop(), Intent::ChooseDevice(name) => { state.forge.conform_device = (!name.is_empty()).then_some(name); } Intent::Conform => { if let Some(device) = state.forge.conform_device.clone() { state.forge_conform_device(&device); } } // The filter panel. Every one of these lands in // `state.search.search_filter`, which is the app's own UI state, and // `apply_search` is what turns a changed filter into a new result // set -- the shipped panel's own `if changed` at the end of the // draw, said once here instead of per control. Intent::Narrow(key, lower, upper) => { let f = &mut state.search.search_filter; let ends: [(&str, &mut Option, &mut Option); 6] = [ ("bpm", &mut f.bpm_min, &mut f.bpm_max), ("duration", &mut f.duration_min, &mut f.duration_max), ("loudness", &mut f.peak_db_min, &mut f.peak_db_max), ("brightness", &mut f.centroid_min, &mut f.centroid_max), ("noisiness", &mut f.flatness_min, &mut f.flatness_max), ("attack", &mut f.attack_min, &mut f.attack_max), ]; for (named, low, high) in ends { if named == key { *low = lower; *high = upper; break; } } state.apply_search(); } Intent::KeyMode(compatible) => { use audiofiles_core::search::KeyFilterMode; state.search.search_filter.key_mode = if compatible { KeyFilterMode::Compatible } else { KeyFilterMode::Exact }; state.apply_search(); } Intent::ToggleKey(key) => { let keys = &mut state.search.search_filter.keys; if let Some(at) = keys.iter().position(|held| *held == key) { keys.remove(at); } else { keys.push(key); } state.apply_search(); } Intent::ClearKeys => { state.search.search_filter.keys.clear(); state.apply_search(); } // The one filter intent that does not re-run the search: what is in // the tag box is not a filter until it is added. Intent::TypingTag(text) => state.search.filter_tag_input = text, Intent::RequireTag(tag) => { state.search.search_filter.required_tags.push(tag); state.apply_search(); } Intent::UnrequireTag(tag) => { state .search .search_filter .required_tags .retain(|held| *held != tag); state.apply_search(); } Intent::ClearTags => { state.search.search_filter.required_tags.clear(); state.apply_search(); } Intent::ClearFilters => { state.search.search_filter.clear(); state.search.search_query.clear(); state.apply_search(); } Intent::TrimSilence => { let threshold = state.forge.trim_threshold_db; state.batch_trim_silence(threshold); } // The tag queue. Opening a tag also resolves the names of the rows // that will be drawn for it, which is a backend call per row and so // is the host's: the shipped screen does it from inside the drawing // and a route holding `&S` could not. Intent::ReadGroup(at) => { state.set_review_selected(at); state.ensure_review_names(at, crate::quasi::queue::RENDER_ROWS); } Intent::TickCandidate(at) => { let selected = state.review_selected(); if let Some(candidate) = state .classifier .review .as_mut() .and_then(|queue| queue.groups.get_mut(selected)) .and_then(|group| group.candidates.get_mut(at)) { candidate.accepted = !candidate.accepted; } } // Only the drawn rows, which is the shipped button's own bound: see // `queue`'s note on why "Accept checked" must not quietly become // "accept everything". Intent::TickShown(ticked) => { let selected = state.review_selected(); if let Some(group) = state .classifier .review .as_mut() .and_then(|queue| queue.groups.get_mut(selected)) { for candidate in group .candidates .iter_mut() .take(crate::quasi::queue::RENDER_ROWS) { candidate.accepted = ticked; } } } Intent::AcceptGroup(scope) => { let selected = state.review_selected(); state.accept_review( selected, match scope { super::Scope::All => crate::state::ReviewSelection::All, super::Scope::Confident => crate::state::ReviewSelection::Confident, super::Scope::Checked => crate::state::ReviewSelection::Checked, }, ); } Intent::AcceptConfident => state.accept_all_confident(), Intent::DismissGroup => { let selected = state.review_selected(); state.dismiss_review_group(selected); } Intent::Rescan => state.classifier_review_library(), Intent::CloseReview => state.close_review_screen(), Intent::BeginExport => state.start_export_flow(None), // The host act with no described step. See `integrity`'s header: // fourth consumer of `quasi:vocabulary:host-save-location`. Intent::LocateLooseFiles => { state .dialogs .pick_folder("Locate missing sample files", |state, folder| { state.locate_missing_loose_files(&folder); }); } } } } /// Put a tag on the selected sample, the way the shipped panel does. /// /// Validated here rather than in the route, because validation is the app's: /// `audiofiles_core::tags::validate_tag` is what the shipped panel calls and a /// described screen that carried a second copy of the rule would be a second /// implementation of what a tag may be. fn add_tag(state: &mut BrowserState, tag: &str) { let Some(hash) = selected_hash(state) else { return; }; if audiofiles_core::tags::validate_tag(tag).is_err() { state.status = format!("Invalid tag: {tag}"); return; } let _ = state.backend.add_tag(&hash, tag); state.detail.tag_input.clear(); state.refresh_selected_tags(); } /// Take a tag off the selected sample, undo entry and all. /// /// **The undo is why this is here and not in the route.** `Backend::remove_tag` /// is `&self` and a handler could call it; what it could not do is push the /// `UndoOp::TagRemove` that makes Cmd+Z put the tag back, because that is /// `&mut BrowserState`. A described screen that called the backend directly /// would remove the tag and silently lose the undo. See `Detail`'s header. fn remove_tag(state: &mut BrowserState, tag: &str) { let Some(hash) = selected_hash(state) else { return; }; if state.backend.remove_tag(&hash, tag).is_ok() { state.push_undo(crate::state::UndoOp::TagRemove { hash: hash.clone(), tag: tag.to_owned(), }); state.status = format!("Removed tag \"{tag}\""); state.refresh_selected_tags(); } } /// The hash of whatever is selected, where it is a sample. fn selected_hash(state: &BrowserState) -> Option { state .selected_node() .and_then(|node| node.node.sample_hash.as_ref().map(ToString::to_string)) } /// The chosen samples this tag operation applies to. /// /// The filtering is the shipped panel's: applying a tag touches only the samples /// that lack it and removing one touches only those that carry it, so the counts /// the described row shows are the counts the operation acts on. fn across( state: &BrowserState, wanted: impl Fn(&audiofiles_core::vfs::VfsNodeWithAnalysis) -> bool, ) -> Vec { state .selected_nodes() .into_iter() .filter(|node| node.node.sample_hash.is_some() && wanted(node)) .filter_map(|node| node.node.sample_hash.as_ref().map(ToString::to_string)) .collect() } /// Write one described number back into the forge's own knobs. /// /// Anything unparseable is dropped rather than defaulted, which is the opposite /// of `configure`'s reading and right for the opposite reason: an export setting /// has a "keep the original" answer that a bad value can honestly fall back to, /// and a sensitivity does not. Leaving the previous number standing is what the /// described control then reads back on the next frame. /// /// Every chop parameter clears the slice marks, which re-arms the preview gate. fn turn(state: &mut BrowserState, knob: super::Knob, value: &str) { match knob { super::Knob::Sensitivity => { if let Ok(sensitivity) = value.parse::() { state.forge.sensitivity = sensitivity.clamp(0.0, 1.0); state.forge.slice_marks.clear(); } } super::Knob::Divisions => { if let Ok(divisions) = value.parse::() { state.forge.divisions = divisions; state.forge.slice_marks.clear(); } } super::Knob::Bpm => { if let Ok(bpm) = value.parse::() { state.forge.bpm = bpm.clamp(20.0, 300.0); state.forge.slice_marks.clear(); } } super::Knob::Subdivisions => { if let Ok(subdivisions) = value.parse::() { state.forge.subdivisions = subdivisions; state.forge.slice_marks.clear(); } } // Not a chop parameter, so it leaves the marks alone: the batch section // is about the selection rather than about this sample. super::Knob::Threshold => { if let Ok(threshold) = value.parse::() { state.forge.trim_threshold_db = threshold.clamp(-96.0, -20.0); } } } } /// Write one described answer back into the import being configured. /// /// **The strategy is re-derived from all three answers on every write**, never /// patched in place, and that is `ui::import_screens::configure`'s own /// arrangement rather than a choice made here. The comment it carries names the /// bug it fixed: the strategy is a function of the three answers, so a site that /// changes one of them and forgets to rebuild it leaves a strategy that /// disagrees with the controls, and the vault-name edit was the only site that /// remembered. /// /// A strategy the app cannot form yet — flat with no vault open, merge with /// nothing to merge into — leaves the previous one standing, which is what the /// described radio then reads back from. That too is the shipped screen's. fn decide(state: &mut BrowserState, decision: super::Decision, value: &str) { use crate::import::ImportStrategy; let current_vfs_id = state.current_vfs_id(); let current_dir = state.nav.current_dir; let crate::state::ImportMode::ConfigureImport { strategy, new_vfs_name, available_vfs, selected_merge_vfs_idx, .. } = &mut state.import_wf.import_mode else { // The flow moved on between the frame that drew the control and the one // that applies it. Dropping the write is right for `configure`'s reason: // there is no longer a configuration for it to land in. return; }; let mut chosen = match strategy { ImportStrategy::Flat { .. } => super::Strategy::Flat, ImportStrategy::NewVfs { .. } => super::Strategy::NewVault, ImportStrategy::MergeIntoVfs { .. } => super::Strategy::Merge, }; match decision { // The route already refused anything else, so an unreadable strategy // here is one the app grew and the description has not learned. super::Decision::Strategy => chosen = super::Strategy::from_key(value).unwrap_or(chosen), super::Decision::VaultName => { new_vfs_name.clear(); new_vfs_name.push_str(value); } super::Decision::MergeVault => { if let Ok(at) = value.parse::() { *selected_merge_vfs_idx = at; } } } let next = match chosen { super::Strategy::Flat => current_vfs_id.map(|vfs_id| ImportStrategy::Flat { vfs_id, parent_id: current_dir, }), super::Strategy::NewVault => Some(ImportStrategy::NewVfs { vfs_name: new_vfs_name.clone(), }), super::Strategy::Merge => { available_vfs .get(*selected_merge_vfs_idx) .map(|vfs| ImportStrategy::MergeIntoVfs { vfs_id: vfs.id, parent_id: None, }) } }; if let Some(next) = next { *strategy = next; } } /// Start the import that is configured, the way the shipped button does. /// /// The strategy is rebuilt from the three answers one last time rather than /// taken as it stands, which is again the shipped button's own code: the vault /// name and the merge index are what the user typed and picked, and the /// strategy is only ever their derivative. fn begin_import(state: &mut BrowserState) { use crate::import::ImportStrategy; let crate::state::ImportMode::ConfigureImport { source, strategy, new_vfs_name, available_vfs, selected_merge_vfs_idx, .. } = &state.import_wf.import_mode else { return; }; let source = source.clone(); let strategy = match strategy { ImportStrategy::Flat { vfs_id, parent_id } => Some(ImportStrategy::Flat { vfs_id: *vfs_id, parent_id: *parent_id, }), ImportStrategy::NewVfs { .. } => Some(ImportStrategy::NewVfs { vfs_name: new_vfs_name.clone(), }), ImportStrategy::MergeIntoVfs { .. } => { available_vfs .get(*selected_merge_vfs_idx) .map(|vfs| ImportStrategy::MergeIntoVfs { vfs_id: vfs.id, parent_id: None, }) } }; if let Some(strategy) = strategy { state.start_folder_import(source, strategy); } } /// Write one described setting back into the app's own export config. /// /// The described value is a string because that is what a control submits, and /// this is where it stops being one. Anything unparseable falls back to the /// setting's "keep the original" reading rather than being ignored: a rate the /// description does not know is not a reason to keep the old one, which would be /// a control that silently does nothing. /// /// Choosing a device profile clears the three fields the profile owns, which is /// what `ui::export_screens` does at the same control and for the same reason: /// they are derived from the profile, so a stale set of them would outlive the /// profile that produced it. fn configure(state: &mut BrowserState, setting: Setting, value: &str) { use audiofiles_core::export::{ExportChannels, ExportFormat}; let crate::state::ImportMode::ConfigureExport { config, .. } = &mut state.import_wf.import_mode else { // The flow moved on between the frame that drew the control and the one // that applies it. Dropping the write is right: there is no longer a // configuration for it to land in. return; }; match setting { Setting::Format => { config.format = match value { "wav" => ExportFormat::Wav, "aiff" => ExportFormat::Aiff, _ => ExportFormat::Original, }; } Setting::SampleRate => config.sample_rate = value.parse().ok(), Setting::BitDepth => config.bit_depth = value.parse().ok(), Setting::Channels => { config.channels = match value { "mono" => ExportChannels::Mono, "stereo" => ExportChannels::Stereo, _ => ExportChannels::Original, }; } Setting::Flatten => config.flatten = !value.is_empty(), Setting::Sidecar => config.metadata_sidecar = !value.is_empty(), Setting::NamingPattern => { config.naming_pattern = (!value.is_empty()).then(|| value.to_owned()); } Setting::DeviceProfile => { config.device_profile = (!value.is_empty()).then(|| value.to_owned()); config.naming_rules = None; config.max_file_size_bytes = None; config.name_overrides = None; } } } /// Where a sample sits in what is on screen. /// /// The described screen addresses a row by its own id and the app selects by /// index, so one of them has to translate. Here rather than in the description: /// an index is a fact about the current filter and sort, which is exactly the /// kind of thing an address should not be. fn index_of(state: &BrowserState, id: i64) -> Option { state .nav .contents .iter() .position(|node| node.node.id.as_i64() == id) } /// Everything a described screen is answered out of. /// /// The four travel together through every function below, so they are one thing /// rather than four parameters repeated three times. What they have in common is /// the reason: each is a handle the *host* holds and the description does not — /// the app's state, its sync manager, the themes it resolved at startup, and the /// place a route leaves what it could not do itself. struct Host<'a> { state: &'a BrowserState, sync: Option<&'a SyncManager>, themes: Vec, intents: &'a RefCell>, } /// Drive one described screen into a `Ui`: draw it, and act on what was pressed. /// /// The whole of a described screen's frame, with no opinion about where it is. /// [`window`] puts a window round it and [`inline`] does not, which is the only /// difference between a described modal and a described full-screen mode: the /// app's own arrangement, not the screen's. fn drive( ui: &mut egui::Ui, runtime: &mut Option, host: &Host<'_>, home: &str, refresh: bool, ) { let immediate = Immediate::new(theme::palette()); // The first frame has no screen yet, so it asks for one. Everything // after it is the loop below. let runtime = match runtime { Some(runtime) => runtime, none => match answer(host, Request::get(home)) { Ok(response) => match response.outcome { // The app's keys, bound to the one table `help::chrome` // holds and the help overlay lists. Every described // window gets them, which is what "works from every // screen" means for an app that has several. // `Over` as well as `Screen`, because a screen that is // an overlay everywhere else is the whole of this window // when the window is what the app opened for it. "Over" // says what a screen is drawn on top of, and a modal // given a window of its own is drawn on top of the app. // // Missing until 2026-08-22, and it did not matter while // every described window was a `Screen`: the first flip // pointed a window at `/library/loose-files`, which is // an `Over`, and the window drew the outcome's `Debug` // rendering instead of the screen. quasi_router::Outcome::Screen(screen) | quasi_router::Outcome::Over(screen) => { none.insert(Runtime::new(screen).with_chrome(super::help::chrome())) } other => { ui.label(format!("the home address answered {other:?}")); return; } }, Err(message) => { ui.label(message); return; } }, }; // Before the drawing, so the frame draws what is true now rather // than showing the previous answer for one more frame. `reload` // re-asks the address the screen came from, and the runtime keeps // what the user has typed and ticked across it. // // **Never while an overlay is open.** `reload` re-asks the address // the screen came from, and an overlay is not a place, so that // address is the screen *underneath* -- which answers // `Outcome::Screen`, which clears the layer stack. An unconditional // refresh would take the modal down on the frame after it opened. // See `bulk`'s header, finding 2. if refresh && !runtime.overlaid() { let step = runtime.reload(); perform(runtime, ui, host, step); } // A screen that says it is live is re-asked on the renderer's own // cadence, which is `quasi_immediate::CADENCE` and is paced by the // runtime rather than by anything here. This is the whole of what // the sync panel's finding asked for: its state moves when an OAuth // callback lands in another process, and until now nothing but an // intent or a per-frame reload would notice. // // Under the same overlay guard as the refresh above, and for the // same reason: a live screen's address is the screen underneath an // open modal, and asking for it would take the modal down. if !runtime.overlaid() { for request in runtime.refreshes() { call(runtime, host, request); } } let step = runtime.show(ui, &immediate); perform(runtime, ui, host, step); // One drain for every described window, rather than one per // `draw_*`: a file is produced by a route and a route is reachable // from all of them, so the host answer belongs where the runtime is // driven. Last in the frame because `perform` above is what may have // just produced one. hand_over(runtime, host.state); } /// One described window: draw it, act on it, and say whether it was closed. fn window( ctx: &egui::Context, title: &str, runtime: &mut Option, host: &Host<'_>, home: &str, refresh: bool, ) -> bool { let mut open = true; egui::Window::new(title) .open(&mut open) .default_width(420.0) .show(ctx, |ui| drive(ui, runtime, host, home, refresh)); !open } /// One described screen, filling whatever it is given. /// /// The full-screen modes take this rather than [`window`]: the review queue, the /// import flow and the export flow are drawn into the app's own pane and have no /// frame of their own to close. fn inline( ui: &mut egui::Ui, runtime: &mut Option, host: &Host<'_>, home: &str, refresh: bool, ) { drive(ui, runtime, host, home, refresh); } /// Do what the runtime asked for. fn perform(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, step: Step) { match step { Step::Idle => {} Step::Call(request) => call(runtime, host, request), // A described control asked before acting. Drawn where it is asked // rather than in a second window, since it is about the control. Step::Ask(question) => { ui.label(&question); ui.horizontal(|ui| { if ui.button("Yes").clicked() { let next = runtime.answer(true); perform(runtime, ui, host, next); } if ui.button("No").clicked() { runtime.answer(false); } }); } // Somewhere outside the app, which is a one-way handoff. Step::Open(address) => open_externally(&address), // A mount of its own, which here would be a second `Runtime` in a // second `egui::Window`. Nothing this app describes asks for one yet, // so rather than hold a runtime nothing fills, the call is made where // it stands -- the documented answer for a host with nowhere to put a // second mount, and the same thing a terminal does with the mark. // // Not an empty arm. That is the `by_host` failure one line up in // quasi-tui: a control drawn, reachable, and doing nothing when // pressed. When a screen here wants a second window, `window` above is // most of it. Step::Mount(request) => call(runtime, host, request), } } /// Ask the router and put the whole answer on the screen. /// /// **The whole answer, which it was not until 2026-08-16.** This used to /// flatten the response to its `Screen` and rebuild a fresh `Response` around /// it, which silently dropped two things every route can say: the `notice`, so /// every `toast` in `settings`, `sync` and `detail` was written and never shown, /// and any outcome that is not a screen, so `Outcome::Over` could not have /// worked at all. `Runtime::apply` takes a `Response` because a response is /// what it is for. /// /// The loop is `Goto`: the runtime answers a redirect with the request to make /// next rather than making it, since asking is the host's. Bounded, because a /// route that redirects to itself is a bug and a loop here would be a hang /// inside a frame. fn call(runtime: &mut Runtime, host: &Host<'_>, request: Request) { let mut request = request; for _ in 0..REDIRECTS { let response = match answer(host, request.clone()) { Ok(response) => response, Err(message) => { runtime.say(message); return; } }; // Somewhere outside the app is the host's to perform, and nothing comes // back from it. The runtime would answer `None` here and the handoff // would never happen. if let quasi_router::Outcome::Goto(action) = &response.outcome && action.destination.route().is_none() { open_externally(action.destination.as_str()); return; } match runtime.apply(&request, response) { Some(next) => request = next, None => return, } } runtime.say("that address kept redirecting"); } /// How many `Goto`s one press may chain before the host calls it a loop. const REDIRECTS: usize = 8; /// Put a file a route answered with wherever the user says. /// /// The host half of `Outcome::File`, which quasi ruled and shipped in 0.50.0 /// (`67881a88`, Max: the route answers with the file and the host puts it /// somewhere) and which nothing on this side had ever drained. A member with no /// consumer reads as a member that does not work, and a described control that /// produces a file was unbuildable here until this existed. /// /// **The description never names a path**, which is the whole point of the /// ruling: `name` is a suggestion and `kind` is what sort of file it is. What /// audiofiles does about that is open a save dialog, because it has one -- /// `ui::dialog`, the subsystem that draws nothing and asks the operating system /// a question. A terminal would write to the working directory and a browser /// would download; one description, three hosts, three answers. /// /// Drained after `apply` rather than inside it: `Runtime::handed` is a one-slot /// mailbox, so a second file replaces the first, and the frame that produced one /// is the frame that should hand it over. fn hand_over(runtime: &mut Runtime, state: &BrowserState) { let Some(handed) = runtime.handed() else { return; }; let quasi_immediate::Handed { name, kind, bytes } = handed; // The dialog wants a filter, and `Accepted` is the same type the upload half // uses rather than a second way to name a file kind. Only a suffix is a // filter a native dialog can take; a family or a media type is a fact about // the file rather than a list of extensions, so those offer no filter and // the user picks freely. let suffix = match &kind { quasi_router::Accepted::Suffix(suffix) => Some(suffix.trim_start_matches('.').to_owned()), // `Accepted` is `#[non_exhaustive]`, so a kind added later lands here // and the user picks freely rather than the build breaking. _ => None, }; let filters: Vec<(String, Vec)> = suffix .into_iter() .map(|suffix| (suffix.to_uppercase(), vec![suffix])) .collect(); let borrowed: Vec<(&str, Vec<&str>)> = filters .iter() .map(|(label, suffixes)| { ( label.as_str(), suffixes.iter().map(String::as_str).collect::>(), ) }) .collect(); let borrowed: Vec<(&str, &[&str])> = borrowed .iter() .map(|(label, suffixes)| (*label, suffixes.as_slice())) .collect(); state.dialogs.save_file( "Save", name, &borrowed, move |s, path| match std::fs::write(&path, &bytes) { Ok(()) => s.status = format!("Saved to {}", path.display()), Err(error) => { tracing::error!("failed to write {}: {error}", path.display()); s.status = format!("Could not save: {error}"); } }, ); } /// Hand an address to the desktop. /// /// The one piece of platform knowledge in the port, and it is the host's by /// definition. **This is what the description deletes from the shipped panel**: /// `ui::sync_panel::draw_disconnected` carries the same three branches inside a /// drawing function, chosen by `#[cfg(target_os)]`, because that is where the /// auth URL happened to be. Here the description says "external" and the host /// answers once, for every control that ever goes outside. fn open_externally(address: &str) { #[cfg(target_os = "macos")] let (program, leading) = ("open", Vec::<&str>::new()); #[cfg(target_os = "linux")] let (program, leading) = ("xdg-open", Vec::<&str>::new()); #[cfg(target_os = "windows")] let (program, leading) = ("cmd", vec!["/c", "start"]); let _ = std::process::Command::new(program) .args(leading) .arg(address) .spawn(); } /// Ask the router, and flatten a refusal into something a user can read. fn answer(host: &Host<'_>, request: Request) -> Result { let Host { state, sync, themes, intents, } = host; let config = FromBackend(&*state.backend); let manager = sync.map(|manager| FromSyncManager { manager, backend: &*state.backend, }); let unconfigured = Unconfigured; let sync: &dyn Sync = match &manager { Some(manager) => manager, None => &unconfigured, }; let files = FromContents { state, intents }; let export = FromExport { state, intents }; let detail = FromSelection { state, intents }; let bulk = FromBulk { state, intents }; let shell = FromWindow { state, intents }; let library = FromLibrary { state, intents }; let bar = FromBar { state, intents }; let naming = FromNaming { state, intents }; let importing = FromImport { state, intents }; let integrity = FromIntegrity { state, intents }; let editor = FromEditor { state, intents }; let forge = FromForge { state, intents }; let queue = FromQueue { state, intents }; let filters = FromFilters { state, intents }; let panels = Panels { config: &config, sync, files: &files, export: &export, detail: &detail, bulk: &bulk, shell: &shell, library: &library, bar: &bar, naming: &naming, importing: &importing, integrity: &integrity, editor: &editor, forge: &forge, queue: &queue, filters: &filters, themes, }; super::router() .handle(&panels, request) .map_err(|error| error.message.clone()) } /// The themes the host has resolved, as the description names them. fn themes() -> Vec { theme::list_themes() .into_iter() .map(|meta| ThemeChoice { source: theme::export_theme_content(&meta.id), id: meta.id, name: meta.name, variant: meta.variant, }) .collect() } /// The screen the app's own adapters answer at `address`, for the parity tests. /// /// The parity harness compares a described screen against the shipped panel it /// replaces, and both have to read one fixture or the comparison proves /// nothing. This is that seam: the same `answer` the window loop calls, with /// the same `Host`, against a real [`BrowserState`]. A test that built its own /// `Panels` out of fakes would be comparing the shipped panel against a fixture /// rather than against the description the app actually serves. /// /// Intents are collected and dropped. A parity read presses nothing. #[cfg(test)] pub(super) fn described_screen(state: &BrowserState, address: &str) -> quasi_router::Screen { use quasi_router::Outcome; let intents = RefCell::new(Vec::new()); let host = Host { state, sync: None, themes: themes(), intents: &intents, }; match answer(&host, Request::get(address)) { Ok(response) => match response.outcome { Outcome::Screen(screen) | Outcome::Over(screen) => screen, other => panic!("{address} answered with {other:?} rather than a screen"), }, Err(message) => panic!("{address} was refused: {message}"), } } #[cfg(test)] mod tests { use super::*; /// A real app with one sample chosen, which is what a tag act needs. fn chosen() -> (BrowserState, tempfile::TempDir) { use std::sync::Arc; let dir = tempfile::TempDir::new().unwrap(); let shared = Arc::new(crate::state::SharedState::new()); let mut state = BrowserState::new(dir.path(), shared, 44_100.0, "Vault").unwrap(); let vfs = state.current_vfs_id().unwrap(); let parent = state.nav.current_dir; let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap(); db.conn() .execute( "INSERT OR IGNORE INTO samples \ (hash, original_name, file_extension, file_size, import_date, last_modified) \ VALUES ('aaa111', 'aaa111.wav', 'wav', 100, 0, 0)", [], ) .unwrap(); state .backend .create_sample_link(vfs, parent, "kick.wav", "aaa111") .unwrap(); state.refresh_contents(); state.nav.selection.set_single(0); (state, dir) } #[test] fn removing_a_tag_from_the_described_panel_is_undoable() { // `162b99a3` states the bar this has to clear: "a flip that loses Cmd+Z // has failed." It is the reason the detail panel's tag writes are // intents rather than handle calls -- a route can remove the tag and // cannot push the undo entry that makes it recoverable, so what the app // does *around* a write is what decides where the write goes. let (mut state, _dir) = chosen(); add_tag(&mut state, "drums"); assert_eq!(state.detail.selected_tags.len(), 1); remove_tag(&mut state, "drums"); assert!(state.detail.selected_tags.is_empty(), "the tag is gone"); assert!(state.can_undo(), "and taking it off is undoable"); state.undo(); assert_eq!( state.detail.selected_tags.len(), 1, "undo puts it back: {:?}", state.detail.selected_tags ); } }