Skip to main content

max / makeover-immediate

0.33.0: an interval is a sixth control shape `makeover-layout` 0.34.0's `FieldKind::Interval`, drawn as `Control::Spanned`: two drag boxes on one row with the word `to` between them. `Filling::Between` carries the two buffers. Dragged rather than typed, because that is what these controls already were. audiofiles' six filter axes are DragValue pairs sharing an extent, a speed and a suffix, and describing them into two text boxes would be a port that cost the app a control. One row, not two wells stacked. Two wells are two questions on screen whatever the description says, and the arrangement is the whole content of the kind. An empty end reads as the bound it stands for. An unset minimum sits on the low edge and stores no filter, which is what the shipped control did; DragValue has no empty state, and a text box in its place would be the regression above. With no extent to fall back on it reads zero -- the one number this renderer invents, invented where the description declined to say anything. The word rather than a dash, which on a signed axis is a minus sign. audiofiles filters loudness in dBFS. `Axis` holds the four facts both boxes share, because they are one axis: reading min, max, step and unit once is what stops the two ends drifting apart.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 01:49 UTC
Signed with PGP, not checked
Commit: 135bb37cb88b060e6f73916f2bf6c1d0fd1c2d65
Parent: 8572198
2 files changed, +217 insertions, -4 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.32.0"
3 + version = "0.33.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for makeover-layout. Immediate mode is the constraint that matters, not the library: no cascade, no retained tree, one stroke per widget. Backed by egui."
6 6 license = "MIT"
@@ -12,7 +12,7 @@
12 12 # Exact patch rather than the minor, as the rest of the suite pins: a
13 13 # minor-only requirement is satisfied by a consumer lock holding an earlier
14 14 # patch, which then fails to compile against an API added in a later one.
15 - makeover-layout = "0.33.0"
15 + makeover-layout = "0.34.0"
16 16
17 17 [lints.rust]
18 18 unused = "warn"
M src/lib.rs +215 -2
@@ -154,6 +154,31 @@
154 154 //! looks for it, and because the silent version reads as "egui does not need
155 155 //! a palette" rather than "nobody has built the renderer yet".
156 156
157 + //! # 0.33.0: an interval is a sixth control shape
158 + //!
159 + //! `makeover-layout` 0.34.0's [`FieldKind::Interval`], drawn as `Control::Spanned`:
160 + //! two drag boxes on one row with the word `to` between them.
161 + //!
162 + //! - **Dragged rather than typed**, because that is what these controls already
163 + //! were. audiofiles' six filter axes are `DragValue` pairs sharing an extent,
164 + //! a speed and a suffix, and describing them into two text boxes would be a
165 + //! port that cost the app a control.
166 + //! - **One row, not two wells stacked.** Two wells are two questions on screen
167 + //! whatever the description says, and the arrangement is the whole content of
168 + //! the kind.
169 + //! - **An empty end reads as the bound it stands for.** An unset minimum sits
170 + //! on the low edge and stores no filter, which is what the shipped control
171 + //! did; egui's `DragValue` has no empty state, and a text box in its place
172 + //! would be the regression above. With no extent to fall back on it reads
173 + //! zero -- the one number this renderer invents, invented where the
174 + //! description declined to say anything.
175 + //! - **The word rather than a dash**, which on a signed axis is a minus sign.
176 + //! audiofiles filters loudness in dBFS.
177 + //!
178 + //! `Axis` holds the four facts both boxes share, because they are one axis:
179 + //! reading `min`, `max`, `step` and `unit` once is what stops the two ends
180 + //! drifting apart.
181 + //!
157 182 //! # 0.32.0: a number draws its unit
158 183 //!
159 184 //! `makeover-layout` 0.33.0's [`Field::unit`], and this host is the one the
@@ -212,8 +237,8 @@
212 237 #![forbid(unsafe_code)]
213 238
214 239 use egui::{
215 - Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Slider,
216 - Stroke, TextEdit, Ui,
240 + Color32, ComboBox, CornerRadius, DragValue, Margin, Painter, Rect, Response, RichText, Shape,
241 + Slider, Stroke, TextEdit, Ui,
217 242 };
218 243 use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State, Tone};
219 244 use std::ops::RangeInclusive;
@@ -585,6 +610,23 @@
585 610 Text(&'a mut String),
586 611 /// A checkbox, on or off.
587 612 On(&'a mut bool),
613 + /// The two buffers behind a [`FieldKind::Interval`], lower first.
614 + ///
615 + /// Two buffers rather than one string with a separator, which is
616 + /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
617 + /// interval is submitted under two names, so it is edited as two values,
618 + /// and a delimiter this crate owned could appear inside either of them.
619 + ///
620 + /// Either end may be empty while the other stands. An open end is an
621 + /// answer -- "over 120 BPM" -- rather than a half-filled box.
622 + ///
623 + /// Added 0.33.0 with makeover-layout 0.34.0.
624 + Between {
625 + /// The lower end's buffer.
626 + lower: &'a mut String,
627 + /// The upper end's buffer.
628 + upper: &'a mut String,
629 + },
588 630 }
589 631
590 632 /// The label, marked if the field is compulsory.
@@ -626,6 +668,13 @@
626 668 /// question means, so a well with a figure in it is not a quieter version
627 669 /// of this control, it is a different one.
628 670 Slid,
671 + /// Two values dragged across one axis, drawn as one question.
672 + ///
673 + /// Apart from [`Typed`](Self::Typed) for the reason
674 + /// [`FieldKind::Interval`] is apart from `Number`: two wells one under the
675 + /// other are two questions on screen, whatever the description says, and
676 + /// the arrangement is the whole content of the kind.
677 + Spanned,
629 678 }
630 679
631 680 /// Which shape a kind takes.
@@ -663,6 +712,7 @@
663 712 FieldKind::Radio => Control::Listed,
664 713 FieldKind::Checkbox => Control::Toggled,
665 714 FieldKind::Range => Control::Slid,
715 + FieldKind::Interval => Control::Spanned,
666 716 _ => Control::Typed,
667 717 }
668 718 }
@@ -787,6 +837,93 @@
787 837 }
788 838 }
789 839
840 + /// Which end of an interval a box is.
841 + ///
842 + /// Named rather than a bool, because what it selects is not a side but a
843 + /// fallback: an empty end reads as the bound it stands for, and which bound
844 + /// that is depends on the end.
845 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
846 + enum Bound {
847 + /// The lower end, falling back to the start of the extent.
848 + Low,
849 + /// The upper end, falling back to its end.
850 + High,
851 + }
852 +
853 + /// The facts an interval's two boxes share.
854 + ///
855 + /// One struct because they are one axis: [`Field::min`], [`Field::max`],
856 + /// [`Field::step`] and [`Field::unit`] describe the question rather than either
857 + /// end of it, so reading them once is what stops the two boxes drifting apart.
858 + struct Axis<'a> {
859 + /// The extent both ends are dragged inside, when it is one this host can
860 + /// read.
861 + extent: Option<RangeInclusive<f64>>,
862 + /// The granularity, as the description writes it.
863 + step: Option<&'a str>,
864 + /// What the axis is measured in.
865 + unit: Option<&'a str>,
866 + }
867 +
868 + impl Axis<'_> {
869 + /// What an empty end reads as: the bound it stands for.
870 + ///
871 + /// Zero with no extent to fall back on. That is the one number this
872 + /// renderer invents, and it invents it where the description declined to
873 + /// say anything: an unbounded interval has no edge for the end to sit on,
874 + /// and a drag box has to start somewhere.
875 + fn edge(&self, which: Bound) -> f64 {
876 + self.extent.as_ref().map_or(0.0, |extent| match which {
877 + Bound::Low => *extent.start(),
878 + Bound::High => *extent.end(),
879 + })
880 + }
881 +
882 + /// One end of the interval, as a drag box.
883 + ///
884 + /// # An empty end reads as its bound
885 + ///
886 + /// Which is what the shipped control did before it was described: an unset
887 + /// minimum sits on the low edge and stores no filter. egui's `DragValue`
888 + /// holds a number and has no empty state to offer, so the alternative was a
889 + /// text box, and that would cost the app a control on the way into being
890 + /// described.
891 + ///
892 + /// With no extent to fall back on, an empty end reads zero. That is the one
893 + /// number this renderer invents, and it invents it where the description
894 + /// declined to say anything: an unbounded interval has no edge for the end
895 + /// to sit on, and a drag box has to start somewhere.
896 + ///
897 + /// Nothing is written back until the user drags, so a value the app put
898 + /// there survives being looked at -- the same guarantee the slider makes.
899 + fn end(&self, ui: &mut Ui, value: &mut String, which: Bound) -> Response {
900 + let mut number = value.parse::<f64>().unwrap_or(self.edge(which));
901 + let mut drag = DragValue::new(&mut number);
902 + if let Some(extent) = self.extent.clone() {
903 + drag = drag.range(extent);
904 + }
905 + if let Some(places) = decimals(self.step) {
906 + drag = drag.max_decimals(places);
907 + }
908 + if let Some(step) = self.step.and_then(|s| s.parse::<f64>().ok()) {
909 + drag = drag.speed(step);
910 + }
911 + // Inside the control, beside the readout, which is where `Field::unit`
912 + // was decided to belong and where these boxes already put it.
913 + if let Some(unit) = self.unit {
914 + drag = drag.suffix(format!(" {unit}"));
915 + }
916 + let response = ui.add(drag);
917 + if response.changed() {
918 + *value = match decimals(self.step) {
919 + Some(places) => format!("{number:.places$}"),
920 + None => number.to_string(),
921 + };
922 + }
923 + response
924 + }
925 + }
926 +
790 927 /// The control alone, without its label, hint or error.
791 928 fn control(
792 929 ui: &mut Ui,
@@ -800,6 +937,9 @@
800 937 // described — visible on screen, in the way an empty select is at the
801 938 // webview renderer, rather than reported in a log nobody reads.
802 939 let mut discard = String::new();
940 + // The interval's second scratch buffer. Two ends means the mismatch path
941 + // needs two places to write nothing to.
942 + let mut spare = String::new();
803 943 let mut off = false;
804 944
805 945 match shape_of(field) {
@@ -855,6 +995,35 @@
855 995 }
856 996 response
857 997 }
998 + // One question, so one row. Two wells stacked would be two questions on
999 + // screen whatever the description said, which is the reading
1000 + // `FieldKind::Interval` exists to prevent.
1001 + //
1002 + // Dragged rather than typed, because that is what these controls
1003 + // already were: audiofiles' six filter axes are `DragValue` pairs with
1004 + // a shared extent, a speed and a suffix, and a port that turned them
1005 + // into text boxes would be a description costing the app a control.
1006 + Control::Spanned => {
1007 + let (lower, upper) = match filling {
1008 + Filling::Between { lower, upper } => (lower, upper),
1009 + _ => (&mut discard, &mut spare),
1010 + };
1011 + let axis = Axis {
1012 + extent: extent(field),
1013 + step: field.step,
1014 + unit: unit_of(field),
1015 + };
1016 + ui.horizontal(|ui| {
1017 + let low = axis.end(ui, lower, Bound::Low);
1018 + // The word rather than a dash. A dash between two numbers is a
1019 + // minus sign on a signed axis, and audiofiles filters loudness
1020 + // in dBFS.
1021 + ui.label(RichText::new("to").color(palette.content_secondary));
1022 + let high = axis.end(ui, upper, Bound::High);
1023 + low | high
1024 + })
1025 + .inner
1026 + }
858 1027 Control::Typed => {
859 1028 let text = match filling {
860 1029 Filling::Text(text) => text,
@@ -1312,6 +1481,50 @@
1312 1481 assert_eq!(extent(&dated), None);
1313 1482 }
1314 1483
1484 + #[test]
1485 + fn an_interval_is_its_own_shape_and_not_two_numbers() {
1486 + // The distinction the kind was added for, at the renderer that has to
1487 + // arrange it: two wells stacked are two questions on screen, whatever
1488 + // the description says.
1489 + assert_eq!(control_shape(FieldKind::Interval), Control::Spanned);
1490 + assert_eq!(shape_of(&Field::interval("a", "b", "A")), Control::Spanned);
1491 +
1492 + // Unlike a range, it owes no extent: its bounds are a rule on each end
1493 + // rather than the control, so a missing one is an open end.
1494 + let axis = Field {
1495 + min: Some("0"),
1496 + max: Some("300"),
1497 + ..Field::interval("bpm_min", "bpm_max", "BPM")
1498 + };
1499 + assert_eq!(shape_of(&axis), Control::Spanned);
1500 + }
1501 +
1502 + #[test]
1503 + fn an_empty_end_reads_as_the_bound_it_stands_for() {
1504 + // Which is what the shipped control did before it was described: an
1505 + // unset minimum sits on the low edge and stores no filter. `DragValue`
1506 + // has no empty state, and a text box instead would cost the app a
1507 + // control on the way into being described.
1508 + let axis = Axis {
1509 + extent: Some(0.0..=300.0),
1510 + step: Some("1"),
1511 + unit: Some("BPM"),
1512 + };
1513 + assert!((axis.edge(Bound::Low) - 0.0).abs() < f64::EPSILON);
1514 + assert!((axis.edge(Bound::High) - 300.0).abs() < f64::EPSILON);
1515 +
1516 + // With no extent there is no edge to sit on, and a drag box has to
1517 + // start somewhere. The one number this renderer invents, invented where
1518 + // the description declined to say anything.
1519 + let open = Axis {
1520 + extent: None,
1521 + step: None,
1522 + unit: None,
1523 + };
1524 + assert!((open.edge(Bound::Low) - 0.0).abs() < f64::EPSILON);
1525 + assert!((open.edge(Bound::High) - 0.0).abs() < f64::EPSILON);
1526 + }
1527 +
1315 1528 #[test]
1316 1529 fn the_step_decides_how_a_dragged_value_is_written_back() {
1317 1530 // Without it a 0-to-1 threshold writes back whatever float the drag