Skip to main content

max / audiofiles

Flip the export flow to the described screens The whole flow serves from `quasi::export` now, and `ui/export_screens.rs` is deleted. `editor.rs` asks `/export` for every export-owned mode, the way it already asks `/import` for the import stages; a cancelled export leaves the import arm, because it is the export flow's own last screen. The destination picker is what this flip waited on. `ec92f9cb` shipped `Outcome::Locate` in quasi 0.60, so the Browse act answers an ask for a place and the host performs the picker: `panel::locate` drains it, opens what `ui::dialog` already has, and writes the answered call into `Described::located` for the next frame of the screen that asked. All three kinds of ask are covered, and the file-dialog filters are shared with the `Outcome::File` half. The disk-space warning survives too, which the module header had recorded as undescribable. The syscall is the host's and stays there, in `export::FreeSpace` with the per-destination cache the shipped screen kept in egui's temp store; the number reaches the description as a fact, like the themes list, and the warning is then the arithmetic the other two warnings already are. What did not survive is the nine token chips, which is the standing gap: nothing in the vocabulary puts text into a field. The tokens are named in the field's hint instead. `the_export_flow_serves_what_it_describes_at_every_phase` reads `/export` against `draw_export` at all five phases, which is the permanent shape a flipped screen's parity test takes.
Author: Max Johnson <me@maxj.phd> · 2026-08-25 19:40 UTC
Signed with PGP, not checked
Commit: 99ce7e12c26c4d546538e93405a09fd5df60ca5a
Parent: 4f9412d
12 files changed, +664 insertions, -649 deletions
M Cargo.lock +28 -28
@@ -4255,7 +4255,7 @@
4255 4255
4256 4256 [[package]]
4257 4257 name = "quasi-immediate"
4258 - version = "0.60.0"
4258 + version = "0.61.1"
4259 4259 dependencies = [
4260 4260 "docengine",
4261 4261 "egui",
@@ -4265,7 +4265,7 @@
4265 4265
4266 4266 [[package]]
4267 4267 name = "quasi-router"
4268 - version = "0.60.0"
4268 + version = "0.61.1"
4269 4269 dependencies = [
4270 4270 "makeover-layout",
4271 4271 ]
@@ -7555,33 +7555,9 @@
7555 7555 ]
7556 7556
7557 7557 [[patch.unused]]
7558 - name = "quasi-axum"
7559 - version = "0.60.0"
7560 -
7561 - [[patch.unused]]
7562 - name = "quasi-basics"
7563 - version = "0.60.0"
7564 -
7565 - [[patch.unused]]
7566 - name = "quasi-http"
7567 - version = "0.60.0"
7568 -
7569 - [[patch.unused]]
7570 - name = "quasi-notifs"
7571 - version = "0.60.0"
7572 -
7573 - [[patch.unused]]
7574 - name = "quasi-store"
7558 + name = "quasi-type"
7575 7559 version = "0.1.0"
7576 7560
7577 - [[patch.unused]]
7578 - name = "quasi-tauri"
7579 - version = "0.60.0"
7580 -
7581 - [[patch.unused]]
7582 - name = "quasi-webview"
7583 - version = "0.60.0"
7584 -
7585 7561 [[patch.unused]]
7586 7562 name = "kberg"
7587 7563 version = "0.1.0"
@@ -7595,5 +7571,29 @@
7595 7571 version = "0.1.0"
7596 7572
7597 7573 [[patch.unused]]
7598 - name = "quasi-type"
7574 + name = "quasi-axum"
7575 + version = "0.61.1"
7576 +
7577 + [[patch.unused]]
7578 + name = "quasi-basics"
7579 + version = "0.61.1"
7580 +
7581 + [[patch.unused]]
7582 + name = "quasi-http"
7583 + version = "0.61.1"
7584 +
7585 + [[patch.unused]]
7586 + name = "quasi-notifs"
7587 + version = "0.61.1"
7588 +
7589 + [[patch.unused]]
7590 + name = "quasi-store"
7599 7591 version = "0.1.0"
7592 +
7593 + [[patch.unused]]
7594 + name = "quasi-tauri"
7595 + version = "0.61.1"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-webview"
7599 + version = "0.61.1"
M Cargo.toml +2 -2
@@ -25,8 +25,8 @@
25 25 makeover-timing = "0.1"
26 26 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
27 27 # with a version requirement, per the tree's rule for cross-repo deps.
28 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
29 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
28 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
29 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
30 30 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
31 31 egui_extras = { version = "0.35", default-features = false }
32 32 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -3,7 +3,7 @@
3 3 use egui;
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 - use crate::ui::{export_screens, instrument_panel, overlays, theme};
6 + use crate::ui::{instrument_panel, overlays, theme};
7 7 use audiofiles_core::vfs::NodeType;
8 8
9 9 /// Top-level draw function called each frame from the update closure.
@@ -46,6 +46,24 @@
46 46 handle_keyboard(ctx, state);
47 47 draw_normal_browser(ui, state, sync_manager);
48 48 }
49 + // Every export-owned stage, from one address: the described flow reads
50 + // the mode and the route decides which of its four screens the user is
51 + // looking at. `ui/export_screens.rs` was three drawing functions chosen
52 + // here, and is deleted.
53 + //
54 + // A cancelled export is the flow's own last screen, which is why the
55 + // import arm below cannot take the whole of `OperationCancelled`: it
56 + // says how many files were written and where they are, and the import
57 + // flow has nothing to say about either.
58 + ImportMode::ConfigureExport { .. }
59 + | ImportMode::Exporting { .. }
60 + | ImportMode::ExportComplete { .. }
61 + | ImportMode::OperationCancelled {
62 + kind: crate::state::CancelKind::Export,
63 + ..
64 + } => {
65 + crate::quasi::panel::draw_export(ui, state);
66 + }
49 67 // Every import-owned stage, from one address. The described flow reads
50 68 // the mode itself, so the app asks `/import` and the route decides what
51 69 // the user is looking at.
@@ -64,17 +82,6 @@
64 82 ImportMode::Cleaning { .. } => {
65 83 crate::quasi::panel::draw_sweep(ui, state);
66 84 }
67 - // The export flow is still shipped: its flip waits on the working-directory
68 - // vocabulary gap (`ec92f9cb`).
69 - ImportMode::Exporting { .. } => {
70 - export_screens::draw_export_progress(ui, state);
71 - }
72 - ImportMode::ConfigureExport { .. } => {
73 - export_screens::draw_configure_export(ui, state);
74 - }
75 - ImportMode::ExportComplete { .. } => {
76 - export_screens::draw_export_complete(ui, state);
77 - }
78 85 ImportMode::ReviewLibrary { .. } => {
79 86 crate::quasi::panel::draw_queue(ui, state);
80 87 }
@@ -145,25 +152,6 @@
145 152 crate::quasi::panel::draw_settings(ctx, state);
146 153 }
147 154
148 - // The described export flow, beside whichever of the shipped export screens
149 - // is showing. On the flow's own state rather than on a toggle, because the
150 - // shipped side is not a window either: it takes over the central pane, and
151 - // which of its three screens is showing is `import_mode`. So the described
152 - // window opens when the flow does and closes when it ends.
153 - #[cfg(feature = "quasi")]
154 - if matches!(
155 - state.import_wf.import_mode,
156 - crate::state::ImportMode::ConfigureExport { .. }
157 - | crate::state::ImportMode::Exporting { .. }
158 - | crate::state::ImportMode::ExportComplete { .. }
159 - | crate::state::ImportMode::OperationCancelled {
160 - kind: crate::state::CancelKind::Export,
161 - ..
162 - }
163 - ) {
164 - crate::quasi::panel::draw_export(ctx, state);
165 - }
166 -
167 155 // Sync panel overlay. One call for both cases now: `None` is `Unconfigured`,
168 156 // which says syncing is unavailable and offers nothing, where the shipped
169 157 // side had a second window for it.
@@ -120,6 +120,96 @@
120 120 }
121 121 }
122 122
123 + /// How much room is left where an export would write, asked once per place.
124 + ///
125 + /// The probe is a syscall against a filesystem that may be a network mount or a
126 + /// removable disk, and the screen asking it is redrawn every frame, so the
127 + /// answer is remembered against the path it was asked about. `ui/export_screens`
128 + /// kept the same cache in egui's temp store keyed on the destination; it lives
129 + /// here now because the screen that needed it is a description, and a
130 + /// description cannot hold a cache or make a syscall.
131 + ///
132 + /// Interior mutability because the host reads this through `&BrowserState`, and
133 + /// a `Mutex` rather than a `RefCell` because `BrowserState` is `Sync` (nih-plug
134 + /// requires it), which is `ui::dialog`'s reason for the same choice.
135 + #[derive(Debug, Default)]
136 + pub struct FreeSpace {
137 + seen: parking_lot::Mutex<Option<(PathBuf, Option<u64>)>>,
138 + }
139 +
140 + impl FreeSpace {
141 + /// Bytes available where this path is, or `None` where the host will not say.
142 + ///
143 + /// `None` is "unknown" rather than "full": a path on a filesystem the probe
144 + /// cannot read is not a reason to warn about space.
145 + pub fn available(&self, path: &std::path::Path) -> Option<u64> {
146 + let mut seen = self.seen.lock();
147 + if let Some((asked, answer)) = seen.as_ref()
148 + && asked == path
149 + {
150 + return *answer;
151 + }
152 + let answer = available_disk_space(path);
153 + *seen = Some((path.to_path_buf(), answer));
154 + answer
155 + }
156 + }
157 +
158 + /// Query available disk space on the filesystem containing the given path.
159 + #[cfg(unix)]
160 + fn available_disk_space(path: &std::path::Path) -> Option<u64> {
161 + use std::ffi::CString;
162 + use std::os::unix::ffi::OsStrExt;
163 +
164 + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
165 + // SAFETY: `statvfs` is a POSIX FFI call. `c_path` is a valid NUL-terminated
166 + // C string (from CString::new). `stat` is zero-initialized, which is a valid
167 + // representation for libc::statvfs. The pointer to `stat` is valid for the
168 + // duration of the call.
169 + unsafe {
170 + let mut stat: libc::statvfs = std::mem::zeroed();
171 + if libc::statvfs(c_path.as_ptr(), &raw mut stat) == 0 {
172 + Some(stat.f_bavail as u64 * stat.f_frsize as u64)
173 + } else {
174 + None
175 + }
176 + }
177 + }
178 +
179 + #[cfg(windows)]
180 + fn available_disk_space(path: &std::path::Path) -> Option<u64> {
181 + use std::os::windows::ffi::OsStrExt;
182 + let wide: Vec<u16> = path
183 + .as_os_str()
184 + .encode_wide()
185 + .chain(std::iter::once(0))
186 + .collect();
187 + let mut free_bytes: u64 = 0;
188 + // SAFETY: `GetDiskFreeSpaceExW` is a Win32 FFI call. `wide` is a valid
189 + // NUL-terminated UTF-16 string (from encode_wide + chain(once(0))).
190 + // `free_bytes` is a valid aligned u64 for the out-parameter. The pointer
191 + // to `wide` is valid for the duration of the call.
192 + unsafe {
193 + if windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
194 + windows::core::PCWSTR(wide.as_ptr()),
195 + Some(&mut free_bytes),
196 + None,
197 + None,
198 + )
199 + .is_ok()
200 + {
201 + Some(free_bytes)
202 + } else {
203 + None
204 + }
205 + }
206 + }
207 +
208 + #[cfg(not(any(unix, windows)))]
209 + fn available_disk_space(_path: &std::path::Path) -> Option<u64> {
210 + None
211 + }
212 +
123 213 #[cfg(test)]
124 214 mod tests {
125 215 use super::*;
@@ -159,6 +249,42 @@
159 249 };
160 250 }
161 251
252 + #[test]
253 + fn the_room_left_is_asked_about_once_per_place() {
254 + // The probe is a syscall and the screen asking it redraws every frame,
255 + // so what matters is that a second ask about the same place is the
256 + // remembered answer rather than a second syscall. Observable as the
257 + // answer being stable, and as the note naming the path it was about.
258 + let dir = tempfile::TempDir::new().unwrap();
259 + let free = FreeSpace::default();
260 +
261 + let first = free.available(dir.path());
262 + assert_eq!(free.available(dir.path()), first);
263 + assert_eq!(
264 + free.seen.lock().as_ref().map(|(path, _)| path.clone()),
265 + Some(dir.path().to_path_buf())
266 + );
267 +
268 + // Somewhere else is a different question, and it replaces the note.
269 + let elsewhere = dir.path().join("nowhere");
270 + let _ = free.available(&elsewhere);
271 + assert_eq!(
272 + free.seen.lock().as_ref().map(|(path, _)| path.clone()),
273 + Some(elsewhere)
274 + );
275 + }
276 +
277 + /// On a real filesystem the probe answers, which is what the warning needs.
278 + #[cfg(any(unix, windows))]
279 + #[test]
280 + fn a_real_directory_reports_how_much_room_it_has() {
281 + let dir = tempfile::TempDir::new().unwrap();
282 + assert!(
283 + FreeSpace::default().available(dir.path()).is_some(),
284 + "the host would not say how much room a temporary directory has"
285 + );
286 + }
287 +
162 288 #[test]
163 289 fn spawn_and_drop_does_not_hang() {
164 290 let dir = tempfile::TempDir::new().unwrap();
@@ -42,35 +42,59 @@
42 42 //! its reason and the reason has moved, which is worth recording as a change to
43 43 //! the premise rather than as an exception being taken.
44 44 //!
45 - //! # What is not describable, and it is three things
45 + //! # What the flip carried over, and the one thing it did not
46 46 //!
47 - //! | The shipped screen does | Described | Why not |
47 + //! This is the whole of the export flow now: `ui/export_screens.rs` is deleted
48 + //! and these four screens are what the app draws. So the table below records
49 + //! what each part of the shipped screen became, rather than listing what a
50 + //! second window beside it was missing.
51 + //!
52 + //! | The shipped screen did | Here | How |
48 53 //! |---|---|---|
49 54 //! | AIFF 4 GB chunk warning | yes | arithmetic over the items and the settings |
50 55 //! | device file-size warning | yes | the same, against the profile's limit |
51 56 //! | 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 |
57 + //! | disk space warning | yes | the host probes and hands the number in |
58 + //! | "Browse..." for the destination | yes | [`Outcome::Locate`], quasi 0.60 |
54 59 //! | **the token chips** | no | see below |
55 60 //!
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.
61 + //! ## The destination picker, which is what this flip waited on
62 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.
63 + //! It was the third consumer of *a control that asks the host where to put
64 + //! something and then acts has no vocabulary*, and it is the site
65 + //! [`Locating::labelled`] cites. `ec92f9cb` closed the gap: the act answers
66 + //! [`Outcome::Locate`], the host performs whatever a picker is for it, and what
67 + //! comes back is a handle written through the same route every other setting is
68 + //! written through. **The description still never learns what the host did**,
69 + //! which is the ruling's bargain: it asks for a somewhere and is told a name
70 + //! for it.
71 + //!
72 + //! ## The disk-space warning is host arithmetic, not a host act
73 + //!
74 + //! `statvfs` on the destination is a syscall no handler can make, which is what
75 + //! this header used to say, and it was the wrong half of the sentence. The
76 + //! *number* is a fact, in the same class as the themes list: the host reads it,
77 + //! puts it in `Phase::Configuring`, and the warning is then the arithmetic the
78 + //! other two warnings already are. What stays host-side is the probe and the
79 + //! cache it needs (`crate::export::FreeSpace`), because a description has
80 + //! nowhere to keep either.
81 + //!
82 + //! ## The token chips are still a gap
83 + //!
84 + //! Nine buttons that each append their own text to the field beside them.
85 + //! Nothing in the vocabulary says "put this text into that field": an `Act`
86 + //! calls a route, and routing a keystroke through a handler to change a buffer
87 + //! the renderer owns is the wrong shape at every layer. Recorded, not papered
88 + //! over. The described screen names the tokens in the field's hint, which keeps
89 + //! the fact and loses the affordance.
90 + //!
91 + //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
92 + //! [`Locating::labelled`]: quasi_router::Locating::labelled
69 93
70 94 use quasi_router::layout::{FieldKind, Selector, Tone};
71 95 use quasi_router::{
72 - Act, Action, Choice, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router,
73 - Screen, Slot,
96 + Act, Action, Choice, Field, Locating, Node, Outcome, RegionKind, Request, Response, RouteError,
97 + Router, Screen, Slot,
74 98 };
75 99
76 100 use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject};
@@ -102,6 +126,7 @@
102 126 .get("/export", index)
103 127 .post("/export/begin", begin)
104 128 .post("/export/set/{setting}", configure)
129 + .post("/export/destination", destination)
105 130 .post("/export/start", start)
106 131 .post("/export/cancel", cancel)
107 132 .post("/export/dismiss", dismiss)
@@ -147,6 +172,28 @@
147 172 Ok(screen(state).into())
148 173 }
149 174
175 + /// `POST /export/destination`
176 + ///
177 + /// Where the files go, which is the one answer on this screen the reader does
178 + /// not type. The route says a place is wanted and stops: opening a picker is
179 + /// the host's, and what comes back arrives at `/export/set/destination` like
180 + /// every other setting, under the name [`Setting::Destination`] already has.
181 + ///
182 + /// No label is asked for, where the shape [`Locating::labelled`] describes would
183 + /// take one. The label exists for a host whose handle is opaque, and this
184 + /// screen already shows the destination: it reads it back off the settings the
185 + /// write lands in, so a second copy travelling beside the handle would be a
186 + /// second answer to the same question.
187 + ///
188 + /// [`Locating::labelled`]: quasi_router::Locating::labelled
189 + fn destination(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
190 + Ok(Response::locate(Locating::folder(
191 + "Export Destination",
192 + writes(Setting::Destination),
193 + Setting::Destination.as_str(),
194 + )))
195 + }
196 +
150 197 /// `POST /export/start`
151 198 fn start(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
152 199 state.export.start();
@@ -173,7 +220,8 @@
173 220 subjects,
174 221 profiles,
175 222 settings,
176 - } => configuring(&subjects, &profiles, &settings),
223 + available_bytes,
224 + } => configuring(&subjects, &profiles, &settings, available_bytes),
177 225 Phase::Running {
178 226 done,
179 227 total,
@@ -206,12 +254,17 @@
206 254 }
207 255
208 256 /// Choosing what and where.
209 - fn configuring(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Slot {
257 + fn configuring(
258 + subjects: &[Subject],
259 + profiles: &[ProfileChoice],
260 + settings: &Settings,
261 + available: Option<u64>,
262 + ) -> Slot {
210 263 let mut body = Slot::new(BODY, RegionKind::Pane)
211 264 .with(Node::page("Export Samples"))
212 265 .with(Node::text(subject_count(subjects.len(), profiles.len())));
213 266
214 - for warning in warnings(subjects, profiles, settings) {
267 + for warning in warnings(subjects, profiles, settings, available) {
215 268 body = body.with(warning);
216 269 }
217 270
@@ -273,11 +326,16 @@
273 326 }
274 327 }
275 328
276 - // Read-only, and the module header says why: naming where files go means
277 - // opening a native folder dialog, which no description reaches.
329 + // The path as it stands, and the act that changes it. The act is what the
330 + // module header's first section is about: it answers `Outcome::Locate`, so
331 + // the description asks for a place and never learns what the host opened.
278 332 body = body
279 333 .with(Node::section("Destination"))
280 - .with(Node::text(settings.destination.clone()));
334 + .with(Node::text(settings.destination.clone()))
335 + .with(Node::Act(Act::new(
336 + "Browse...",
337 + Action::post("/export/destination"),
338 + )));
281 339
282 340 body.with(Node::Act(Act::new("Export", Action::post("/export/start"))))
283 341 .with(Node::Act(Act::new(
@@ -378,12 +436,39 @@
378 436
379 437 /// Everything worth warning about before anything is written.
380 438 ///
381 - /// Two of the shipped screen's three, and the third is named in the module
382 - /// header. Both of these are arithmetic over facts the description already
383 - /// carries, which is what makes them describable at all.
384 - fn warnings(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Vec<Node> {
439 + /// All three the shipped screen had. Each is arithmetic over facts the
440 + /// description carries, which is what makes them sayable here at all: two over
441 + /// the items and the settings, and the third over the same settings and the one
442 + /// number the host had to read a filesystem for.
443 + fn warnings(
444 + subjects: &[Subject],
445 + profiles: &[ProfileChoice],
446 + settings: &Settings,
447 + available: Option<u64>,
448 + ) -> Vec<Node> {
385 449 let mut said = Vec::new();
386 450
451 + // Room left where the files would go, against what they would take. The
452 + // tenth is headroom, and it is the shipped screen's: a projection that
453 + // exactly fills a disk is a failure, not a success.
454 + if let Some(available) = available {
455 + let wanted: f64 = subjects
456 + .iter()
457 + .filter_map(|subject| subject.duration)
458 + .map(|seconds| seconds.max(0.0) * bytes_per_sec(settings))
459 + .sum();
460 + if wanted > 0.0 && wanted * 1.1 > available as f64 {
461 + said.push(Node::banner(
462 + Tone::Warning,
463 + format!(
464 + "Low disk space: {:.1} GB available, about {:.1} GB needed.",
465 + available as f64 / 1_073_741_824.0,
466 + wanted / 1_073_741_824.0,
467 + ),
468 + ));
469 + }
470 + }
471 +
387 472 if settings.format == Format::Aiff {
388 473 let longest = subjects
389 474 .iter()
@@ -19,12 +19,17 @@
19 19 //! # THE FINDING, fourth consumer: Locate is a host act
20 20 //!
21 21 //! `Locate missing files...` opens a native folder picker
22 - //! (`state.dialogs.pick_folder`), and `quasi:vocabulary:host-save-location` is
22 + //! (`state.dialogs.pick_folder`), and `quasi:vocabulary:host-save-location` was
23 23 //! the gap that nothing describes one. Same shape as the export destination, the
24 24 //! import source and the theme export before it. It is an ordinary act here and
25 25 //! the host does what only a host can, which is the same workaround those three
26 26 //! took.
27 27 //!
28 + //! The gap is closed: `ec92f9cb` shipped `Outcome::Locate` in quasi 0.60, and
29 + //! the export flip took it at the destination picker. This site has not been
30 + //! converted, so the paragraph above describes what the code does rather than
31 + //! what it should do.
32 + //!
28 33 //! # Purge says what it takes, on the control
29 34 //!
30 35 //! The shipped modal draws the blast radius as a warning-toned line above the
@@ -94,13 +94,21 @@
94 94 //! a host concern.
95 95 //!
96 96 //! Recorded rather than skipped, because it is the sharpest measurement this
97 - //! layer has of `quasi:vocabulary:host-save-location`. The gap has eight
98 - //! consumers across the app — the export destination, Export Theme, Locate
99 - //! missing files, and the import flow's four doors — and every one of them
100 - //! reaches this file. What the count says is that a native picker is not an
101 - //! oversight in one screen but a whole subsystem the description cannot name,
102 - //! and that `FieldKind::File` covering "pick a file to submit" answers the one
103 - //! shape of it nobody here uses.
97 + //! layer has of `quasi:vocabulary:host-save-location`. The gap had eight
98 + //! consumers across the app: the export destination, Export Theme, Locate
99 + //! missing files, and the import flow's four doors, every one of which reaches
100 + //! this file. What the count said is that a native picker is not an oversight in
101 + //! one screen but a whole subsystem the description could not name, and that
102 + //! `FieldKind::File` covering "pick a file to submit" answers the one shape of
103 + //! it nobody here uses.
104 + //!
105 + //! **`ec92f9cb` closed it in quasi 0.60**, with `Outcome::Locate`: the route
106 + //! says a place is wanted, the host performs the picker, and a handle comes
107 + //! back. The export destination is the first site here to take it, because the
108 + //! export flip is what the ruling was measured for; `panel::locate` is the host
109 + //! half and serves every described window. The other six are still ordinary
110 + //! intents, which is a conversion each of them is owed rather than a shape they
111 + //! were argued into.
104 112 //!
105 113 //! There is a second half, filed with the import flow: a route that hands off to
106 114 //! the host has no [`Outcome`](quasi_router::Outcome) meaning "nothing here
@@ -1142,6 +1150,14 @@
1142 1150 profiles: Vec<ProfileChoice>,
1143 1151 /// The settings as they stand.
1144 1152 settings: Settings,
1153 + /// Room left where the files would go, or `None` where the host will
1154 + /// not say.
1155 + ///
1156 + /// A fact the host resolved, in the class the themes list is in: the
1157 + /// description does arithmetic with it and never asks a filesystem
1158 + /// anything. What made it look otherwise is that the shipped screen
1159 + /// made the syscall itself, inside the drawing.
1160 + available_bytes: Option<u64>,
1145 1161 },
1146 1162 /// Files being written.
1147 1163 Running {
@@ -1281,6 +1297,15 @@
1281 1297 NamingPattern,
1282 1298 /// [`Settings::device_profile`].
1283 1299 DeviceProfile,
1300 + /// [`Settings::destination`].
1301 + ///
1302 + /// Written by nothing a reader types: the value arrives from the host's own
1303 + /// folder picker, through the call [`Locating::answered`] builds. It is the
1304 + /// same setting all the same, so it is written by the same route rather
1305 + /// than by one of its own.
1306 + ///
1307 + /// [`Locating::answered`]: quasi_router::Locating::answered
1308 + Destination,
1284 1309 }
1285 1310
1286 1311 impl Setting {
@@ -1296,6 +1321,7 @@
1296 1321 Self::Sidecar => "sidecar",
1297 1322 Self::NamingPattern => "naming-pattern",
1298 1323 Self::DeviceProfile => "device-profile",
1324 + Self::Destination => "destination",
1299 1325 }
1300 1326 }
1301 1327
@@ -1315,6 +1341,7 @@
1315 1341 "sidecar" => Some(Self::Sidecar),
1316 1342 "naming-pattern" => Some(Self::NamingPattern),
1317 1343 "device-profile" => Some(Self::DeviceProfile),
1344 + "destination" => Some(Self::Destination),
1318 1345 _ => None,
1319 1346 }
1320 1347 }
@@ -1410,6 +1437,7 @@
1410 1437 destination: config.destination.display().to_string(),
1411 1438 device_profile: config.device_profile.clone(),
1412 1439 },
1440 + available_bytes: self.state.described.free.available(&config.destination),
1413 1441 },
1414 1442 ImportMode::Exporting {
1415 1443 completed,
@@ -76,6 +76,27 @@
76 76 /// moves with no intent at all. So the answer is `quasi` 0.12.0's
77 77 /// `Runtime::reload`, and this is the flag that says when to call it.
78 78 stale: bool,
79 + /// Calls a host picker answered, waiting for the frame that can make them.
80 + ///
81 + /// `Outcome::Locate`'s host half. A picker runs off the frame thread and
82 + /// answers through a handler that holds `&mut BrowserState` and no runtime,
83 + /// so what it can do is write the call down; [`drive`] makes it on the next
84 + /// frame of the screen that asked. Keyed by that screen's home address,
85 + /// because one of these is shared by every described window and a call
86 + /// belongs to the runtime that raised it.
87 + ///
88 + /// A queue rather than a slot: [`Sought::Files`] is one ask answered once
89 + /// per file, and a slot would keep the last of them.
90 + ///
91 + /// [`Sought::Files`]: quasi_router::Sought::Files
92 + located: parking_lot::Mutex<Vec<(String, Request)>>,
93 + /// How much room is left where an export would write.
94 + ///
95 + /// A host fact the export description does arithmetic with, and the cache
96 + /// that keeps the probe off every frame. See `crate::export::FreeSpace`; it
97 + /// is here because `Described` is the host half of the port and a syscall is
98 + /// exactly what a description cannot make.
99 + pub(super) free: crate::export::FreeSpace,
79 100 }
80 101
81 102 /// Draw the described settings window, and act on whatever was pressed.
@@ -146,15 +167,24 @@
146 167 }
147 168 }
148 169
149 - /// Draw the described export flow, and act on whatever was pressed.
170 + /// Draw the export flow, and act on whatever was pressed.
150 171 ///
151 - /// **Refreshed unconditionally**, where the other three refresh only after an
152 - /// intent. The progress screen's subject is a worker writing files: it moves
153 - /// with nothing the user did, so there is no event to hang a refresh on and the
154 - /// frame is the only clock the host has. The other phases pay one router call
155 - /// per frame for it, which is a table lookup and a walk over state already in
156 - /// memory — less than the shipped screen does laying out the same panel.
157 - pub fn draw_export(ctx: &egui::Context, state: &mut BrowserState) {
172 + /// One call for the whole flow, the way `draw_import` is: the shipped side was
173 + /// three drawing functions chosen by a `match` on `ImportMode` in the app's own
174 + /// dispatcher, and the described side is one address whose answer depends on
175 + /// what the export is doing.
176 + ///
177 + /// Into the app's own pane rather than a window, because that is what the
178 + /// shipped flow was: it took the central pane over, and which of its screens
179 + /// was showing was the mode. `ui/export_screens.rs` is deleted as of this flip.
180 + ///
181 + /// **Refreshed unconditionally**, and this flow is where the reason was found.
182 + /// The progress screen's subject is a worker writing files: it moves with
183 + /// nothing the user did, so there is no event to hang a refresh on and the frame
184 + /// is the only clock the host has. The other phases pay one router call per
185 + /// frame for it, which is a table lookup and a walk over state already in
186 + /// memory, less than the shipped screen did laying out the same panel.
187 + pub fn draw_export(ui: &mut egui::Ui, state: &mut BrowserState) {
158 188 let intents = RefCell::new(Vec::new());
159 189 let mut runtime = state.described.export.take();
160 190 let host = Host {
@@ -163,19 +193,9 @@
163 193 themes: themes(),
164 194 intents: &intents,
165 195 };
166 - let closed = window(
167 - ctx,
168 - "Export (described)",
169 - &mut runtime,
170 - &host,
171 - "/export",
172 - true,
173 - );
196 + inline(ui, &mut runtime, &host, "/export", true);
174 197 state.described.export = runtime;
175 - apply(ctx, state, None, intents.into_inner());
176 - if closed {
177 - state.described.export = None;
178 - }
198 + apply(ui.ctx(), state, None, intents.into_inner());
179 199 }
180 200
181 201 /// Draw the detail panel, and act on whatever was pressed.
@@ -1699,9 +1719,9 @@
1699 1719 /// a control that silently does nothing.
1700 1720 ///
1701 1721 /// Choosing a device profile clears the three fields the profile owns, which is
1702 - /// what `ui::export_screens` does at the same control and for the same reason:
1703 - /// they are derived from the profile, so a stale set of them would outlive the
1704 - /// profile that produced it.
1722 + /// what the deleted `ui::export_screens` did at the same control and for the
1723 + /// same reason: they are derived from the profile, so a stale set of them would
1724 + /// outlive the profile that produced it.
1705 1725 fn configure(state: &mut BrowserState, setting: Setting, value: &str) {
1706 1726 use audiofiles_core::export::{ExportChannels, ExportFormat};
1707 1727
@@ -1741,6 +1761,15 @@
1741 1761 config.max_file_size_bytes = None;
1742 1762 config.name_overrides = None;
1743 1763 }
1764 + // The one value here that came from a picker rather than from a
1765 + // control. An empty write is a picker that answered nothing, which
1766 + // `ui::dialog` does not report at all; refusing it here as well keeps a
1767 + // hand-typed request from blanking the destination.
1768 + Setting::Destination => {
1769 + if !value.is_empty() {
1770 + config.destination = std::path::PathBuf::from(value);
1771 + }
1772 + }
1744 1773 }
1745 1774 }
1746 1775
@@ -1823,6 +1852,13 @@
1823 1852 },
1824 1853 };
1825 1854
1855 + // What a host picker answered, called now that there is a runtime to answer
1856 + // into. Before the refresh below, so the frame that shows the new
1857 + // destination is the frame the reader's choice lands on. See `locate`.
1858 + for request in answered(host.state, home) {
1859 + call(runtime, host, request);
1860 + }
1861 +
1826 1862 // Before the drawing, so the frame draws what is true now rather
1827 1863 // than showing the previous answer for one more frame. `reload`
1828 1864 // re-asks the address the screen came from, and the runtime keeps
@@ -1864,6 +1900,7 @@
1864 1900 // driven. Last in the frame because `perform` above is what may have
1865 1901 // just produced one.
1866 1902 hand_over(runtime, host.state);
1903 + locate(runtime, host.state, home);
1867 1904 }
1868 1905
1869 1906 /// One described window: draw it, act on it, and say whether it was closed.
@@ -2002,22 +2039,135 @@
2002 2039 return;
2003 2040 };
2004 2041 let quasi_immediate::Handed { name, kind, bytes } = handed;
2005 - // The dialog wants a filter, and `Accepted` is the same type the upload half
2006 - // uses rather than a second way to name a file kind. Only a suffix is a
2007 - // filter a native dialog can take; a family or a media type is a fact about
2008 - // the file rather than a list of extensions, so those offer no filter and
2009 - // the user picks freely.
2010 - let suffix = match &kind {
2011 - quasi_router::Accepted::Suffix(suffix) => Some(suffix.trim_start_matches('.').to_owned()),
2012 - // `Accepted` is `#[non_exhaustive]`, so a kind added later lands here
2013 - // and the user picks freely rather than the build breaking.
2014 - _ => None,
2042 + // The dialog wants a filter, and `Accepted` is the same type the ask-for-a-
2043 + // place half uses rather than a second way to name a file kind, so the two
2044 + // share `with_filters`.
2045 + with_filters(std::slice::from_ref(&kind), |filters| {
2046 + state
2047 + .dialogs
2048 + .save_file("Save", name, filters, move |s, path| {
2049 + match std::fs::write(&path, &bytes) {
2050 + Ok(()) => s.status = format!("Saved to {}", path.display()),
2051 + Err(error) => {
2052 + tracing::error!("failed to write {}: {error}", path.display());
2053 + s.status = format!("Could not save: {error}");
2054 + }
2055 + }
2056 + });
2057 + });
2058 + }
2059 +
2060 + /// Ask the host where something goes, and write the answer down for later.
2061 + ///
2062 + /// The host half of `Outcome::Locate`, which quasi ruled and shipped in 0.60
2063 + /// (`ec92f9cb`, Max: the route says a place is wanted, the host performs the
2064 + /// picker, and back comes a handle and a label). audiofiles is the app the
2065 + /// ruling was measured against: seven sites here open a native picker, and the
2066 + /// export destination is the one that is part of a form.
2067 + ///
2068 + /// **Nothing is drawn and nothing navigates.** `ui::dialog` runs the picker off
2069 + /// the frame thread and answers frames later through a handler that holds
2070 + /// `&mut BrowserState`, so the call the ask names cannot be made where it is
2071 + /// raised. The handler writes it into [`Described::located`] and [`drive`] makes
2072 + /// it on the next frame of the screen that asked.
2073 + ///
2074 + /// A reader who backs out has answered nothing: `ui::dialog` skips the handler
2075 + /// on an empty result, so no call is written and the screen is untouched.
2076 + fn locate(runtime: &mut Runtime, state: &BrowserState, home: &str) {
2077 + let Some(asking) = runtime.locating() else {
2078 + return;
2015 2079 };
2016 - let filters: Vec<(String, Vec<String>)> = suffix
2017 - .into_iter()
2018 - .map(|suffix| (suffix.to_uppercase(), vec![suffix]))
2080 + let home = home.to_owned();
2081 + let prompt = asking.prompt.clone();
2082 +
2083 + match asking.sought.clone() {
2084 + quasi_router::Sought::Folder => {
2085 + state.dialogs.pick_folder(prompt, move |state, folder| {
2086 + write_down(state, &home, &asking, [folder]);
2087 + });
2088 + }
2089 + quasi_router::Sought::File { accept } => {
2090 + with_filters(&accept, |filters| {
2091 + state
2092 + .dialogs
2093 + .pick_file(prompt, filters, move |state, file| {
2094 + write_down(state, &home, &asking, [file]);
2095 + });
2096 + });
2097 + }
2098 + // One ask, one call per file. The alternative would be a convention for
2099 + // putting several paths in one handle, which is a spelling this host
2100 + // would be inventing: `Locating::answered` takes one.
2101 + quasi_router::Sought::Files { accept } => {
2102 + with_filters(&accept, |filters| {
2103 + state
2104 + .dialogs
2105 + .pick_files(prompt, filters, move |state, files| {
2106 + write_down(state, &home, &asking, files);
2107 + });
2108 + });
2109 + }
2110 + }
2111 + }
2112 +
2113 + /// Note down the calls a picker's answer makes, for the frame that can make them.
2114 + ///
2115 + /// The handle is the path as this host spells it, which is what a folder is here
2116 + /// and is the whole of what the description learns about it. The label is the
2117 + /// same string: nothing on this host is opaque enough for the two to differ, and
2118 + /// a route that asked for no label never sees it.
2119 + fn write_down(
2120 + state: &mut BrowserState,
2121 + home: &str,
2122 + asking: &quasi_router::Locating,
2123 + picked: impl IntoIterator<Item = std::path::PathBuf>,
2124 + ) {
2125 + let mut waiting = state.described.located.lock();
2126 + for path in picked {
2127 + let shown = path.display().to_string();
2128 + if let Some(request) = asking.answered(&shown, &shown) {
2129 + waiting.push((home.to_owned(), request));
2130 + }
2131 + }
2132 + }
2133 +
2134 + /// The calls this screen's pickers have answered, in the order they were picked.
2135 + ///
2136 + /// Keyed by home address because one queue serves every described window, and a
2137 + /// call raised by the export flow is not the settings screen's to make.
2138 + fn answered(state: &BrowserState, home: &str) -> Vec<Request> {
2139 + let mut waiting = state.described.located.lock();
2140 + let (mine, theirs) = waiting
2141 + .drain(..)
2142 + .partition::<Vec<_>, _>(|(asked, _)| asked == home);
2143 + *waiting = theirs;
2144 + mine.into_iter().map(|(_, request)| request).collect()
2145 + }
2146 +
2147 + /// Run `use_them` with the filters a native dialog takes, from what a
2148 + /// description accepts.
2149 + ///
2150 + /// Only a suffix is a filter a dialog can take; a family or a media type is a
2151 + /// fact about the file rather than a list of extensions, so those offer no
2152 + /// filter and the reader picks freely. The three borrows are what
2153 + /// `ui::dialog`'s signature asks for, done once here rather than at each site.
2154 + fn with_filters<R>(
2155 + accept: &[quasi_router::Accepted],
2156 + use_them: impl FnOnce(&[(&str, &[&str])]) -> R,
2157 + ) -> R {
2158 + let owned: Vec<(String, Vec<String>)> = accept
2159 + .iter()
2160 + .filter_map(|kind| match kind {
2161 + quasi_router::Accepted::Suffix(suffix) => {
2162 + let suffix = suffix.trim_start_matches('.').to_owned();
2163 + Some((suffix.to_uppercase(), vec![suffix]))
2164 + }
2165 + // `Accepted` is `#[non_exhaustive]`, so a kind added later offers no
2166 + // filter rather than breaking the build.
2167 + _ => None,
2168 + })
2019 2169 .collect();
2020 - let borrowed: Vec<(&str, Vec<&str>)> = filters
2170 + let borrowed: Vec<(&str, Vec<&str>)> = owned
2021 2171 .iter()
2022 2172 .map(|(label, suffixes)| {
2023 2173 (
@@ -2026,23 +2176,11 @@
2026 2176 )
2027 2177 })
2028 2178 .collect();
2029 - let borrowed: Vec<(&str, &[&str])> = borrowed
2179 + let filters: Vec<(&str, &[&str])> = borrowed
2030 2180 .iter()
2031 2181 .map(|(label, suffixes)| (*label, suffixes.as_slice()))
2032 2182 .collect();
2033 -
2034 - state.dialogs.save_file(
2035 - "Save",
2036 - name,
2037 - &borrowed,
2038 - move |s, path| match std::fs::write(&path, &bytes) {
2039 - Ok(()) => s.status = format!("Saved to {}", path.display()),
2040 - Err(error) => {
2041 - tracing::error!("failed to write {}: {error}", path.display());
2042 - s.status = format!("Could not save: {error}");
2043 - }
2044 - },
2045 - );
2183 + use_them(&filters)
2046 2184 }
2047 2185
2048 2186 /// Hand an address to the desktop.
@@ -1176,6 +1176,72 @@
1176 1176 }
1177 1177 }
1178 1178
1179 + /// The export flow's phases, in the states a test can stand one up in.
1180 + ///
1181 + /// Self-parity: `ui/export_screens.rs` is deleted, so what `/export` says and
1182 + /// what `draw_export` draws are read against each other rather than against a
1183 + /// second implementation. The import flow's test has the same shape and the
1184 + /// same reason: a fidelity test that visited one phase would say almost nothing
1185 + /// about a flow whose whole claim is that one address answers four screens.
1186 + #[test]
1187 + fn the_export_flow_serves_what_it_describes_at_every_phase() {
1188 + type Reach = fn(&mut crate::state::BrowserState);
1189 +
1190 + let phases: [(&str, Reach); 5] = [
1191 + ("idle", |_state| {}),
1192 + // Through the app's own door rather than by building a config here: the
1193 + // configure screen is answered out of an `ExportConfig` with a dozen
1194 + // fields, and a hand-built one would be this test agreeing with itself.
1195 + ("configuring", |state| {
1196 + state.start_export_flow(None);
1197 + assert!(
1198 + matches!(
1199 + state.import_wf.import_mode,
1200 + crate::state::ImportMode::ConfigureExport { .. }
1201 + ),
1202 + "the fixture had nothing to export, so this phase was never reached"
1203 + );
1204 + }),
1205 + ("writing", |state| {
1206 + state.import_wf.import_mode = crate::state::ImportMode::Exporting {
1207 + completed: 3,
1208 + total: 9,
1209 + current_name: "kick.wav".to_owned(),
1210 + };
1211 + }),
1212 + ("finished", |state| {
1213 + state.import_wf.import_mode = crate::state::ImportMode::ExportComplete {
1214 + total: 9,
1215 + errors: vec![("snare.wav".to_owned(), "no room".to_owned())],
1216 + };
1217 + }),
1218 + ("stopped", |state| {
1219 + state.import_wf.import_mode = crate::state::ImportMode::OperationCancelled {
1220 + kind: crate::state::CancelKind::Export,
1221 + completed: 3,
1222 + total: 9,
1223 + destination: None,
1224 + };
1225 + }),
1226 + ];
1227 +
1228 + for (phase, reach) in phases {
1229 + let (mut state, _dir) = fixture();
1230 + reach(&mut state);
1231 +
1232 + let described = described(&super::panel::described_screen(&state, "/export"));
1233 + let drawn = shipped(|ui| {
1234 + super::panel::draw_export(ui, &mut state);
1235 + });
1236 +
1237 + described.addresses_resolve();
1238 + // Into the app's own pane, like every other full-screen mode, so there
1239 + // is no frame to discount.
1240 + Parity::strict().assert(&described, &drawn);
1241 + println!(" {phase}: ok");
1242 + }
1243 + }
1244 +
1179 1245 #[test]
1180 1246 fn the_sweep_serves_what_it_describes() {
1181 1247 let (mut state, _dir) = fixture();
@@ -2198,6 +2198,7 @@
2198 2198 subjects: vec![subject("kick", 2.0), subject("snare", 1.0)],
2199 2199 profiles: Vec::new(),
2200 2200 settings: defaults(),
2201 + available_bytes: None,
2201 2202 });
2202 2203 let screen = exported(&export);
2203 2204 let said = said(&screen);
@@ -2219,6 +2220,7 @@
2219 2220 subjects: vec![subject("kick", 2.0)],
2220 2221 profiles: Vec::new(),
2221 2222 settings: defaults(),
2223 + available_bytes: None,
2222 2224 });
2223 2225 let said_of_original = said(&exported(&original));
2224 2226 assert!(
@@ -2237,6 +2239,7 @@
2237 2239 format: Format::Wav,
2238 2240 ..defaults()
2239 2241 },
2242 + available_bytes: None,
2240 2243 });
2241 2244 let said_of_wav = said(&exported(&wav));
2242 2245 assert!(said_of_wav.contains("Sample Rate"), "{said_of_wav}");
@@ -2266,6 +2269,7 @@
2266 2269 device_profile: Some("SP-404 MKII".to_owned()),
2267 2270 ..defaults()
2268 2271 },
2272 + available_bytes: None,
2269 2273 });
2270 2274 let said = said(&exported(&export));
2271 2275
@@ -2287,6 +2291,7 @@
2287 2291 format: Format::Aiff,
2288 2292 ..defaults()
2289 2293 },
2294 + available_bytes: None,
2290 2295 });
2291 2296 assert!(said(&exported(&over)).contains("AIFF chunks cap at 4 GB"),);
2292 2297
@@ -2297,6 +2302,7 @@
2297 2302 format: Format::Aiff,
2298 2303 ..defaults()
2299 2304 },
2305 + available_bytes: None,
2300 2306 });
2301 2307 assert!(!said(&exported(&under)).contains("AIFF chunks cap"),);
2302 2308 }
@@ -2319,6 +2325,7 @@
2319 2325 device_profile: Some("SP-404 MKII".to_owned()),
2320 2326 ..defaults()
2321 2327 },
2328 + available_bytes: None,
2322 2329 })
2323 2330 };
2324 2331
@@ -2356,6 +2363,7 @@
2356 2363 naming_pattern: Some(pattern.to_owned()),
2357 2364 ..defaults()
2358 2365 },
2366 + available_bytes: None,
2359 2367 })
2360 2368 };
2361 2369
@@ -2383,16 +2391,87 @@
2383 2391 naming_pattern: Some("{name}".to_owned()),
2384 2392 ..defaults()
2385 2393 },
2394 + available_bytes: None,
2386 2395 });
2387 2396 assert!(!said(&exported(&export)).contains("Naming Pattern"),);
2388 2397 }
2389 2398
2399 + #[test]
2400 + fn asking_where_the_files_go_leaves_as_an_ask_rather_than_as_a_screen() {
2401 + // `ec92f9cb`. The route says a folder is wanted and stops: the picker is
2402 + // the host's, and the answer comes back through the setting route every
2403 + // other control writes through.
2404 + let export = FakeExport::at(Phase::Configuring {
2405 + subjects: vec![subject("kick", 2.0)],
2406 + profiles: Vec::new(),
2407 + settings: defaults(),
2408 + available_bytes: None,
2409 + });
2410 +
2411 + let response = exporting(&export, Request::post("/export/destination")).unwrap();
2412 + let quasi_router::Outcome::Locate(asking) = response.outcome else {
2413 + panic!("asking where the files go did not ask for a place");
2414 + };
2415 + assert_eq!(asking.sought, quasi_router::Sought::Folder);
2416 + assert!(
2417 + export.asked.borrow().is_empty(),
2418 + "the ask wrote a setting before the reader had chosen anything"
2419 + );
2420 +
2421 + // What the host sends back is an ordinary write, built by the crate that
2422 + // stated the parameter name.
2423 + let answered = asking
2424 + .answered("/music/out", "/music/out")
2425 + .expect("a route to answer to");
2426 + assert_eq!(answered.path, "/export/set/destination");
2427 + // Handed to the router as it stands, captures and all: the host does not
2428 + // build that request, and a test that patched one up would be checking a
2429 + // path nothing takes.
2430 + exporting(&export, answered).unwrap();
2431 + assert_eq!(
2432 + export.asked.borrow().as_slice(),
2433 + ["set:destination=/music/out"]
2434 + );
2435 + }
2436 +
2437 + #[test]
2438 + fn an_export_that_would_not_fit_says_so_before_it_starts() {
2439 + // Two hours at the biasing defaults is about 2 GB, so a gigabyte of room is
2440 + // not enough and a terabyte is. The tenth of headroom is the shipped
2441 + // screen's: a projection that exactly fills a disk is a failure.
2442 + let with_room = |available: u64| {
2443 + FakeExport::at(Phase::Configuring {
2444 + subjects: vec![subject("set", 2.0 * 3600.0)],
2445 + profiles: Vec::new(),
2446 + settings: defaults(),
2447 + available_bytes: Some(available),
2448 + })
2449 + };
2450 +
2451 + assert!(
2452 + said(&exported(&with_room(1_073_741_824))).contains("Low disk space"),
2453 + "a disk with a gigabyte left took a two-gigabyte export without a word"
2454 + );
2455 + assert!(!said(&exported(&with_room(1_099_511_627_776))).contains("Low disk space"),);
2456 +
2457 + // A host that will not say how much room there is says nothing, rather than
2458 + // warning about a number it does not have.
2459 + let unknown = FakeExport::at(Phase::Configuring {
2460 + subjects: vec![subject("set", 2.0 * 3600.0)],
2461 + profiles: Vec::new(),
2462 + settings: defaults(),
2463 + available_bytes: None,
2464 + });
2465 + assert!(!said(&exported(&unknown)).contains("Low disk space"),);
2466 + }
2467 +
2390 2468 #[test]
2391 2469 fn every_control_writes_through_one_route_and_an_undeclared_setting_is_refused() {
2392 2470 let export = FakeExport::at(Phase::Configuring {
2393 2471 subjects: vec![subject("kick", 2.0)],
2394 2472 profiles: Vec::new(),
2395 2473 settings: defaults(),
2474 + available_bytes: None,
2396 2475 });
2397 2476
2398 2477 exporting(
@@ -2564,6 +2643,7 @@
2564 2643 subjects: vec![subject("kick", 2.0)],
2565 2644 profiles: Vec::new(),
2566 2645 settings: defaults(),
2646 + available_bytes: None,
2567 2647 },
2568 2648 Phase::Finished {
2569 2649 total: 1,
@@ -3,7 +3,6 @@
3 3 pub mod classifier;
4 4 pub mod color;
5 5 pub mod dialog;
6 - pub mod export_screens;
7 6 pub mod file_list_menus;
8 7 pub mod instrument_panel;
9 8 pub mod overlays;
@@ -1,749 +1,0 @@
1 - //! Export workflow screens: configure export settings, progress bar, and completion summary.
2 -
3 - use std::path::Path;
4 -
5 - use egui;
6 -
7 - use crate::state::{BrowserState, ImportMode};
8 - use audiofiles_core::export::{ExportChannels, ExportConfig, ExportFormat};
9 -
10 - use super::{theme, widgets};
11 -
12 - /// Query available disk space on the filesystem containing the given path.
13 - #[cfg(unix)]
14 - fn available_disk_space(path: &Path) -> Option<u64> {
15 - use std::ffi::CString;
16 - use std::os::unix::ffi::OsStrExt;
17 -
18 - let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
19 - // SAFETY: `statvfs` is a POSIX FFI call. `c_path` is a valid NUL-terminated
20 - // C string (from CString::new). `stat` is zero-initialized, which is a valid
21 - // representation for libc::statvfs. The pointer to `stat` is valid for the
22 - // duration of the call.
23 - unsafe {
24 - let mut stat: libc::statvfs = std::mem::zeroed();
25 - if libc::statvfs(c_path.as_ptr(), &raw mut stat) == 0 {
26 - Some(stat.f_bavail as u64 * stat.f_frsize as u64)
27 - } else {
28 - None
29 - }
30 - }
31 - }
32 -
33 - #[cfg(windows)]
34 - fn available_disk_space(path: &Path) -> Option<u64> {
35 - use std::os::windows::ffi::OsStrExt;
36 - let wide: Vec<u16> = path
37 - .as_os_str()
38 - .encode_wide()
39 - .chain(std::iter::once(0))
40 - .collect();
41 - let mut free_bytes: u64 = 0;
42 - // SAFETY: `GetDiskFreeSpaceExW` is a Win32 FFI call. `wide` is a valid
43 - // NUL-terminated UTF-16 string (from encode_wide + chain(once(0))).
44 - // `free_bytes` is a valid aligned u64 for the out-parameter. The pointer
45 - // to `wide` is valid for the duration of the call.
46 - unsafe {
47 - if windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
48 - windows::core::PCWSTR(wide.as_ptr()),
49 - Some(&mut free_bytes),
50 - None,
51 - None,
52 - )
53 - .is_ok()
54 - {
55 - Some(free_bytes)
56 - } else {
57 - None
58 - }
59 - }
60 - }
61 -
62 - #[cfg(not(any(unix, windows)))]
63 - fn available_disk_space(_path: &Path) -> Option<u64> {
64 - None
65 - }
66 -
67 - /// Effective bytes-per-second of audio under the current export config.
68 - /// Used by the disk-space and AIFF-size pre-flight warnings (M-3 / M-4) so
69 - /// the magnitude warnings reflect the user's actual selection rather than a
70 - /// worst-case heuristic. Defaults (`None` config values) bias high so we err
71 - /// on the side of warning when the user picks "Original".
72 - fn bytes_per_sec_for_config(config: &ExportConfig) -> u64 {
73 - bytes_per_sec(config.sample_rate, config.bit_depth, &config.channels)
74 - }
75 -
76 - /// Bytes-per-second of PCM audio for the given rate/depth/channels. `None` rate
77 - /// or depth means "Original", which biases high (48 kHz / 24-bit) so the callers'
78 - /// size warnings err toward warning. `Original` channels also biases to stereo.
79 - fn bytes_per_sec(rate: Option<u32>, depth: Option<u16>, channels: &ExportChannels) -> u64 {
80 - let rate = rate.unwrap_or(48_000) as u64;
81 - let depth_bytes = (depth.unwrap_or(24) as u64).div_ceil(8);
82 - let channels = match channels {
83 - ExportChannels::Mono => 1u64,
84 - ExportChannels::Stereo => 2u64,
85 - ExportChannels::Original => 2u64,
86 - };
87 - rate.saturating_mul(depth_bytes).saturating_mul(channels)
88 - }
89 -
90 - /// Draw the export configuration screen.
91 - pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
92 - let (item_count, profile_count) = match &state.import_wf.import_mode {
93 - ImportMode::ConfigureExport {
94 - items,
95 - available_profiles,
96 - ..
97 - } => (items.len(), available_profiles.len()),
98 - _ => return,
99 - };
100 -
101 - egui::Panel::bottom("export_footer").show(ui, |ui| {
102 - ui.add_space(theme::space::bound());
103 -
104 - // Warnings
105 - if let ImportMode::ConfigureExport {
106 - ref items,
107 - ref config,
108 - ref available_profiles,
109 - ..
110 - } = state.import_wf.import_mode
111 - {
112 - // AIFF size limit warning (M-4): the 4 GB chunk limit translates
113 - // to ~124 minutes at the worst-case config (stereo 24-bit 96kHz)
114 - // and considerably more at smaller depths/rates. Compute the
115 - // actual safe duration from the current config rather than warning
116 - // at a fixed 20-minute threshold. Yellow because this is
117 - // anticipation, not error.
118 - if config.format == ExportFormat::Aiff {
119 - let max_duration = items
120 - .iter()
121 - .filter_map(|i| i.duration)
122 - .fold(0.0f64, f64::max);
123 - let bps = bytes_per_sec_for_config(config) as f64;
124 - // 90% of u32::MAX gives headroom for chunk headers + rounding.
125 - let safe_secs = (u32::MAX as f64 * 0.9) / bps.max(1.0);
126 - if max_duration > safe_secs {
127 - ui.label(
128 - egui::RichText::new(format!(
129 - "Warning: AIFF chunks cap at 4 GB. At the current rate/depth/channels, \
130 - samples longer than ~{:.0} min may fail to export.",
131 - safe_secs / 60.0,
132 - ))
133 - .small()
134 - .color(theme::warning()),
135 - );
136 - }
137 - }
138 -
139 - // Device file size limit warning
140 - if let Some(ref profile_name) = config.device_profile
141 - && let Some(profile) = available_profiles.iter().find(|p| &p.name == profile_name)
142 - && let Some(max_bytes) = profile.max_file_size_bytes
143 - {
144 - // Estimate: duration * sample_rate * channels * bytes_per_sample
145 - // Use worst case: stereo 24-bit at 48kHz = 288000 bytes/sec
146 - let bytes_per_sec: f64 = 288_000.0;
147 - let over_limit: Vec<&str> = items
148 - .iter()
149 - .filter(|item| {
150 - item.duration
151 - .is_some_and(|d| (d * bytes_per_sec) as u64 > max_bytes)
152 - })
153 - .map(|item| item.name.as_str())
154 - .collect();
155 - if !over_limit.is_empty() {
156 - let msg = if let [only] = over_limit.as_slice() {
157 - format!(
158 - "\"{}\" may exceed device file size limit ({:.0} MB)",
159 - only,
160 - max_bytes as f64 / 1_048_576.0,
161 - )
162 - } else {
163 - format!(
164 - "{} samples may exceed device file size limit ({:.0} MB)",
165 - over_limit.len(),
166 - max_bytes as f64 / 1_048_576.0,
167 - )
168 - };
169 - ui.label(egui::RichText::new(msg).small().color(theme::danger()));
170 - }
171 - }
172 -
173 - // Disk space check (M-3): estimate from actual per-item durations
174 - // and the current encoding config rather than a fixed 10 MB/item
175 - // heuristic. Only warn when the projection exceeds available space
176 - // with a 10% headroom. Yellow because this is an anticipation
177 - // warning, not a confirmed failure.
178 - //
179 - // Memoize the statvfs/GetDiskFreeSpaceEx probe in egui temp data keyed
180 - // on the destination, so a static config screen doesn't issue a disk
181 - // syscall every frame (which hitches on a slow/network/removable dest).
182 - let disk_cache_id = egui::Id::new("export_disk_space_probe");
183 - let cached: Option<(std::path::PathBuf, Option<u64>)> =
184 - ui.data(|d| d.get_temp(disk_cache_id));
185 - let available = match cached {
186 - Some((path, value)) if path == config.destination => value,
187 - _ => {
188 - let value = available_disk_space(&config.destination);
189 - ui.data_mut(|d| {
190 - d.insert_temp(disk_cache_id, (config.destination.clone(), value))
191 - });
192 - value
193 - }
194 - };
195 - if let Some(available) = available {
196 - let bps = bytes_per_sec_for_config(config);
197 - let estimated_bytes: u64 = items
198 - .iter()
199 - .filter_map(|i| i.duration)
200 - .map(|d| (d.max(0.0) * bps as f64) as u64)
201 - .sum();
202 - if estimated_bytes > 0 && (estimated_bytes as f64) * 1.1 > available as f64 {
203 - ui.label(
204 - egui::RichText::new(format!(
205 - "Low disk space: {:.1} GB available, ~{:.1} GB needed",
206 - available as f64 / 1_073_741_824.0,
207 - estimated_bytes as f64 / 1_073_741_824.0,
208 - ))
209 - .small()
210 - .color(theme::warning()),
211 - );
212 - }
213 - }
214 - }
215 -
216 - ui.horizontal(|ui| {
217 - if ui.button("Cancel").clicked() {
218 - state.import_wf.import_mode = ImportMode::None;
219 - }
220 - if ui.button("Export").clicked()
221 - && let ImportMode::ConfigureExport {
222 - ref items,
223 - ref config,
224 - ..
225 - } = state.import_wf.import_mode
226 - {
227 - let items = items.clone();
228 - let config = config.clone();
229 - state.run_export(items, config);
230 - }
231 - });
232 - ui.add_space(theme::space::hair());
233 - });
234 -
235 - egui::CentralPanel::default().show(ui, |ui| {
236 - egui::ScrollArea::vertical().show(ui, |ui| {
237 - ui.heading("Export Samples");
238 - ui.add_space(theme::space::bound());
239 - ui.horizontal(|ui| {
240 - ui.label(format!("{item_count} samples to export"));
241 - if profile_count > 0 {
242 - ui.label(
243 - egui::RichText::new(format!(
244 - "\u{00B7} {profile_count} device profiles available"
245 - ))
246 - .small()
247 - .color(theme::content_muted()),
248 - );
249 - }
250 - });
251 - ui.add_space(theme::space::group());
252 - draw_export_fields(ui, state);
253 -
254 - // --- Destination ---
255 - ui.label(egui::RichText::new("Destination").strong());
256 - // Capture the browse click inside the import_mode borrow, then request
257 - // the dialog after it ends (the async handler re-borrows import_mode).
258 - let mut browse_from: Option<std::path::PathBuf> = None;
259 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode
260 - {
261 - ui.horizontal(|ui| {
262 - let dest_display = config.destination.display().to_string();
263 - ui.label(&dest_display);
264 - if ui.button("Browse...").clicked() {
265 - browse_from = Some(config.destination.clone());
266 - }
267 - });
268 - }
269 - if let Some(start_dir) = browse_from {
270 - state
271 - .dialogs
272 - .pick_folder_in("Export Destination", start_dir, |s, p| {
273 - if let ImportMode::ConfigureExport { config, .. } =
274 - &mut s.import_wf.import_mode
275 - {
276 - config.destination = p;
277 - }
278 - });
279 - }
280 - });
281 - });
282 - }
283 -
284 - /// The export configuration form, as described fields.
285 - ///
286 - /// Six questions, in two groups split by the metadata-sidecar checkbox that
287 - /// sits between them: a bare toggle is a control rather than a field, which is
288 - /// the rule the settings port set, so it is drawn between the
289 - /// groups instead of being described as one. The destination stays outside for
290 - /// the other standing reason — there is no `FieldKind::Path`, deliberately, and
291 - /// a button plus a native dialog is not a question a form holds an answer to.
292 - ///
293 - /// The whole form used to be six hand-drawn `strong` labels over raw egui
294 - /// controls, and it was invisible to the port's sweep for three passes because
295 - /// that sweep matched `radio_value` and `selectable_value` and this screen asks
296 - /// five of its six questions with `ui.radio`.
297 - fn draw_export_fields(ui: &mut egui::Ui, state: &mut BrowserState) {
298 - let ImportMode::ConfigureExport {
299 - ref config,
300 - ref available_profiles,
301 - ref items,
302 - ..
303 - } = state.import_wf.import_mode
304 - else {
305 - return;
306 - };
307 -
308 - // Owned first, borrowed second: `Choice` holds `&str` and the fillings need
309 - // `state` mutably. Same division as `import_screens::configure` — the
310 - // question is described, this app's encoding of the answer stays private.
311 - //
312 - // The empty value is "no profile", which is a real answer and not an
313 - // unanswered chooser: it is the default, it has a label, and choosing it is
314 - // what "export exactly what I configured" means. So this field is not the
315 - // shape makeover-layout `661ff8ba` blocks.
316 - let profile_options: Vec<(String, String)> =
317 - std::iter::once((String::new(), String::from("None (manual)")))
318 - .chain(
319 - available_profiles
320 - .iter()
321 - .map(|p| (p.name.clone(), format!("{} ({})", p.name, p.manufacturer))),
322 - )
323 - .collect();
324 - let profile_choices: Vec<makeover_layout::Choice<'_>> = profile_options
325 - .iter()
326 - .map(|(value, label)| makeover_layout::Choice::new(value, label))
327 - .collect();
328 -
329 - // The chosen device's readout, which stays a block under the field rather
330 - // than becoming its `hint`. The rule this port has been applying is that a
331 - // message belongs to the thing it is about; these lines are about the
332 - // device, not about the question, and there are up to four of them.
333 - let profile_detail: Vec<String> = config
334 - .device_profile
335 - .as_ref()
336 - .and_then(|name| available_profiles.iter().find(|p| &p.name == name))
337 - .map(|p| {
338 - std::iter::once(format!("by {}", p.manufacturer))
339 - .chain(p.format_summary.clone())
340 - .chain(p.category.clone())
341 - .chain(p.notes.clone())
342 - .collect()
343 - })
344 - .unwrap_or_default();
345 -
346 - // A profile answers the format, rate, depth and channel questions itself,
347 - // so those four are not asked while one is chosen. Conditional membership
348 - // in the field list, the same way the import strategy's vault name is.
349 - let has_profile = config.device_profile.is_some();
350 - let needs_encoding_options = matches!(config.format, ExportFormat::Wav | ExportFormat::Aiff);
351 - let re_encoding = config.format != ExportFormat::Original;
352 -
353 - let mut profile_value = config.device_profile.clone().unwrap_or_default();
354 - let mut format_value = String::from(match config.format {
355 - ExportFormat::Original => "original",
356 - ExportFormat::Wav => "wav",
357 - ExportFormat::Aiff => "aiff",
358 - });
359 - let mut rate_value = config
360 - .sample_rate
361 - .map(|r| r.to_string())
362 - .unwrap_or_default();
363 - let mut depth_value = config.bit_depth.map(|d| d.to_string()).unwrap_or_default();
364 - let mut channels_value = String::from(match config.channels {
365 - ExportChannels::Original => "original",
366 - ExportChannels::Mono => "mono",
367 - ExportChannels::Stereo => "stereo",
368 - });
369 - let mut structure_value = String::from(if config.flatten {
370 - "flatten"
371 - } else {
372 - "preserve"
373 - });
374 - let mut sidecar = config.metadata_sidecar;
375 - let mut pattern = config.naming_pattern.clone().unwrap_or_default();
376 - let flatten = config.flatten;
377 -
378 - // The pattern's preview and its parse error are both facts about the value
379 - // in the field, and they are mutually exclusive, so one field carries both:
380 - // the preview as `hint`, the parse failure as `error`. It used to be two
381 - // detached lines under a text box with no well.
382 - let (pattern_hint, pattern_error) = if pattern.is_empty() {
383 - (None, None)
384 - } else {
385 - match audiofiles_core::rename::RenamePattern::parse(&pattern) {
386 - Ok(parsed) => (
387 - items.first().map(|first| {
388 - let ctx = audiofiles_core::rename::RenameContext {
389 - name: first.name.clone(),
390 - extension: first.ext.clone(),
391 - bpm: first.bpm,
392 - musical_key: first.musical_key.clone(),
393 - duration: first.duration,
394 - index: 0,
395 - };
396 - let stem = parsed.resolve(&ctx);
397 - if first.ext.is_empty() {
398 - format!("Preview: {stem}")
399 - } else {
400 - format!("Preview: {stem}.{}", first.ext)
401 - }
402 - }),
403 - None,
404 - ),
405 - Err(e) => (None, Some(format!("Pattern: {e}"))),
406 - }
407 - };
408 -
409 - const FORMATS: [makeover_layout::Choice<'_>; 3] = [
410 - makeover_layout::Choice::new("original", "Original (copy as-is)"),
411 - makeover_layout::Choice::new("wav", "WAV (decode and re-encode)"),
412 - makeover_layout::Choice::new("aiff", "AIFF (decode and re-encode)"),
413 - ];
414 - const RATES: [makeover_layout::Choice<'_>; 4] = [
415 - makeover_layout::Choice::new("", "Original"),
416 - makeover_layout::Choice::new("44100", "44,100 Hz"),
417 - makeover_layout::Choice::new("48000", "48,000 Hz"),
418 - makeover_layout::Choice::new("96000", "96,000 Hz"),
419 - ];
420 - const DEPTHS: [makeover_layout::Choice<'_>; 3] = [
421 - makeover_layout::Choice::new("", "Original"),
422 - makeover_layout::Choice::new("16", "16-bit"),
423 - makeover_layout::Choice::new("24", "24-bit"),
424 - ];
425 - const CHANNELS: [makeover_layout::Choice<'_>; 3] = [
426 - makeover_layout::Choice::new("original", "Original"),
427 - makeover_layout::Choice::new("mono", "Mono"),
428 - makeover_layout::Choice::new("stereo", "Stereo"),
429 - ];
430 - const STRUCTURES: [makeover_layout::Choice<'_>; 2] = [
431 - makeover_layout::Choice::new("preserve", "Preserve tree"),
432 - makeover_layout::Choice::new("flatten", "Flatten (all files in one folder)"),
433 - ];
434 -
435 - // Radio rather than select for all five: each decides what comes out of the
436 - // export and none of them can be revised after it runs, which is the
437 - // property `FieldKind::Radio` exists to carry. The device profile is the one
438 - // select, because its option list is as long as the profile catalogue.
439 - let mut fields = Vec::new();
440 - if !available_profiles.is_empty() {
441 - fields.push(makeover_layout::Field::select(
442 - "device_profile",
443 - "Device Profile",
444 - &profile_choices,
445 - ));
446 - }
447 - if !has_profile {
448 - fields.push(makeover_layout::Field::radio("format", "Format", &FORMATS));
449 - if needs_encoding_options {
450 - fields.push(makeover_layout::Field::radio(
451 - "sample_rate",
452 - "Sample Rate",
453 - &RATES,
454 - ));
455 - fields.push(makeover_layout::Field::radio(
456 - "bit_depth",
457 - "Bit Depth",
458 - &DEPTHS,
459 - ));
460 - }
461 - fields.push(makeover_layout::Field::radio(
462 - "channels", "Channels", &CHANNELS,
463 - ));
464 - }
465 - fields.push(makeover_layout::Field::radio(
466 - "structure",
467 - "Structure",
468 - &STRUCTURES,
469 - ));
470 -
471 - widgets::group(ui, &fields, false, |ui, field| {
472 - let filling = match field.name {
473 - "device_profile" => makeover_immediate::Filling::Text(&mut profile_value),
474 - "format" => makeover_immediate::Filling::Text(&mut format_value),
475 - "sample_rate" => makeover_immediate::Filling::Text(&mut rate_value),
476 - "bit_depth" => makeover_immediate::Filling::Text(&mut depth_value),
477 - "channels" => makeover_immediate::Filling::Text(&mut channels_value),
478 - _ => makeover_immediate::Filling::Text(&mut structure_value),
479 - };
480 - widgets::field(ui, field, filling, None);
481 -
482 - match field.name {
483 - "device_profile" => {
484 - for line in &profile_detail {
485 - ui.label(
486 - egui::RichText::new(line)
487 - .small()
488 - .color(theme::content_muted()),
489 - );
490 - }
491 - }
492 - // Stays a warning line rather than becoming the field's `hint`,
493 - // and the colour is the reason: re-encoding silently drops BWF,
494 - // iXML, loop points, cue markers and ID3, and nothing in the
495 - // description says "this answer is valid and costs you something".
496 - // A `hint` is muted standing help and an `error` says the form
497 - // refuses, and this is neither. Filed as the fifth gap this port
498 - // has found.
499 - "format" if re_encoding => {
500 - ui.add_space(theme::space::hair());
Lines truncated