Skip to main content

max / audiofiles

Describe the export flow Five phases as five members of one read rather than five returns with a match around them, which is sync.rs's shape: the region is one region and the description loops over the member that is there. Eight declarations for sixteen shapes. Nine were restructured away rather than declared: the seven field wrappers and `picker` are written where the fields are used, since a field stands in a region without a form around it; `preview` folded into the read as two mutually exclusive strings, which is the third R10 candidate to turn out to want a loop over an Option instead; `setting_route` is an address said where it is used, as `knob_route` was; and `idle` is one guarded `empty` in the screen's own region. `warnings` answers `Vec<Warning>` rather than `Vec<Node>`, so the arithmetic stays a read and the banners are written in the declaration. No production earned: everything this screen says, the form already said.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 23:43 UTC
Signed with PGP, not checked
Commit: 52fcfec54ac1d8a08960a193b33f506bc9898fa4
Parent: 41b7750
1 file changed, +386 insertions, -59 deletions
@@ -91,18 +91,16 @@
91 91 //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
92 92 //! [`Locating::labelled`]: quasi_router::Locating::labelled
93 93
94 - use quasi_router::layout::{FieldKind, Tone};
95 - use quasi_router::{
96 - Act, Action, Choice, Field, Locating, Node, Outcome, RegionKind, Request, Response, RouteError,
97 - Router, Screen, Slot,
98 - };
94 + use quasi_declare::declare;
95 + use quasi_router::layout::Tone;
96 + use quasi_router::{Action, Choice, Locating, Outcome, Request, Response, RouteError, Router};
99 97
100 - use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject};
98 + use super::{Channels, Export, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject};
101 99
102 100 /// The region the whole flow answers into.
103 101 ///
104 - /// One region for four screens, because they are four answers to one address
105 - /// rather than four places. Nothing navigates between them and the back button
102 + /// One region for five screens, because they are five answers to one address
103 + /// rather than five places. Nothing navigates between them and the back button
106 104 /// has nowhere to go, which is the truth: the user cannot walk back into
107 105 /// configuring an export that is already running.
108 106 const BODY: &str = "export-body";
@@ -134,7 +132,7 @@
134 132
135 133 /// `GET /export`
136 134 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
137 - Ok(screen(state).into())
135 + Ok(showing(state))
138 136 }
139 137
140 138 /// `POST /export/begin`
@@ -169,7 +167,7 @@
169 167 Setting::from_key(name).ok_or_else(|| RouteError::not_found("no such export setting"))?;
170 168 let value = request.payload.get(name).unwrap_or_default();
171 169 state.export.configure(setting, value);
172 - Ok(screen(state).into())
170 + Ok(showing(state))
173 171 }
174 172
175 173 /// `POST /export/destination`
@@ -189,7 +187,7 @@
189 187 fn destination(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
190 188 Ok(Response::locate(Locating::folder(
191 189 "Export Destination",
192 - setting_route(Setting::Destination),
190 + Action::post("/export/set/destination"),
193 191 Setting::Destination.as_str(),
194 192 )))
195 193 }
@@ -197,221 +195,590 @@
197 195 /// `POST /export/start`
198 196 fn start(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
199 197 state.export.start();
200 - Ok(screen(state).into())
198 + Ok(showing(state))
201 199 }
202 200
203 201 /// `POST /export/cancel`
204 202 fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
205 203 state.export.cancel();
206 - Ok(screen(state).into())
204 + Ok(showing(state))
207 205 }
208 206
209 207 /// `POST /export/dismiss`
210 208 fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
211 209 state.export.dismiss();
212 - Ok(screen(state).into())
210 + Ok(showing(state))
213 211 }
214 212
215 - /// Whichever of the four screens the flow is on.
216 - fn screen(state: &Panels<'_>) -> Screen {
217 - let body = match state.export.phase() {
218 - Phase::Idle => idle(),
219 - Phase::Configuring {
220 - subjects,
221 - profiles,
222 - settings,
223 - available_bytes,
224 - } => configuring(&subjects, &profiles, &settings, available_bytes),
225 - Phase::Running {
226 - done,
227 - total,
228 - current,
229 - } => running(done, total, &current),
230 - Phase::Finished {
231 - total,
232 - errors,
233 - destination,
234 - } => finished(total, &errors, destination.as_deref()),
235 - Phase::Cancelled {
236 - done,
237 - total,
238 - destination,
239 - } => cancelled(done, total, destination.as_deref()),
240 - };
241 - Screen::sidebar_content("Export").with(body)
213 + /// The screen, read and then described.
214 + fn showing(state: &Panels<'_>) -> Response {
215 + Response::from(screen(&read(state.export)))
242 216 }
243 217
244 - /// Nothing to export.
218 + /// Whichever of the five screens the flow is on.
245 219 ///
246 - /// A stand-in rather than an empty pane, and with no way out offered: the export
247 - /// flow is entered by selecting samples in the file list, which is a different
248 - /// screen. Offering a control here would be inventing an affordance the shipped
249 - /// app does not have.
250 - fn idle() -> Slot {
251 - Slot::new(BODY, RegionKind::Pane).with(Node::empty(
252 - "Nothing is being exported. Select samples and choose Export to start.",
253 - ))
220 + /// Five members rather than five returns, because the region is one region: the
221 + /// flow is in exactly one phase, and the description says which by looping over
222 + /// the one member that is there. A match assigning the region five ways would
223 + /// have to remember, in each arm, everything the other four do not draw.
224 + struct Exporting {
225 + /// Whether nothing is being exported and nothing is being set up.
226 + idle: bool,
227 + /// Choosing what and where, while that is what is happening.
228 + configuring: Option<Configuring>,
229 + /// Files being written, while any are.
230 + running: Option<Running>,
231 + /// However it went, once it is over.
232 + finished: Option<Finished>,
233 + /// How far it got, where it was given up on.
234 + cancelled: Option<Cancelled>,
254 235 }
255 236
256 - /// Choosing what and where.
257 - fn configuring(
237 + /// Choosing what and where, before anything is written.
238 + struct Configuring {
239 + /// What is about to be exported, and what is available to do it with.
240 + said: String,
241 + /// Everything worth saying before anything is written.
242 + warnings: Vec<Warning>,
243 + /// The profile picker, while there are profiles to pick from.
244 + ///
245 + /// Absent rather than empty when there are none: an empty dropdown is a
246 + /// control that cannot be used.
247 + device: Option<Device>,
248 + /// The audio settings, while no profile is locking them.
249 + audio: Option<Audio>,
250 + /// Whether the tree survives the export, as the control carries it.
251 + flatten: &'static str,
252 + /// Whether a sidecar goes beside each file, as the control carries it.
253 + sidecar: &'static str,
254 + /// How to name the files, while they are all landing in one folder.
255 + naming: Option<Naming>,
256 + /// Where they go, as the host spells the path.
257 + destination: String,
258 + }
259 +
260 + /// Something worth warning about before anything is written.
261 + struct Warning {
262 + said: String,
263 + tone: Tone,
264 + }
265 +
266 + /// The device profiles on offer, and what the chosen one says about itself.
267 + struct Device {
268 + /// Every profile, and the manual answer that is not one.
269 + options: Vec<Choice>,
270 + /// The one in force, or empty for manual.
271 + chosen: String,
272 + /// What that profile says about itself, as one line.
273 + describes: Option<String>,
274 + }
275 +
276 + /// What to write and at what, which only exists while nothing is locking it.
277 + struct Audio {
278 + /// The format, as the control carries it.
279 + format: &'static str,
280 + /// Whether that format means decoding and re-encoding.
281 + ///
282 + /// What it costs is said on the control that chose it rather than in a
283 + /// banner under it: a note is the same sentence attached to the answer that
284 + /// earns it.
285 + reencoding: bool,
286 + /// The rate and the depth, which are only questions when re-encoding.
287 + resampling: Option<Resampling>,
288 + /// The channel layout, as the control carries it.
289 + channels: &'static str,
290 + }
291 +
292 + /// What to re-encode at.
293 + struct Resampling {
294 + /// The sample rate, or empty for each file's own.
295 + rate: String,
296 + /// The bit depth, or empty for each file's own.
297 + depth: String,
298 + }
299 +
300 + /// How to name the output files, and what the first one would be called.
301 + struct Naming {
302 + /// The pattern as it stands.
303 + pattern: String,
304 + /// What the first file would be called, where the pattern resolves.
305 + named: Option<String>,
306 + /// What is wrong with the pattern, where it does not parse.
307 + ///
308 + /// At most one of the two, and neither when there is nothing to resolve
309 + /// against. A parse failure is the point rather than an error to swallow: it
310 + /// is what catches a typo before two hundred files are written under it.
311 + problem: Option<String>,
312 + }
313 +
314 + /// Files being written.
315 + struct Running {
316 + /// Whether the worker has not counted the files yet.
317 + ///
318 + /// Zero is that rather than an empty export, and a meter of zero of zero
319 + /// would draw as finished. Pending is the honest reading.
320 + starting: bool,
321 + /// How far along, once there is a total to be a proportion of.
322 + gauge: Option<Gauge>,
323 + /// The file being written now, while one is named.
324 + current: Option<String>,
325 + }
326 +
327 + /// Files written against files to write.
328 + struct Gauge {
329 + done: u32,
330 + total: u32,
331 + }
332 +
333 + /// Finished, however it went.
334 + struct Finished {
335 + /// What a clean finish says.
336 + said: Option<String>,
337 + /// What a dirty one says, and every file that failed.
338 + failures: Option<Failures>,
339 + /// Where the files landed, where the flow was told.
340 + destination: Option<String>,
341 + }
342 +
343 + /// The files that did not make it.
344 + struct Failures {
345 + /// How many, and out of how many.
346 + said: String,
347 + /// Each one, by name and by what went wrong.
348 + each: Vec<Failure>,
349 + }
350 +
351 + /// One file that did not make it.
352 + struct Failure {
353 + name: String,
354 + said: String,
355 + }
356 +
357 + /// Given up on partway.
358 + struct Cancelled {
359 + /// How far it got before it stopped.
360 + said: String,
361 + /// Where the partial files sit, where the flow was told.
362 + destination: Option<String>,
363 + }
364 +
365 + /// What the flow draws, read off the app.
366 + fn read(export: &dyn Export) -> Exporting {
367 + let phase = export.phase();
368 + Exporting {
369 + idle: matches!(phase, Phase::Idle),
370 + configuring: match &phase {
371 + Phase::Configuring {
372 + subjects,
373 + profiles,
374 + settings,
375 + available_bytes,
376 + } => Some(configuring_read(
377 + subjects,
378 + profiles,
379 + settings,
380 + *available_bytes,
381 + )),
382 + _ => None,
383 + },
384 + running: match &phase {
385 + Phase::Running {
386 + done,
387 + total,
388 + current,
389 + } => Some(running_read(*done, *total, current)),
390 + _ => None,
391 + },
392 + finished: match &phase {
393 + Phase::Finished {
394 + total,
395 + errors,
396 + destination,
397 + } => Some(finished_read(*total, errors, destination.as_deref())),
398 + _ => None,
399 + },
400 + cancelled: match &phase {
401 + Phase::Cancelled {
402 + done,
403 + total,
404 + destination,
405 + } => Some(cancelled_read(*done, *total, destination.as_deref())),
406 + _ => None,
407 + },
408 + }
409 + }
410 +
411 + /// The configure screen, read off what is going and what it may be written as.
412 + fn configuring_read(
258 413 subjects: &[Subject],
259 414 profiles: &[ProfileChoice],
260 415 settings: &Settings,
261 416 available: Option<u64>,
262 - ) -> Slot {
263 - let mut body = Slot::new(BODY, RegionKind::Pane)
264 - .with(Node::page("Export Samples"))
265 - .with(Node::text(subject_count(subjects.len(), profiles.len())));
417 + ) -> Configuring {
418 + Configuring {
419 + said: subject_count(subjects.len(), profiles.len()),
420 + warnings: warnings(subjects, profiles, settings, available),
421 + device: (!profiles.is_empty())
422 + .then(|| device_read(profiles, settings.device_profile.as_deref())),
423 + audio: settings
424 + .device_profile
425 + .is_none()
426 + .then(|| audio_read(settings)),
427 + flatten: switched(settings.flatten),
428 + sidecar: switched(settings.sidecar),
429 + naming: settings
430 + .flatten
431 + .then(|| naming_read(settings.naming_pattern.as_deref(), subjects.first())),
432 + destination: settings.destination.clone(),
433 + }
434 + }
266 435
267 - for warning in warnings(subjects, profiles, settings, available) {
268 - body = body.with(warning);
436 + /// The profiles on offer, and whatever the chosen one says about itself.
437 + fn device_read(profiles: &[ProfileChoice], chosen: Option<&str>) -> Device {
438 + let mut options = vec![Choice::new(String::new(), "None (manual)")];
439 + options.extend(profiles.iter().map(|profile| {
440 + Choice::new(
441 + profile.name.clone(),
442 + format!("{} ({})", profile.name, profile.manufacturer),
443 + )
444 + }));
445 + Device {
446 + options,
447 + chosen: chosen.unwrap_or_default().to_owned(),
448 + describes: chosen_profile(profiles, chosen).map(describe),
449 + }
450 + }
451 +
452 + /// What to write and at what, read off the settings.
453 + fn audio_read(settings: &Settings) -> Audio {
454 + let reencoding = settings.format != Format::Original;
455 + Audio {
456 + format: match settings.format {
457 + Format::Original => "original",
458 + Format::Wav => "wav",
459 + Format::Aiff => "aiff",
460 + },
461 + reencoding,
462 + resampling: reencoding.then(|| Resampling {
463 + rate: settings
464 + .sample_rate
465 + .map_or_else(String::new, |rate| rate.to_string()),
466 + depth: settings
467 + .bit_depth
468 + .map_or_else(String::new, |depth| depth.to_string()),
469 + }),
470 + channels: match settings.channels {
471 + Channels::Original => "original",
472 + Channels::Mono => "mono",
473 + Channels::Stereo => "stereo",
474 + },
475 + }
476 + }
477 +
478 + /// The naming pattern, and what the first file would be called under it.
479 + ///
480 + /// Describable because it is pure: parsing a pattern and resolving it against
481 + /// one item's own fields reaches nothing outside the description.
482 + fn naming_read(pattern: Option<&str>, first: Option<&Subject>) -> Naming {
483 + let mut naming = Naming {
484 + pattern: pattern.unwrap_or_default().to_owned(),
485 + named: None,
486 + problem: None,
487 + };
488 + let Some(source) = pattern.filter(|pattern| !pattern.is_empty()) else {
489 + return naming;
490 + };
491 + match audiofiles_core::rename::RenamePattern::parse(source) {
492 + Ok(parsed) => {
493 + if let Some(first) = first {
494 + let stem = parsed.resolve(&audiofiles_core::rename::RenameContext {
495 + name: first.name.clone(),
496 + extension: first.ext.clone(),
497 + bpm: first.bpm,
498 + musical_key: first.musical_key.clone(),
499 + duration: first.duration,
500 + index: 0,
501 + });
502 + naming.named = Some(if first.ext.is_empty() {
503 + stem
504 + } else {
505 + format!("{stem}.{}", first.ext)
506 + });
507 + }
508 + }
509 + Err(error) => naming.problem = Some(error.to_string()),
510 + }
511 + naming
512 + }
513 +
514 + /// How far a running export has got.
515 + fn running_read(done: usize, total: usize, current: &str) -> Running {
516 + Running {
517 + starting: total == 0,
518 + gauge: (total > 0).then(|| Gauge {
519 + done: clamp(done),
520 + total: clamp(total),
521 + }),
522 + current: (!current.is_empty()).then(|| current.to_owned()),
523 + }
524 + }
525 +
526 + /// How a finished export went.
527 + fn finished_read(total: usize, errors: &[(String, String)], destination: Option<&str>) -> Finished {
528 + Finished {
529 + said: errors
530 + .is_empty()
531 + .then(|| format!("Successfully exported {total} files.")),
532 + failures: (!errors.is_empty()).then(|| Failures {
533 + said: format!("Exported {total} files with {} errors.", errors.len()),
534 + each: errors
535 + .iter()
536 + .map(|(name, said)| Failure {
537 + name: name.clone(),
538 + said: said.clone(),
539 + })
540 + .collect(),
541 + }),
542 + destination: destination.map(str::to_owned),
543 + }
544 + }
545 +
546 + /// How far a cancelled export had got.
547 + fn cancelled_read(done: usize, total: usize, destination: Option<&str>) -> Cancelled {
548 + Cancelled {
549 + said: format!("{done} of {total} samples were written before this stopped."),
550 + destination: destination.map(str::to_owned),
551 + }
552 + }
553 +
554 + declare! {
555 + /// The screen, which is a different screen per phase.
556 + ///
557 + /// One route answering five shapes rather than five routes: the phase is not
558 + /// an address, and a user cannot navigate to `Running` -- they arrive there
559 + /// because something happened. Five addresses would be five places you could
560 + /// bookmark into a lie.
561 + shape screen(flow: &Exporting) -> Screen;
562 +
563 + screen sidebar_content "Export" {
564 + region BODY as Pane {
565 + // A stand-in rather than an empty pane, and with no way out
566 + // offered: the flow is entered by selecting samples in the file
567 + // list, which is a different screen. Offering a control here would
568 + // be inventing an affordance the shipped app does not have.
569 + empty "Nothing is being exported. Select samples and choose Export \
570 + to start." when flow.idle;
571 +
572 + for setup in flow.configuring.iter() {
573 + extend configuring(setup);
574 + }
575 + for run in flow.running.iter() {
576 + extend running(run);
577 + }
578 + for done in flow.finished.iter() {
579 + extend finished(done);
580 + }
581 + for stopped in flow.cancelled.iter() {
582 + extend cancelled(stopped);
583 + }
584 + }
585 + }
586 + }
587 +
588 + declare! {
589 + /// Choosing what and where.
590 + shape configuring(setup: &Configuring) -> Vec<Node>;
591 +
592 + page "Export Samples";
593 + text &setup.said;
594 +
595 + for warning in setup.warnings.iter() {
596 + banner warning.tone &warning.said;
269 597 }
270 598
271 - if !profiles.is_empty() {
Lines truncated