| 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 |
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
|