max / audiofiles
12 files changed,
+664 insertions,
-649 deletions
| @@ -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" |
| @@ -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