//! The sample editor, described: seven operations on one sample, and three on //! everything chosen. //! //! The thirteenth audiofiles port, and the widest single screen the app has: //! `ui/edit_panel.rs` is 760 lines and dispatches nine distinct operations from //! one window. //! //! # What the description deletes: eleven knobs on `EditUiState` //! //! `trim_start`, `trim_end`, `gain_db`, `norm_peak`, `norm_target`, `fade_in`, //! `fade_duration_ms`, `fade_curve`, `silence_position_ms`, //! `silence_duration_ms`, `remove_start_ms`, `remove_end_ms`. Twelve, counting //! properly. Every one is a buffer for a control that is being adjusted, read //! only by the `apply_*` function the button beside it calls, and //! [`bulk`](super::bulk) already made this deletion once for `BulkModal`: what //! is being typed into a described screen is the runtime's, and it arrives with //! the submit that used it. //! //! What is left on the app's side is the *sample* — the hash, whether an edit is //! running, what a finished one is waiting for — which is the app's state and //! not a control's. //! //! It also fixes something on the way. The batch section's three buttons //! **piggyback on the single-sample sliders**: `batch_normalize_peak` takes //! `state.edit.norm_target`, `batch_gain` takes `state.edit.gain_db`. That was //! caught once already (M-14: "removes the silent-piggyback footgun where the //! user couldn't tell what value the batch button would use") and answered by //! baking the number into the button's label. Here the batch operations are //! forms with their own fields, so there is nothing to piggyback on and nothing //! to bake. //! //! # One address, two shapes, and why this one is not the overlay finding //! //! `state.edit.result_prompt` makes the shipped panel draw a different body: //! "How should the edited sample be handled?", with the editor's own controls //! gone. That is a state the user *arrived* at rather than a place they went, so //! it is a shape at `/edit` — the rule `sync`, `export` and `detail` settled. //! //! Worth saying because the pass before this filed //! `quasi:vocabulary:unprompted-overlay` for exactly the shape this looks like. //! It is not the same: the shipped app draws the prompt *in place*, not over //! what the user was doing, so nothing here is trying to raise itself. //! //! # THE FINDING: an undo offer cannot outlive the answer that raised it //! //! `Response::undoable` is the vocabulary's shape for "that happened, take it //! back", and its header is right that the timeout is renderer policy. It hangs //! off a `Response`'s notice, which means the offer exists only in the answer //! that made it. //! //! An audiofiles edit finishes on a worker thread. The answer that dispatched it //! was built and shown seconds earlier, and there is no answer being made at the //! moment the result lands — so the one place `undoable` can be attached is the //! one place nothing knows an undo is available yet. `Screen::notices` is a //! `Vec`, and `Node::Notice` carries no action, so a screen cannot say it //! either. //! //! What this port does instead is describe the standing affordance the shipped //! panel draws: the last edit's name, and an `Act` beside it. That works and it //! is not the same claim — an act is a control the user finds, and an undo offer //! is a consequence the app volunteers. //! //! Filed as `quasi:vocabulary:undo-outlives-the-answer`. //! //! # A third consumer for `5672cad4`, the valid answer that costs something //! //! "Peak: -2.0 dB -> 1.5 dB (clips!)" is not an error — the gain is a legal //! value and the edit will run — and it is not standing help either, because it //! depends on what has been typed. makeover-layout `5672cad4` is the gap that a //! `Field`'s two message slots are help and error with nothing between, and this //! is its third measured site after the export screen's re-encoding warning. //! //! The workaround is the rename preview's: `Field::changes` answers a fragment //! into a region beside the field. It costs a route and a region for what wants //! to be a member. //! //! # A second consumer for `91114ff1`, the interval //! //! Trim is one question with two values that constrain each other: start must be //! before end, and the shipped panel enforces it by writing one of them //! (`if trim_start >= trim_end { trim_start = trim_end - 0.001 }`) every frame. //! `FieldKind::Range` describes one value in an extent, so the description here //! is two ranges that do not know about each other and a route that refuses the //! inverted pair. Same gap audiofiles' filter panel is waiting on, from a second //! app surface. //! //! # What is deliberately not described //! //! - **The waveform**, its trim wash, its draggable handles and click-to-seek. //! Domain rendering: a click that maps a pixel to a frame and writes into a //! mutex an audio thread is filling is a host fact, not a fact about a sample. //! The numeric path *is* described, which is what the shipped panel calls the //! sliders beside it. //! - **The in-progress greying.** Every section disables itself while an edit //! runs, deliberately keeping the layout rather than collapsing to a spinner. //! Described as `Act::disabled` on what an edit would start, which says the //! same thing without naming a colour. use quasi_router::layout::{FieldKind, Notice, Tone}; use quasi_router::{ Act, Action, Choice, Field, Figure, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use audiofiles_core::edit::FadeCurve; use super::{Editing, Panels}; use crate::state::EditResultMode; /// The region the editor answers into. const BODY: &str = "edit-body"; /// The region the clipping warning lands in. const CLIPPING: &str = "edit-clipping"; /// The names a control submits under. const START: &str = "start"; /// See [`START`]. const END: &str = "end"; /// See [`START`]. const GAIN: &str = "gain"; /// See [`START`]. const MODE: &str = "mode"; /// See [`START`]. const TARGET: &str = "target"; /// See [`START`]. const CURVE: &str = "curve"; /// See [`START`]. const LENGTH: &str = "length"; /// See [`START`]. const AT: &str = "at"; /// See [`START`]. const FROM: &str = "from"; /// See [`START`]. const TO: &str = "to"; /// See [`START`]. const RESULT: &str = "result"; /// See [`START`]. const REMEMBER: &str = "remember"; /// The value the peak/loudness choice submits for peak. const PEAK: &str = "peak"; /// The value it submits for loudness. const LUFS: &str = "lufs"; /// Where the fade direction's two answers are written. const FADE_IN: &str = "in"; /// Register the editor's routes. pub fn routes(router: Router>) -> Router> { router .get("/edit", screen) .post("/edit/trim", trim) .post("/edit/gain", gain) .post("/edit/gain/preview", clipping) .post("/edit/normalize", normalize) .post("/edit/reverse", reverse) .post("/edit/fade", fade) .post("/edit/silence/insert", insert_silence) .post("/edit/silence/remove", remove_range) .post("/edit/play", play) .post("/edit/stop", stop) .post("/edit/cancel", cancel) .post("/edit/undo", undo) .post("/edit/result", remember) .post("/edit/result/choose", choose) .post("/edit/result/discard", discard) .post("/edit/batch/normalize", batch_normalize) .post("/edit/batch/gain", batch_gain) .post("/edit/batch/reverse", batch_reverse) } /// `GET /edit` fn screen(state: &Panels<'_>, _request: Request) -> Result { Ok(editor(state)?.into()) } /// The whole editor, at whichever of its two shapes it is in. fn editor(state: &Panels<'_>) -> Result { let sample = subject(state)?; let body = if sample.asking { asking(&sample) } else { editing(&sample) }; Ok(Screen::sidebar_content("Sample Editor").with(body)) } /// What is being edited, or a refusal. /// /// Nothing is a `NotFound` rather than an empty editor: the shipped window is /// only open because something is being edited, and a described screen for "no /// sample" would be a screen the app does not have. fn subject(state: &Panels<'_>) -> Result { state .editor .subject() .ok_or_else(|| RouteError::not_found("nothing is being edited")) } /// The question a finished edit is waiting on. /// /// A form rather than the shipped three buttons, and the reason is a gap: /// "Replace Original" and "Create Sibling" each need to carry the answer to /// "Remember my choice", and an `Act` cannot carry a value another control is /// holding. That is makeover-layout `28a777df` (a control carries an action but /// no computed payload), and this is a consumer of it. Discard stays an act /// because it carries nothing. fn asking(sample: &Editing) -> Slot { Slot::new(BODY, RegionKind::Pane) .with(Node::page("Edit Result")) .with(Node::text("How should the edited sample be handled?")) .with(Node::Form { fields: vec![ Field::radio(RESULT, "Result", result_modes()).value( sample .result .clone() .unwrap_or_else(|| EditResultMode::Sibling.as_value().to_owned()), ), // The shipped checkbox is ticked when a standing answer exists, // which is the same fact this reads. Field::new(FieldKind::Checkbox, REMEMBER, "Remember my choice") .value(if sample.result.is_some() { "on" } else { "" }), ], submit: "Use this".to_owned(), action: Action::post("/edit/result/choose"), }) .with(Node::Act( Act::new("Discard edit", Action::post("/edit/result/discard")) .tone(Tone::Danger) .confirm("Throw this edit away?"), )) } /// The editor proper. fn editing(sample: &Editing) -> Slot { let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page(sample.name.clone())); // What the shipped info line says, as facts rather than one muted string. body = body.with(Node::Figure(Figure::new( sample.sample_rate.to_string(), "Hz", ))); if let Some(duration) = sample.duration { body = body.with(Node::Figure(Figure::new( format!("{duration:.3}"), "seconds", ))); } if let Some(peak) = sample.peak_db { body = body.with(Node::Figure(Figure::new(format!("{peak:.1}"), "dBFS"))); } if sample.working { body = body .with(Node::Notice { kind: Notice::Banner, tone: Tone::Info, text: "Applying edit...".to_owned(), }) .with(Node::Act(Act::new("Cancel", Action::post("/edit/cancel")))); } body = transport(body, sample); body = trim_section(body); body = levels(body, sample); body = transform(body, sample); body = silence(body, sample); body = result(body, sample); batch(body, sample) } /// Play, pause and stop, independent of the main list's selection. fn transport(body: Slot, sample: &Editing) -> Slot { body.with(Node::Act( Act::new( if sample.playing { "Pause" } else { "Play" }, Action::post("/edit/play"), ) .key("space"), )) .with(Node::Act(Act::new("Stop", Action::post("/edit/stop")))) } /// The span to keep. fn trim_section(body: Slot) -> Slot { body.with(Node::section("Trim")).with(Node::Form { // Two ranges over a fraction of the sample, which is what the shipped // sliders are. The seconds the shipped panel prints beside each is the // same number in the sample's units, and a description that carried both // would be describing a label. See the module header on the interval // gap: these two constrain each other and cannot say so. fields: vec![span(START, "Start"), span(END, "End").value("1")], submit: "Trim".to_owned(), action: Action::post("/edit/trim"), }) } /// One end of the trim, as a fraction of the whole. fn span(name: &str, label: &str) -> Field { Field::range(name, label, "0", "1").step("0.001").value("0") } /// Gain and normalise. fn levels(body: Slot, sample: &Editing) -> Slot { let mut body = body.with(Node::section("Levels")); body = body .with(Node::Form { fields: vec![ Field::range(GAIN, "Gain", "-24", "24") .step("0.1") .value("0") // The clipping consequence, which the field cannot carry // itself. See the module header, `5672cad4`. .changes(Action::post("/edit/gain/preview")), ], submit: "Apply gain".to_owned(), action: Action::post("/edit/gain"), }) .with(Node::Region( Slot::new(CLIPPING, RegionKind::Group).with(clips(sample.peak_db, 0.0)), )); // Peak and LUFS have different ranges (-24..0 dBFS against -24..-6 LUFS) and // different defaults, and the shipped panel resets the target when the mode // changes because "the carried-over value is meaningless across modes". Both // facts are about the pair rather than about either control, and neither is // sayable: the widest range is described and the route refuses what falls // outside the chosen mode's half. body.with(Node::Form { fields: vec![ Field::radio( MODE, "Normalize by", vec![ Choice::new(PEAK, "Peak"), Choice::new(LUFS, "Loudness (LUFS)"), ], ) .value(PEAK), Field::range(TARGET, "Target", "-24", "0") .step("0.1") .value("-1"), ], submit: "Normalize".to_owned(), action: Action::post("/edit/normalize"), }) } /// What the gain about to be applied would do to the peak. /// /// Its own node so `Field::changes` can answer it as a fragment while the /// control is being moved, which is the rename preview's arrangement. fn clips(peak: Option, gain: f64) -> Node { let Some(peak) = peak else { return Node::empty("Peak unknown until this sample is analysed."); }; let predicted = peak + gain; if predicted <= 0.0 { return Node::text(format!("Peak: {peak:.1} dB -> {predicted:.1} dB")); } Node::Notice { kind: Notice::Banner, tone: Tone::Danger, text: format!("Peak: {peak:.1} dB -> {predicted:.1} dB (clips!)"), } } /// Reverse and fade. fn transform(body: Slot, sample: &Editing) -> Slot { body.with(Node::section("Transform")) .with(Node::Act(disable_while( Act::new("Reverse", Action::post("/edit/reverse")), sample, ))) .with(Node::Form { // The fade row was left out of the forms conversion as "a slider, a // chooser and an Apply composing one operation", which was the right // call about a *field* and is what a `Form` is for: several answers // and one submit that uses them together. fields: vec![ Field::radio( FADE_IN, "Fade", vec![Choice::new("in", "In"), Choice::new("out", "Out")], ) .value("in"), Field::range(LENGTH, "Length", "10", "10000") .step("10") .value("100"), Field::select( CURVE, "Curve", FadeCurve::all() .into_iter() .map(|curve| Choice::new(curve.as_value(), curve.label())) .collect(), ) .value(FadeCurve::Linear.as_value()), ], submit: "Apply fade".to_owned(), action: Action::post("/edit/fade"), }) } /// Insert and remove. fn silence(body: Slot, sample: &Editing) -> Slot { // The shipped drag values clamp to the sample's length where analysis has // said what it is. A described `max` says the same thing, and where the // length is unknown there is nothing to say rather than a made-up ceiling. let cap = sample.duration.map(|seconds| seconds * 1000.0); body.with(Node::section("Silence")) .with(Node::Form { fields: vec![ milliseconds(AT, "Insert at", cap).value("0"), milliseconds(LENGTH, "Duration", Some(60_000.0)).value("100"), ], submit: "Insert".to_owned(), action: Action::post("/edit/silence/insert"), }) .with(Node::Form { fields: vec![ milliseconds(FROM, "Remove from", cap).value("0"), milliseconds(TO, "to", cap).value("0"), ], submit: "Remove".to_owned(), action: Action::post("/edit/silence/remove"), }) } /// A number of milliseconds, bounded where the app knows the bound. fn milliseconds(name: &str, label: &str, cap: Option) -> Field { // `min` and `max` are fields rather than builders on this type, where // `step` is a builder. Set directly, the way the settings screen sets a // value. let mut field = Field::new(FieldKind::Number, name, label).step("10"); field.min = Some("0".to_owned()); field.max = cap.map(|cap| format!("{cap:.0}")); field } /// What happens to the edited sample, and what happened to the last one. fn result(body: Slot, sample: &Editing) -> Slot { let mut field = Field::radio(RESULT, "Result", result_modes()).changes(Action::post("/edit/result")); if let Some(chosen) = &sample.result { field = field.value(chosen.clone()); } let mut body = body .with(Node::section("Result")) .with(Node::Field(Box::new(field))); if sample.result.as_deref() == Some(EditResultMode::Replace.as_value()) { body = body.with(Node::Notice { kind: Notice::Banner, tone: Tone::Warning, text: "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or Undo below to revert.".to_owned(), }); } // The standing undo. See the module header: this is an act rather than // `Response::undoable`, and the ten-second timeout the shipped panel keeps // (with an `egui::Id` round trip and a `request_repaint_after` to land it) // is renderer policy that no longer has anywhere to be written down. if let Some(last) = &sample.undoing { body = body .with(Node::text(format!("Last edit: {last}"))) .with(Node::Act(Act::new("Undo", Action::post("/edit/undo")))); } body } /// The two answers to "Result". fn result_modes() -> Vec { vec![ Choice::new(EditResultMode::Replace.as_value(), "Replace original"), Choice::new(EditResultMode::Sibling.as_value(), "Create sibling"), ] } /// Everything chosen at once. fn batch(body: Slot, sample: &Editing) -> Slot { if sample.chosen < 2 { return body; } let chosen = sample.chosen; body.with(Node::section(format!("Batch: {chosen} samples"))) .with(Node::text("Applies to every chosen sample at once.")) .with(Node::Form { // Its own value rather than the single-sample slider's. See the // module header on the piggyback this deletes. fields: vec![ Field::radio( MODE, "Normalize by", vec![ Choice::new(PEAK, "Peak"), Choice::new(LUFS, "Loudness (LUFS)"), ], ) .value(PEAK), Field::range(TARGET, "Target", "-24", "0") .step("0.1") .value("-1"), ], submit: format!("Normalize {chosen} samples"), action: Action::post("/edit/batch/normalize"), }) .with(Node::Form { fields: vec![ Field::range(GAIN, "Gain", "-24", "24") .step("0.1") .value("0"), ], submit: format!("Apply gain to {chosen} samples"), action: Action::post("/edit/batch/gain"), }) .with(Node::Act( // The shipped panel asks before reversing more than ten, through // `ConfirmAction::ReverseSamples` and the 140-line match behind it. // `Act::confirm` is the whole of that here -- the fourth variant // this port has replaced with a builder method. reverse_batch(chosen), )) } /// Reversing everything chosen, asking first where there is enough to regret. fn reverse_batch(chosen: usize) -> Act { let act = Act::new( format!("Reverse {chosen} samples"), Action::post("/edit/batch/reverse"), ); if chosen > REGRET { act.confirm(format!("Reverse {chosen} samples?")) } else { act } } /// How many samples make a batch reverse worth asking about. /// /// The shipped threshold, kept with its reasoning: single-sample Reverse is its /// own undo (click it again), and on a large selection that trick requires /// remembering it ran at all. const REGRET: usize = 10; /// Held back while an edit is running, which is the shipped disabled flag. fn disable_while(act: Act, sample: &Editing) -> Act { if sample.working { act.disabled() } else { act } } /// `POST /edit/trim` fn trim(state: &Panels<'_>, request: Request) -> Result { let sample = subject(state)?; let start = fraction(&request, START)?; let end = fraction(&request, END)?; // The pair the description cannot state. The shipped panel keeps it true by // writing one of the two every frame; here it is a refusal, which is the // honest form of the same rule for an address reachable by typing. if start >= end { return Err(RouteError::not_found("start must be before end")); } state.editor.trim(start, end); answered(state, format!("Trimming {}.", sample.name)) } /// `POST /edit/gain` fn gain(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let db = decimal(&request, GAIN)?; state.editor.gain(db); answered(state, format!("Applying {db:.1} dB.")) } /// `POST /edit/gain/preview` /// /// A fragment, so the warning changes while the control moves and the screen /// under it stays where it was. fn clipping(state: &Panels<'_>, request: Request) -> Result { let sample = subject(state)?; // A control mid-drag can send anything, including half a number, so an // unreadable value is "no gain yet" rather than a refusal the user would see // as an error. let gain = request .payload .get(GAIN) .and_then(|value| value.parse().ok()) .unwrap_or(0.0); Ok(Response::fragment(CLIPPING, clips(sample.peak_db, gain))) } /// `POST /edit/normalize` fn normalize(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let (peak, target) = normalizing(&request)?; state.editor.normalize(peak, target); answered(state, "Normalizing.".to_owned()) } /// `POST /edit/reverse` fn reverse(state: &Panels<'_>, _request: Request) -> Result { subject(state)?; state.editor.reverse(); answered(state, "Reversing.".to_owned()) } /// `POST /edit/fade` fn fade(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let fading_in = request.payload.get(FADE_IN).unwrap_or("in") != "out"; let ms = decimal(&request, LENGTH)?; let curve = request.payload.get(CURVE).unwrap_or_default(); // Refused rather than defaulted: a curve the app cannot read back is a fade // the user did not ask for, and `FadeCurve::from_value` is the pairing that // says which is which. let curve = FadeCurve::from_value(curve).ok_or_else(|| RouteError::not_found("no such fade curve"))?; state.editor.fade(fading_in, ms, curve.as_value()); answered( state, format!( "Fading {} over {ms:.0} ms.", if fading_in { "in" } else { "out" } ), ) } /// `POST /edit/silence/insert` fn insert_silence(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let at = decimal(&request, AT)?; let ms = decimal(&request, LENGTH)?; if ms <= 0.0 { return Err(RouteError::not_found("silence has to be longer than that")); } state.editor.insert_silence(at, ms); answered(state, format!("Inserting {ms:.0} ms.")) } /// `POST /edit/silence/remove` fn remove_range(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let from = decimal(&request, FROM)?; let to = decimal(&request, TO)?; if from >= to { return Err(RouteError::not_found( "the span has to start before it ends", )); } state.editor.remove_range(from, to); answered(state, format!("Removing {:.0} ms.", to - from)) } /// `POST /edit/play` fn play(state: &Panels<'_>, _request: Request) -> Result { subject(state)?; state.editor.play(); Ok(editor(state)?.into()) } /// `POST /edit/stop` fn stop(state: &Panels<'_>, _request: Request) -> Result { subject(state)?; state.editor.stop(); Ok(editor(state)?.into()) } /// `POST /edit/cancel` fn cancel(state: &Panels<'_>, _request: Request) -> Result { subject(state)?; state.editor.cancel(); answered(state, "Edit cancelled.".to_owned()) } /// `POST /edit/undo` fn undo(state: &Panels<'_>, _request: Request) -> Result { let sample = subject(state)?; // Refused where there is nothing to take back, which the standing act is an // affordance for rather than a guarantee. let last = sample .undoing .ok_or_else(|| RouteError::not_found("there is nothing to undo"))?; state.editor.undo(); answered(state, format!("Undoing {last}.")) } /// `POST /edit/result` fn remember(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let mode = mode_named(&request)?; state.editor.remember(mode.as_value()); Ok(editor(state)?.into()) } /// `POST /edit/result/choose` fn choose(state: &Panels<'_>, request: Request) -> Result { subject(state)?; let mode = mode_named(&request)?; let remember = request.payload.get(REMEMBER).unwrap_or_default() == "on"; state.editor.choose(mode.as_value(), remember); answered(state, "Edit applied.".to_owned()) } /// `POST /edit/result/discard` fn discard(state: &Panels<'_>, _request: Request) -> Result { subject(state)?; state.editor.discard(); answered(state, "Edit result discarded.".to_owned()) } /// `POST /edit/batch/normalize` fn batch_normalize(state: &Panels<'_>, request: Request) -> Result { let chosen = batched(state)?; let (peak, target) = normalizing(&request)?; state.editor.batch_normalize(peak, target); answered(state, format!("Normalizing {chosen} samples.")) } /// `POST /edit/batch/gain` fn batch_gain(state: &Panels<'_>, request: Request) -> Result { let chosen = batched(state)?; let db = decimal(&request, GAIN)?; state.editor.batch_gain(db); answered(state, format!("Applying {db:.1} dB to {chosen} samples.")) } /// `POST /edit/batch/reverse` fn batch_reverse(state: &Panels<'_>, _request: Request) -> Result { let chosen = batched(state)?; state.editor.batch_reverse(); answered(state, format!("Reversing {chosen} samples.")) } /// How many samples a batch operation would touch, refusing where it is not a /// batch. /// /// The section is hidden below two, and the address is reachable regardless. fn batched(state: &Panels<'_>) -> Result { let chosen = subject(state)?.chosen; if chosen < 2 { return Err(RouteError::not_found("choose more than one sample first")); } Ok(chosen) } /// The normalise mode and target, as a pair, since neither is readable alone. /// /// The bounds differ by mode -- peak runs to 0 dBFS and loudness stops at -6 /// LUFS -- and the description carries the wider of the two, so this is where /// the narrower one is enforced. fn normalizing(request: &Request) -> Result<(bool, f64), RouteError> { let peak = request.payload.get(MODE).unwrap_or(PEAK) != LUFS; let target = decimal(request, TARGET)?; let allowed = if peak { -24.0..=0.0 } else { -24.0..=-6.0 }; if !allowed.contains(&target) { return Err(RouteError::not_found(if peak { "a peak target runs from -24 to 0 dBFS" } else { "a loudness target runs from -24 to -6 LUFS" })); } Ok((peak, target)) } /// The result mode a request names. fn mode_named(request: &Request) -> Result { EditResultMode::from_value(request.payload.get(RESULT).unwrap_or_default()) .ok_or_else(|| RouteError::not_found("no such result mode")) } /// A fraction of the sample's length, as a control submits one. fn fraction(request: &Request, name: &str) -> Result { let value: f32 = request .payload .get(name) .unwrap_or_default() .parse() .map_err(|_| RouteError::not_found("that is not a position"))?; if !(0.0..=1.0).contains(&value) { return Err(RouteError::not_found("a position runs from 0 to 1")); } Ok(value) } /// A number a control submitted. fn decimal(request: &Request, name: &str) -> Result { request .payload .get(name) .unwrap_or_default() .parse() .map_err(|_| RouteError::not_found("that is not a number")) } /// The editor again, saying what was just asked for. /// /// Every operation answers the whole screen rather than going anywhere: an edit /// is something done *to* what is on screen, and the shipped panel stays open /// through all of them. fn answered(state: &Panels<'_>, say: String) -> Result { Ok(Response::from(editor(state)?).toast(Tone::Success, say)) }