max / audiofiles
7 files changed,
+1298 insertions,
-21 deletions
| @@ -7543,10 +7543,6 @@ | |||
| 7543 | 7543 | "winnow 1.0.4", | |
| 7544 | 7544 | ] | |
| 7545 | 7545 | ||
| 7546 | - | [[patch.unused]] | |
| 7547 | - | name = "quasi-type" | |
| 7548 | - | version = "0.1.0" | |
| 7549 | - | ||
| 7550 | 7546 | [[patch.unused]] | |
| 7551 | 7547 | name = "quasi-axum" | |
| 7552 | 7548 | version = "0.19.0" | |
| @@ -7582,3 +7578,7 @@ | |||
| 7582 | 7578 | [[patch.unused]] | |
| 7583 | 7579 | name = "painhours" | |
| 7584 | 7580 | version = "0.1.0" | |
| 7581 | + | ||
| 7582 | + | [[patch.unused]] | |
| 7583 | + | name = "quasi-type" | |
| 7584 | + | version = "0.1.0" |
| @@ -282,6 +282,14 @@ | |||
| 282 | 282 | if state.forge.show_window { | |
| 283 | 283 | forge_panel::draw_forge_window(ctx, state); | |
| 284 | 284 | } | |
| 285 | + | ||
| 286 | + | // The described forge, beside the shipped one and on the same condition: | |
| 287 | + | // it is a window the app opens for a sample rather than one with a toggle, | |
| 288 | + | // so there is no second flag to keep in step. | |
| 289 | + | #[cfg(feature = "quasi")] | |
| 290 | + | if state.forge.show_window { | |
| 291 | + | crate::quasi::panel::draw_forge(ctx, state); | |
| 292 | + | } | |
| 285 | 293 | } | |
| 286 | 294 | ||
| 287 | 295 | /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list. |
| @@ -78,6 +78,7 @@ | |||
| 78 | 78 | pub mod edit; | |
| 79 | 79 | pub mod export; | |
| 80 | 80 | pub mod files; | |
| 81 | + | pub mod forge; | |
| 81 | 82 | pub mod help; | |
| 82 | 83 | pub mod importing; | |
| 83 | 84 | pub mod integrity; | |
| @@ -663,6 +664,22 @@ | |||
| 663 | 664 | PurgeFailed(Option<usize>), | |
| 664 | 665 | /// Give up on the sweep that is running. | |
| 665 | 666 | StopSweep, | |
| 667 | + | /// Stop the storage-layout migration until this vault reopens. | |
| 668 | + | PauseMigration, | |
| 669 | + | /// Slice the forged sample this way. | |
| 670 | + | SliceBy(Chop), | |
| 671 | + | /// Set one of the forge's numbers. | |
| 672 | + | Turn(Knob, String), | |
| 673 | + | /// Work out where the slices would fall. | |
| 674 | + | PreviewSlices, | |
| 675 | + | /// Write them. | |
| 676 | + | Chop, | |
| 677 | + | /// Aim a conform at this device. | |
| 678 | + | ChooseDevice(String), | |
| 679 | + | /// Conform to whichever device is chosen. | |
| 680 | + | Conform, | |
| 681 | + | /// Trim silence off everything chosen. | |
| 682 | + | TrimSilence, | |
| 666 | 683 | /// Open the export flow on whatever is selected. | |
| 667 | 684 | BeginExport, | |
| 668 | 685 | /// Put the loose-files warning away without acting. | |
| @@ -1876,11 +1893,36 @@ | |||
| 1876 | 1893 | /// The focused sample's tags. | |
| 1877 | 1894 | fn tags(&self) -> Vec<String>; | |
| 1878 | 1895 | ||
| 1896 | + | /// The storage-layout migration, if one is running. See [`Migrating`]. | |
| 1897 | + | fn migrating(&self) -> Option<Migrating>; | |
| 1898 | + | ||
| 1879 | 1899 | /// Stop the preview. | |
| 1880 | 1900 | fn stop(&self); | |
| 1881 | 1901 | ||
| 1882 | 1902 | /// Put the first-launch hint away. | |
| 1883 | 1903 | fn dismiss_hint(&self); | |
| 1904 | + | ||
| 1905 | + | /// Stop the migration until this vault is opened again. | |
| 1906 | + | fn pause_migration(&self); | |
| 1907 | + | } | |
| 1908 | + | ||
| 1909 | + | /// Blobs being moved from the flat store into hash-prefix shards. | |
| 1910 | + | /// | |
| 1911 | + | /// On [`Shell`] rather than on a capability of its own, and the shipped app's | |
| 1912 | + | /// own placement is the argument: `draw_layout_strip` is a band of the main | |
| 1913 | + | /// window, declared after the footer so it stacks above it, and it is a band for | |
| 1914 | + | /// a stated reason — the migration auto-starts at vault open, the library stays | |
| 1915 | + | /// usable while it runs because reads resolve both layouts, and seizing the | |
| 1916 | + | /// window would be the wrong trade. | |
| 1917 | + | /// | |
| 1918 | + | /// So this is what the window's band says while a background job runs, which is | |
| 1919 | + | /// what every other member of [`Shell`] already is. | |
| 1920 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1921 | + | pub struct Migrating { | |
| 1922 | + | /// How many blobs have moved. | |
| 1923 | + | pub done: usize, | |
| 1924 | + | /// How many there are. | |
| 1925 | + | pub total: usize, | |
| 1884 | 1926 | } | |
| 1885 | 1927 | ||
| 1886 | 1928 | /// The app's main window, as the narrow thing the described band borrows. | |
| @@ -1979,6 +2021,18 @@ | |||
| 1979 | 2021 | fn dismiss_hint(&self) { | |
| 1980 | 2022 | self.intents.borrow_mut().push(Intent::DismissHint); | |
| 1981 | 2023 | } | |
| 2024 | + | ||
| 2025 | + | fn migrating(&self) -> Option<Migrating> { | |
| 2026 | + | let running = self.state.layout_migration?; | |
| 2027 | + | Some(Migrating { | |
| 2028 | + | done: running.completed, | |
| 2029 | + | total: running.total, | |
| 2030 | + | }) | |
| 2031 | + | } | |
| 2032 | + | ||
| 2033 | + | fn pause_migration(&self) { | |
| 2034 | + | self.intents.borrow_mut().push(Intent::PauseMigration); | |
| 2035 | + | } | |
| 1982 | 2036 | } | |
| 1983 | 2037 | ||
| 1984 | 2038 | /// A duration in seconds, as a whole number the transport can show. | |
| @@ -3710,6 +3764,258 @@ | |||
| 3710 | 3764 | } | |
| 3711 | 3765 | } | |
| 3712 | 3766 | ||
| 3767 | + | /// The sample in the forge, and everything the maker surface asks about it. | |
| 3768 | + | /// | |
| 3769 | + | /// One struct where [`Stage`] is an enum, and the difference is the screen: the | |
| 3770 | + | /// forge is three sections of one window that are all live at once, so there is | |
| 3771 | + | /// no state a reader arrives at. `busy` is a field rather than a shape for the | |
| 3772 | + | /// same reason — the shipped window keeps drawing every control while a run is | |
| 3773 | + | /// in flight and greys them, because the sample is still the subject. | |
| 3774 | + | #[derive(Debug, Clone, PartialEq)] | |
| 3775 | + | pub struct Forging { | |
| 3776 | + | /// What the sample is called. | |
| 3777 | + | pub name: String, | |
| 3778 | + | /// What it was recorded at. | |
| 3779 | + | pub rate: u32, | |
| 3780 | + | /// Whether a chop or a conform is in flight. | |
| 3781 | + | pub busy: bool, | |
| 3782 | + | /// How it would be sliced. | |
| 3783 | + | pub how: Chop, | |
| 3784 | + | /// Transient sensitivity, from zero to one. | |
| 3785 | + | pub sensitivity: f32, | |
| 3786 | + | /// How many equal divisions. | |
| 3787 | + | pub divisions: usize, | |
| 3788 | + | /// The tempo the grid is built on. | |
| 3789 | + | pub bpm: f64, | |
| 3790 | + | /// Grid subdivisions per beat: one, two or four. | |
| 3791 | + | pub subdivisions: u32, | |
| 3792 | + | /// How many slices the last preview found, or zero for no preview. | |
| 3793 | + | /// | |
| 3794 | + | /// A count rather than the boundary fractions, and that is the waveform | |
| 3795 | + | /// exclusion showing through: the marks are drawn over a rendered waveform, | |
| 3796 | + | /// which no description reaches, and what the *controls* need of them is how | |
| 3797 | + | /// many there are. See [`forge`]'s header. | |
| 3798 | + | pub slices: usize, | |
| 3799 | + | /// The devices a conform could target. | |
| 3800 | + | pub devices: Vec<DeviceChoice>, | |
| 3801 | + | /// Which of them is chosen, if one is. | |
| 3802 | + | pub device: Option<String>, | |
| 3803 | + | /// How many samples are chosen, for the batch section. | |
| 3804 | + | pub chosen: usize, | |
| 3805 | + | /// The level below which batch trim treats audio as silence. | |
| 3806 | + | pub threshold_db: f64, | |
| 3807 | + | } | |
| 3808 | + | ||
| 3809 | + | /// How a sample would be sliced. | |
| 3810 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 3811 | + | pub enum Chop { | |
| 3812 | + | /// At detected transients. | |
| 3813 | + | Transient, | |
| 3814 | + | /// Into equal divisions. | |
| 3815 | + | Equal, | |
| 3816 | + | /// On a tempo grid. | |
| 3817 | + | Bpm, | |
| 3818 | + | } | |
| 3819 | + | ||
| 3820 | + | impl Chop { | |
| 3821 | + | /// Every one of them, in the order the shipped window offers them. | |
| 3822 | + | pub const ALL: [Self; 3] = [Self::Transient, Self::Equal, Self::Bpm]; | |
| 3823 | + | ||
| 3824 | + | /// The name a described address is built from. | |
| 3825 | + | #[must_use] | |
| 3826 | + | pub const fn as_str(self) -> &'static str { | |
| 3827 | + | match self { | |
| 3828 | + | Self::Transient => "transient", | |
| 3829 | + | Self::Equal => "divisions", | |
| 3830 | + | Self::Bpm => "bpm", | |
| 3831 | + | } | |
| 3832 | + | } | |
| 3833 | + | ||
| 3834 | + | /// What the control says. | |
| 3835 | + | #[must_use] | |
| 3836 | + | pub const fn label(self) -> &'static str { | |
| 3837 | + | match self { | |
| 3838 | + | Self::Transient => "Transient", | |
| 3839 | + | Self::Equal => "Divisions", | |
| 3840 | + | Self::Bpm => "BPM grid", | |
| 3841 | + | } | |
| 3842 | + | } | |
| 3843 | + | ||
| 3844 | + | /// The method that name means, if it means one. | |
| 3845 | + | #[must_use] | |
| 3846 | + | pub fn from_key(name: &str) -> Option<Self> { | |
| 3847 | + | Self::ALL.into_iter().find(|held| held.as_str() == name) | |
| 3848 | + | } | |
| 3849 | + | } | |
| 3850 | + | ||
| 3851 | + | /// A device a conform could target. | |
| 3852 | + | /// | |
| 3853 | + | /// [`ProfileChoice`]'s smaller cousin, and separate from it for that type's own | |
| 3854 | + | /// reason: the export screen needs the manufacturer, the category and the file | |
| 3855 | + | /// size cap, and this needs the name and one line about what it takes. | |
| 3856 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 3857 | + | pub struct DeviceChoice { | |
| 3858 | + | /// What the device is called, which is also what a conform names. | |
| 3859 | + | pub name: String, | |
| 3860 | + | /// What it accepts, as the registry phrases it. | |
| 3861 | + | pub summary: String, | |
| 3862 | + | } | |
| 3863 | + | ||
| 3864 | + | /// One number the forge's controls may change. | |
| 3865 | + | /// | |
| 3866 | + | /// [`Setting`], [`Measure`] and [`Decision`]'s fourth peer, closed for the same | |
| 3867 | + | /// reason: one write route serves five controls without a second list of the | |
| 3868 | + | /// names it will answer to. | |
| 3869 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 3870 | + | pub enum Knob { | |
| 3871 | + | /// [`Forging::sensitivity`]. | |
| 3872 | + | Sensitivity, | |
| 3873 | + | /// [`Forging::divisions`]. | |
| 3874 | + | Divisions, | |
| 3875 | + | /// [`Forging::bpm`]. | |
| 3876 | + | Bpm, | |
| 3877 | + | /// [`Forging::subdivisions`]. | |
| 3878 | + | Subdivisions, | |
| 3879 | + | /// [`Forging::threshold_db`]. | |
| 3880 | + | Threshold, | |
| 3881 | + | } | |
| 3882 | + | ||
| 3883 | + | impl Knob { | |
| 3884 | + | /// The name a described address is built from. | |
| 3885 | + | #[must_use] | |
| 3886 | + | pub const fn as_str(self) -> &'static str { | |
| 3887 | + | match self { | |
| 3888 | + | Self::Sensitivity => "sensitivity", | |
| 3889 | + | Self::Divisions => "divisions", | |
| 3890 | + | Self::Bpm => "bpm", | |
| 3891 | + | Self::Subdivisions => "subdivisions", | |
| 3892 | + | Self::Threshold => "threshold", | |
| 3893 | + | } | |
| 3894 | + | } | |
| 3895 | + | ||
| 3896 | + | /// The knob that name means, if it means one. | |
| 3897 | + | #[must_use] | |
| 3898 | + | pub fn from_key(name: &str) -> Option<Self> { | |
| 3899 | + | match name { | |
| 3900 | + | "sensitivity" => Some(Self::Sensitivity), | |
| 3901 | + | "divisions" => Some(Self::Divisions), | |
| 3902 | + | "bpm" => Some(Self::Bpm), | |
| 3903 | + | "subdivisions" => Some(Self::Subdivisions), | |
| 3904 | + | "threshold" => Some(Self::Threshold), | |
| 3905 | + | _ => None, | |
| 3906 | + | } | |
| 3907 | + | } | |
| 3908 | + | } | |
| 3909 | + | ||
| 3910 | + | /// The forge, as much of it as a described screen needs. | |
| 3911 | + | /// | |
| 3912 | + | /// The fourteenth narrow trait. Every write is an [`Intent`] and every one of | |
| 3913 | + | /// them lands on `ForgeUiState`, which is the app's own screen state — the rule | |
| 3914 | + | /// [`Files`] set and [`Export`] and [`Importing`] both follow. | |
| 3915 | + | pub trait Forge { | |
| 3916 | + | /// The sample in the forge, if one is. | |
| 3917 | + | fn forging(&self) -> Option<Forging>; | |
| 3918 | + | ||
| 3919 | + | /// Slice it this way. | |
| 3920 | + | fn slice_by(&self, how: Chop); | |
| 3921 | + | ||
| 3922 | + | /// Set one of the numbers the slicing reads. | |
| 3923 | + | fn turn(&self, knob: Knob, value: &str); | |
| 3924 | + | ||
| 3925 | + | /// Work out where the slices would fall. | |
| 3926 | + | fn preview(&self); | |
| 3927 | + | ||
| 3928 | + | /// Write them. | |
| 3929 | + | fn chop(&self); | |
| 3930 | + | ||
| 3931 | + | /// Aim a conform at this device. | |
| 3932 | + | fn choose_device(&self, name: &str); | |
| 3933 | + | ||
| 3934 | + | /// Conform to whichever is chosen. | |
| 3935 | + | fn conform(&self); | |
| 3936 | + | ||
| 3937 | + | /// Trim silence off everything chosen. | |
| 3938 | + | fn trim_silence(&self); | |
| 3939 | + | } | |
| 3940 | + | ||
| 3941 | + | /// The app's forge, as the narrow thing the described window borrows. | |
| 3942 | + | pub struct FromForge<'a> { | |
| 3943 | + | /// What the app has loaded into the forge. | |
| 3944 | + | pub state: &'a crate::state::BrowserState, | |
| 3945 | + | /// What the described screen asked for, applied after the frame. | |
| 3946 | + | pub intents: &'a std::cell::RefCell<Vec<Intent>>, | |
| 3947 | + | } | |
| 3948 | + | ||
| 3949 | + | impl Forge for FromForge<'_> { | |
| 3950 | + | fn forging(&self) -> Option<Forging> { | |
| 3951 | + | let forge = &self.state.forge; | |
| 3952 | + | forge.hash.as_ref()?; | |
| 3953 | + | Some(Forging { | |
| 3954 | + | name: forge.name.clone(), | |
| 3955 | + | rate: forge.source_rate, | |
| 3956 | + | busy: forge.busy, | |
| 3957 | + | how: match forge.chop_mode { | |
| 3958 | + | crate::state::ChopMode::Transient => Chop::Transient, | |
| 3959 | + | crate::state::ChopMode::Equal => Chop::Equal, | |
| 3960 | + | crate::state::ChopMode::Bpm => Chop::Bpm, | |
| 3961 | + | }, | |
| 3962 | + | sensitivity: forge.sensitivity, | |
| 3963 | + | divisions: forge.divisions, | |
| 3964 | + | bpm: forge.bpm, | |
| 3965 | + | subdivisions: forge.subdivisions, | |
| 3966 | + | // The marks are boundaries and the slices are the gaps between them, | |
| 3967 | + | // which is the shipped button's own arithmetic. | |
| 3968 | + | slices: forge.slice_marks.len().saturating_sub(1), | |
| 3969 | + | devices: forge | |
| 3970 | + | .devices | |
| 3971 | + | .iter() | |
| 3972 | + | .map(|(name, summary)| DeviceChoice { | |
| 3973 | + | name: name.clone(), | |
| 3974 | + | summary: summary.clone(), | |
| 3975 | + | }) | |
| 3976 | + | .collect(), | |
| 3977 | + | device: forge.conform_device.clone(), | |
| 3978 | + | chosen: self.state.selected_sample_hashes().len(), | |
| 3979 | + | threshold_db: forge.trim_threshold_db, | |
| 3980 | + | }) | |
| 3981 | + | } | |
| 3982 | + | ||
| 3983 | + | fn slice_by(&self, how: Chop) { | |
| 3984 | + | self.push(Intent::SliceBy(how)); | |
| 3985 | + | } | |
| 3986 | + | ||
| 3987 | + | fn turn(&self, knob: Knob, value: &str) { | |
| 3988 | + | self.push(Intent::Turn(knob, value.to_owned())); | |
| 3989 | + | } | |
| 3990 | + | ||
| 3991 | + | fn preview(&self) { | |
| 3992 | + | self.push(Intent::PreviewSlices); | |
| 3993 | + | } | |
| 3994 | + | ||
| 3995 | + | fn chop(&self) { | |
| 3996 | + | self.push(Intent::Chop); | |
| 3997 | + | } | |
| 3998 | + | ||
| 3999 | + | fn choose_device(&self, name: &str) { | |
| 4000 | + | self.push(Intent::ChooseDevice(name.to_owned())); | |
| 4001 | + | } | |
| 4002 | + | ||
| 4003 | + | fn conform(&self) { | |
| 4004 | + | self.push(Intent::Conform); | |
| 4005 | + | } | |
| 4006 | + | ||
| 4007 | + | fn trim_silence(&self) { | |
| 4008 | + | self.push(Intent::TrimSilence); | |
| 4009 | + | } | |
| 4010 | + | } | |
| 4011 | + | ||
| 4012 | + | impl FromForge<'_> { | |
| 4013 | + | /// Record what the described screen asked for. | |
| 4014 | + | fn push(&self, intent: Intent) { | |
| 4015 | + | self.intents.borrow_mut().push(intent); | |
| 4016 | + | } | |
| 4017 | + | } | |
| 4018 | + | ||
| 3713 | 4019 | /// The sample being edited, as much as the editor needs to say about it. | |
| 3714 | 4020 | /// | |
| 3715 | 4021 | /// What is **not** here is the eleven knobs `EditUiState` carries — trim bounds, | |
| @@ -3985,6 +4291,8 @@ | |||
| 3985 | 4291 | pub integrity: &'a dyn Integrity, | |
| 3986 | 4292 | /// The sample being edited, for the editor. | |
| 3987 | 4293 | pub editor: &'a dyn Edit, | |
| 4294 | + | /// The sample in the forge, for the maker surface. | |
| 4295 | + | pub forge: &'a dyn Forge, | |
| 3988 | 4296 | /// The themes on offer, resolved by the host at startup. | |
| 3989 | 4297 | pub themes: &'a [ThemeChoice], | |
| 3990 | 4298 | } | |
| @@ -3995,12 +4303,12 @@ | |||
| 3995 | 4303 | /// cost is nothing, and building it fresh is what lets the state borrow. | |
| 3996 | 4304 | #[must_use] | |
| 3997 | 4305 | pub fn router<'a>() -> Router<Panels<'a>> { | |
| 3998 | - | edit::routes(integrity::routes(importing::routes(naming::routes( | |
| 3999 | - | toolbar::routes(library::routes(shell::routes(help::routes(bulk::routes( | |
| 4000 | - | detail::routes(export::routes(files::routes(sync::routes( | |
| 4001 | - | settings::routes(Router::new()), | |
| 4002 | - | )))), | |
| 4003 | - | ))))), | |
| 4306 | + | forge::routes(edit::routes(integrity::routes(importing::routes( | |
| 4307 | + | naming::routes(toolbar::routes(library::routes(shell::routes( | |
| 4308 | + | help::routes(bulk::routes(detail::routes(export::routes(files::routes( | |
| 4309 | + | sync::routes(settings::routes(Router::new())), | |
| 4310 | + | ))))), | |
| 4311 | + | )))), | |
| 4004 | 4312 | )))) | |
| 4005 | 4313 | } | |
| 4006 | 4314 |
| @@ -33,7 +33,7 @@ | |||
| 33 | 33 | use std::cell::RefCell; | |
| 34 | 34 | ||
| 35 | 35 | use super::{ | |
| 36 | - | FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromImport, | |
| 36 | + | FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromForge, FromImport, | |
| 37 | 37 | FromIntegrity, FromLibrary, FromNaming, FromSelection, FromSyncManager, FromWindow, Intent, | |
| 38 | 38 | Panels, Setting, Sync, ThemeChoice, Unconfigured, | |
| 39 | 39 | }; | |
| @@ -54,6 +54,7 @@ | |||
| 54 | 54 | detail: Option<Runtime>, | |
| 55 | 55 | shell: Option<Runtime>, | |
| 56 | 56 | edit: Option<Runtime>, | |
| 57 | + | forge: Option<Runtime>, | |
| 57 | 58 | import: Option<Runtime>, | |
| 58 | 59 | sweep: Option<Runtime>, | |
| 59 | 60 | /// Whether the described main window is open. | |
| @@ -298,6 +299,36 @@ | |||
| 298 | 299 | } | |
| 299 | 300 | } | |
| 300 | 301 | ||
| 302 | + | /// Draw the described forge, and act on whatever was pressed. | |
| 303 | + | /// | |
| 304 | + | /// **Refreshed unconditionally**, and it is the editor's reason: a chop and a | |
| 305 | + | /// conform both run on a worker, so `busy` goes false with nothing pressed. The | |
| 306 | + | /// slice count moves the same way — a preview is work the app did, and the | |
| 307 | + | /// button that commits to it is gated on the result. | |
| 308 | + | pub fn draw_forge(ctx: &egui::Context, state: &mut BrowserState) { | |
| 309 | + | let intents = RefCell::new(Vec::new()); | |
| 310 | + | let mut runtime = state.described.forge.take(); | |
| 311 | + | let host = Host { | |
| 312 | + | state, | |
| 313 | + | sync: None, | |
| 314 | + | themes: themes(), | |
| 315 | + | intents: &intents, | |
| 316 | + | }; | |
| 317 | + | let closed = window( | |
| 318 | + | ctx, | |
| 319 | + | "Sample Forge (described)", | |
| 320 | + | &mut runtime, | |
| 321 | + | &host, | |
| 322 | + | "/forge", | |
| 323 | + | true, | |
| 324 | + | ); | |
| 325 | + | state.described.forge = runtime; | |
| 326 | + | apply(ctx, state, intents.into_inner()); | |
| 327 | + | if closed { | |
| 328 | + | state.described.forge = None; | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 301 | 332 | /// Draw the described import flow, and act on whatever was pressed. | |
| 302 | 333 | /// | |
| 303 | 334 | /// **Refreshed unconditionally**, and it is the export flow's reason twice over: | |
| @@ -933,6 +964,48 @@ | |||
| 933 | 964 | state.execute_confirmed_action(); | |
| 934 | 965 | } | |
| 935 | 966 | Intent::StopSweep => state.cancel_cleanup(), | |
| 967 | + | // The strip. Cleared here rather than on the worker's terminal | |
| 968 | + | // event, which is the shipped button's own note: the click has to | |
| 969 | + | // feel like it did something, and the Complete event that follows | |
| 970 | + | // would clear this anyway. | |
| 971 | + | Intent::PauseMigration => { | |
| 972 | + | if let Err(error) = state.backend.cancel_layout_migration() { | |
| 973 | + | tracing::warn!("failed to cancel layout migration: {error}"); | |
| 974 | + | } | |
| 975 | + | state.layout_migration = None; | |
| 976 | + | } | |
| 977 | + | // The forge. Every one of these writes what the described control | |
| 978 | + | // submitted into the knob the shipped window keeps for it and then | |
| 979 | + | // calls what the shipped button calls, which is `edit`'s | |
| 980 | + | // arrangement: that the knobs still exist is the shipped window's | |
| 981 | + | // business while both are open. | |
| 982 | + | // | |
| 983 | + | // Changing any chop parameter clears the marks, which is what | |
| 984 | + | // re-arms the preview gate. Done here and not in the route for the | |
| 985 | + | // reason every write is here: it is `&mut`. | |
| 986 | + | Intent::SliceBy(how) => { | |
| 987 | + | state.forge.chop_mode = match how { | |
| 988 | + | super::Chop::Transient => crate::state::ChopMode::Transient, | |
| 989 | + | super::Chop::Equal => crate::state::ChopMode::Equal, | |
| 990 | + | super::Chop::Bpm => crate::state::ChopMode::Bpm, | |
| 991 | + | }; | |
| 992 | + | state.forge.slice_marks.clear(); | |
| 993 | + | } | |
| 994 | + | Intent::Turn(knob, value) => turn(state, knob, &value), | |
| 995 | + | Intent::PreviewSlices => state.forge_preview_slices(), | |
| 996 | + | Intent::Chop => state.forge_apply_chop(), | |
| 997 | + | Intent::ChooseDevice(name) => { | |
| 998 | + | state.forge.conform_device = (!name.is_empty()).then_some(name); | |
| 999 | + | } | |
| 1000 | + | Intent::Conform => { | |
| 1001 | + | if let Some(device) = state.forge.conform_device.clone() { | |
| 1002 | + | state.forge_conform_device(&device); | |
| 1003 | + | } | |
| 1004 | + | } | |
| 1005 | + | Intent::TrimSilence => { | |
| 1006 | + | let threshold = state.forge.trim_threshold_db; | |
| 1007 | + | state.batch_trim_silence(threshold); | |
| 1008 | + | } | |
| 936 | 1009 | Intent::BeginExport => state.start_export_flow(None), | |
| 937 | 1010 | // The host act with no described step. See `integrity`'s header: | |
| 938 | 1011 | // fourth consumer of `quasi:vocabulary:host-save-location`. | |
| @@ -1011,6 +1084,51 @@ | |||
| 1011 | 1084 | .collect() | |
| 1012 | 1085 | } | |
| 1013 | 1086 | ||
| 1087 | + | /// Write one described number back into the forge's own knobs. | |
| 1088 | + | /// | |
| 1089 | + | /// Anything unparseable is dropped rather than defaulted, which is the opposite | |
| 1090 | + | /// of `configure`'s reading and right for the opposite reason: an export setting | |
| 1091 | + | /// has a "keep the original" answer that a bad value can honestly fall back to, | |
| 1092 | + | /// and a sensitivity does not. Leaving the previous number standing is what the | |
| 1093 | + | /// described control then reads back on the next frame. | |
| 1094 | + | /// | |
| 1095 | + | /// Every chop parameter clears the slice marks, which re-arms the preview gate. | |
| 1096 | + | fn turn(state: &mut BrowserState, knob: super::Knob, value: &str) { | |
| 1097 | + | match knob { | |
| 1098 | + | super::Knob::Sensitivity => { | |
| 1099 | + | if let Ok(sensitivity) = value.parse::<f32>() { | |
| 1100 | + | state.forge.sensitivity = sensitivity.clamp(0.0, 1.0); | |
| 1101 | + | state.forge.slice_marks.clear(); | |
| 1102 | + | } | |
| 1103 | + | } | |
| 1104 | + | super::Knob::Divisions => { | |
| 1105 | + | if let Ok(divisions) = value.parse::<usize>() { | |
| 1106 | + | state.forge.divisions = divisions; | |
| 1107 | + | state.forge.slice_marks.clear(); | |
| 1108 | + | } | |
| 1109 | + | } | |
| 1110 | + | super::Knob::Bpm => { | |
| 1111 | + | if let Ok(bpm) = value.parse::<f64>() { | |
| 1112 | + | state.forge.bpm = bpm.clamp(20.0, 300.0); | |
| 1113 | + | state.forge.slice_marks.clear(); | |
| 1114 | + | } | |
| 1115 | + | } | |
| 1116 | + | super::Knob::Subdivisions => { | |
| 1117 | + | if let Ok(subdivisions) = value.parse::<u32>() { | |
| 1118 | + | state.forge.subdivisions = subdivisions; | |
| 1119 | + | state.forge.slice_marks.clear(); | |
| 1120 | + | } | |
| 1121 | + | } | |
| 1122 | + | // Not a chop parameter, so it leaves the marks alone: the batch section | |
| 1123 | + | // is about the selection rather than about this sample. | |
| 1124 | + | super::Knob::Threshold => { | |
| 1125 | + | if let Ok(threshold) = value.parse::<f64>() { | |
| 1126 | + | state.forge.trim_threshold_db = threshold.clamp(-96.0, -20.0); | |
| 1127 | + | } | |
| 1128 | + | } | |
| 1129 | + | } | |
| 1130 | + | } | |
| 1131 | + | ||
| 1014 | 1132 | /// Write one described answer back into the import being configured. | |
| 1015 | 1133 | /// | |
| 1016 | 1134 | /// **The strategy is re-derived from all three answers on every write**, never | |
| @@ -1395,6 +1513,7 @@ | |||
| 1395 | 1513 | let importing = FromImport { state, intents }; | |
| 1396 | 1514 | let integrity = FromIntegrity { state, intents }; | |
| 1397 | 1515 | let editor = FromEditor { state, intents }; | |
| 1516 | + | let forge = FromForge { state, intents }; | |
| 1398 | 1517 | let panels = Panels { | |
| 1399 | 1518 | config: &config, | |
| 1400 | 1519 | sync, | |
| @@ -1409,6 +1528,7 @@ | |||
| 1409 | 1528 | importing: &importing, | |
| 1410 | 1529 | integrity: &integrity, | |
| 1411 | 1530 | editor: &editor, | |
| 1531 | + | forge: &forge, | |
| 1412 | 1532 | themes, | |
| 1413 | 1533 | }; | |
| 1414 | 1534 | super::router() |
| @@ -81,6 +81,29 @@ | |||
| 81 | 81 | //! module is named for the shell rather than the footer: `toolbar.rs` (706) | |
| 82 | 82 | //! and `sidebar.rs` (722) are the other two bands of the same window, and each | |
| 83 | 83 | //! is its own pass. The screen below has the two regions that exist. | |
| 84 | + | //! | |
| 85 | + | //! # The migration strip, and why it is here rather than anywhere else | |
| 86 | + | //! | |
| 87 | + | //! `ui/layout_strip.rs` is 62 lines and its header argues, correctly, that | |
| 88 | + | //! moving blobs into hash-prefix shards deserves a strip rather than a modal or | |
| 89 | + | //! a mode: the migration auto-starts at vault open, the library stays usable | |
| 90 | + | //! while it runs because reads resolve both layouts, and seizing the window | |
| 91 | + | //! would be the wrong trade. That argument is about *where the fact goes*, which | |
| 92 | + | //! makes it this module's: it is a band of the main window, declared after the | |
| 93 | + | //! footer so it stacks above it. | |
| 94 | + | //! | |
| 95 | + | //! So there is no `/storage` address and no fourteenth capability for it. It is | |
| 96 | + | //! [`Shell::migrating`](super::Shell::migrating), a band that is there while the | |
| 97 | + | //! fact is true, and a described screen answering that band every time it is | |
| 98 | + | //! asked is what makes "appears while a job runs" sayable at all. Compare the | |
| 99 | + | //! loose-files warning three paragraphs down: the shipped app raises *that* as | |
| 100 | + | //! an overlay, which is the thing no description can do, and this one was | |
| 101 | + | //! already a band. | |
| 102 | + | //! | |
| 103 | + | //! Pause is honest here in a way a cancel usually is not, and the description | |
| 104 | + | //! says so on the control: the sweep is resumable and records nothing until a | |
| 105 | + | //! pass verifies the root is clean, so stopping defers the remainder rather than | |
| 106 | + | //! abandoning it. | |
| 84 | 107 | ||
| 85 | 108 | use quasi_router::layout::{Notice, Priority, Tone}; | |
| 86 | 109 | use quasi_router::{ | |
| @@ -93,12 +116,16 @@ | |||
| 93 | 116 | /// The band under the list. | |
| 94 | 117 | const FOOT: &str = "shell-foot"; | |
| 95 | 118 | ||
| 119 | + | /// The band above it, while blobs are being moved. | |
| 120 | + | const STRIP: &str = "shell-migration"; | |
| 121 | + | ||
| 96 | 122 | /// Register the main screen's routes. | |
| 97 | 123 | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 98 | 124 | router | |
| 99 | 125 | .get("/", index) | |
| 100 | 126 | .post("/playback/stop", stop) | |
| 101 | 127 | .post("/hint/dismiss", dismiss) | |
| 128 | + | .post("/storage/pause", pause) | |
| 102 | 129 | } | |
| 103 | 130 | ||
| 104 | 131 | /// `GET /` | |
| @@ -118,6 +145,18 @@ | |||
| 118 | 145 | Ok(screen(state).into()) | |
| 119 | 146 | } | |
| 120 | 147 | ||
| 148 | + | /// `POST /storage/pause` | |
| 149 | + | /// | |
| 150 | + | /// Refused when nothing is migrating, for the sweep's reason: the address is | |
| 151 | + | /// reachable by typing and pausing nothing is not a thing that happened. | |
| 152 | + | fn pause(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 153 | + | if state.shell.migrating().is_none() { | |
| 154 | + | return Err(RouteError::not_found("no migration is running")); | |
| 155 | + | } | |
| 156 | + | state.shell.pause_migration(); | |
| 157 | + | Ok(screen(state).into()) | |
| 158 | + | } | |
| 159 | + | ||
| 121 | 160 | /// The window: what you can filter by, what is in it, and what it is doing. | |
| 122 | 161 | /// | |
| 123 | 162 | /// `pub(super)` because the sidebar's routes answer it. Every control in | |
| @@ -125,11 +164,39 @@ | |||
| 125 | 164 | /// applying a tag filter, opening a collection — so the answer is the window | |
| 126 | 165 | /// rather than the corner of it that was pressed. | |
| 127 | 166 | pub(super) fn screen(state: &Panels<'_>) -> Screen { | |
| 128 | - | Screen::sidebar_content("audiofiles") | |
| 167 | + | let screen = Screen::sidebar_content("audiofiles") | |
| 129 | 168 | .with(super::toolbar::body(state)) | |
| 130 | 169 | .with(super::library::body(state)) | |
| 131 | - | .with(super::files::body(state)) | |
| 132 | - | .with(foot(state)) | |
| 170 | + | .with(super::files::body(state)); | |
| 171 | + | ||
| 172 | + | // Above the footer, which is where the shipped strip declares itself, and | |
| 173 | + | // only while there is something to say. See the header. | |
| 174 | + | match migrating(state) { | |
| 175 | + | Some(strip) => screen.with(strip).with(foot(state)), | |
| 176 | + | None => screen.with(foot(state)), | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Blobs being moved into their shards, while any are. | |
| 181 | + | fn migrating(state: &Panels<'_>) -> Option<Slot> { | |
| 182 | + | let running = state.shell.migrating()?; | |
| 183 | + | Some( | |
| 184 | + | Slot::new(STRIP, RegionKind::Band) | |
| 185 | + | .with(Node::text("Optimising storage layout")) | |
| 186 | + | .with(Node::Meter( | |
| 187 | + | Meter::new(clamp(running.done), clamp(running.total)).label("files"), | |
| 188 | + | )) | |
| 189 | + | .with(Node::Act( | |
| 190 | + | Act::new("Pause", Action::post("/storage/pause")).confirm( | |
| 191 | + | "Stop for now. The remainder resumes the next time this vault opens. Pause?", | |
| 192 | + | ), | |
| 193 | + | )), | |
| 194 | + | ) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /// A count as the meter carries one. | |
| 198 | + | fn clamp(count: usize) -> u32 { | |
| 199 | + | u32::try_from(count).unwrap_or(u32::MAX) | |
| 133 | 200 | } | |
| 134 | 201 | ||
| 135 | 202 | /// The status band. |
| @@ -12,13 +12,13 @@ | |||
| 12 | 12 | use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen}; | |
| 13 | 13 | ||
| 14 | 14 | use super::{ | |
| 15 | - | Analysed, Analysis, Bar, Bulk, Channels, Chosen, Collection, ColumnsShown, Config, Coverage, | |
| 16 | - | Crumb, Decision, Detail, Detailed, Editing, Export, Failure, Files, Filter, Focus, Folder, | |
| 17 | - | FolderTags, Format, Halted, Holding, Importing, Integrity, Library, Measure, Measures, Naming, | |
| 18 | - | Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Reviewed, Sample, | |
| 19 | - | Saying, Searching, Setting, Settings, Shared, Shell, Source, Spread, Stage, State, Status, | |
| 20 | - | Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, | |
| 21 | - | Vault, VaultChoice, Where, router, | |
| 15 | + | Analysed, Analysis, Bar, Bulk, Channels, Chop, Chosen, Collection, ColumnsShown, Config, | |
| 16 | + | Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Editing, Export, Failure, Files, | |
| 17 | + | Filter, Focus, Folder, FolderTags, Forge, Forging, Format, Halted, Holding, Importing, | |
| 18 | + | Integrity, Knob, Library, Measure, Measures, Migrating, Naming, Order, Panel, Panels, Phase, | |
| 19 | + | Playing, Preflight, Pricing, ProfileChoice, Reviewed, Sample, Saying, Searching, Setting, | |
| 20 | + | Settings, Shared, Shell, Source, Spread, Stage, State, Status, Strategy, Subject, Subscription, | |
| 21 | + | Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router, | |
| 22 | 22 | }; | |
| 23 | 23 | ||
| 24 | 24 | /// A config store in memory. | |
| @@ -228,6 +228,7 @@ | |||
| 228 | 228 | importing: &NoImport, | |
| 229 | 229 | integrity: &Sound, | |
| 230 | 230 | editor: &Unedited, | |
| 231 | + | forge: &Unforged, | |
| 231 | 232 | themes: &themes, | |
| 232 | 233 | }; | |
| 233 | 234 | router().handle(&state, request) | |
| @@ -298,6 +299,7 @@ | |||
| 298 | 299 | importing: &NoImport, | |
| 299 | 300 | integrity: &Sound, | |
| 300 | 301 | editor: &Unedited, | |
| 302 | + | forge: &Unforged, | |
| 301 | 303 | themes: &themes, | |
| 302 | 304 | }; | |
| 303 | 305 | router().handle(&state, request) | |
| @@ -452,6 +454,7 @@ | |||
| 452 | 454 | importing: &NoImport, | |
| 453 | 455 | integrity: &Sound, | |
| 454 | 456 | editor: &Unedited, | |
| 457 | + | forge: &Unforged, | |
| 455 | 458 | themes: &themes, | |
| 456 | 459 | }; | |
| 457 | 460 | let response = router() | |
| @@ -506,6 +509,7 @@ | |||
| 506 | 509 | importing: &NoImport, | |
| 507 | 510 | integrity: &Sound, | |
| 508 | 511 | editor: &Unedited, | |
| 512 | + | forge: &Unforged, | |
| 509 | 513 | themes: &themes, | |
| 510 | 514 | }; | |
| 511 | 515 | ||
| @@ -554,6 +558,7 @@ | |||
| 554 | 558 | importing: &NoImport, | |
| 555 | 559 | integrity: &Sound, | |
| 556 | 560 | editor: &Unedited, | |
| 561 | + | forge: &Unforged, | |
| 557 | 562 | themes: &themes, | |
| 558 | 563 | }; | |
| 559 | 564 | let refused = router().handle( | |
| @@ -588,6 +593,7 @@ | |||
| 588 | 593 | importing: &NoImport, | |
| 589 | 594 | integrity: &Sound, | |
| 590 | 595 | editor: &Unedited, | |
| 596 | + | forge: &Unforged, | |
| 591 | 597 | themes: &themes, | |
| 592 | 598 | }; | |
| 593 | 599 | ||
| @@ -645,6 +651,7 @@ | |||
| 645 | 651 | importing: &NoImport, | |
| 646 | 652 | integrity: &Sound, | |
| 647 | 653 | editor: &Unedited, | |
| 654 | + | forge: &Unforged, | |
| 648 | 655 | themes: &themes, | |
| 649 | 656 | }; | |
| 650 | 657 | let response = router() | |
| @@ -698,6 +705,7 @@ | |||
| 698 | 705 | importing: &NoImport, | |
| 699 | 706 | integrity: &Sound, | |
| 700 | 707 | editor: &Unedited, | |
| 708 | + | forge: &Unforged, | |
| 701 | 709 | themes: &themes, | |
| 702 | 710 | }; | |
| 703 | 711 | let response = router() | |
| @@ -857,6 +865,7 @@ | |||
| 857 | 865 | importing: &NoImport, | |
| 858 | 866 | integrity: &Sound, | |
| 859 | 867 | editor: &Unedited, | |
| 868 | + | forge: &Unforged, | |
| 860 | 869 | themes: &themes, | |
| 861 | 870 | }; | |
| 862 | 871 | router().handle(&state, request) | |
| @@ -1877,6 +1886,7 @@ | |||
| 1877 | 1886 | importing: &NoImport, | |
| 1878 | 1887 | integrity: &Sound, | |
| 1879 | 1888 | editor: &Unedited, | |
| 1889 | + | forge: &Unforged, | |
| 1880 | 1890 | themes: &themes, | |
| 1881 | 1891 | }; | |
| 1882 | 1892 | router().handle(&state, request) | |
| @@ -2411,6 +2421,7 @@ | |||
| 2411 | 2421 | importing: &NoImport, | |
| 2412 | 2422 | integrity: &Sound, | |
| 2413 | 2423 | editor: &Unedited, | |
| 2424 | + | forge: &Unforged, | |
| 2414 | 2425 | themes: &themes, | |
| 2415 | 2426 | }; | |
| 2416 | 2427 | router().handle(&state, request) | |
| @@ -2785,6 +2796,7 @@ | |||
| 2785 | 2796 | importing: &NoImport, | |
| 2786 | 2797 | integrity: &Sound, | |
| 2787 | 2798 | editor: &Unedited, | |
| 2799 | + | forge: &Unforged, | |
| 2788 | 2800 | themes: &themes, | |
| 2789 | 2801 | }; | |
| 2790 | 2802 | router().handle(&state, request) | |
| @@ -2839,6 +2851,7 @@ | |||
| 2839 | 2851 | importing: &NoImport, | |
| 2840 | 2852 | integrity: &Sound, | |
| 2841 | 2853 | editor: &Unedited, | |
| 2854 | + | forge: &Unforged, | |
| 2842 | 2855 | themes: &themes, | |
| 2843 | 2856 | }; | |
| 2844 | 2857 | ||
| @@ -2979,8 +2992,13 @@ | |||
| 2979 | 2992 | Vec::new() | |
| 2980 | 2993 | } | |
| 2981 | 2994 | ||
| 2995 | + | fn migrating(&self) -> Option<Migrating> { | |
| 2996 | + | None | |
| 2997 | + | } | |
| 2998 | + | ||
| 2982 | 2999 | fn stop(&self) {} | |
| 2983 | 3000 | fn dismiss_hint(&self) {} | |
| 3001 | + | fn pause_migration(&self) {} | |
| 2984 | 3002 | } | |
| 2985 | 3003 | ||
| 2986 | 3004 | /// A window in memory, recording what was asked of it. | |
| @@ -2993,6 +3011,7 @@ | |||
| 2993 | 3011 | hinting: bool, | |
| 2994 | 3012 | device: Option<String>, | |
| 2995 | 3013 | tags: Vec<String>, | |
| 3014 | + | migrating: Option<Migrating>, | |
| 2996 | 3015 | asked: RefCell<Vec<String>>, | |
| 2997 | 3016 | } | |
| 2998 | 3017 | ||
| @@ -3025,6 +3044,10 @@ | |||
| 3025 | 3044 | self.tags.clone() | |
| 3026 | 3045 | } | |
| 3027 | 3046 | ||
| 3047 | + | fn migrating(&self) -> Option<Migrating> { | |
| 3048 | + | self.migrating | |
| 3049 | + | } | |
| 3050 | + | ||
| 3028 | 3051 | fn stop(&self) { | |
| 3029 | 3052 | self.asked.borrow_mut().push("stop".to_owned()); | |
| 3030 | 3053 | } | |
| @@ -3032,6 +3055,10 @@ | |||
| 3032 | 3055 | fn dismiss_hint(&self) { | |
| 3033 | 3056 | self.asked.borrow_mut().push("dismiss".to_owned()); | |
| 3034 | 3057 | } | |
| 3058 | + | ||
| 3059 | + | fn pause_migration(&self) { | |
| 3060 | + | self.asked.borrow_mut().push("pause".to_owned()); | |
| 3061 | + | } | |
| 3035 | 3062 | } | |
| 3036 | 3063 | ||
| 3037 | 3064 | /// A router call against this window. | |
| @@ -3054,6 +3081,7 @@ | |||
| 3054 | 3081 | importing: &NoImport, | |
| 3055 | 3082 | integrity: &Sound, | |
| 3056 | 3083 | editor: &Unedited, | |
| 3084 | + | forge: &Unforged, | |
| 3057 | 3085 | themes: &themes, | |
| 3058 | 3086 | }; | |
| 3059 | 3087 | router().handle(&state, request) | |
| @@ -3472,6 +3500,7 @@ | |||
| 3472 | 3500 | importing: &NoImport, | |
| 3473 | 3501 | integrity: &Sound, | |
| 3474 | 3502 | editor: &Unedited, | |
| 3503 | + | forge: &Unforged, | |
| 3475 | 3504 | themes: &themes, | |
| 3476 | 3505 | }; | |
| 3477 | 3506 | router().handle(&state, request) | |
| @@ -3906,6 +3935,7 @@ | |||
| 3906 | 3935 | importing: &NoImport, | |
| 3907 | 3936 | integrity: &Sound, | |
| 3908 | 3937 | editor: &Unedited, | |
| 3938 | + | forge: &Unforged, | |
| 3909 | 3939 | themes: &themes, | |
| 3910 | 3940 | }; | |
| 3911 | 3941 | router().handle(&state, request) | |
| @@ -4461,6 +4491,7 @@ | |||
| 4461 | 4491 | importing: &NoImport, | |
| 4462 | 4492 | integrity: &Sound, | |
| 4463 | 4493 | editor: &Unedited, | |
| 4494 | + | forge: &Unforged, | |
| 4464 | 4495 | themes: &themes, | |
| 4465 | 4496 | }; | |
| 4466 | 4497 | router().handle(&state, request) | |
| @@ -4617,6 +4648,7 @@ | |||
| 4617 | 4648 | importing, | |
| 4618 | 4649 | integrity: &Sound, | |
| 4619 | 4650 | editor: &Unedited, | |
| 4651 | + | forge: &Unforged, | |
| 4620 | 4652 | themes: &themes, | |
| 4621 | 4653 | }; | |
| 4622 | 4654 | router().handle(&state, request) | |
| @@ -4920,6 +4952,7 @@ | |||
| 4920 | 4952 | importing: &NoImport, | |
| 4921 | 4953 | integrity, | |
| 4922 | 4954 | editor: &Unedited, | |
| 4955 | + | forge: &Unforged, | |
| 4923 | 4956 | themes: &themes, | |
| 4924 | 4957 | }; | |
| 4925 | 4958 | router().handle(&state, request) | |
| @@ -5000,6 +5033,7 @@ | |||
| 5000 | 5033 | importing: &NoImport, | |
| 5001 | 5034 | integrity: &vault, | |
| 5002 | 5035 | editor: &Unedited, | |
| 5036 | + | forge: &Unforged, | |
| 5003 | 5037 | themes: &themes, | |
| 5004 | 5038 | }; | |
| 5005 | 5039 | let response = router().handle(&state, Request::get("/")).unwrap(); | |
| @@ -5178,6 +5212,7 @@ | |||
| 5178 | 5212 | importing: &NoImport, | |
| 5179 | 5213 | integrity: &Sound, | |
| 5180 | 5214 | editor, | |
| 5215 | + | forge: &Unforged, | |
| 5181 | 5216 | themes: &themes, | |
| 5182 | 5217 | }; | |
| 5183 | 5218 | router().handle(&state, request) | |
| @@ -6618,3 +6653,475 @@ | |||
| 6618 | 6653 | assert!(labels.contains(&"Import".to_owned()), "{labels:?}"); | |
| 6619 | 6654 | assert!(labels.contains(&"Export".to_owned()), "{labels:?}"); | |
| 6620 | 6655 | } | |
| 6656 | + | ||
| 6657 | + | // --- the forge --- | |
| 6658 | + | ||
| 6659 | + | /// Nothing is in the forge, and nothing can be put there. | |
| 6660 | + | /// | |
| 6661 | + | /// [`Idle`] and [`NoImport`]'s third peer: every method is a refusal, so a test | |
| 6662 | + | /// of some other screen cannot chop a sample by accident. | |
| 6663 | + | struct Unforged; | |
| 6664 | + | ||
| 6665 | + | impl Forge for Unforged { | |
| 6666 | + | fn forging(&self) -> Option<Forging> { | |
| 6667 | + | None | |
| 6668 | + | } | |
| 6669 | + | fn slice_by(&self, _how: Chop) {} | |
| 6670 | + | fn turn(&self, _knob: Knob, _value: &str) {} | |
| 6671 | + | fn preview(&self) {} | |
| 6672 | + | fn chop(&self) {} | |
| 6673 | + | fn choose_device(&self, _name: &str) {} | |
| 6674 | + | fn conform(&self) {} | |
| 6675 | + | fn trim_silence(&self) {} | |
| 6676 | + | } | |
| 6677 | + | ||
| 6678 | + | /// A forge in memory, recording what was asked of it. | |
| 6679 | + | struct FakeForge { | |
| 6680 | + | forging: Option<Forging>, | |
| 6681 | + | asked: RefCell<Vec<String>>, | |
| 6682 | + | } | |
| 6683 | + | ||
| 6684 | + | impl FakeForge { | |
| 6685 | + | fn with(forging: Forging) -> Self { | |
| 6686 | + | Self { | |
| 6687 | + | forging: Some(forging), | |
| 6688 | + | asked: RefCell::new(Vec::new()), | |
| 6689 | + | } | |
| 6690 | + | } | |
| 6691 | + | ||
| 6692 | + | fn empty() -> Self { | |
| 6693 | + | Self { | |
| 6694 | + | forging: None, | |
| 6695 | + | asked: RefCell::new(Vec::new()), | |
| 6696 | + | } | |
| 6697 | + | } | |
| 6698 | + | ||
| 6699 | + | fn asked(&self) -> Vec<String> { | |
| 6700 | + | self.asked.borrow().clone() | |
| 6701 | + | } | |
| 6702 | + | ||
| 6703 | + | fn say(&self, said: impl Into<String>) { | |
| 6704 | + | self.asked.borrow_mut().push(said.into()); | |
| 6705 | + | } | |
| 6706 | + | } | |
| 6707 | + | ||
| 6708 | + | impl Forge for FakeForge { | |
| 6709 | + | fn forging(&self) -> Option<Forging> { | |
| 6710 | + | self.forging.clone() | |
| 6711 | + | } | |
| 6712 | + | ||
| 6713 | + | fn slice_by(&self, how: Chop) { | |
| 6714 | + | self.say(format!("slice:{}", how.as_str())); | |
| 6715 | + | } | |
| 6716 | + | ||
| 6717 | + | fn turn(&self, knob: Knob, value: &str) { | |
| 6718 | + | self.say(format!("set:{}={value}", knob.as_str())); | |
| 6719 | + | } | |
| 6720 | + | ||
| 6721 | + | fn preview(&self) { | |
| 6722 | + | self.say("preview"); | |
| 6723 | + | } | |
| 6724 | + | ||
| 6725 | + | fn chop(&self) { | |
| 6726 | + | self.say("chop"); | |
| 6727 | + | } | |
| 6728 | + | ||
| 6729 | + | fn choose_device(&self, name: &str) { | |
| 6730 | + | self.say(format!("device={name}")); | |
| 6731 | + | } | |
| 6732 | + | ||
| 6733 | + | fn conform(&self) { | |
| 6734 | + | self.say("conform"); | |
| 6735 | + | } | |
| 6736 | + | ||
| 6737 | + | fn trim_silence(&self) { | |
| 6738 | + | self.say("trim"); | |
| 6739 | + | } | |
| 6740 | + | } | |
| 6741 | + | ||
| 6742 | + | /// A sample loaded into the forge, with whatever a test wants of it. | |
| 6743 | + | fn forging() -> Forging { | |
| 6744 | + | Forging { | |
| 6745 | + | name: "break.wav".to_owned(), | |
| 6746 | + | rate: 44_100, | |
| 6747 | + | busy: false, | |
| 6748 | + | how: Chop::Equal, | |
| 6749 | + | sensitivity: 0.5, | |
| 6750 | + | divisions: 8, | |
| 6751 | + | bpm: 120.0, | |
| 6752 | + | subdivisions: 1, | |
| 6753 | + | slices: 0, | |
| 6754 | + | devices: vec![ | |
| 6755 | + | DeviceChoice { | |
| 6756 | + | name: "SP-404".to_owned(), | |
| 6757 | + | summary: "WAV 44.1k/16".to_owned(), | |
| 6758 | + | }, | |
| 6759 | + | DeviceChoice { | |
| 6760 | + | name: "Digitakt".to_owned(), | |
| 6761 | + | summary: String::new(), | |
| 6762 | + | }, | |
| 6763 | + | ], | |
| 6764 | + | device: None, | |
| 6765 | + | chosen: 1, | |
| 6766 | + | threshold_db: -60.0, | |
| 6767 | + | } | |
| 6768 | + | } | |
| 6769 | + | ||
| 6770 | + | /// A router call against this forge. | |
| 6771 | + | fn forged(forge: &FakeForge, request: Request) -> Result<Response, quasi_router::RouteError> { | |
| 6772 | + | let store = Store::default(); | |
| 6773 | + | let sync = Offline; | |
| 6774 | + | let files = FakeFiles::default(); | |
| 6775 | + | let themes = themes(); | |
| 6776 | + | let state = Panels { | |
| 6777 | + | config: &store, | |
| 6778 | + | sync: &sync, | |
| 6779 | + | files: &files, | |
| 6780 | + | export: &Idle, | |
| 6781 | + | detail: &Unfocused, | |
| 6782 | + | bulk: &Unchosen, | |
| 6783 | + | shell: &Quiet, | |
| 6784 | + | library: &Empty, | |
| 6785 | + | bar: &Still, | |
| 6786 | + | naming: &Unnamed, | |
| 6787 | + | importing: &NoImport, | |
| 6788 | + | integrity: &Sound, | |
| 6789 | + | editor: &Unedited, | |
| 6790 | + | forge, | |
| 6791 | + | themes: &themes, | |
| 6792 | + | }; | |
| 6793 | + | router().handle(&state, request) | |
| 6794 | + | } | |
| 6795 | + | ||
| 6796 | + | /// The forge window, with whatever is loaded into it. | |
| 6797 | + | fn forge_screen(forge: &FakeForge) -> Screen { | |
| 6798 | + | screen_of(&forged(forge, Request::get("/forge")).unwrap()).clone() | |
| 6799 | + | } | |
| 6800 | + | ||
| 6801 | + | #[test] | |
| 6802 | + | fn an_empty_forge_says_what_would_fill_it() { | |
| 6803 | + | let forge = FakeForge::empty(); | |
| 6804 | + | let said = deep_said(&forge_screen(&forge)); | |
| 6805 | + | assert!( | |
| 6806 | + | said.contains("Select a sample and open the forge"), | |
| 6807 | + | "{said}" | |
| 6808 | + | ); | |
| 6809 | + | ||
| 6810 | + | // And every write is refused, because there is nothing to write to. | |
| 6811 | + | for address in [ | |
| 6812 | + | "/forge/preview", | |
| 6813 | + | "/forge/chop", | |
| 6814 | + | "/forge/conform", | |
| 6815 | + | "/forge/trim", | |
| 6816 | + | ] { | |
| 6817 | + | assert!(forged(&forge, Request::post(address)).is_err(), "{address}"); | |
| 6818 | + | } | |
| 6819 | + | } | |
| 6820 | + | ||
| 6821 | + | #[test] | |
| 6822 | + | fn the_forge_is_one_shape_because_busy_is_a_property_of_the_sample() { | |
| 6823 | + | // The rule from the other side: a state a reader arrived at is a shape, and | |
| 6824 | + | // a property of the subject is a field. Every section is still described | |
| 6825 | + | // while a run is in flight. | |
| 6826 | + | let busy = FakeForge::with(Forging { | |
| 6827 | + | busy: true, | |
| 6828 | + | chosen: 3, | |
| 6829 | + | ..forging() | |
| 6830 | + | }); | |
| 6831 | + | let screen = forge_screen(&busy); | |
| 6832 | + | let said = deep_said(&screen); | |
| 6833 | + | ||
| 6834 | + | assert!(said.contains("Working..."), "{said}"); | |
| 6835 | + | assert!(said.contains("Chop"), "{said}"); | |
| 6836 | + | assert!(said.contains("Batch"), "{said}"); | |
| 6837 | + | // The conform section's heading is the picker's own label, which is the | |
| 6838 | + | // shipped screen's call: the question names itself and the `strong` line | |
| 6839 | + | // above it went. | |
| 6840 | + | assert!( | |
| 6841 | + | deep_fields(&screen) | |
| 6842 | + | .into_iter() | |
| 6843 | + | .any(|field| field.label == "Conform for device") | |
| 6844 | + | ); | |
| 6845 | + | ||
| 6846 | + | // The acts can say they are dead. The fields cannot, which is the finding. | |
| 6847 | + | let preview = deep_acts(&screen) | |
| 6848 | + | .into_iter() | |
| 6849 | + | .find(|act| act.label == "Preview slices") | |
| 6850 | + | .expect("Preview is offered"); | |
| 6851 | + | assert!(!preview.interactive()); | |
| 6852 | + | } | |
| 6853 | + | ||
| 6854 | + | #[test] | |
| 6855 | + | fn only_the_parameters_the_chosen_method_reads_are_described() { | |
| 6856 | + | // The shipped window's own `match`, and the settings screen's line: a | |
| 6857 | + | // control that cannot be used is worse than one that is not there. | |
| 6858 | + | let transient = FakeForge::with(Forging { | |
| 6859 | + | how: Chop::Transient, | |
| 6860 | + | ..forging() | |
| 6861 | + | }); | |
| 6862 | + | let named: Vec<String> = deep_fields(&forge_screen(&transient)) | |
| 6863 | + | .into_iter() | |
| 6864 | + | .map(|field| field.name) | |
| 6865 | + | .collect(); | |
| 6866 | + | assert!( | |
| 6867 | + | named.contains(&Knob::Sensitivity.as_str().to_owned()), | |
| 6868 | + | "{named:?}" | |
| 6869 | + | ); | |
| 6870 | + | assert!(!named.contains(&Knob::Bpm.as_str().to_owned()), "{named:?}"); | |
| 6871 | + | ||
| 6872 | + | let grid = FakeForge::with(Forging { | |
| 6873 | + | how: Chop::Bpm, | |
| 6874 | + | ..forging() | |
| 6875 | + | }); | |
| 6876 | + | let named: Vec<String> = deep_fields(&forge_screen(&grid)) | |
| 6877 | + | .into_iter() | |
| 6878 | + | .map(|field| field.name) | |
| 6879 | + | .collect(); | |
| 6880 | + | assert!(named.contains(&Knob::Bpm.as_str().to_owned()), "{named:?}"); | |
| 6881 | + | assert!( | |
| 6882 | + | !named.contains(&Knob::Sensitivity.as_str().to_owned()), | |
| 6883 | + | "{named:?}" | |
| 6884 | + | ); | |
| 6885 | + | ||
| 6886 | + | // Divisions is a strip of a handful of values rather than a field, which is | |
| 6887 | + | // what the shipped row of selectable buttons is. | |
| 6888 | + | let equal = FakeForge::with(forging()); | |
| 6889 | + | let named: Vec<String> = deep_fields(&forge_screen(&equal)) | |
| 6890 | + | .into_iter() | |
| 6891 | + | .map(|field| field.name) | |
| 6892 | + | .collect(); | |
| 6893 | + | assert!( | |
| 6894 | + | !named.contains(&Knob::Divisions.as_str().to_owned()), | |
| 6895 | + | "{named:?}" | |
| 6896 | + | ); | |
| 6897 | + | } | |
| 6898 | + | ||
| 6899 | + | #[test] | |
| 6900 | + | fn chopping_is_gated_on_a_preview_and_the_label_carries_the_count() { | |
| 6901 | + | // AF-9: committing to an unknown slice count is what the preview exists to | |
| 6902 | + | // stop, and the count on the label is the blast radius before the press. | |
| 6903 | + | let unpreviewed = FakeForge::with(forging()); | |
| 6904 | + | let screen = forge_screen(&unpreviewed); | |
| 6905 | + | let chop = deep_acts(&screen) | |
| 6906 | + | .into_iter() | |
| 6907 | + | .find(|act| act.label.starts_with("Chop")) | |
| 6908 | + | .expect("Chop is offered"); | |
| 6909 | + | assert_eq!(chop.label, "Chop"); | |
| 6910 | + | assert!(!chop.interactive()); | |
| 6911 | + | // The fifth consumer of `quasi:vocabulary:disabled-reason`, degraded to a | |
| 6912 | + | // line beside the control. | |
| 6913 | + | assert!(deep_said(&screen).contains("Preview the slices first")); | |
| 6914 | + | assert!(forged(&unpreviewed, Request::post("/forge/chop")).is_err()); | |
| 6915 | + | ||
| 6916 | + | let previewed = FakeForge::with(Forging { | |
| 6917 | + | slices: 14, | |
| 6918 | + | ..forging() | |
| 6919 | + | }); | |
| 6920 | + | let screen = forge_screen(&previewed); | |
| 6921 | + | let chop = deep_acts(&screen) | |
| 6922 | + | .into_iter() | |
| 6923 | + | .find(|act| act.label.starts_with("Chop")) | |
| 6924 | + | .expect("Chop is offered"); | |
| 6925 | + | assert_eq!(chop.label, "Chop into 14 slices"); | |
| 6926 | + | assert!(chop.interactive()); | |
| 6927 | + | forged(&previewed, Request::post("/forge/chop")).unwrap(); | |
| 6928 | + | assert_eq!(previewed.asked(), ["chop"]); | |
| 6929 | + | } | |
| 6930 | + | ||
| 6931 | + | #[test] | |
| 6932 | + | fn the_device_picker_says_what_to_do_in_its_own_ghost_text() { | |
| 6933 | + | // The call the shipped screen already made: a select with nothing chosen | |
| 6934 | + | // reads as an empty box, and the greyed button beside it is the wrong place | |
| 6935 | + | // to explain that. | |
| 6936 | + | let forge = FakeForge::with(forging()); | |
| 6937 | + | let screen = forge_screen(&forge); | |
| 6938 | + | let picker = deep_fields(&screen) | |
| 6939 | + | .into_iter() | |
| 6940 | + | .find(|field| field.name == "device") | |
| 6941 | + | .expect("the device is asked for"); | |
| 6942 | + | assert_eq!(picker.placeholder.as_deref(), Some("Select device...")); | |
| 6943 | + | assert_eq!(picker.value.as_deref(), Some("")); | |
| 6944 | + | ||
| 6945 | + | // The summary is part of what the option reads as, and a device with none |
Lines truncated
| @@ -1,0 +1,449 @@ | |||
| 1 | + | //! The Sample Forge, described: three things you can make out of one sample. | |
| 2 | + | //! | |
| 3 | + | //! The fourteenth port and one of the two smallest, which is why it is worth | |
| 4 | + | //! being clear about what it is not. `ui/forge_panel.rs` is 346 lines and the | |
| 5 | + | //! description is not much shorter, because almost none of those lines are | |
| 6 | + | //! layout: the window is three sections of live controls over one sample, and | |
| 7 | + | //! the port is a straight reading of what each control asks. | |
| 8 | + | //! | |
| 9 | + | //! # One shape, not several, and it is the first screen here that is | |
| 10 | + | //! | |
| 11 | + | //! Every flow ported before this answers a different screen per state. | |
| 12 | + | //! [`export`](super::export) has five, [`importing`](super::importing) nine, | |
| 13 | + | //! [`edit`](super::edit) two. This has one: the shipped window keeps drawing | |
| 14 | + | //! every control while a chop or a conform is in flight and greys them, because | |
| 15 | + | //! the sample is still the subject and nothing has been arrived at. So `busy` is | |
| 16 | + | //! a field on [`Forging`](super::Forging) rather than a shape, and it is what | |
| 17 | + | //! deadens the controls -- as far as the vocabulary lets it, which turns out to | |
| 18 | + | //! be the acts and not the fields. See the finding below. | |
| 19 | + | //! | |
| 20 | + | //! That is the rule stated from the other side for once, and it is worth having | |
| 21 | + | //! both halves written down: **a state a reader arrived at is a shape, and a | |
| 22 | + | //! property of the subject is a field.** "An export is running" is the first; | |
| 23 | + | //! "this sample is busy" is the second. | |
| 24 | + | //! | |
| 25 | + | //! # What the description deletes: the fourth piggyback | |
| 26 | + | //! | |
| 27 | + | //! `state.forge.conform_device` is an `Option<String>` written back out of the | |
| 28 | + | //! draw every frame, with the empty string meaning nothing chosen and a comment | |
| 29 | + | //! saying so. It is a buffer for a picker, which is [`bulk`](super::bulk)'s | |
| 30 | + | //! eleven `BulkModal` fields and [`edit`](super::edit)'s twelve knobs for the | |
| 31 | + | //! fourth time: what is being chosen in a described screen is the runtime's, and | |
| 32 | + | //! it arrives with the act that used it. | |
| 33 | + | //! | |
| 34 | + | //! `slice_marks` is **not** in that class and stays. It is the result of work the | |
| 35 | + | //! app did, not a control's buffer, and the description carries what the controls | |
| 36 | + | //! need of it — how many slices a preview found — as | |
| 37 | + | //! [`Forging::slices`](super::Forging::slices). | |
| 38 | + | //! | |
| 39 | + | //! # THE FINDING: a field cannot be disabled at all | |
| 40 | + | //! | |
| 41 | + | //! [`Act`](quasi_router::Act) carries a | |
| 42 | + | //! [`State`](quasi_router::layout::State) and | |
| 43 | + | //! [`Act::disabled`](quasi_router::Act::disabled) sets it. | |
| 44 | + | //! [`Field`](quasi_router::Field) carries no such member: its fourteen fields | |
| 45 | + | //! are kind, name, label, hint, error, placeholder, options, required, | |
| 46 | + | //! max_length, min, max, step, extended and width, and none of them is "not | |
| 47 | + | //! answering right now". | |
| 48 | + | //! | |
| 49 | + | //! Every control in this window is greyed while a chop or a conform runs, which | |
| 50 | + | //! is nine `add_enabled(!disabled, ..)` calls in the shipped file. The acts can | |
| 51 | + | //! say it and **the sensitivity slider, the BPM dial, the device picker and the | |
| 52 | + | //! trim threshold cannot**, so a described forge mid-run offers four live-looking | |
| 53 | + | //! controls whose writes the routes then have to refuse. | |
| 54 | + | //! | |
| 55 | + | //! This is sharper than the neighbouring gap rather than the same one: | |
| 56 | + | //! `quasi:vocabulary:disabled-reason` is a control that says it is dead without | |
| 57 | + | //! saying why, and this is a control with no way to say it is dead. Filed as | |
| 58 | + | //! `quasi:vocabulary:field-state`, four consumers in this one window. The | |
| 59 | + | //! degradation is the routes, which refuse the write and are what the reader | |
| 60 | + | //! would have been stopped from making. | |
| 61 | + | //! | |
| 62 | + | //! # A second consumer for `quasi:vocabulary:disabled-reason` | |
| 63 | + | //! | |
| 64 | + | //! Chop is disabled until a preview has run, and the shipped button explains | |
| 65 | + | //! itself in an `on_disabled_hover_text`: "Preview the slices first to see how | |
| 66 | + | //! many will be created." That is the gap [`importing`](super::importing) filed | |
| 67 | + | //! this pass with four consumers of its own — | |
| 68 | + | //! [`Act::disabled`](quasi_router::Act::disabled) carries no reason where | |
| 69 | + | //! [`Choice::unless`](quasi_router::Choice::unless) does — and this is the fifth. | |
| 70 | + | //! Degraded the same way: the sentence is a line of its own beside the control. | |
| 71 | + | //! | |
| 72 | + | //! The count on the label survives, and it is the better half of that button | |
| 73 | + | //! anyway. "Chop into 14 slices" says the blast radius before the press, which | |
| 74 | + | //! is the correction the shipped screen made to itself (AF-9) and the same one | |
| 75 | + | //! the review screen's "Apply 3 Tags" is. | |
| 76 | + | //! | |
| 77 | + | //! # What is deliberately not described | |
| 78 | + | //! | |
| 79 | + | //! - **The waveform and its slice markers.** [`edit`](super::edit)'s exclusion, | |
| 80 | + | //! unchanged and for its reason: a rendered picture of samples, with lines | |
| 81 | + | //! painted over it at pixel positions derived from fractions. Domain | |
| 82 | + | //! rendering. What the description keeps is the number of slices, which is the | |
| 83 | + | //! only thing any control here reads off it. | |
| 84 | + | //! - **The plugin-host foreshadow.** "Plugin processing (CLAP/VST): coming soon" | |
| 85 | + | //! is marketing copy for something that does not exist, and a description of a | |
| 86 | + | //! screen should not carry a description of a screen that has not been built. | |
| 87 | + | //! The shipped window may keep it; there is nothing to port. | |
| 88 | + | ||
| 89 | + | use quasi_router::layout::{Selector, Tone}; | |
| 90 | + | use quasi_router::{ | |
| 91 | + | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 92 | + | Slot, | |
| 93 | + | }; | |
| 94 | + | ||
| 95 | + | use super::{Chop, DeviceChoice, Forging, Knob, Panels}; | |
| 96 | + | ||
| 97 | + | /// The region the window answers into. | |
| 98 | + | const BODY: &str = "forge-body"; | |
| 99 | + | ||
| 100 | + | /// The name the device picker submits under. | |
| 101 | + | const DEVICE: &str = "device"; | |
| 102 | + | ||
| 103 | + | /// Register the forge's routes. | |
| 104 | + | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 105 | + | router | |
| 106 | + | .get("/forge", index) | |
| 107 | + | .post("/forge/slice/{how}", slice_by) | |
| 108 | + | .post("/forge/set/{knob}", turn) | |
| 109 | + | .post("/forge/preview", preview) | |
| 110 | + | .post("/forge/chop", chop) | |
| 111 | + | .post("/forge/device", choose_device) | |
| 112 | + | .post("/forge/conform", conform) | |
| 113 | + | .post("/forge/trim", trim_silence) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// `GET /forge` | |
| 117 | + | fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 118 | + | Ok(screen(state).into()) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// `POST /forge/slice/{how}` | |
| 122 | + | fn slice_by(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 123 | + | let name = request.captures.require("how")?; | |
| 124 | + | let how = Chop::from_key(name).ok_or_else(|| RouteError::not_found("no such chop method"))?; | |
| 125 | + | state.forge.slice_by(how); | |
| 126 | + | Ok(screen(state).into()) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// `POST /forge/set/{knob}` | |
| 130 | + | /// | |
| 131 | + | /// One route for five controls across two sections, which is [`export`]'s | |
| 132 | + | /// arrangement and [`Knob`](super::Knob) is what closes the set. | |
| 133 | + | /// | |
| 134 | + | /// [`export`]: super::export | |
| 135 | + | fn turn(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 136 | + | let name = request.captures.require("knob")?; | |
| 137 | + | let knob = Knob::from_key(name).ok_or_else(|| RouteError::not_found("no such control"))?; | |
| 138 | + | let value = request | |
| 139 | + | .payload | |
| 140 | + | .get(name) | |
| 141 | + | .or_else(|| request.payload.get(Node::SELECTED)) | |
| 142 | + | .unwrap_or_default(); | |
| 143 | + | state.forge.turn(knob, value); | |
| 144 | + | Ok(screen(state).into()) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// `POST /forge/preview` | |
| 148 | + | fn preview(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 149 | + | forging(state)?; | |
| 150 | + | state.forge.preview(); | |
| 151 | + | Ok(screen(state).into()) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// `POST /forge/chop` | |
| 155 | + | /// | |
| 156 | + | /// Refused without a preview, which is what the shipped button is disabled on | |
| 157 | + | /// and for the reason it was made to be (AF-9): committing to an unknown slice | |
| 158 | + | /// count is the thing the preview exists to stop. Changing any chop parameter | |
| 159 | + | /// clears the marks, so the gate re-arms itself. | |
| 160 | + | fn chop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 161 | + | let forging = forging(state)?; | |
| 162 | + | if forging.slices == 0 { | |
| 163 | + | return Err(RouteError::not_found("preview the slices first")); | |
| 164 | + | } | |
| 165 | + | state.forge.chop(); | |
| 166 | + | Ok(screen(state).into()) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// `POST /forge/device` | |
| 170 | + | /// | |
| 171 | + | /// An empty value is "nothing chosen" rather than a device named the empty | |
| 172 | + | /// string, which is the reading the shipped write-back makes. A name no profile | |
| 173 | + | /// carries is a refusal: the address is reachable by typing. | |
| 174 | + | fn choose_device(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 175 | + | let forging = forging(state)?; | |
| 176 | + | let chosen = request | |
| 177 | + | .payload | |
| 178 | + | .get(DEVICE) | |
| 179 | + | .or_else(|| request.payload.get(Node::SELECTED)) | |
| 180 | + | .unwrap_or_default(); | |
| 181 | + | if !chosen.is_empty() && !forging.devices.iter().any(|device| device.name == chosen) { | |
| 182 | + | return Err(RouteError::not_found("no such device profile")); | |
| 183 | + | } | |
| 184 | + | state.forge.choose_device(chosen); | |
| 185 | + | Ok(screen(state).into()) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// `POST /forge/conform` | |
| 189 | + | fn conform(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 190 | + | let forging = forging(state)?; | |
| 191 | + | if forging.device.is_none() { | |
| 192 | + | return Err(RouteError::not_found("choose a device first")); | |
| 193 | + | } | |
| 194 | + | state.forge.conform(); | |
| 195 | + | Ok(screen(state).into()) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// `POST /forge/trim` | |
| 199 | + | /// | |
| 200 | + | /// Refused under two samples, which is what the shipped section is hidden | |
| 201 | + | /// behind: trimming a batch of one is the single-sample operation wearing the | |
| 202 | + | /// batch's label. | |
| 203 | + | fn trim_silence(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 204 | + | let forging = forging(state)?; | |
| 205 | + | if forging.chosen < 2 { | |
| 206 | + | return Err(RouteError::not_found("choose two or more samples")); | |
| 207 | + | } | |
| 208 | + | state.forge.trim_silence(); | |
| 209 | + | Ok(screen(state).into()) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// The sample in the forge, refusing every write when there is none. | |
| 213 | + | fn forging(state: &Panels<'_>) -> Result<Forging, RouteError> { | |
| 214 | + | state | |
| 215 | + | .forge | |
| 216 | + | .forging() | |
| 217 | + | .ok_or_else(|| RouteError::not_found("no sample is in the forge")) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// The window. | |
| 221 | + | fn screen(state: &Panels<'_>) -> Screen { | |
| 222 | + | let body = match state.forge.forging() { | |
| 223 | + | Some(forging) => loaded(&forging), | |
| 224 | + | None => Slot::new(BODY, RegionKind::Pane).with(Node::empty( | |
| 225 | + | "Select a sample and open the forge to chop, conform, or batch-process it.", | |
| 226 | + | )), | |
| 227 | + | }; | |
| 228 | + | Screen::sidebar_content("Sample Forge").with(body) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// A sample, and the three things that can be made out of it. | |
| 232 | + | fn loaded(forging: &Forging) -> Slot { | |
| 233 | + | let mut body = Slot::new(BODY, RegionKind::Pane) | |
| 234 | + | .with(Node::page(forging.name.clone())) | |
| 235 | + | .with(Node::text(format!("{} Hz", forging.rate))); | |
| 236 | + | ||
| 237 | + | // Said rather than drawn as a spinner beside a separator: what a reader | |
| 238 | + | // needs from it is that every control below is currently inert, and the | |
| 239 | + | // controls say that themselves through `State::Disabled`. | |
| 240 | + | if forging.busy { | |
| 241 | + | body = body.with(Node::banner(Tone::Info, "Working...")); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | body = body.with(Node::Region(chopping(forging))); | |
| 245 | + | body = body.with(Node::Region(conforming(forging))); | |
| 246 | + | body.with(Node::Region(batching(forging))) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// Slicing one sample into several. | |
| 250 | + | fn chopping(forging: &Forging) -> Slot { | |
| 251 | + | let mut group = Slot::new("forge-chop", RegionKind::Group) | |
| 252 | + | .with(Node::section("Chop")) | |
| 253 | + | .with(Node::Select { | |
| 254 | + | kind: Selector::Segmented, | |
| 255 | + | options: Chop::ALL | |
| 256 | + | .into_iter() | |
| 257 | + | .map(|how| (Choice::new(how.as_str(), how.label()), None)) | |
| 258 | + | .collect(), | |
| 259 | + | chosen: Some(forging.how.as_str().to_owned()), | |
| 260 | + | action: Some(Action::post(format!( | |
| 261 | + | "/forge/slice/{}", | |
| 262 | + | forging.how.as_str() | |
| 263 | + | ))), | |
| 264 | + | }); | |
| 265 | + | ||
| 266 | + | // Only the parameters the chosen method reads, which is the shipped | |
| 267 | + | // window's own `match` and the settings screen's line: a control that | |
| 268 | + | // cannot be used is worse than one that is not there. | |
| 269 | + | group = match forging.how { | |
| 270 | + | Chop::Transient => group.with(dial( | |
| 271 | + | forging, | |
| 272 | + | Field::range(Knob::Sensitivity.as_str(), "Sensitivity", "0", "1") | |
| 273 | + | .step("0.01") | |
| 274 | + | .value(format!("{:.2}", forging.sensitivity)), | |
| 275 | + | )), | |
| 276 | + | Chop::Equal => group.with(strip( | |
| 277 | + | forging, | |
| 278 | + | Knob::Divisions, | |
| 279 | + | &forging.divisions.to_string(), | |
| 280 | + | [2_usize, 4, 8, 16, 32].map(|n| (n.to_string(), n.to_string())), | |
| 281 | + | )), | |
| 282 | + | Chop::Bpm => group | |
| 283 | + | .with(dial( | |
| 284 | + | forging, | |
| 285 | + | Field::range(Knob::Bpm.as_str(), "BPM", "20", "300") | |
| 286 | + | .step("0.5") | |
| 287 | + | .value(format!("{:.1}", forging.bpm)), | |
| 288 | + | )) | |
| 289 | + | .with(strip( | |
| 290 | + | forging, | |
| 291 | + | Knob::Subdivisions, | |
| 292 | + | &forging.subdivisions.to_string(), | |
| 293 | + | [("1", "1/4"), ("2", "1/8"), ("4", "1/16")] | |
| 294 | + | .map(|(value, label)| (value.to_owned(), label.to_owned())), | |
| 295 | + | )), | |
| 296 | + | }; | |
| 297 | + | ||
| 298 | + | group = group.with(Node::Act(live( | |
| 299 | + | forging, | |
| 300 | + | Act::new("Preview slices", Action::post("/forge/preview")), | |
| 301 | + | ))); | |
| 302 | + | ||
| 303 | + | // The count on the label, which is the correction the shipped button made to | |
| 304 | + | // itself: a commit says its blast radius before it is pressed. | |
| 305 | + | let mut go = Act::new( | |
| 306 | + | if forging.slices == 0 { | |
| 307 | + | "Chop".to_owned() | |
| 308 | + | } else { | |
| 309 | + | format!( | |
| 310 | + | "Chop into {} slice{}", | |
| 311 | + | forging.slices, | |
| 312 | + | if forging.slices == 1 { "" } else { "s" } | |
| 313 | + | ) | |
| 314 | + | }, | |
| 315 | + | Action::post("/forge/chop"), | |
| 316 | + | ); | |
| 317 | + | if forging.slices == 0 { | |
| 318 | + | // The fifth consumer of `quasi:vocabulary:disabled-reason`: the shipped | |
| 319 | + | // button says this to a pointer and nothing else can. | |
| 320 | + | group = group.with(Node::text( | |
| 321 | + | "Preview the slices first to see how many will be created.", | |
| 322 | + | )); | |
| 323 | + | go = go.disabled(); | |
| 324 | + | } else { | |
| 325 | + | go = live(forging, go); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | group.with(Node::Act(go)).with(Node::text( | |
| 329 | + | "Slices are written into a new folder beside this sample.", | |
| 330 | + | )) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// Making one sample fit a piece of hardware. | |
| 334 | + | fn conforming(forging: &Forging) -> Slot { | |
| 335 | + | let group = Slot::new("forge-conform", RegionKind::Group); | |
| 336 | + | ||
| 337 | + | if forging.devices.is_empty() { | |
| 338 | + | return group | |
| 339 | + | .with(Node::section("Conform for device")) | |
| 340 | + | .with(Node::empty("No device profiles available.")); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | let mut field = Field::select( | |
| 344 | + | DEVICE, | |
| 345 | + | "Conform for device", | |
| 346 | + | forging | |
| 347 | + | .devices | |
| 348 | + | .iter() | |
| 349 | + | .map(|device| Choice::new(device.name.clone(), describe(device))) | |
| 350 | + | .collect(), | |
| 351 | + | ) | |
| 352 | + | .changes(Action::post("/forge/device")); | |
| 353 | + | // The instruction is the picker's ghost text rather than a disabled button's | |
| 354 | + | // job, which is the call the shipped screen already made: a select with | |
| 355 | + | // nothing chosen reads as an empty box, and the greyed control beside it is | |
| 356 | + | // the wrong place to explain that. | |
| 357 | + | field.placeholder = Some("Select device...".to_owned()); | |
| 358 | + | field.value = Some(forging.device.clone().unwrap_or_default()); | |
| 359 | + | ||
| 360 | + | let mut go = Act::new("Conform", Action::post("/forge/conform")); | |
| 361 | + | if forging.device.is_none() { | |
| 362 | + | go = go.disabled(); | |
| 363 | + | } else { | |
| 364 | + | go = live(forging, go); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | group | |
| 368 | + | .with(Node::Field(Box::new(field))) | |
| 369 | + | .with(Node::Act(go)) | |
| 370 | + | .with(Node::text( | |
| 371 | + | "Resamples and converts bit depth to match the device, as a new sample.", | |
| 372 | + | )) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /// The one operation here that is about the selection rather than the sample. | |
| 376 | + | fn batching(forging: &Forging) -> Slot { | |
| 377 | + | let group = Slot::new("forge-batch", RegionKind::Group).with(Node::section("Batch")); | |
| 378 | + | ||
| 379 | + | if forging.chosen < 2 { | |
| 380 | + | return group.with(Node::empty("Select 2+ samples to batch trim silence.")); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | group | |
| 384 | + | .with(Node::Field(Box::new( | |
| 385 | + | Field::range(Knob::Threshold.as_str(), "Threshold (dBFS)", "-96", "-20") | |
| 386 | + | .step("1") | |
| 387 | + | .value(format!("{:.0}", forging.threshold_db)) | |
| 388 | + | .changes(writes(Knob::Threshold)), | |
| 389 | + | ))) | |
| 390 | + | .with(Node::Act(live( | |
| 391 | + | forging, | |
| 392 | + | Act::new( | |
| 393 | + | format!("Trim silence on {} samples", forging.chosen), | |
| 394 | + | Action::post("/forge/trim"), | |
| 395 | + | ), | |
| 396 | + | ))) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | /// What a device profile says about itself, as one option. | |
| 400 | + | fn describe(device: &DeviceChoice) -> String { | |
| 401 | + | if device.summary.is_empty() { | |
| 402 | + | device.name.clone() | |
| 403 | + | } else { | |
| 404 | + | format!("{} ({})", device.name, device.summary) | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /// A number the slicing reads, live unless a run is in flight. | |
| 409 | + | fn dial(forging: &Forging, field: Field) -> Node { | |
| 410 | + | let _ = forging; | |
| 411 | + | let name = field.name.clone(); | |
| 412 | + | Node::Field(Box::new( | |
| 413 | + | field.changes(Action::post(format!("/forge/set/{name}"))), | |
| 414 | + | )) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// A handful of values that do not fold away. | |
| 418 | + | /// | |
| 419 | + | /// `Selector::Segmented` rather than a `Field`, which is the line `settings.rs` | |
| 420 | + | /// drew and the shipped window agrees with: five slice counts and three | |
| 421 | + | /// subdivisions are drawn as rows of selectable buttons, and naming "exactly one | |
| 422 | + | /// of these few" is describing the choice rather than choosing the widget. | |
| 423 | + | fn strip( | |
| 424 | + | forging: &Forging, | |
| 425 | + | knob: Knob, | |
| 426 | + | chosen: &str, | |
| 427 | + | options: impl IntoIterator<Item = (String, String)>, | |
| 428 | + | ) -> Node { | |
| 429 | + | let _ = forging; | |
| 430 | + | Node::Select { | |
| 431 | + | kind: Selector::Segmented, | |
| 432 | + | options: options | |
| 433 | + | .into_iter() | |
| 434 | + | .map(|(value, label)| (Choice::new(value, label), None)) | |
| 435 | + | .collect(), | |
| 436 | + | chosen: Some(chosen.to_owned()), | |
| 437 | + | action: Some(writes(knob)), | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | /// The address a control changing this number calls. | |
| 442 | + | fn writes(knob: Knob) -> Action { | |
| 443 | + | Action::post(format!("/forge/set/{}", knob.as_str())) | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | /// The same control, dead while a run is in flight. | |
| 447 | + | fn live(forging: &Forging, act: Act) -> Act { | |
| 448 | + | if forging.busy { act.disabled() } else { act } | |
| 449 | + | } |