Skip to main content

max / audiofiles

Describe the forge, and the migration strip `ui/forge_panel.rs` (346) as its own address, `/forge`. The first screen here that is one shape rather than several, which states the rule from the other side: a state a reader arrived at is a shape, and a property of the subject is a field. "An export is running" is the first; "this sample is busy" is the second. `ui/layout_strip.rs` (62) is not its own address and not a capability. It is a band of the main window, which is exactly what its own header argues it should be, so it is `Shell::migrating` and a region `shell::screen` includes while the fact is true. The nearest thing to an unprompted overlay that turns out to be sayable, and the contrast with the loose-files warning three paragraphs down in that module is the point. Findings: - `quasi:vocabulary:field-state`. `Act` carries a `State` and `Field` carries nothing: there is no way to say a field is not answering. Nine `add_enabled(!busy, ..)` in the shipped window, of which the four fields cannot be described at all. Sharper than the neighbouring gap, which is a control that says it is dead without saying why. - Fifth consumer of `quasi:vocabulary:disabled-reason`: Chop's "preview first". Deletes `conform_device`, the fourth control buffer this layer has taken off `BrowserState` after `BulkModal`'s eleven and the editor's twelve. `slice_marks` stays: it is work the app did, not a control's buffer. 557 tests green with --features quasi, 11 of them new.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 21:00 UTC
Signed with PGP, not checked
Commit: 9f0ae681ebbb546743516218478c5227ab13a7cb
Parent: 04d8628
7 files changed, +1298 insertions, -21 deletions
M Cargo.lock +4 -4
@@ -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 + }