//! The export flow, described rather than built. //! //! The fourth port, and the first that is a **flow** rather than a screen. The //! three before it each answered one address for as long as they were open; //! this one has four screens and the user does not choose between them. Which //! one is showing is a fact about the app — is anything being written, has it //! finished — and the description says so by answering a different screen from //! the same address. //! //! # The finding this port made, and it changed `quasi` //! //! **A described screen could not say that the thing it is about had moved.** //! A route answers a screen built from the state at the moment it was asked, //! and the runtime keeps that answer until the user fires something. That is //! right for a settings panel, where nothing changes unless the user changes //! it. It is wrong for every screen here: //! //! - The progress screen changes with **no user input at all**. Files are being //! written by a worker; the count moves on its own. //! - The configure screen changes on input the description *did* carry, but a //! frame late: a write here is an [`Intent`](super::Intent) the host applies //! after the frame, so the answer built in the same frame is built from state //! the write has not reached yet. This is not new with this port — //! `files.rs`'s sort caret had it — but here every single control has it, so //! it stopped being survivable. //! //! `quasi` 0.12.0's `Runtime::reload` is the answer, and the shape of the answer //! is the part worth keeping: it is a **host** call, not a description member. //! Nothing in a `Screen` says how often it goes stale, because how often a fact //! moves is a property of the app holding it rather than of the screen showing //! it. The host knows it started an export. The description does not, and should //! not have to. //! //! # Why a meter, when `Meter`'s own documentation says not for this //! //! [`Meter`](quasi_router::Meter) says it is "a proportion of a set and not the //! progress of an operation", on the grounds that an operation is live and a //! screen is described once per answer. Files-written of files-to-write **is** a //! proportion of a set; what made it look like an operation was the second half //! of that sentence, and `reload` is what stops it being true. Each answer still //! describes a static fact, and the host asks again. The refusal was right for //! its reason and the reason has moved, which is worth recording as a change to //! the premise rather than as an exception being taken. //! //! # What is not describable, and it is three things //! //! | The shipped screen does | Described | Why not | //! |---|---|---| //! | AIFF 4 GB chunk warning | yes | arithmetic over the items and the settings | //! | device file-size warning | yes | the same, against the profile's limit | //! | naming-pattern live preview | yes | `RenamePattern` resolved against the first item, and pure | //! | **disk space warning** | no | `statvfs` on the destination: a fact about this host's filesystem | //! | **"Browse..." for the destination** | no | a native folder dialog | //! | **the token chips** | no | see below | //! //! The destination picker is the **third consumer** of a finding both prior //! ports filed: *a control that asks the host where to put something and then //! acts has no vocabulary.* `FieldKind::File` covers picking a file to submit; //! nothing covers opening a save dialog and writing there. goingson's settings //! port found it, audiofiles' settings port confirmed it at Export Theme, and //! this is the third. Under the evidence rule three consumers is not drift. //! //! The token chips are a **new** gap and a smaller one: nine buttons that each //! append their own text to the field beside them. Nothing in the vocabulary //! says "put this text into that field" — an `Act` calls a route, and routing a //! keystroke through a handler to change a buffer the renderer owns is the wrong //! shape at every layer. Recorded, not papered over: the described screen names //! the tokens in the field's hint, which keeps the fact and loses the affordance. use quasi_router::layout::{FieldKind, Selector, Tone}; use quasi_router::{ Act, Action, Choice, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject}; /// The region the whole flow answers into. /// /// One region for four screens, because they are four answers to one address /// rather than four places. Nothing navigates between them and the back button /// has nowhere to go, which is the truth: the user cannot walk back into /// configuring an export that is already running. const BODY: &str = "export-body"; /// The largest an AIFF chunk may be, with headroom for the headers. /// /// The shipped screen's number, kept because the warning is the same warning. /// Ninety per cent of `u32::MAX` leaves room for chunk headers and rounding. const AIFF_SAFE_BYTES: f64 = u32::MAX as f64 * 0.9; /// Worst-case bytes per second, for the device size check. /// /// Stereo 24-bit at 48 kHz, which is what the shipped screen assumes and for the /// same reason: the check is a warning, and a warning that under-estimates is /// worse than one that over-estimates. const WORST_CASE_BYTES_PER_SEC: f64 = 288_000.0; /// Register this flow's routes. pub fn routes(router: Router>) -> Router> { router .get("/export", index) .post("/export/begin", begin) .post("/export/set/{setting}", configure) .post("/export/start", start) .post("/export/cancel", cancel) .post("/export/dismiss", dismiss) } /// `GET /export` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(screen(state).into()) } /// `POST /export/begin` /// /// The toolbar's Export button, which the toolbar port left out because it /// "belongs with the import flow, which is its own remaining pass". It is the /// door rather than a stage: the flow's four screens were described first and /// had no way in, so pressing Export was the one thing about exporting that the /// description could not say. /// /// It answers the flow rather than the screen it was pressed on, because the /// flow takes over the pane in the shipped app too. The answer is built before /// the intent lands, so the first frame reads `Idle` and the reload corrects it /// — the standing cost this module's header is about. fn begin(state: &Panels<'_>, _request: Request) -> Result { state.export.open(); Ok(Response::from(Outcome::Goto(Action::get("/export")))) } /// `POST /export/set/{setting}` /// /// One route for every control on the configure screen, which is `settings.rs`'s /// arrangement and works here for the same reason: [`Setting`] closes the set, /// so the route carries no second list of what it will name. /// /// The answer is built **before** the change lands, and that is not a bug in /// this route. The write is an intent the host applies after the frame; the /// host then reloads. See the module header. fn configure(state: &Panels<'_>, request: Request) -> Result { let name = request.captures.require("setting")?; let setting = Setting::from_key(name).ok_or_else(|| RouteError::not_found("no such export setting"))?; let value = request.payload.get(name).unwrap_or_default(); state.export.configure(setting, value); Ok(screen(state).into()) } /// `POST /export/start` fn start(state: &Panels<'_>, _request: Request) -> Result { state.export.start(); Ok(screen(state).into()) } /// `POST /export/cancel` fn cancel(state: &Panels<'_>, _request: Request) -> Result { state.export.cancel(); Ok(screen(state).into()) } /// `POST /export/dismiss` fn dismiss(state: &Panels<'_>, _request: Request) -> Result { state.export.dismiss(); Ok(screen(state).into()) } /// Whichever of the four screens the flow is on. fn screen(state: &Panels<'_>) -> Screen { let body = match state.export.phase() { Phase::Idle => idle(), Phase::Configuring { subjects, profiles, settings, } => configuring(&subjects, &profiles, &settings), Phase::Running { done, total, current, } => running(done, total, ¤t), Phase::Finished { total, errors, destination, } => finished(total, &errors, destination.as_deref()), Phase::Cancelled { done, total, destination, } => cancelled(done, total, destination.as_deref()), }; Screen::sidebar_content("Export").with(body) } /// Nothing to export. /// /// A stand-in rather than an empty pane, and with no way out offered: the export /// flow is entered by selecting samples in the file list, which is a different /// screen. Offering a control here would be inventing an affordance the shipped /// app does not have. fn idle() -> Slot { Slot::new(BODY, RegionKind::Pane).with(Node::empty( "Nothing is being exported. Select samples and choose Export to start.", )) } /// Choosing what and where. fn configuring(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Slot { let mut body = Slot::new(BODY, RegionKind::Pane) .with(Node::page("Export Samples")) .with(Node::text(subject_count(subjects.len(), profiles.len()))); for warning in warnings(subjects, profiles, settings) { body = body.with(warning); } if !profiles.is_empty() { body = body .with(Node::section("Device Profile")) .with(profile_field(profiles, settings.device_profile.as_deref())); // What the lock is hiding, said rather than implied. The shipped screen // puts four muted lines under the picker; each is a fact about the // device, so each is prose. if let Some(chosen) = chosen_profile(profiles, settings.device_profile.as_deref()) { body = body.with(Node::text(describe(chosen))); } } // A profile locks the audio settings, so the description stops naming them: // a control that cannot be used is worse than one that is not there, and the // shipped screen agrees -- it hides the whole block behind `!has_profile`. if settings.device_profile.is_none() { body = body .with(Node::section("Format")) .with(format_field(settings.format)); if settings.format != Format::Original { body = body.with(Node::banner( Tone::Warning, "Re-encoding strips embedded metadata chunks (BWF, iXML, loop points, \ cue markers, ID3). Choose Original to preserve them.", )); body = body .with(Node::section("Sample Rate")) .with(sample_rate_field(settings.sample_rate)) .with(Node::section("Bit Depth")) .with(bit_depth_field(settings.bit_depth)); } body = body .with(Node::section("Channels")) .with(channels_field(settings.channels)); } body = body .with(Node::section("Structure")) .with(structure_field(settings.flatten)) .with(Node::Field(Box::new( Field::new( FieldKind::Checkbox, Setting::Sidecar.as_str(), "Include metadata (.audiofiles.json)", ) .value(if settings.sidecar { "on" } else { "" }) .changes(writes(Setting::Sidecar)), ))); if settings.flatten { body = body .with(Node::section("Naming Pattern")) .with(naming_field(settings.naming_pattern.as_deref())); if let Some(preview) = preview(settings.naming_pattern.as_deref(), subjects.first()) { body = body.with(preview); } } // Read-only, and the module header says why: naming where files go means // opening a native folder dialog, which no description reaches. body = body .with(Node::section("Destination")) .with(Node::text(settings.destination.clone())); body.with(Node::Act(Act::new("Export", Action::post("/export/start")))) .with(Node::Act(Act::new( "Cancel", Action::post("/export/dismiss"), ))) } /// Files being written. fn running(done: usize, total: usize, current: &str) -> Slot { let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Exporting")); // Zero is "the worker has not counted them yet" rather than an empty export, // and a meter of 0/0 would draw as finished. Pending is the honest reading // and it is what `Readiness` is for. body = if total == 0 { body.with(Node::StandIn { state: quasi_router::layout::Readiness::Pending, message: "Starting export...".to_owned(), act: None, }) } else { body.with(Node::Meter( quasi_router::Meter::new(clamp(done), clamp(total)).label("samples"), )) }; if !current.is_empty() { body = body.with(Node::text(format!("Exporting: {current}"))); } body.with(Node::Act(Act::new( "Cancel", Action::post("/export/cancel"), ))) } /// Finished, however it went. fn finished(total: usize, errors: &[(String, String)], destination: Option<&str>) -> Slot { let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Export Complete")); body = if errors.is_empty() { body.with(Node::text(format!("Successfully exported {total} files."))) } else { let listed = body.with(Node::banner( Tone::Danger, format!("Exported {total} files with {} errors.", errors.len()), )); // One list of every failure rather than a list per row: the set is the // thing being reported, and a run of one-row lists would say each error // is its own collection. listed.with(Node::list(errors.iter().map(|(name, error)| { quasi_router::Row::new(name.clone()).secondary(quasi_router::Prose::Text(error.clone())) }))) }; body = body.with(Node::Act(Act::new("Done", Action::post("/export/dismiss")))); match destination { Some(path) => body.with(Node::Act(Act::new( "Open destination folder", Action::external(path), ))), None => body, } } /// Given up on partway. fn cancelled(done: usize, total: usize, destination: Option<&str>) -> Slot { let mut body = Slot::new(BODY, RegionKind::Pane) .with(Node::page("Export Cancelled")) .with(Node::text(format!( "{done} of {total} samples were written before this stopped." ))); if let Some(path) = destination { body = body .with(Node::text(format!( "The files already written are in {path}." ))) .with(Node::Act(Act::new( "Open destination folder", Action::external(path), ))); } body.with(Node::Act(Act::new("Done", Action::post("/export/dismiss")))) } /// What is about to be exported, and what is available to do it with. fn subject_count(subjects: usize, profiles: usize) -> String { let head = format!("{subjects} samples to export"); if profiles == 0 { head } else { format!("{head}. {profiles} device profiles available.") } } /// Everything worth warning about before anything is written. /// /// Two of the shipped screen's three, and the third is named in the module /// header. Both of these are arithmetic over facts the description already /// carries, which is what makes them describable at all. fn warnings(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Vec { let mut said = Vec::new(); if settings.format == Format::Aiff { let longest = subjects .iter() .filter_map(|subject| subject.duration) .fold(0.0_f64, f64::max); let safe = AIFF_SAFE_BYTES / bytes_per_sec(settings).max(1.0); if longest > safe { said.push(Node::banner( Tone::Warning, format!( "AIFF chunks cap at 4 GB. At the current rate, depth and channels, \ samples longer than about {:.0} min may fail to export.", safe / 60.0 ), )); } } if let Some(profile) = chosen_profile(profiles, settings.device_profile.as_deref()) && let Some(cap) = profile.max_file_size_bytes { let over: Vec<&str> = subjects .iter() .filter(|subject| { subject .duration .is_some_and(|seconds| (seconds * WORST_CASE_BYTES_PER_SEC) as u64 > cap) }) .map(|subject| subject.name.as_str()) .collect(); let megabytes = cap as f64 / 1_048_576.0; match over.as_slice() { [] => {} [only] => said.push(Node::banner( Tone::Danger, format!("\"{only}\" may exceed the device file size limit ({megabytes:.0} MB)."), )), many => said.push(Node::banner( Tone::Danger, format!( "{} samples may exceed the device file size limit ({megabytes:.0} MB).", many.len() ), )), } } said } /// What one second of audio costs at these settings. /// /// The shipped screen's `bytes_per_sec`, against the described settings rather /// than the config. Defaults bias high — an absent rate or depth is the largest /// each may be — because this feeds a warning and a warning that under-estimates /// is the one that does harm. fn bytes_per_sec(settings: &Settings) -> f64 { let rate = f64::from(settings.sample_rate.unwrap_or(48_000)); let depth = f64::from(settings.bit_depth.unwrap_or(24)) .div_euclid(8.0) .max(1.0); let channels = match settings.channels { Channels::Mono => 1.0, Channels::Stereo | Channels::Original => 2.0, }; rate * depth * channels } /// The profile in force, if one is. fn chosen_profile<'a>( profiles: &'a [ProfileChoice], chosen: Option<&str>, ) -> Option<&'a ProfileChoice> { let name = chosen?; profiles.iter().find(|profile| profile.name == name) } /// What a device profile says about itself, as one line. /// /// Joined rather than four nodes, because the four facts are one statement about /// one device and the shipped screen's four muted labels are a layout choice. fn describe(profile: &ProfileChoice) -> String { let mut said = vec![format!("by {}", profile.manufacturer)]; said.extend(profile.summary.clone()); said.extend(profile.category.clone()); said.extend(profile.notes.clone()); said.join(". ") } /// The device profile picker. /// /// A `Field` rather than a `Node::Select` for `settings.rs`'s reason: the choice /// count is open — profiles are plugins — so it has to be able to fold away, and /// that is a dropdown. fn profile_field(profiles: &[ProfileChoice], chosen: Option<&str>) -> Node { let mut options = vec![Choice::new(String::new(), "None (manual)")]; options.extend(profiles.iter().map(|profile| { Choice::new( profile.name.clone(), format!("{} ({})", profile.name, profile.manufacturer), ) })); let mut field = Field::select(Setting::DeviceProfile.as_str(), "Device profile", options) .changes(writes(Setting::DeviceProfile)); field.value = Some(chosen.unwrap_or_default().to_owned()); Node::Field(Box::new(field)) } /// What to write. fn format_field(format: Format) -> Node { let chosen = match format { Format::Original => "original", Format::Wav => "wav", Format::Aiff => "aiff", }; picker( Setting::Format, chosen, [ ("original", "Original (copy as-is)"), ("wav", "WAV (decode and re-encode)"), ("aiff", "AIFF (decode and re-encode)"), ], ) } /// The sample rate to write at. fn sample_rate_field(rate: Option) -> Node { let chosen = rate.map_or_else(String::new, |rate| rate.to_string()); picker( Setting::SampleRate, &chosen, [ ("", "Original"), ("44100", "44,100 Hz"), ("48000", "48,000 Hz"), ("96000", "96,000 Hz"), ], ) } /// The bit depth to write at. fn bit_depth_field(depth: Option) -> Node { let chosen = depth.map_or_else(String::new, |depth| depth.to_string()); picker( Setting::BitDepth, &chosen, [("", "Original"), ("16", "16-bit"), ("24", "24-bit")], ) } /// The channel layout to write. fn channels_field(channels: Channels) -> Node { let chosen = match channels { Channels::Original => "original", Channels::Mono => "mono", Channels::Stereo => "stereo", }; picker( Setting::Channels, chosen, [ ("original", "Original"), ("mono", "Mono"), ("stereo", "Stereo"), ], ) } /// Whether the tree survives the export. fn structure_field(flatten: bool) -> Node { picker( Setting::Flatten, if flatten { "on" } else { "" }, [ ("", "Preserve tree"), ("on", "Flatten (all files in one folder)"), ], ) } /// One of a handful of choices, drawn as a strip. /// /// `Selector::Segmented` and not a `Field`, which is the line `settings.rs` /// drew: a handful of options that do not fold away is a strip, and the shipped /// screen draws every one of these as a column of radios. Naming the widget /// would be the description choosing a control; naming "exactly one of these /// few" is describing the choice. fn picker<'a>( setting: Setting, chosen: &str, options: impl IntoIterator, ) -> Node { Node::Select { kind: Selector::Segmented, options: options .into_iter() .map(|(value, label)| (Choice::new(value, label), None)) .collect(), chosen: Some(chosen.to_owned()), action: Some(writes(setting)), } } /// How to name the output files. /// /// The hint carries the tokens, which is the token-chip finding in its degraded /// form: the nine names survive and the click-to-append does not. fn naming_field(pattern: Option<&str>) -> Node { Node::Field(Box::new( Field::new( FieldKind::Text, Setting::NamingPattern.as_str(), "Naming pattern", ) .value(pattern.unwrap_or_default()) .hint("Tokens: {name} {bpm} {key} {class} {duration} {n} {nn} {nnn} {ext}") .changes(writes(Setting::NamingPattern)), )) } /// What the first file would be called. /// /// Describable because it is pure: parsing a pattern and resolving it against /// one item's own fields reaches nothing outside the description. A parse /// failure is the point rather than an error to swallow — it is what catches a /// typo before two hundred files are written under it. fn preview(pattern: Option<&str>, first: Option<&Subject>) -> Option { let pattern = pattern.filter(|pattern| !pattern.is_empty())?; match audiofiles_core::rename::RenamePattern::parse(pattern) { Ok(parsed) => { let first = first?; let stem = parsed.resolve(&audiofiles_core::rename::RenameContext { name: first.name.clone(), extension: first.ext.clone(), bpm: first.bpm, musical_key: first.musical_key.clone(), duration: first.duration, index: 0, }); let named = if first.ext.is_empty() { stem } else { format!("{stem}.{}", first.ext) }; Some(Node::text(format!("Preview: {named}"))) } Err(error) => Some(Node::banner(Tone::Warning, format!("Pattern: {error}"))), } } /// The address a control changing this setting calls. fn writes(setting: Setting) -> Action { Action::post(format!("/export/set/{}", setting.as_str())) } /// A count as the meter carries one. /// /// Saturating rather than `as`, because a truncating cast on a count that came /// from a worker is the kind of arithmetic that reads as fine and is not. fn clamp(count: usize) -> u32 { u32::try_from(count).unwrap_or(u32::MAX) }