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