Skip to main content

max / audiofiles

Describe the export flow, and stop described screens going stale The fourth port and the first that is a flow rather than a screen: four answers to one address, and which one shows is a fact about the app rather than something the user navigates to. It found the hole the three ports before it had been living with. A route answers a screen built from the state at the moment it was asked, and the runtime keeps that answer until something fires. files.rs's sort caret was one interaction stale because its write is an intent applied after the frame; nobody noticed, because a caret that is wrong until the next click reads as a rendering quirk. Here every control on the configure screen has that shape, and the progress screen moves with no user input at all. quasi 0.12.0's Runtime::reload is the answer. The export window refreshes every frame, since a worker writing files gives the host no event to hang a refresh on; the other three refresh after an intent lands. Describable: the AIFF chunk warning, the device size warning, the naming pattern's live preview. Not: the disk-space check (statvfs on the destination), the Browse button (a native folder dialog, and the third consumer of a finding both prior ports filed), the token chips (nothing says "put this text into that field"; the hint names the tokens instead).
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 01:28 UTC
Signed with PGP, not checked
Commit: ad861beaf5f9c52ee04ff527395dd05fa042bcdc
Parent: 5a68342
7 files changed, +1525 insertions, -59 deletions
M Cargo.lock +7 -7
@@ -4242,7 +4242,7 @@
4242 4242
4243 4243 [[package]]
4244 4244 name = "quasi-immediate"
4245 - version = "0.11.0"
4245 + version = "0.12.0"
4246 4246 dependencies = [
4247 4247 "docengine",
4248 4248 "egui",
@@ -4252,7 +4252,7 @@
4252 4252
4253 4253 [[package]]
4254 4254 name = "quasi-router"
4255 - version = "0.11.0"
4255 + version = "0.12.0"
4256 4256 dependencies = [
4257 4257 "makeover-layout",
4258 4258 ]
@@ -7543,15 +7543,15 @@
7543 7543
7544 7544 [[patch.unused]]
7545 7545 name = "quasi-axum"
7546 - version = "0.11.0"
7546 + version = "0.12.0"
7547 7547
7548 7548 [[patch.unused]]
7549 7549 name = "quasi-basics"
7550 - version = "0.11.0"
7550 + version = "0.12.0"
7551 7551
7552 7552 [[patch.unused]]
7553 7553 name = "quasi-http"
7554 - version = "0.11.0"
7554 + version = "0.12.0"
7555 7555
7556 7556 [[patch.unused]]
7557 7557 name = "quasi-store"
@@ -7559,11 +7559,11 @@
7559 7559
7560 7560 [[patch.unused]]
7561 7561 name = "quasi-tauri"
7562 - version = "0.11.0"
7562 + version = "0.12.0"
7563 7563
7564 7564 [[patch.unused]]
7565 7565 name = "quasi-webview"
7566 - version = "0.11.0"
7566 + version = "0.12.0"
7567 7567
7568 7568 [[patch.unused]]
7569 7569 name = "kberg"
M Cargo.toml +2 -2
@@ -21,8 +21,8 @@
21 21 makeover-immediate = "0.25.0"
22 22 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 23 # with a version requirement, per the tree's rule for cross-repo deps.
24 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.11" }
25 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.11" }
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.12" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.12" }
26 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
27 27 egui_extras = { version = "0.35", default-features = false }
28 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -183,6 +183,25 @@
183 183 crate::quasi::panel::draw_files(ctx, state);
184 184 }
185 185
186 + // The described export flow, beside whichever of the shipped export screens
187 + // is showing. On the flow's own state rather than on a toggle, because the
188 + // shipped side is not a window either: it takes over the central pane, and
189 + // which of its three screens is showing is `import_mode`. So the described
190 + // window opens when the flow does and closes when it ends.
191 + #[cfg(feature = "quasi")]
192 + if matches!(
193 + state.import_wf.import_mode,
194 + crate::state::ImportMode::ConfigureExport { .. }
195 + | crate::state::ImportMode::Exporting { .. }
196 + | crate::state::ImportMode::ExportComplete { .. }
197 + | crate::state::ImportMode::OperationCancelled {
198 + kind: crate::state::CancelKind::Export,
199 + ..
200 + }
201 + ) {
202 + crate::quasi::panel::draw_export(ctx, state);
203 + }
204 +
186 205 // Sync panel overlay
187 206 if state.sync.show_panel {
188 207 // The described one beside it, on the same toggle. `None` is the case
@@ -10,27 +10,37 @@
10 10 //!
11 11 //! A handler is `fn(&S, Request)`: sync, holding only what the app put in `S`.
12 12 //! [`Panels`] is therefore the *narrowest* thing the described screens need
13 - //! rather than the whole of [`BrowserState`](crate::state::BrowserState), and
14 - //! that turns out to be two things:
13 + //! rather than the whole of [`BrowserState`](crate::state::BrowserState). One
14 + //! capability per screen plus one host fact, and each screen's type says which
15 + //! of them it may touch:
15 16 //!
16 - //! - **The backend**, whose `get_config` and `set_config` already take `&self`.
17 - //! Every control on the settings screen writes a `user_config` key, so this is
18 - //! the whole of what the screen does.
19 - //! - **The themes**, resolved once by the host. This is the settled rule from
20 - //! goingson's settings port applied first time out: *a host fact readable at
21 - //! startup goes in `S`*, resolved where the app still has a handle to ask. The
22 - //! alternative — a capability surface on quasi — was refused on 2026-08-09 and
23 - //! nothing here reopens it.
17 + //! | Capability | Screen | Written through |
18 + //! |---|---|---|
19 + //! | [`Config`] | [`settings`] | the backend's own `&self` methods |
20 + //! | [`Sync`] | [`sync`] | the sync manager's own `&self` methods |
21 + //! | [`Files`] | [`files`] | an [`Intent`], applied after the frame |
22 + //! | [`Export`] | [`export`] | an [`Intent`], applied after the frame |
23 + //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
24 24 //!
25 - //! Notably absent is anything `&mut`. The described screens read and write
26 - //! through the backend and touch no in-memory UI state, which is what makes the
27 - //! feature safe to leave off: with it off, nothing here is compiled at all.
25 + //! The themes are the settled rule from goingson's settings port applied first
26 + //! time out: *a host fact readable at startup goes in `S`*, resolved where the
27 + //! app still has a handle to ask. The alternative — a capability surface on
28 + //! quasi — was refused on 2026-08-09 and nothing here reopens it.
29 + //!
30 + //! Notably absent is anything `&mut`, and the right-hand column is why it can
31 + //! be. Two of the four write through a handle that already takes `&self`; the
32 + //! other two write to the app's own UI state, which a route cannot hold, so they
33 + //! record an [`Intent`] and the panel applies it with the `&mut` the app has
34 + //! anyway. See [`files`]'s header for the rule and [`export`]'s for what it
35 + //! costs — an intent lands after the answer was built, which is what
36 + //! `Runtime::reload` exists to correct.
28 37
29 38 // Handlers take their request by value because `quasi_router::Handler` is a
30 39 // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
31 40 // a choice made here.
32 41 #![allow(clippy::needless_pass_by_value)]
33 42
43 + pub mod export;
34 44 pub mod files;
35 45 pub mod panel;
36 46 pub mod settings;
@@ -466,6 +476,14 @@
466 476 Play(i64),
467 477 /// Order by a column.
468 478 SortBy(String),
479 + /// Change one export setting.
480 + Configure(Setting, String),
481 + /// Begin the export.
482 + StartExport,
483 + /// Give up on the running one.
484 + CancelExport,
485 + /// Put the flow away.
486 + DismissExport,
469 487 }
470 488
471 489 /// The app's file list, as the narrow thing a described screen borrows.
@@ -545,6 +563,345 @@
545 563 }
546 564 }
547 565
566 + /// Where the export flow has got to.
567 + ///
568 + /// The phase carries what only exists in it, which is the shape the app's own
569 + /// `ImportMode` already has: there are no items to configure while an export is
570 + /// running and no errors to read before one has finished. A flat struct with
571 + /// everything optional would have made every screen ask whether the field it
572 + /// wants is there this time.
573 + #[derive(Debug, Clone, PartialEq)]
574 + pub enum Phase {
575 + /// No export in progress and none being set up.
576 + Idle,
577 + /// Choosing what and where, before anything is written.
578 + Configuring {
579 + /// What would be exported.
580 + subjects: Vec<Subject>,
581 + /// The device profiles on offer.
582 + profiles: Vec<ProfileChoice>,
583 + /// The settings as they stand.
584 + settings: Settings,
585 + },
586 + /// Files being written.
587 + Running {
588 + /// How many have been written.
589 + done: usize,
590 + /// How many there are. Zero before the worker has counted them, which
591 + /// the screen reports as pending rather than as an empty export.
592 + total: usize,
593 + /// The one being written now.
594 + current: String,
595 + },
596 + /// Finished, with whatever went wrong on the way.
597 + Finished {
598 + /// How many were written.
599 + total: usize,
600 + /// The ones that failed, by name.
601 + errors: Vec<(String, String)>,
602 + /// Where they landed.
603 + destination: Option<String>,
604 + },
605 + /// Given up on partway.
606 + Cancelled {
607 + /// How many had been written when it stopped.
608 + done: usize,
609 + /// How many there would have been.
610 + total: usize,
611 + /// Where the partial files sit.
612 + destination: Option<String>,
613 + },
614 + }
615 +
616 + /// One sample about to be exported, as the description needs to name it.
617 + ///
618 + /// [`Sample`]'s peer for a different screen, and separate from it for the reason
619 + /// that type is separate from the app's own node: what the export screen needs
620 + /// is the rename context and the duration, and what the file list needs is the
621 + /// row. Sharing one type would put every field either screen wants in both.
622 + #[derive(Debug, Clone, PartialEq)]
623 + pub struct Subject {
624 + /// What it is called.
625 + pub name: String,
626 + /// Its extension, without the dot.
627 + pub ext: String,
628 + /// How long it runs, in seconds.
629 + pub duration: Option<f64>,
630 + /// Beats per minute, where analysis found some.
631 + pub bpm: Option<f64>,
632 + /// The musical key, where analysis found one.
633 + pub musical_key: Option<String>,
634 + }
635 +
636 + /// A device profile, as the description needs to name it.
637 + #[derive(Debug, Clone, PartialEq, Eq)]
638 + pub struct ProfileChoice {
639 + /// What the device is called, which is also what the config stores.
640 + pub name: String,
641 + /// Who makes it.
642 + pub manufacturer: String,
643 + /// What it accepts, as the registry phrases it.
644 + pub summary: Option<String>,
645 + /// What kind of device it is.
646 + pub category: Option<String>,
647 + /// Anything else the manifest said.
648 + pub notes: Option<String>,
649 + /// The largest file it will take, if it says.
650 + pub max_file_size_bytes: Option<u64>,
651 + }
652 +
653 + /// The export settings, as the description names them.
654 + #[derive(Debug, Clone, PartialEq, Eq)]
655 + pub struct Settings {
656 + /// What to write.
657 + pub format: Format,
658 + /// Target sample rate, or `None` to keep each file's own.
659 + pub sample_rate: Option<u32>,
660 + /// Target bit depth, or `None` to keep each file's own.
661 + pub bit_depth: Option<u16>,
662 + /// Target channel layout.
663 + pub channels: Channels,
664 + /// Whether every file lands in one folder.
665 + pub flatten: bool,
666 + /// Whether a `.audiofiles.json` sidecar goes beside each file.
667 + pub sidecar: bool,
668 + /// How to name the output files, when flattened.
669 + pub naming_pattern: Option<String>,
670 + /// Where they go, as the host spells the path.
671 + pub destination: String,
672 + /// The device profile in force, which locks the audio settings.
673 + pub device_profile: Option<String>,
674 + }
675 +
676 + /// What to write.
677 + ///
678 + /// Mirrored rather than re-exported, for the reason [`State`] is: a described
679 + /// screen should not depend on the shape of the thing it reports on.
680 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
681 + pub enum Format {
682 + /// Copy each file as it is.
683 + Original,
684 + /// Decode and re-encode as WAV.
685 + Wav,
686 + /// Decode and re-encode as AIFF.
687 + Aiff,
688 + }
689 +
690 + /// The channel layout to write.
691 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
692 + pub enum Channels {
693 + /// Keep each file's own.
694 + Original,
695 + /// Mix down to one.
696 + Mono,
697 + /// Mix to two.
698 + Stereo,
699 + }
700 +
701 + /// The settings a described control may change.
702 + ///
703 + /// A closed set, which is what lets one write route serve the whole screen the
704 + /// way `ConfigKey` lets `settings.rs` have one. Without it the route would carry
705 + /// a second list of what it is willing to name, and the two would drift.
706 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
707 + pub enum Setting {
708 + /// [`Settings::format`].
709 + Format,
710 + /// [`Settings::sample_rate`].
711 + SampleRate,
712 + /// [`Settings::bit_depth`].
713 + BitDepth,
714 + /// [`Settings::channels`].
715 + Channels,
716 + /// [`Settings::flatten`].
717 + Flatten,
718 + /// [`Settings::sidecar`].
719 + Sidecar,
720 + /// [`Settings::naming_pattern`].
721 + NamingPattern,
722 + /// [`Settings::device_profile`].
723 + DeviceProfile,
724 + }
725 +
726 + impl Setting {
727 + /// The name a described address is built from.
728 + #[must_use]
729 + pub const fn as_str(self) -> &'static str {
730 + match self {
731 + Self::Format => "format",
732 + Self::SampleRate => "sample-rate",
733 + Self::BitDepth => "bit-depth",
734 + Self::Channels => "channels",
735 + Self::Flatten => "flatten",
736 + Self::Sidecar => "sidecar",
737 + Self::NamingPattern => "naming-pattern",
738 + Self::DeviceProfile => "device-profile",
739 + }
740 + }
741 +
742 + /// The setting that name means, if it means one.
743 + ///
744 + /// The refusal that makes the write route safe: an address is reachable by
745 + /// typing, so an undeclared name is a `NotFound` rather than a panic or a
746 + /// silent no-op.
747 + #[must_use]
748 + pub fn from_key(name: &str) -> Option<Self> {
749 + match name {
750 + "format" => Some(Self::Format),
751 + "sample-rate" => Some(Self::SampleRate),
752 + "bit-depth" => Some(Self::BitDepth),
753 + "channels" => Some(Self::Channels),
754 + "flatten" => Some(Self::Flatten),
755 + "sidecar" => Some(Self::Sidecar),
756 + "naming-pattern" => Some(Self::NamingPattern),
757 + "device-profile" => Some(Self::DeviceProfile),
758 + _ => None,
759 + }
760 + }
761 + }
762 +
763 + /// The export flow, as much of it as a described screen needs.
764 + ///
765 + /// The fourth narrow trait, and the first whose **reads** are UI state as well
766 + /// as its writes. `Config` and `Sync` both read through a handle that owns the
767 + /// fact; the export flow's phase lives in `BrowserState::import_wf`, which is
768 + /// the app's own screen state. So this trait reads it and records every write as
769 + /// an [`Intent`], which is `files.rs`'s rule applied whole: *a described screen
770 + /// writing to UI state records an intent.*
771 + pub trait Export {
772 + /// Where the flow has got to.
773 + fn phase(&self) -> Phase;
774 +
775 + /// Change one setting.
776 + fn configure(&self, setting: Setting, value: &str);
777 +
778 + /// Begin writing files.
779 + fn start(&self);
780 +
781 + /// Give up on the running export.
782 + fn cancel(&self);
783 +
784 + /// Put the flow away, from either end of it.
785 + fn dismiss(&self);
786 + }
787 +
788 + /// The app's export flow, as the narrow thing a described screen borrows.
789 + pub struct FromExport<'a> {
790 + /// Where the flow is, read off the app's own screen state.
791 + pub state: &'a crate::state::BrowserState,
792 + /// What the described screen asked for, applied after the frame.
793 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
794 + }
795 +
796 + impl Export for FromExport<'_> {
797 + fn phase(&self) -> Phase {
798 + use crate::state::ImportMode;
799 +
800 + match &self.state.import_wf.import_mode {
801 + ImportMode::ConfigureExport {
802 + items,
803 + config,
804 + available_profiles,
805 + } => Phase::Configuring {
806 + subjects: items
807 + .iter()
808 + .map(|item| Subject {
809 + name: item.name.clone(),
810 + ext: item.ext.clone(),
811 + duration: item.duration,
812 + bpm: item.bpm,
813 + musical_key: item.musical_key.clone(),
814 + })
815 + .collect(),
816 + profiles: available_profiles
817 + .iter()
818 + .map(|profile| ProfileChoice {
819 + name: profile.name.clone(),
820 + manufacturer: profile.manufacturer.clone(),
821 + summary: profile.format_summary.clone(),
822 + category: profile.category.clone(),
823 + notes: profile.notes.clone(),
824 + max_file_size_bytes: profile.max_file_size_bytes,
825 + })
826 + .collect(),
827 + settings: Settings {
828 + format: match config.format {
829 + audiofiles_core::export::ExportFormat::Original => Format::Original,
830 + audiofiles_core::export::ExportFormat::Wav => Format::Wav,
831 + audiofiles_core::export::ExportFormat::Aiff => Format::Aiff,
832 + },
833 + sample_rate: config.sample_rate,
834 + bit_depth: config.bit_depth,
835 + channels: match config.channels {
836 + audiofiles_core::export::ExportChannels::Original => Channels::Original,
837 + audiofiles_core::export::ExportChannels::Mono => Channels::Mono,
838 + audiofiles_core::export::ExportChannels::Stereo => Channels::Stereo,
839 + },
840 + flatten: config.flatten,
841 + sidecar: config.metadata_sidecar,
842 + naming_pattern: config.naming_pattern.clone(),
843 + destination: config.destination.display().to_string(),
844 + device_profile: config.device_profile.clone(),
845 + },
846 + },
847 + ImportMode::Exporting {
848 + completed,
849 + total,
850 + current_name,
851 + } => Phase::Running {
852 + done: *completed,
853 + total: *total,
854 + current: current_name.clone(),
855 + },
856 + ImportMode::ExportComplete { total, errors } => Phase::Finished {
857 + total: *total,
858 + errors: errors.clone(),
859 + destination: self.destination(),
860 + },
861 + ImportMode::OperationCancelled {
862 + kind: crate::state::CancelKind::Export,
863 + completed,
864 + total,
865 + destination,
866 + } => Phase::Cancelled {
867 + done: *completed,
868 + total: *total,
869 + destination: destination.as_ref().map(|path| path.display().to_string()),
870 + },
871 + _ => Phase::Idle,
872 + }
873 + }
874 +
875 + fn configure(&self, setting: Setting, value: &str) {
876 + self.intents
877 + .borrow_mut()
878 + .push(Intent::Configure(setting, value.to_owned()));
879 + }
880 +
881 + fn start(&self) {
882 + self.intents.borrow_mut().push(Intent::StartExport);
883 + }
884 +
885 + fn cancel(&self) {
886 + self.intents.borrow_mut().push(Intent::CancelExport);
887 + }
888 +
889 + fn dismiss(&self) {
890 + self.intents.borrow_mut().push(Intent::DismissExport);
891 + }
892 + }
893 +
894 + impl FromExport<'_> {
895 + /// Where the last export was told to write.
896 + fn destination(&self) -> Option<String> {
897 + self.state
898 + .import_wf
899 + .last_export_destination
900 + .as_ref()
901 + .map(|path| path.display().to_string())
902 + }
903 + }
904 +
548 905 /// A theme the host resolved, as the description needs to name it.
549 906 ///
550 907 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -574,6 +931,8 @@
574 931 pub sync: &'a dyn Sync,
575 932 /// The sample list, for the files screen.
576 933 pub files: &'a dyn Files,
934 + /// The export flow, for the export screens.
935 + pub export: &'a dyn Export,
577 936 /// The themes on offer, resolved by the host at startup.
578 937 pub themes: &'a [ThemeChoice],
579 938 }
@@ -584,7 +943,7 @@
584 943 /// cost is nothing, and building it fresh is what lets the state borrow.
585 944 #[must_use]
586 945 pub fn router<'a>() -> Router<Panels<'a>> {
587 - files::routes(sync::routes(settings::routes(Router::new())))
946 + export::routes(files::routes(sync::routes(settings::routes(Router::new()))))
588 947 }
589 948
590 949 #[cfg(test)]
@@ -33,7 +33,8 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromContents, FromSyncManager, Intent, Panels, Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromContents, FromExport, FromSyncManager, Intent, Panels, Setting, Sync,
37 + ThemeChoice, Unconfigured,
37 38 };
38 39 use crate::state::BrowserState;
39 40 use crate::ui::theme;
@@ -48,26 +49,47 @@
48 49 settings: Option<Runtime>,
49 50 sync: Option<Runtime>,
50 51 files: Option<Runtime>,
52 + export: Option<Runtime>,
51 53 /// Whether the described file list is open.
52 54 ///
53 55 /// Its own flag rather than the shipped list's, because the shipped list is
54 56 /// always showing: it is the app's main pane and not a window. So this is
55 57 /// the one described screen with no toggle to share, and it gets its own.
56 58 pub show_files: bool,
59 + /// Whether a described screen's subject moved while it was showing.
60 + ///
61 + /// **Set by the frame that changed something and read by the next one.** An
62 + /// intent is applied after the drawing, so the screen the router answered
63 + /// during the drawing was built from state the intent had not reached. Every
64 + /// port before the export flow lived with that — `files.rs`'s sort caret was
65 + /// one interaction stale and nobody noticed, because a caret that is wrong
66 + /// until the next click reads as a rendering quirk.
67 + ///
68 + /// The export flow made it unsurvivable in two ways at once: every control
69 + /// on the configure screen writes through an intent, and the progress screen
70 + /// moves with no intent at all. So the answer is `quasi` 0.12.0's
71 + /// `Runtime::reload`, and this is the flag that says when to call it.
72 + stale: bool,
57 73 }
58 74
59 75 /// Draw the described settings window, and act on whatever was pressed.
60 76 pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) {
61 77 let intents = RefCell::new(Vec::new());
62 78 let mut runtime = state.described.settings.take();
79 + let stale = state.described.stale;
80 + let host = Host {
81 + state,
82 + sync: None,
83 + themes: themes(),
84 + intents: &intents,
85 + };
63 86 let closed = window(
64 87 ctx,
65 88 "Settings (described)",
66 89 &mut runtime,
67 - state,
68 - None,
69 - &intents,
90 + &host,
70 91 "/settings",
92 + stale,
71 93 );
72 94 state.described.settings = runtime;
73 95 apply(state, intents.into_inner());
@@ -85,14 +107,20 @@
85 107 pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
86 108 let intents = RefCell::new(Vec::new());
87 109 let mut runtime = state.described.sync.take();
110 + let stale = state.described.stale;
111 + let host = Host {
112 + state,
113 + sync,
114 + themes: themes(),
115 + intents: &intents,
116 + };
88 117 let closed = window(
89 118 ctx,
90 119 "Cloud Sync (described)",
91 120 &mut runtime,
92 - state,
93 - sync,
94 - &intents,
121 + &host,
95 122 "/sync",
123 + stale,
96 124 );
97 125 state.described.sync = runtime;
98 126 apply(state, intents.into_inner());
@@ -106,14 +134,20 @@
106 134 pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState) {
107 135 let intents = RefCell::new(Vec::new());
108 136 let mut runtime = state.described.files.take();
137 + let stale = state.described.stale;
138 + let host = Host {
139 + state,
140 + sync: None,
141 + themes: themes(),
142 + intents: &intents,
143 + };
109 144 let closed = window(
110 145 ctx,
111 146 "Samples (described)",
112 147 &mut runtime,
113 - state,
114 - None,
115 - &intents,
148 + &host,
116 149 "/files",
150 + stale,
117 151 );
118 152 state.described.files = runtime;
119 153 apply(state, intents.into_inner());
@@ -123,6 +157,38 @@
123 157 }
124 158 }
125 159
160 + /// Draw the described export flow, and act on whatever was pressed.
161 + ///
162 + /// **Refreshed unconditionally**, where the other three refresh only after an
163 + /// intent. The progress screen's subject is a worker writing files: it moves
164 + /// with nothing the user did, so there is no event to hang a refresh on and the
165 + /// frame is the only clock the host has. The other phases pay one router call
166 + /// per frame for it, which is a table lookup and a walk over state already in
167 + /// memory — less than the shipped screen does laying out the same panel.
168 + pub fn draw_export(ctx: &egui::Context, state: &mut BrowserState) {
169 + let intents = RefCell::new(Vec::new());
170 + let mut runtime = state.described.export.take();
171 + let host = Host {
172 + state,
173 + sync: None,
174 + themes: themes(),
175 + intents: &intents,
176 + };
177 + let closed = window(
178 + ctx,
179 + "Export (described)",
180 + &mut runtime,
181 + &host,
182 + "/export",
183 + true,
184 + );
185 + state.described.export = runtime;
186 + apply(state, intents.into_inner());
187 + if closed {
188 + state.described.export = None;
189 + }
190 + }
191 +
126 192 /// Do what a described screen asked the app to do to itself.
127 193 ///
128 194 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -134,8 +200,28 @@
134 200 /// fields itself: a described screen that set `nav.selection` by hand would be a
135 201 /// second implementation of selection, which is what the port is for avoiding.
136 202 fn apply(state: &mut BrowserState, intents: Vec<Intent>) {
203 + // Anything applied here landed *after* the router answered, so the screen
204 + // showing was built without it. The next frame reloads.
205 + state.described.stale = !intents.is_empty();
206 +
137 207 for intent in intents {
138 208 match intent {
209 + Intent::Configure(setting, value) => configure(state, setting, &value),
210 + Intent::StartExport => {
211 + if let crate::state::ImportMode::ConfigureExport { items, config, .. } =
212 + &state.import_wf.import_mode
213 + {
214 + let (items, config) = (items.clone(), config.clone());
215 + state.run_export(items, config);
216 + }
217 + }
218 + Intent::CancelExport => state.cancel_export(),
219 + // Whatever phase it is in, the flow is over. The shipped screens
220 + // each write `ImportMode::None` at their own Done or Cancel, and
221 + // this is the one place the described side does.
222 + Intent::DismissExport => {
223 + state.import_wf.import_mode = crate::state::ImportMode::None;
224 + }
139 225 Intent::Open(id) => {
140 226 if let Some(at) = index_of(state, id) {
141 227 state.nav.selection.set_single(at);
@@ -165,6 +251,60 @@
165 251 }
166 252 }
167 253
254 + /// Write one described setting back into the app's own export config.
255 + ///
256 + /// The described value is a string because that is what a control submits, and
257 + /// this is where it stops being one. Anything unparseable falls back to the
258 + /// setting's "keep the original" reading rather than being ignored: a rate the
259 + /// description does not know is not a reason to keep the old one, which would be
260 + /// a control that silently does nothing.
261 + ///
262 + /// Choosing a device profile clears the three fields the profile owns, which is
263 + /// what `ui::export_screens` does at the same control and for the same reason:
264 + /// they are derived from the profile, so a stale set of them would outlive the
265 + /// profile that produced it.
266 + fn configure(state: &mut BrowserState, setting: Setting, value: &str) {
267 + use audiofiles_core::export::{ExportChannels, ExportFormat};
268 +
269 + let crate::state::ImportMode::ConfigureExport { config, .. } = &mut state.import_wf.import_mode
270 + else {
271 + // The flow moved on between the frame that drew the control and the one
272 + // that applies it. Dropping the write is right: there is no longer a
273 + // configuration for it to land in.
274 + return;
275 + };
276 +
277 + match setting {
278 + Setting::Format => {
279 + config.format = match value {
280 + "wav" => ExportFormat::Wav,
281 + "aiff" => ExportFormat::Aiff,
282 + _ => ExportFormat::Original,
283 + };
284 + }
285 + Setting::SampleRate => config.sample_rate = value.parse().ok(),
286 + Setting::BitDepth => config.bit_depth = value.parse().ok(),
287 + Setting::Channels => {
288 + config.channels = match value {
289 + "mono" => ExportChannels::Mono,
290 + "stereo" => ExportChannels::Stereo,
291 + _ => ExportChannels::Original,
292 + };
293 + }
294 + Setting::Flatten => config.flatten = !value.is_empty(),
295 + Setting::Sidecar => config.metadata_sidecar = !value.is_empty(),
296 + Setting::NamingPattern => {
297 + config.naming_pattern = (!value.is_empty()).then(|| value.to_owned());
298 + }
299 + Setting::DeviceProfile => {
300 + config.device_profile = (!value.is_empty()).then(|| value.to_owned());
301 + config.naming_rules = None;
302 + config.max_file_size_bytes = None;
303 + config.name_overrides = None;
304 + }
305 + }
306 + }
307 +
168 308 /// Where a sample sits in what is on screen.
169 309 ///
170 310 /// The described screen addresses a row by its own id and the app selects by
@@ -179,17 +319,29 @@
179 319 .position(|node| node.node.id.as_i64() == id)
180 320 }
181 321
322 + /// Everything a described screen is answered out of.
323 + ///
324 + /// The four travel together through every function below, so they are one thing
325 + /// rather than four parameters repeated three times. What they have in common is
326 + /// the reason: each is a handle the *host* holds and the description does not —
327 + /// the app's state, its sync manager, the themes it resolved at startup, and the
328 + /// place a route leaves what it could not do itself.
329 + struct Host<'a> {
330 + state: &'a BrowserState,
331 + sync: Option<&'a SyncManager>,
332 + themes: Vec<ThemeChoice>,
333 + intents: &'a RefCell<Vec<Intent>>,
334 + }
335 +
182 336 /// One described window: draw it, act on it, and say whether it was closed.
183 337 fn window(
184 338 ctx: &egui::Context,
185 339 title: &str,
186 340 runtime: &mut Option<Runtime>,
187 - state: &BrowserState,
188 - sync: Option<&SyncManager>,
189 - intents: &RefCell<Vec<Intent>>,
341 + host: &Host<'_>,
190 342 home: &str,
343 + refresh: bool,
191 344 ) -> bool {
192 - let themes = themes();
193 345 let mut open = true;
194 346
195 347 egui::Window::new(title)
@@ -202,7 +354,7 @@
202 354 // after it is the loop below.
203 355 let runtime = match runtime {
204 356 Some(runtime) => runtime,
205 - none => match answer(state, sync, &themes, intents, Request::get(home)) {
357 + none => match answer(host, Request::get(home)) {
206 358 Ok(screen) => none.insert(Runtime::new(screen)),
207 359 Err(message) => {
208 360 ui.label(message);
@@ -211,26 +363,27 @@
211 363 },
212 364 };
213 365
366 + // Before the drawing, so the frame draws what is true now rather
367 + // than showing the previous answer for one more frame. `reload`
368 + // re-asks the address the screen came from, and the runtime keeps
369 + // what the user has typed and ticked across it.
370 + if refresh {
371 + let step = runtime.reload();
372 + perform(runtime, ui, host, step);
373 + }
374 +
214 375 let step = runtime.show(ui, &immediate);
215 - perform(runtime, ui, state, sync, &themes, intents, step);
376 + perform(runtime, ui, host, step);
216 377 });
217 378
218 379 !open
219 380 }
220 381
221 382 /// Do what the runtime asked for.
222 - fn perform(
223 - runtime: &mut Runtime,
224 - ui: &mut egui::Ui,
225 - state: &BrowserState,
226 - sync: Option<&SyncManager>,
227 - themes: &[ThemeChoice],
228 - intents: &RefCell<Vec<Intent>>,
229 - step: Step,
230 - ) {
383 + fn perform(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, step: Step) {
231 384 match step {
232 385 Step::Idle => {}
233 - Step::Call(request) => match answer(state, sync, themes, intents, request.clone()) {
386 + Step::Call(request) => match answer(host, request.clone()) {
234 387 Ok(screen) => {
235 388 runtime.apply(&request, Response::screen(screen));
236 389 }
@@ -243,7 +396,7 @@
243 396 ui.horizontal(|ui| {
244 397 if ui.button("Yes").clicked() {
245 398 let next = runtime.answer(true);
246 - perform(runtime, ui, state, sync, themes, intents, next);
399 + perform(runtime, ui, host, next);
247 400 }
248 401 if ui.button("No").clicked() {
249 402 runtime.answer(false);
@@ -278,13 +431,13 @@
278 431 }
279 432
280 433 /// Ask the router, and flatten a refusal into something a user can read.
281 - fn answer(
282 - state: &BrowserState,
283 - sync: Option<&SyncManager>,
284 - themes: &[ThemeChoice],
285 - intents: &RefCell<Vec<Intent>>,
286 - request: Request,
287 - ) -> Result<Screen, String> {
434 + fn answer(host: &Host<'_>, request: Request) -> Result<Screen, String> {
435 + let Host {
436 + state,
437 + sync,
438 + themes,
439 + intents,
440 + } = host;
288 441 let config = FromBackend(&*state.backend);
289 442 let manager = sync.map(FromSyncManager);
290 443 let unconfigured = Unconfigured;
@@ -294,10 +447,12 @@
294 447 };
295 448
296 449 let files = FromContents { state, intents };
450 + let export = FromExport { state, intents };
297 451 let panels = Panels {
298 452 config: &config,
299 453 sync,
300 454 files: &files,
455 + export: &export,
301 456 themes,
302 457 };
303 458 let response = super::router()
@@ -12,8 +12,8 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - ColumnsShown, Config, Files, Panels, Pricing, Sample, State, Status, Subscription, Sync,
16 - ThemeChoice, router,
15 + Channels, ColumnsShown, Config, Export, Files, Format, Panels, Phase, Pricing, ProfileChoice,
16 + Sample, Setting, Settings, State, Status, Subject, Subscription, Sync, ThemeChoice, router,
17 17 };
18 18
19 19 /// A config store in memory.
@@ -117,6 +117,132 @@
117 117 }
118 118 }
119 119
120 + /// An export flow that is not running, for the screens that are not about one.
121 + ///
122 + /// Its own type rather than a `FakeExport` in the idle phase, because every
123 + /// method on it is a refusal: the other screens' tests should not be able to
124 + /// start an export by accident, and a fake that recorded the call would let one.
125 + struct Idle;
126 +
127 + impl Export for Idle {
128 + fn phase(&self) -> Phase {
129 + Phase::Idle
130 + }
131 + fn configure(&self, _setting: Setting, _value: &str) {}
132 + fn start(&self) {}
133 + fn cancel(&self) {}
134 + fn dismiss(&self) {}
135 + }
136 +
137 + /// An export flow in memory, recording what was asked of it.
138 + ///
139 + /// The phase is fixed per test rather than advancing, which is the honest shape:
140 + /// what moves the phase is the app applying an intent, and these tests are of
141 + /// the description rather than of the host. What is recorded is the asking.
142 + struct FakeExport {
143 + phase: Phase,
144 + asked: RefCell<Vec<String>>,
145 + }
146 +
147 + impl FakeExport {
148 + fn at(phase: Phase) -> Self {
149 + Self {
150 + phase,
151 + asked: RefCell::new(Vec::new()),
152 + }
153 + }
154 + }
155 +
156 + impl Export for FakeExport {
157 + fn phase(&self) -> Phase {
158 + self.phase.clone()
159 + }
160 + fn configure(&self, setting: Setting, value: &str) {
161 + self.asked
162 + .borrow_mut()
163 + .push(format!("set:{}={value}", setting.as_str()));
164 + }
165 + fn start(&self) {
166 + self.asked.borrow_mut().push("start".to_owned());
167 + }
168 + fn cancel(&self) {
169 + self.asked.borrow_mut().push("cancel".to_owned());
170 + }
171 + fn dismiss(&self) {
172 + self.asked.borrow_mut().push("dismiss".to_owned());
173 + }
174 + }
175 +
176 + /// Settings as the app defaults them: copy as-is, into a tree.
177 + fn defaults() -> Settings {
178 + Settings {
179 + format: Format::Original,
180 + sample_rate: None,
181 + bit_depth: None,
182 + channels: Channels::Original,
183 + flatten: false,
184 + sidecar: false,
185 + naming_pattern: None,
186 + destination: "/tmp/export".to_owned(),
187 + device_profile: None,
188 + }
189 + }
190 +
191 + /// One sample about to be exported.
192 + fn subject(name: &str, seconds: f64) -> Subject {
193 + Subject {
194 + name: name.to_owned(),
195 + ext: "wav".to_owned(),
196 + duration: Some(seconds),
197 + bpm: Some(120.0),
198 + musical_key: Some("Am".to_owned()),
199 + }
200 + }
201 +
202 + /// A router call against this export flow.
203 + fn exporting(export: &FakeExport, request: Request) -> Result<Response, quasi_router::RouteError> {
204 + let store = Store::default();
205 + let sync = Offline;
206 + let files = FakeFiles::default();
207 + let themes = themes();
208 + let state = Panels {
209 + config: &store,
210 + sync: &sync,
211 + files: &files,
212 + export,
213 + themes: &themes,
214 + };
215 + router().handle(&state, request)
216 + }
217 +
218 + /// The screen the export flow answers, at whatever phase it is in.
219 + fn exported(export: &FakeExport) -> Screen {
220 + screen_of(&exporting(export, Request::get("/export")).unwrap()).clone()
221 + }
222 +
223 + /// Every node on a screen, in order.
224 + fn nodes(screen: &Screen) -> Vec<&Node> {
225 + screen.slots.iter().flat_map(|slot| &slot.body).collect()
226 + }
227 +
228 + /// The text of every prose and notice node on a screen, joined.
229 + ///
230 + /// Assertions read against this rather than against node positions: what a
231 + /// screen *says* is the described fact, and where the renderer puts it is not.
232 + fn said(screen: &Screen) -> String {
233 + nodes(screen)
234 + .iter()
235 + .filter_map(|node| match node {
236 + Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => {
237 + Some(text.clone())
238 + }
239 + Node::StandIn { message, .. } => Some(message.clone()),
240 + _ => None,
241 + })
242 + .collect::<Vec<_>>()
243 + .join(" | ")
244 + }
245 +
120 246 /// A sample with the fields a row names.
121 247 fn sample(id: i64, name: &str) -> Sample {
122 248 Sample {
@@ -139,6 +265,7 @@
139 265 config: &store,
140 266 sync: &sync,
141 267 files,
268 + export: &Idle,
142 269 themes: &themes,
143 270 };
144 271 router().handle(&state, request)
@@ -248,6 +375,7 @@
248 375 config: &store,
249 376 sync: &sync,
250 377 files: &files,
378 + export: &Idle,
251 379 themes: &themes,
252 380 };
253 381 let response = router()
@@ -292,6 +420,7 @@
292 420 config: &store,
293 421 sync: &sync,
294 422 files: &files,
423 + export: &Idle,
295 424 themes: &themes,
296 425 };
297 426
@@ -330,6 +459,7 @@
330 459 config: &store,
331 460 sync: &sync,
332 461 files: &files,
462 + export: &Idle,
333 463 themes: &themes,
334 464 };
335 465 let refused = router().handle(
@@ -354,6 +484,7 @@
354 484 config: &store,
355 485 sync: &sync,
356 486 files: &files,
487 + export: &Idle,
357 488 themes: &themes,
358 489 };
359 490
@@ -401,6 +532,7 @@
401 532 config: &store,
402 533 sync: &sync,
403 534 files: &files,
535 + export: &Idle,
404 536 themes: &themes,
405 537 };
406 538 let response = router()
@@ -444,6 +576,7 @@
444 576 config: &store,
445 577 sync: &sync,
446 578 files: &files,
579 + export: &Idle,
447 580 themes: &themes,
448 581 };
449 582 let response = router()
@@ -593,6 +726,7 @@
593 726 config: &store,
594 727 sync,
595 728 files: &files,
729 + export: &Idle,
596 730 themes: &themes,
597 731 };
598 732 router().handle(&state, request)
@@ -1090,3 +1224,408 @@
1090 1224 [false, true]
1091 1225 );
1092 1226 }
1227 +
1228 + // --- the export flow ---
1229 +
1230 + #[test]
1231 + fn nothing_to_export_says_so_and_offers_no_way_to_start_one() {
1232 + // The flow is entered from the file list, so a control here would be an
1233 + // affordance the shipped app does not have.
1234 + let export = FakeExport::at(Phase::Idle);
1235 + let screen = exported(&export);
1236 +
1237 + assert!(matches!(
1238 + nodes(&screen).as_slice(),
1239 + [Node::StandIn { act: None, .. }]
1240 + ));
1241 + }
1242 +
1243 + #[test]
1244 + fn the_configure_screen_names_what_is_going_and_what_it_will_be_written_as() {
1245 + let export = FakeExport::at(Phase::Configuring {
1246 + subjects: vec![subject("kick", 2.0), subject("snare", 1.0)],
1247 + profiles: Vec::new(),
1248 + settings: defaults(),
1249 + });
1250 + let screen = exported(&export);
1251 + let said = said(&screen);
1252 +
1253 + assert!(said.contains("2 samples to export"), "{said}");
1254 + // No profiles, so the picker is not offered at all rather than offered
1255 + // empty: an empty dropdown is a control that cannot be used.
1256 + assert!(!said.contains("Device Profile"), "{said}");
1257 + assert!(said.contains("Format"), "{said}");
1258 + assert!(said.contains("Destination"), "{said}");
1259 + }
1260 +
1261 + #[test]
1262 + fn copying_as_is_says_nothing_about_rates_and_re_encoding_says_everything() {
1263 + // The rate and depth exist only when something is being re-encoded, which
1264 + // is the shipped screen's own rule. A described screen that named them
1265 + // under Original would be describing controls that do nothing.
1266 + let original = FakeExport::at(Phase::Configuring {
1267 + subjects: vec![subject("kick", 2.0)],
1268 + profiles: Vec::new(),
1269 + settings: defaults(),
1270 + });
1271 + let said_of_original = said(&exported(&original));
1272 + assert!(
1273 + !said_of_original.contains("Sample Rate"),
1274 + "{said_of_original}"
1275 + );
1276 + assert!(
1277 + !said_of_original.contains("strips embedded metadata"),
1278 + "{said_of_original}"
1279 + );
1280 +
1281 + let wav = FakeExport::at(Phase::Configuring {
1282 + subjects: vec![subject("kick", 2.0)],
1283 + profiles: Vec::new(),
1284 + settings: Settings {
1285 + format: Format::Wav,
1286 + ..defaults()
1287 + },
1288 + });
1289 + let said_of_wav = said(&exported(&wav));
1290 + assert!(said_of_wav.contains("Sample Rate"), "{said_of_wav}");
1291 + assert!(said_of_wav.contains("Bit Depth"), "{said_of_wav}");
1292 + assert!(
1293 + said_of_wav.contains("strips embedded metadata"),
1294 + "{said_of_wav}"
1295 + );
1296 + }
1297 +
1298 + #[test]
1299 + fn a_device_profile_takes_the_audio_settings_off_the_screen() {
1300 + // The profile owns them, so a control for them would be one the export
1301 + // pipeline overrides. The shipped screen hides the whole block; so does
1302 + // this, and it says what the lock is hiding instead.
1303 + let export = FakeExport::at(Phase::Configuring {
1304 + subjects: vec![subject("kick", 2.0)],
1305 + profiles: vec![ProfileChoice {
1306 + name: "SP-404 MKII".to_owned(),
1307 + manufacturer: "Roland".to_owned(),
1308 + summary: Some("WAV, 44.1k, 16-bit, Mono".to_owned()),
1309 + category: Some("Sampler".to_owned()),
1310 + notes: None,
1311 + max_file_size_bytes: None,
1312 + }],
1313 + settings: Settings {
1314 + device_profile: Some("SP-404 MKII".to_owned()),
1315 + ..defaults()
1316 + },
1317 + });
1318 + let said = said(&exported(&export));
1319 +
1320 + assert!(said.contains("Device Profile"), "{said}");
1321 + assert!(said.contains("by Roland"), "{said}");
1322 + assert!(said.contains("WAV, 44.1k, 16-bit, Mono"), "{said}");
1323 + assert!(!said.contains("Sample Rate"), "{said}");
1324 + assert!(!said.contains("Channels"), "{said}");
1325 + }
1326 +
1327 + #[test]
1328 + fn a_sample_too_long_for_an_aiff_chunk_is_warned_about_before_anything_is_written() {
1329 + // Four gigabytes at 48 kHz / 24-bit stereo is about 4 hours, so five hours
1330 + // is over and one minute is not. The arithmetic is the shipped screen's.
1331 + let over = FakeExport::at(Phase::Configuring {
1332 + subjects: vec![subject("drone", 5.0 * 3600.0)],
1333 + profiles: Vec::new(),
1334 + settings: Settings {
1335 + format: Format::Aiff,
1336 + ..defaults()
1337 + },
1338 + });
1339 + assert!(said(&exported(&over)).contains("AIFF chunks cap at 4 GB"),);
1340 +
1341 + let under = FakeExport::at(Phase::Configuring {
1342 + subjects: vec![subject("kick", 60.0)],
1343 + profiles: Vec::new(),
1344 + settings: Settings {
1345 + format: Format::Aiff,
1346 + ..defaults()
1347 + },
1348 + });
1349 + assert!(!said(&exported(&under)).contains("AIFF chunks cap"),);
1350 + }
1351 +
1352 + #[test]
1353 + fn a_sample_too_big_for_the_device_is_warned_about_by_name_when_it_is_the_only_one() {
1354 + let profile = |cap: u64| ProfileChoice {
1355 + name: "SP-404 MKII".to_owned(),
1356 + manufacturer: "Roland".to_owned(),
1357 + summary: None,
1358 + category: None,
1359 + notes: None,
1360 + max_file_size_bytes: Some(cap),
1361 + };
1362 + let configuring = |subjects: Vec<Subject>, cap: u64| {
1363 + FakeExport::at(Phase::Configuring {
1364 + subjects,
1365 + profiles: vec![profile(cap)],
1366 + settings: Settings {
1367 + device_profile: Some("SP-404 MKII".to_owned()),
1368 + ..defaults()
1369 + },
1370 + })
1371 + };
1372 +
1373 + // One over the cap is named; two are counted. The difference is the shipped
1374 + // screen's and it is worth keeping: a name is actionable and a count is not.
1375 + let one = configuring(
1376 + vec![subject("drone", 600.0), subject("kick", 0.5)],
1377 + 1_000_000,
1378 + );
1379 + let said_of_one = said(&exported(&one));
1380 + assert!(
1381 + said_of_one.contains("\"drone\" may exceed"),
1382 + "{said_of_one}"
1383 + );
1384 +
1385 + let two = configuring(
1386 + vec![subject("drone", 600.0), subject("pad", 700.0)],
1387 + 1_000_000,
1388 + );
1389 + let said_of_two = said(&exported(&two));
1390 + assert!(
1391 + said_of_two.contains("2 samples may exceed"),
1392 + "{said_of_two}"
1393 + );
1394 + }
1395 +
1396 + #[test]
1397 + fn a_naming_pattern_is_previewed_against_the_first_sample_and_a_typo_is_reported() {
1398 + let flattened = |pattern: &str| {
1399 + FakeExport::at(Phase::Configuring {
1400 + subjects: vec![subject("kick", 2.0)],
1401 + profiles: Vec::new(),
1402 + settings: Settings {
1403 + flatten: true,
1404 + naming_pattern: Some(pattern.to_owned()),
1405 + ..defaults()
1406 + },
1407 + })
1408 + };
1409 +
1410 + let good = flattened("{name}-{bpm}");
1411 + let said_of_good = said(&exported(&good));
1412 + assert!(said_of_good.contains("Preview: kick-120"), "{said_of_good}");
1413 +
1414 + // The point of the preview: a typo is caught before two hundred files are
1415 + // written under it.
1416 + let bad = flattened("{nmae}");
1417 + let said_of_bad = said(&exported(&bad));
1418 + assert!(said_of_bad.contains("Pattern:"), "{said_of_bad}");
1419 + assert!(!said_of_bad.contains("Preview:"), "{said_of_bad}");
1420 + }
1421 +
1422 + #[test]
1423 + fn a_naming_pattern_is_only_described_when_the_tree_is_being_flattened() {
1424 + // It names files in one folder. With the tree preserved there is nothing
1425 + // for it to do, and the shipped screen does not draw it either.
1426 + let export = FakeExport::at(Phase::Configuring {
1427 + subjects: vec![subject("kick", 2.0)],
1428 + profiles: Vec::new(),
1429 + settings: Settings {
1430 + flatten: false,
1431 + naming_pattern: Some("{name}".to_owned()),
1432 + ..defaults()
1433 + },
1434 + });
1435 + assert!(!said(&exported(&export)).contains("Naming Pattern"),);
1436 + }
1437 +
1438 + #[test]
1439 + fn every_control_writes_through_one_route_and_an_undeclared_setting_is_refused() {
1440 + let export = FakeExport::at(Phase::Configuring {
1441 + subjects: vec![subject("kick", 2.0)],
1442 + profiles: Vec::new(),
1443 + settings: defaults(),
1444 + });
1445 +
1446 + exporting(
1447 + &export,
1448 + Request {
1449 + method: Method::Post,
1450 + path: "/export/set/format".to_owned(),
1451 + captures: Params::new().with("setting", "format"),
1452 + payload: Params::new().with("format", "wav"),
1453 + carried: Params::new(),
1454 + },
1455 + )
1456 + .unwrap();
1457 + assert_eq!(export.asked.borrow().as_slice(), ["set:format=wav"]);
1458 +
1459 + // An address is reachable by typing, so a name the description does not
1460 + // carry is a refusal rather than a panic or a silent no-op.
1461 + let refused = exporting(
1462 + &export,
1463 + Request {
1464 + method: Method::Post,
1465 + path: "/export/set/bitrate".to_owned(),
1466 + captures: Params::new().with("setting", "bitrate"),
1467 + payload: Params::new(),
1468 + carried: Params::new(),
1469 + },
1470 + )
1471 + .unwrap_err();
1472 + assert_eq!(refused.class, quasi_router::Class::NotFound);
1473 + assert_eq!(export.asked.borrow().len(), 1, "the refusal wrote nothing");
1474 + }
1475 +
1476 + #[test]
1477 + fn a_worker_that_has_not_counted_the_files_yet_reads_as_pending_not_as_finished() {
1478 + // A meter of 0 of 0 draws full, which would say the export is done before
1479 + // it has started. Readiness is what says "working on it".
1480 + let starting = FakeExport::at(Phase::Running {
1481 + done: 0,
1482 + total: 0,
1483 + current: String::new(),
1484 + });
1485 + let screen = exported(&starting);
1486 + assert!(nodes(&screen).iter().any(|node| matches!(
1487 + node,
1488 + Node::StandIn {
1489 + state: quasi_router::layout::Readiness::Pending,
1490 + ..
1491 + }
1492 + )));
1493 + assert!(
1494 + !nodes(&screen)
1495 + .iter()
1496 + .any(|node| matches!(node, Node::Meter(_)))
1497 + );
1498 +
1499 + let running = FakeExport::at(Phase::Running {
1500 + done: 3,
1501 + total: 10,
1502 + current: "kick.wav".to_owned(),
1503 + });
1504 + let screen = exported(&running);
1505 + let meter = nodes(&screen)
1506 + .into_iter()
1507 + .find_map(|node| match node {
1508 + Node::Meter(meter) => Some(meter.clone()),
1509 + _ => None,
1510 + })
1511 + .expect("a counted export describes its proportion");
1512 + assert_eq!((meter.done, meter.total), (3, 10));
1513 + assert!(said(&screen).contains("Exporting: kick.wav"));
1514 + }
1515 +
1516 + #[test]
1517 + fn cancelling_a_running_export_asks_the_app_rather_than_deciding_itself() {
1518 + let export = FakeExport::at(Phase::Running {
1519 + done: 3,
1520 + total: 10,
1521 + current: "kick.wav".to_owned(),
1522 + });
1523 + exporting(&export, Request::post("/export/cancel")).unwrap();
1524 + assert_eq!(export.asked.borrow().as_slice(), ["cancel"]);
1525 + }
Lines truncated
@@ -1,0 +1,632 @@
1 + //! The export flow, described rather than built.
2 + //!
3 + //! The fourth port, and the first that is a **flow** rather than a screen. The
4 + //! three before it each answered one address for as long as they were open;
5 + //! this one has four screens and the user does not choose between them. Which
6 + //! one is showing is a fact about the app — is anything being written, has it
7 + //! finished — and the description says so by answering a different screen from
8 + //! the same address.
9 + //!
10 + //! # The finding this port made, and it changed `quasi`
11 + //!
12 + //! **A described screen could not say that the thing it is about had moved.**
13 + //! A route answers a screen built from the state at the moment it was asked,
14 + //! and the runtime keeps that answer until the user fires something. That is
15 + //! right for a settings panel, where nothing changes unless the user changes
16 + //! it. It is wrong for every screen here:
17 + //!
18 + //! - The progress screen changes with **no user input at all**. Files are being
19 + //! written by a worker; the count moves on its own.
20 + //! - The configure screen changes on input the description *did* carry, but a
21 + //! frame late: a write here is an [`Intent`](super::Intent) the host applies
22 + //! after the frame, so the answer built in the same frame is built from state
23 + //! the write has not reached yet. This is not new with this port —
24 + //! `files.rs`'s sort caret had it — but here every single control has it, so
25 + //! it stopped being survivable.
26 + //!
27 + //! `quasi` 0.12.0's `Runtime::reload` is the answer, and the shape of the answer
28 + //! is the part worth keeping: it is a **host** call, not a description member.
29 + //! Nothing in a `Screen` says how often it goes stale, because how often a fact
30 + //! moves is a property of the app holding it rather than of the screen showing
31 + //! it. The host knows it started an export. The description does not, and should
32 + //! not have to.
33 + //!
34 + //! # Why a meter, when `Meter`'s own documentation says not for this
35 + //!
36 + //! [`Meter`](quasi_router::Meter) says it is "a proportion of a set and not the
37 + //! progress of an operation", on the grounds that an operation is live and a
38 + //! screen is described once per answer. Files-written of files-to-write **is** a
39 + //! proportion of a set; what made it look like an operation was the second half
40 + //! of that sentence, and `reload` is what stops it being true. Each answer still
41 + //! describes a static fact, and the host asks again. The refusal was right for
42 + //! its reason and the reason has moved, which is worth recording as a change to
43 + //! the premise rather than as an exception being taken.
44 + //!
45 + //! # What is not describable, and it is three things
46 + //!
47 + //! | The shipped screen does | Described | Why not |
48 + //! |---|---|---|
49 + //! | AIFF 4 GB chunk warning | yes | arithmetic over the items and the settings |
50 + //! | device file-size warning | yes | the same, against the profile's limit |
51 + //! | naming-pattern live preview | yes | `RenamePattern` resolved against the first item, and pure |
52 + //! | **disk space warning** | no | `statvfs` on the destination: a fact about this host's filesystem |
53 + //! | **"Browse..." for the destination** | no | a native folder dialog |
54 + //! | **the token chips** | no | see below |
55 + //!
56 + //! The destination picker is the **third consumer** of a finding both prior
57 + //! ports filed: *a control that asks the host where to put something and then
58 + //! acts has no vocabulary.* `FieldKind::File` covers picking a file to submit;
59 + //! nothing covers opening a save dialog and writing there. goingson's settings
60 + //! port found it, audiofiles' settings port confirmed it at Export Theme, and
61 + //! this is the third. Under the evidence rule three consumers is not drift.
62 + //!
63 + //! The token chips are a **new** gap and a smaller one: nine buttons that each
64 + //! append their own text to the field beside them. Nothing in the vocabulary
65 + //! says "put this text into that field" — an `Act` calls a route, and routing a
66 + //! keystroke through a handler to change a buffer the renderer owns is the wrong
67 + //! shape at every layer. Recorded, not papered over: the described screen names
68 + //! the tokens in the field's hint, which keeps the fact and loses the affordance.
69 +
70 + use quasi_router::layout::{FieldKind, Selector, Tone};
71 + use quasi_router::{
72 + Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
73 + Slot,
74 + };
75 +
76 + use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject};
77 +
78 + /// The region the whole flow answers into.
79 + ///
80 + /// One region for four screens, because they are four answers to one address
81 + /// rather than four places. Nothing navigates between them and the back button
82 + /// has nowhere to go, which is the truth: the user cannot walk back into
83 + /// configuring an export that is already running.
84 + const BODY: &str = "export-body";
85 +
86 + /// The largest an AIFF chunk may be, with headroom for the headers.
87 + ///
88 + /// The shipped screen's number, kept because the warning is the same warning.
89 + /// Ninety per cent of `u32::MAX` leaves room for chunk headers and rounding.
90 + const AIFF_SAFE_BYTES: f64 = u32::MAX as f64 * 0.9;
91 +
92 + /// Worst-case bytes per second, for the device size check.
93 + ///
94 + /// Stereo 24-bit at 48 kHz, which is what the shipped screen assumes and for the
95 + /// same reason: the check is a warning, and a warning that under-estimates is
96 + /// worse than one that over-estimates.
97 + const WORST_CASE_BYTES_PER_SEC: f64 = 288_000.0;
98 +
99 + /// Register this flow's routes.
100 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
101 + router
102 + .get("/export", index)
103 + .post("/export/set/{setting}", configure)
104 + .post("/export/start", start)
105 + .post("/export/cancel", cancel)
106 + .post("/export/dismiss", dismiss)
107 + }
108 +
109 + /// `GET /export`
110 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
111 + Ok(screen(state).into())
112 + }
113 +
114 + /// `POST /export/set/{setting}`
115 + ///
116 + /// One route for every control on the configure screen, which is `settings.rs`'s
117 + /// arrangement and works here for the same reason: [`Setting`] closes the set,
118 + /// so the route carries no second list of what it will name.
119 + ///
120 + /// The answer is built **before** the change lands, and that is not a bug in
121 + /// this route. The write is an intent the host applies after the frame; the
122 + /// host then reloads. See the module header.
123 + fn configure(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
124 + let name = request.captures.require("setting")?;
125 + let setting =
126 + Setting::from_key(name).ok_or_else(|| RouteError::not_found("no such export setting"))?;
127 + let value = request.payload.get(name).unwrap_or_default();
128 + state.export.configure(setting, value);
129 + Ok(screen(state).into())
130 + }
131 +
132 + /// `POST /export/start`
133 + fn start(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
134 + state.export.start();
135 + Ok(screen(state).into())
136 + }
137 +
138 + /// `POST /export/cancel`
139 + fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
140 + state.export.cancel();
141 + Ok(screen(state).into())
142 + }
143 +
144 + /// `POST /export/dismiss`
145 + fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
146 + state.export.dismiss();
147 + Ok(screen(state).into())
148 + }
149 +
150 + /// Whichever of the four screens the flow is on.
151 + fn screen(state: &Panels<'_>) -> Screen {
152 + let body = match state.export.phase() {
153 + Phase::Idle => idle(),
154 + Phase::Configuring {
155 + subjects,
156 + profiles,
157 + settings,
158 + } => configuring(&subjects, &profiles, &settings),
159 + Phase::Running {
160 + done,
161 + total,
162 + current,
163 + } => running(done, total, &current),
164 + Phase::Finished {
165 + total,
166 + errors,
167 + destination,
168 + } => finished(total, &errors, destination.as_deref()),
169 + Phase::Cancelled {
170 + done,
171 + total,
172 + destination,
173 + } => cancelled(done, total, destination.as_deref()),
174 + };
175 + Screen::sidebar_content("Export").with(body)
176 + }
177 +
178 + /// Nothing to export.
179 + ///
180 + /// A stand-in rather than an empty pane, and with no way out offered: the export
181 + /// flow is entered by selecting samples in the file list, which is a different
182 + /// screen. Offering a control here would be inventing an affordance the shipped
183 + /// app does not have.
184 + fn idle() -> Slot {
185 + Slot::new(BODY, RegionKind::Pane).with(Node::empty(
186 + "Nothing is being exported. Select samples and choose Export to start.",
187 + ))
188 + }
189 +
190 + /// Choosing what and where.
191 + fn configuring(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Slot {
192 + let mut body = Slot::new(BODY, RegionKind::Pane)
193 + .with(Node::page("Export Samples"))
194 + .with(Node::text(subject_count(subjects.len(), profiles.len())));
195 +
196 + for warning in warnings(subjects, profiles, settings) {
197 + body = body.with(warning);
198 + }
199 +
200 + if !profiles.is_empty() {
201 + body = body
202 + .with(Node::section("Device Profile"))
203 + .with(profile_field(profiles, settings.device_profile.as_deref()));
204 + // What the lock is hiding, said rather than implied. The shipped screen
205 + // puts four muted lines under the picker; each is a fact about the
206 + // device, so each is prose.
207 + if let Some(chosen) = chosen_profile(profiles, settings.device_profile.as_deref()) {
208 + body = body.with(Node::text(describe(chosen)));
209 + }
210 + }
211 +
212 + // A profile locks the audio settings, so the description stops naming them:
213 + // a control that cannot be used is worse than one that is not there, and the
214 + // shipped screen agrees -- it hides the whole block behind `!has_profile`.
215 + if settings.device_profile.is_none() {
216 + body = body
217 + .with(Node::section("Format"))
218 + .with(format_field(settings.format));
219 + if settings.format != Format::Original {
220 + body = body.with(Node::banner(
221 + Tone::Warning,
222 + "Re-encoding strips embedded metadata chunks (BWF, iXML, loop points, \
223 + cue markers, ID3). Choose Original to preserve them.",
224 + ));
225 + body = body
226 + .with(Node::section("Sample Rate"))
227 + .with(sample_rate_field(settings.sample_rate))
228 + .with(Node::section("Bit Depth"))
229 + .with(bit_depth_field(settings.bit_depth));
230 + }
231 + body = body
232 + .with(Node::section("Channels"))
233 + .with(channels_field(settings.channels));
234 + }
235 +
236 + body = body
237 + .with(Node::section("Structure"))
238 + .with(structure_field(settings.flatten))
239 + .with(Node::Field(Box::new(
240 + Field::new(
241 + FieldKind::Checkbox,
242 + Setting::Sidecar.as_str(),
243 + "Include metadata (.audiofiles.json)",
244 + )
245 + .value(if settings.sidecar { "on" } else { "" })
246 + .changes(writes(Setting::Sidecar)),
247 + )));
248 +
249 + if settings.flatten {
250 + body = body
251 + .with(Node::section("Naming Pattern"))
252 + .with(naming_field(settings.naming_pattern.as_deref()));
253 + if let Some(preview) = preview(settings.naming_pattern.as_deref(), subjects.first()) {
254 + body = body.with(preview);
255 + }
256 + }
257 +
258 + // Read-only, and the module header says why: naming where files go means
259 + // opening a native folder dialog, which no description reaches.
260 + body = body
261 + .with(Node::section("Destination"))
262 + .with(Node::text(settings.destination.clone()));
263 +
264 + body.with(Node::Act(Act::new("Export", Action::post("/export/start"))))
265 + .with(Node::Act(Act::new(
266 + "Cancel",
267 + Action::post("/export/dismiss"),
268 + )))
269 + }
270 +
271 + /// Files being written.
272 + fn running(done: usize, total: usize, current: &str) -> Slot {
273 + let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Exporting"));
274 +
275 + // Zero is "the worker has not counted them yet" rather than an empty export,
276 + // and a meter of 0/0 would draw as finished. Pending is the honest reading
277 + // and it is what `Readiness` is for.
278 + body = if total == 0 {
279 + body.with(Node::StandIn {
280 + state: quasi_router::layout::Readiness::Pending,
281 + message: "Starting export...".to_owned(),
282 + act: None,
283 + })
284 + } else {
285 + body.with(Node::Meter(
286 + quasi_router::Meter::new(clamp(done), clamp(total)).label("samples"),
287 + ))
288 + };
289 +
290 + if !current.is_empty() {
291 + body = body.with(Node::text(format!("Exporting: {current}")));
292 + }
293 +
294 + body.with(Node::Act(Act::new(
295 + "Cancel",
296 + Action::post("/export/cancel"),
297 + )))
298 + }
299 +
300 + /// Finished, however it went.
301 + fn finished(total: usize, errors: &[(String, String)], destination: Option<&str>) -> Slot {
302 + let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Export Complete"));
303 +
304 + body = if errors.is_empty() {
305 + body.with(Node::text(format!("Successfully exported {total} files.")))
306 + } else {
307 + let listed = body.with(Node::banner(
308 + Tone::Danger,
309 + format!("Exported {total} files with {} errors.", errors.len()),
310 + ));
311 + // One list of every failure rather than a list per row: the set is the
312 + // thing being reported, and a run of one-row lists would say each error
313 + // is its own collection.
314 + listed.with(Node::list(errors.iter().map(|(name, error)| {
315 + quasi_router::Row::new(name.clone()).secondary(quasi_router::Prose::Text(error.clone()))
316 + })))
317 + };
318 +
319 + body = body.with(Node::Act(Act::new("Done", Action::post("/export/dismiss"))));
320 + match destination {
321 + Some(path) => body.with(Node::Act(Act::new(
322 + "Open destination folder",
323 + Action::external(path),
324 + ))),
325 + None => body,
326 + }
327 + }
328 +
329 + /// Given up on partway.
330 + fn cancelled(done: usize, total: usize, destination: Option<&str>) -> Slot {
331 + let mut body = Slot::new(BODY, RegionKind::Pane)
332 + .with(Node::page("Export Cancelled"))
333 + .with(Node::text(format!(
334 + "{done} of {total} samples were written before this stopped."
335 + )));
336 +
337 + if let Some(path) = destination {
338 + body = body
339 + .with(Node::text(format!(
340 + "The files already written are in {path}."
341 + )))
342 + .with(Node::Act(Act::new(
343 + "Open destination folder",
344 + Action::external(path),
345 + )));
346 + }
347 +
348 + body.with(Node::Act(Act::new("Done", Action::post("/export/dismiss"))))
349 + }
350 +
351 + /// What is about to be exported, and what is available to do it with.
352 + fn subject_count(subjects: usize, profiles: usize) -> String {
353 + let head = format!("{subjects} samples to export");
354 + if profiles == 0 {
355 + head
356 + } else {
357 + format!("{head}. {profiles} device profiles available.")
358 + }
359 + }
360 +
361 + /// Everything worth warning about before anything is written.
362 + ///
363 + /// Two of the shipped screen's three, and the third is named in the module
364 + /// header. Both of these are arithmetic over facts the description already
365 + /// carries, which is what makes them describable at all.
366 + fn warnings(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Vec<Node> {
367 + let mut said = Vec::new();
368 +
369 + if settings.format == Format::Aiff {
370 + let longest = subjects
371 + .iter()
372 + .filter_map(|subject| subject.duration)
373 + .fold(0.0_f64, f64::max);
374 + let safe = AIFF_SAFE_BYTES / bytes_per_sec(settings).max(1.0);
375 + if longest > safe {
376 + said.push(Node::banner(
377 + Tone::Warning,
378 + format!(
379 + "AIFF chunks cap at 4 GB. At the current rate, depth and channels, \
380 + samples longer than about {:.0} min may fail to export.",
381 + safe / 60.0
382 + ),
383 + ));
384 + }
385 + }
386 +
387 + if let Some(profile) = chosen_profile(profiles, settings.device_profile.as_deref())
388 + && let Some(cap) = profile.max_file_size_bytes
389 + {
390 + let over: Vec<&str> = subjects
391 + .iter()
392 + .filter(|subject| {
393 + subject
394 + .duration
395 + .is_some_and(|seconds| (seconds * WORST_CASE_BYTES_PER_SEC) as u64 > cap)
396 + })
397 + .map(|subject| subject.name.as_str())
398 + .collect();
399 + let megabytes = cap as f64 / 1_048_576.0;
400 + match over.as_slice() {
401 + [] => {}
402 + [only] => said.push(Node::banner(
403 + Tone::Danger,
404 + format!("\"{only}\" may exceed the device file size limit ({megabytes:.0} MB)."),
405 + )),
406 + many => said.push(Node::banner(
407 + Tone::Danger,
408 + format!(
409 + "{} samples may exceed the device file size limit ({megabytes:.0} MB).",
410 + many.len()
411 + ),
412 + )),
413 + }
414 + }
415 +
416 + said
417 + }
418 +
419 + /// What one second of audio costs at these settings.
420 + ///
421 + /// The shipped screen's `bytes_per_sec`, against the described settings rather
422 + /// than the config. Defaults bias high — an absent rate or depth is the largest
423 + /// each may be — because this feeds a warning and a warning that under-estimates
424 + /// is the one that does harm.
425 + fn bytes_per_sec(settings: &Settings) -> f64 {
426 + let rate = f64::from(settings.sample_rate.unwrap_or(48_000));
427 + let depth = f64::from(settings.bit_depth.unwrap_or(24))
428 + .div_euclid(8.0)
429 + .max(1.0);
430 + let channels = match settings.channels {
431 + Channels::Mono => 1.0,
432 + Channels::Stereo | Channels::Original => 2.0,
433 + };
434 + rate * depth * channels
435 + }
436 +
437 + /// The profile in force, if one is.
438 + fn chosen_profile<'a>(
439 + profiles: &'a [ProfileChoice],
440 + chosen: Option<&str>,
441 + ) -> Option<&'a ProfileChoice> {
442 + let name = chosen?;
443 + profiles.iter().find(|profile| profile.name == name)
444 + }
445 +
446 + /// What a device profile says about itself, as one line.
447 + ///
448 + /// Joined rather than four nodes, because the four facts are one statement about
449 + /// one device and the shipped screen's four muted labels are a layout choice.
450 + fn describe(profile: &ProfileChoice) -> String {
451 + let mut said = vec![format!("by {}", profile.manufacturer)];
452 + said.extend(profile.summary.clone());
453 + said.extend(profile.category.clone());
454 + said.extend(profile.notes.clone());
455 + said.join(". ")
456 + }
457 +
458 + /// The device profile picker.
459 + ///
460 + /// A `Field` rather than a `Node::Select` for `settings.rs`'s reason: the choice
461 + /// count is open — profiles are plugins — so it has to be able to fold away, and
462 + /// that is a dropdown.
463 + fn profile_field(profiles: &[ProfileChoice], chosen: Option<&str>) -> Node {
464 + let mut options = vec![Choice::new(String::new(), "None (manual)")];
465 + options.extend(profiles.iter().map(|profile| {
466 + Choice::new(
467 + profile.name.clone(),
468 + format!("{} ({})", profile.name, profile.manufacturer),
469 + )
470 + }));
471 +
472 + let mut field = Field::select(Setting::DeviceProfile.as_str(), "Device profile", options)
473 + .changes(writes(Setting::DeviceProfile));
474 + field.value = Some(chosen.unwrap_or_default().to_owned());
475 + Node::Field(Box::new(field))
476 + }
477 +
478 + /// What to write.
479 + fn format_field(format: Format) -> Node {
480 + let chosen = match format {
481 + Format::Original => "original",
482 + Format::Wav => "wav",
483 + Format::Aiff => "aiff",
484 + };
485 + picker(
486 + Setting::Format,
487 + chosen,
488 + [
489 + ("original", "Original (copy as-is)"),
490 + ("wav", "WAV (decode and re-encode)"),
491 + ("aiff", "AIFF (decode and re-encode)"),
492 + ],
493 + )
494 + }
495 +
496 + /// The sample rate to write at.
497 + fn sample_rate_field(rate: Option<u32>) -> Node {
498 + let chosen = rate.map_or_else(String::new, |rate| rate.to_string());
499 + picker(
500 + Setting::SampleRate,
Lines truncated