Skip to main content

max / audiofiles

A described field answers when it settles, not on every write Stated by Max 2026-08-21 about the forge's tempo, which the previous commit left as the one undescribed control in the chop section. The reason it gave was real and the conclusion was wrong: the fix is not to keep the DragValue, it is to stop writing state on every keystroke. The rule was already here three times, unnamed. `widgets::settled` is the one spelling now: a drag reports `changed()` every frame it moves, so acting on that is acting on a value the user is still choosing. The classifier's per-tag thresholds carried the expression as a local closure and its layer weight carried it inline; both call the helper. `widgets::number_field` is the same rule for a control with no release to wait for. The buffer is the panel's (`PendingNumber` on `ForgeUiState`), and nothing is parsed or written until the field loses focus, which egui reports for a pressed Enter and for a click elsewhere alike. So "2" on the way to "200" never reaches `state.forge.bpm` and never re-grids the sample. The tempo is a described `Number` on that helper. What it gains over the DragValue is the refusal: `DragValue::range` clamps in silence, so a user who meant 400 got 300 and was never told, and an out-of-range entry now stays on screen as typed with the bounds as the field's own error. Transient sensitivity moves onto `settled` in the same pass -- the value still follows the drag because the control has to show what the hand is doing, but the slice marks are dropped once, on release, rather than once per frame. The ADSR envelope is subsumed by this and still cannot convert: it is waiting on the logarithmic-scale gap (makeover-layout 45fc1a38) and the unit gap (32215e21), and inherits `settled` when they close. 1365 tests pass, clippy clean, fmt clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-21 16:58 UTC
Signed with PGP, not checked
Commit: a3e59e3410e31feb7a0f6c2a9f16cb8ec1c63df3
Parent: 32e2950
4 files changed, +326 insertions, -35 deletions
@@ -911,6 +911,12 @@
911 911 pub source_rate: u32,
912 912 /// Currently selected chop method.
913 913 pub chop_mode: ChopMode,
914 + /// The tempo as the user is typing it, until they submit it.
915 + ///
916 + /// The described BPM field answers on submit rather than on every
917 + /// keystroke, so the half-typed value lives here and `bpm` below keeps the
918 + /// last one that was actually chosen. See `ui::widgets::PendingNumber`.
919 + pub bpm_input: crate::ui::widgets::PendingNumber,
914 920 /// Transient sensitivity, 0..1.
915 921 pub sensitivity: f32,
916 922 /// Equal-divisions slice count.
@@ -945,6 +951,7 @@
945 951 name: String::new(),
946 952 source_rate: 44100,
947 953 chop_mode: ChopMode::Equal,
954 + bpm_input: crate::ui::widgets::PendingNumber::default(),
948 955 sensitivity: 0.5,
949 956 divisions: 8,
950 957 bpm: 120.0,
@@ -661,15 +661,11 @@
661 661 ui.horizontal(|ui| {
662 662 let r = threshold_field(ui, "review_threshold", "review", &mut review);
663 663 let a = threshold_field(ui, "auto_threshold", "auto", &mut auto);
664 - // Committed on release, or on a change that was not a drag
665 - // (a keyboard nudge), so a drag across the bar is one write
666 - // and not one per frame. Unchanged by the conversion.
667 - let settled = |response: Option<egui::Response>| {
668 - response.is_some_and(|response| {
669 - response.drag_stopped() || (response.changed() && !response.dragged())
670 - })
671 - };
672 - if settled(r) || settled(a) {
664 + // Committed when the control settles, which is
665 + // `widgets::settled`: a drag across the bar is one write
666 + // and not one per frame. This site is where that rule was
667 + // first written; it is the shared helper now.
668 + if widgets::settled(r) || widgets::settled(a) {
673 669 changed = Some((tag.clone(), review as f64, auto as f64));
674 670 }
675 671 });
@@ -1183,7 +1179,7 @@
1183 1179 1.0; lower means weaker.",
1184 1180 )
1185 1181 });
1186 - if r.is_some_and(|r| r.drag_stopped() || (r.changed() && !r.dragged())) {
1182 + if widgets::settled(r) {
1187 1183 weight_change = Some((layer.id.clone(), w as f64));
1188 1184 }
1189 1185 if confirming {
@@ -146,16 +146,20 @@
146 146 ..makeover_layout::Field::range("sensitivity", "Sensitivity", "0", "1")
147 147 };
148 148 let mut text = format!("{:.2}", state.forge.sensitivity);
149 - widgets::field(
149 + let response = widgets::field(
150 150 ui,
151 151 &described,
152 152 makeover_immediate::Filling::Text(&mut text),
153 153 state_when_busy,
154 154 );
155 - if let Ok(parsed) = text.parse::<f32>()
156 - && (parsed - state.forge.sensitivity).abs() > f32::EPSILON
157 - {
155 + // The value follows the drag, because the control has to show what
156 + // the hand is doing. The marks are dropped when it settles, which
157 + // is `widgets::settled`'s rule: a drag across the bar is one answer
158 + // and not one per frame.
159 + if let Ok(parsed) = text.parse::<f32>() {
158 160 state.forge.sensitivity = parsed;
161 + }
162 + if widgets::settled(response) {
159 163 state.forge.slice_marks.clear();
160 164 }
161 165 }
@@ -180,27 +184,36 @@
180 184 }
181 185 }
182 186 ChopMode::Bpm => {
183 - // The tempo stays a `DragValue` and is the one control in this
184 - // section that is not described. It is a validated number rather
185 - // than a range -- 20 and 300 are guard rails on an answer people
186 - // arrive with, not the two ends the question means -- and a
187 - // described `Number` is a text well here, which needs a buffer that
188 - // survives frames. `state.forge.bpm` is an `f32`, so a half-typed
189 - // "2" on the way to "200" would re-grid the sample at 2 BPM.
190 - ui.horizontal(|ui| {
191 - ui.label("BPM:");
192 - if ui
193 - .add_enabled(
194 - !disabled,
195 - egui::DragValue::new(&mut state.forge.bpm)
196 - .speed(0.5)
197 - .range(20.0..=300.0),
198 - )
199 - .changed()
200 - {
201 - state.forge.slice_marks.clear();
202 - }
203 - });
187 + // A described `Number`, not a `Range`: 20 and 300 are guard rails
188 + // on an answer people arrive with rather than the two ends the
189 + // question means. Nobody drags to find a tempo, they know it.
190 + //
191 + // It answers on submit. The half-typed "2" on the way to "200" is
192 + // what kept this control undescribed in the 2026-08-21 pass, and
193 + // `widgets::number_field` is the answer to it: the buffer is the
194 + // panel's, and 2 never reaches `state.forge.bpm` at all.
195 + //
196 + // What the description buys over the `DragValue` it replaces is the
197 + // refusal. `DragValue::range` clamps in silence, so a user who
198 + // meant 400 got 300 and was never told; an out-of-range entry now
199 + // stays on screen as typed, with the bounds as the field's error.
200 + let spec = widgets::NumberFieldSpec {
201 + name: "bpm",
202 + label: "BPM",
203 + min: 20.0,
204 + max: 300.0,
205 + decimals: 0,
206 + hint: None,
207 + };
208 + if widgets::number_field(
209 + ui,
210 + &spec,
211 + &mut state.forge.bpm_input,
212 + &mut state.forge.bpm,
213 + state_when_busy,
214 + ) {
215 + state.forge.slice_marks.clear();
216 + }
204 217
205 218 let options: Vec<makeover_layout::Choice<'_>> = SUBDIVISION_CHOICES
206 219 .iter()
@@ -515,6 +515,184 @@
515 515 makeover_immediate::group(ui, fields, show_extended, &field_style(), draw);
516 516 }
517 517
518 + /// Whether a described field's response is an answer, or a gesture still in
519 + /// progress.
520 + ///
521 + /// **A described field's value reaches the app when it settles, not on every
522 + /// write.** Stated by Max 2026-08-21 about the forge's tempo and it is the rule
523 + /// this app had already written three times without naming: the classifier's
524 + /// per-tag thresholds and its layer weight each carried their own copy of the
525 + /// expression below, and a fourth site read `changed()` alone.
526 + ///
527 + /// A drag reports `changed()` on every frame it moves, so acting on `changed()`
528 + /// is acting on a value the user is still choosing: an intermediate 0.31 on the
529 + /// way to 0.72 gets persisted, re-runs whatever the value drives, and is
530 + /// replaced two frames later. `drag_stopped()` is the release; the second arm
531 + /// is the keyboard, where a nudge changes the value without a drag ever
532 + /// starting and is an answer immediately.
533 + ///
534 + /// This is the pointer half of the rule. The typed half is
535 + /// [`number_field`], where settling is a submit rather than a release, and
536 + /// [`PendingNumber`] is what holds the half-typed answer in between.
537 + ///
538 + /// Takes the `Option` [`field`] returns rather than a `Response`, since a
539 + /// described field that drew no control has not settled either.
540 + #[must_use]
541 + pub fn settled(response: Option<egui::Response>) -> bool {
542 + response.is_some_and(|response| {
543 + response.drag_stopped() || (response.changed() && !response.dragged())
544 + })
545 + }
546 +
547 + /// A number the user is typing, held until they submit it.
548 + ///
549 + /// [`settled`]'s rule for a control with no release to wait for. A slider
550 + /// cannot be half-way between two valid values, so its intermediate states are
551 + /// harmless; a text box passes through one on every keystroke, and each of them
552 + /// is a perfectly readable number. The forge's tempo is the measured case: `2`
553 + /// on the way to `200` parses, sits inside no bound this app can check per
554 + /// keystroke, and re-grids the sample at 2 BPM before the second digit arrives.
555 + ///
556 + /// So the buffer is the app's, not the value's: `typing` is what the user has
557 + /// entered and has not submitted, and it is `None` whenever the control is
558 + /// showing the app's own value. Nothing is parsed or written until the field
559 + /// loses focus, which egui reports for both a pressed Enter and a click
560 + /// elsewhere.
561 + #[derive(Debug, Default)]
562 + pub struct PendingNumber {
563 + /// What the user has typed and not submitted.
564 + typing: Option<String>,
565 + /// Why the last submit was refused, as the message the field carries.
566 + ///
567 + /// A `String` rather than a flag because it is the field's `error` and the
568 + /// bounds it names are the spec's, so the sentence is built where the
569 + /// bounds are known and read where the field is drawn.
570 + refused: Option<String>,
571 + }
572 +
573 + impl PendingNumber {
574 + /// Resolve a submitted answer against the extent it has to sit in.
575 + ///
576 + /// `Some` is an accepted number the caller writes through; `None` is a
577 + /// refusal, which puts the text back in the buffer so it stays on screen as
578 + /// the user typed it and leaves the reason on `refused`.
579 + ///
580 + /// Separate from [`number_field`] because this is the whole rule and the
581 + /// rest of that function is drawing: the bounds are inclusive, an
582 + /// unparseable answer and an out-of-range one are refused the same way, and
583 + /// surrounding whitespace is not an error. None of that is testable through
584 + /// an `egui::Ui`.
585 + ///
586 + /// The bounds arrive twice, as numbers to check against and as the text the
587 + /// message names, because the caller has already formatted them for the
588 + /// description and formatting them again here could disagree.
589 + fn submit(
590 + &mut self,
591 + typed: String,
592 + min: f64,
593 + max: f64,
594 + min_text: &str,
595 + max_text: &str,
596 + ) -> Option<f64> {
597 + match typed.trim().parse::<f64>() {
598 + Ok(parsed) if (min..=max).contains(&parsed) => {
599 + self.refused = None;
600 + Some(parsed)
601 + }
602 + _ => {
603 + self.refused = Some(format!("A number between {min_text} and {max_text}."));
604 + self.typing = Some(typed);
605 + None
606 + }
607 + }
608 + }
609 + }
610 +
611 + /// The question a [`number_field`] asks, and the extent it will accept.
612 + ///
613 + /// Deliberately not a `makeover_layout::Field`: the description carries its
614 + /// bounds as text, and the check on submit needs them as numbers. Building the
615 + /// field from these keeps one spelling of each bound, so the message a refusal
616 + /// shows and the rule it was refused by cannot disagree.
617 + pub struct NumberFieldSpec<'a> {
618 + pub name: &'a str,
619 + pub label: &'a str,
620 + pub min: f64,
621 + pub max: f64,
622 + /// How the value is written when the field is showing the app's own, and
623 + /// the precision the bounds read at.
624 + pub decimals: usize,
625 + pub hint: Option<&'a str>,
626 + }
627 +
628 + /// A described `Number` that only answers on submit.
629 + ///
630 + /// Returns whether `value` changed, which is the caller's cue to act: nothing
631 + /// happens on a keystroke, so this is true at most once per submit and is
632 + /// false for a submit that re-entered the value already there.
633 + ///
634 + /// A refused answer stays on screen as the user typed it, with the reason on
635 + /// the field. That is the point of refusing rather than clamping: egui's
636 + /// `DragValue::range` silently rewrites an out-of-range entry to the nearest
637 + /// bound, so a user who meant 400 gets 300 and is never told.
638 + pub fn number_field(
639 + ui: &mut egui::Ui,
640 + spec: &NumberFieldSpec<'_>,
641 + pending: &mut PendingNumber,
642 + value: &mut f64,
643 + state: Option<makeover_layout::State>,
644 + ) -> bool {
645 + let decimals = spec.decimals;
646 + let min = format!("{:.decimals$}", spec.min);
647 + let max = format!("{:.decimals$}", spec.max);
648 + // Cloned out before the field borrows it, so the write-back below is free
649 + // to clear it.
650 + let refused = pending.refused.clone();
651 + let described = makeover_layout::Field {
652 + min: Some(&min),
653 + max: Some(&max),
654 + hint: spec.hint,
655 + error: refused.as_deref(),
656 + ..makeover_layout::Field::new(makeover_layout::FieldKind::Number, spec.name, spec.label)
657 + };
658 +
659 + let shown = pending
660 + .typing
661 + .clone()
662 + .unwrap_or_else(|| format!("{value:.decimals$}"));
663 + let mut text = shown.clone();
664 + let response = field(
665 + ui,
666 + &described,
667 + makeover_immediate::Filling::Text(&mut text),
668 + state,
669 + );
670 + if text != shown {
671 + // Editing clears the refusal rather than leaving it under a value it no
672 + // longer describes.
673 + pending.typing = Some(text);
674 + pending.refused = None;
675 + }
676 +
677 + let Some(response) = response else {
678 + return false;
679 + };
680 + if !response.lost_focus() {
681 + return false;
682 + }
683 + let Some(typed) = pending.typing.take() else {
684 + // Focused and left without typing. Nothing to submit, and no refusal
685 + // either: not answering is not a wrong answer.
686 + return false;
687 + };
688 + let Some(parsed) = pending.submit(typed, spec.min, spec.max, &min, &max) else {
689 + return false;
690 + };
691 + let changed = (parsed - *value).abs() > f64::EPSILON;
692 + *value = parsed;
693 + changed
694 + }
695 +
518 696 /// Inline informational banner: raised card, body text in `content_secondary`.
519 697 /// Used for one-time tips and unobtrusive panel notices.
520 698 pub fn info_banner(ui: &mut egui::Ui, body: &str) {
@@ -1084,6 +1262,103 @@
1084 1262 mod tests {
1085 1263 use super::*;
1086 1264
1265 + // A number that answers on submit
1266 + //
1267 + // `number_field` needs a live `egui::Ui`; the rule it enforces does not,
1268 + // and the rule is the whole reason the type exists.
1269 +
1270 + fn bpm() -> PendingNumber {
1271 + PendingNumber::default()
1272 + }
1273 +
1274 + #[test]
1275 + fn a_number_inside_the_extent_is_accepted_and_clears_the_refusal() {
1276 + let mut pending = bpm();
1277 + pending.refused = Some("stale".to_owned());
1278 + assert_eq!(
1279 + pending.submit("140".to_owned(), 20.0, 300.0, "20", "300"),
1280 + Some(140.0)
1281 + );
1282 + assert!(pending.refused.is_none());
1283 + assert!(pending.typing.is_none());
1284 + }
1285 +
1286 + #[test]
1287 + fn both_ends_of_the_extent_are_answers() {
1288 + let mut pending = bpm();
1289 + assert_eq!(
1290 + pending.submit("20".to_owned(), 20.0, 300.0, "20", "300"),
1291 + Some(20.0)
1292 + );
1293 + assert_eq!(
1294 + pending.submit("300".to_owned(), 20.0, 300.0, "20", "300"),
1295 + Some(300.0)
1296 + );
1297 + }
1298 +
1299 + #[test]
1300 + fn an_out_of_range_answer_is_refused_rather_than_clamped() {
1301 + // The `DragValue` this replaced would have written 300 and said
1302 + // nothing, which is the whole reason for the change.
1303 + let mut pending = bpm();
1304 + assert_eq!(
1305 + pending.submit("400".to_owned(), 20.0, 300.0, "20", "300"),
1306 + None
1307 + );
1308 + assert_eq!(pending.typing.as_deref(), Some("400"));
1309 + assert_eq!(
1310 + pending.refused.as_deref(),
1311 + Some("A number between 20 and 300.")
1312 + );
1313 + }
1314 +
1315 + #[test]
1316 + fn the_half_typed_tempo_never_becomes_an_answer() {
1317 + // "2" on the way to "200". It parses and it is refused, so nothing
1318 + // downstream ever sees a 2 BPM grid.
1319 + let mut pending = bpm();
1320 + assert_eq!(
1321 + pending.submit("2".to_owned(), 20.0, 300.0, "20", "300"),
1322 + None
1323 + );
1324 + assert_eq!(pending.typing.as_deref(), Some("2"));
1325 + }
1326 +
1327 + #[test]
1328 + fn an_unparseable_answer_is_refused_the_same_way_as_an_out_of_range_one() {
1329 + let mut pending = bpm();
1330 + assert_eq!(
1331 + pending.submit(String::new(), 20.0, 300.0, "20", "300"),
1332 + None
1333 + );
1334 + assert_eq!(
1335 + pending.submit("fast".to_owned(), 20.0, 300.0, "20", "300"),
1336 + None
1337 + );
1338 + assert!(pending.refused.is_some());
1339 + }
1340 +
1341 + #[test]
1342 + fn surrounding_whitespace_is_not_a_wrong_answer() {
1343 + let mut pending = bpm();
1344 + assert_eq!(
1345 + pending.submit(" 128 ".to_owned(), 20.0, 300.0, "20", "300"),
1346 + Some(128.0)
1347 + );
1348 + }
1349 +
1350 + #[test]
1351 + fn the_refusal_names_the_bounds_as_the_field_shows_them() {
1352 + // The message and the description read the same two strings, so a
1353 + // field showing "-96.0" cannot be refused by a message saying "-96".
1354 + let mut pending = bpm();
1355 + pending.submit("0".to_owned(), -96.0, -20.0, "-96.0", "-20.0");
1356 + assert_eq!(
1357 + pending.refused.as_deref(),
1358 + Some("A number between -96.0 and -20.0.")
1359 + );
1360 + }
1361 +
1087 1362 // Button styles
1088 1363 //
1089 1364 // The styles are what the vocabulary is made of, and each named helper is