Skip to main content

max / makeover-layout

A slider is a fraction and a mapping Max, 2026-08-21: "the data of a slider is a value 0-1 + a function taking numbers to numbers". `min` and `max` were never the control's extent. A slider's extent is always 0 to 1 and the bounds are f(0) and f(1); linear is the constant-slope case, which is why the mapping was invisible and why four renderers each hard-coded it without anyone deciding to. So `Curve` is not a scale flag bolted onto a range. The question it settles (makeover-layout 45fc1a38) asked whether to name a decoration, and what was unnamed is half of what a slider is. The step spacing rides on the curve, Max in the same breath: if the family is prescriptive anyway the granularity belongs in it. On a slider the two are one decision, and holding them apart is what let a 0-to-1 threshold ship as a two-position control. `Field::step` narrows to the typed kinds, where there is no mapping to decide with. The step is in the value's own units under every curve, so a renderer reading it for display precision is unaffected. A closed family rather than `fn(f64) -> f64`: a fn pointer cannot be emitted into a browser and cannot be compared or hashed meaningfully, which `Field` needs for its derives. Nothing measured wants an arbitrary function. The mapping computes here (`value_at`, `position_of`) rather than in each renderer, so a terminal's bar, an egui slider and a browser's input cannot disagree about where a value sits. `Logarithmic` needs positive bounds and falls back to linear without them, which is not hypothetical: an envelope's sustain is a 0-to-1 level. Additive -- `Curve::Linear { step: None }` is what every range meant before -- so no existing site changes meaning. 127 tests pass, clippy clean, fmt clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-21 17:22 UTC
Signed with PGP, not checked
Commit: 863c03beebc360077206e61cd573616abb2b23cd
Parent: 8eebb8a
2 files changed, +346 insertions, -9 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.31.0"
3 + version = "0.32.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +345 -8
@@ -382,6 +382,38 @@
382 382 //! that one page: audiofiles' library browser and goingson's filters are the
383 383 //! same shape.
384 384 //!
385 + //! 0.32.0 is the slider's real shape. **The data of a slider is a fraction and
386 + //! a function taking numbers to numbers** (Max, 2026-08-21), so [`Curve`]
387 + //! arrives and [`Field::curve`] with it. [`Field::min`] and [`Field::max`] were
388 + //! never the control's extent: a slider's extent is always 0 to 1, and the
389 + //! bounds are `f(0)` and `f(1)`. Linear is the constant-slope case, which is
390 + //! why the mapping was invisible — under it the extent and the bounds coincide
391 + //! numerically — and why four renderers each hard-coded it without anyone
392 + //! deciding to.
393 + //!
394 + //! - It is not a scale flag on a range. The question this replaced asked
395 + //! whether to name a decoration; what was unnamed is half of what a slider
396 + //! *is*, which is why the member is a mapping and not an adjective.
397 + //! - **The step spacing rides on the curve.** Max, in the same breath: if the
398 + //! family is prescriptive anyway, the granularity belongs in it. On a slider
399 + //! the two are one decision, and holding them apart is what let a 0-to-1
400 + //! threshold ship as a two-position control. [`Field::step`] narrows to the
401 + //! *typed* kinds, where there is no mapping to decide with.
402 + //! - A closed family rather than `fn(f64) -> f64`, which is the literal reading
403 + //! and does not cross the description boundary: a fn pointer cannot be
404 + //! emitted into a browser and cannot be compared or hashed meaningfully,
405 + //! which [`Field`] needs. Nothing measured wants an arbitrary function — one
406 + //! non-linear shape across five controls, and no second shape.
407 + //! - The mapping computes here rather than in each renderer
408 + //! ([`Curve::value_at`], [`Curve::position_of`]), so a terminal's bar, an
409 + //! egui slider and a browser's input cannot disagree about where a value
410 + //! sits. This crate otherwise describes rather than computes; four copies of
411 + //! two formulas is the cost of holding that line here.
412 + //!
413 + //! Additive: [`Curve::Linear`] with no step is what every range meant before,
414 + //! so no existing site changes meaning. Consumers: audiofiles' ADSR envelope
415 + //! (three logarithmic times) and its storage cap picker.
416 + //!
385 417 //! 0.31.0 finishes the file field. [`FieldKind::File`] arrived at 0.11.0
386 418 //! carrying neither an accepted-types list nor a multiplicity flag, and said so
387 419 //! in its own doc: `accept` appeared at zero sites in either app, and a member
@@ -3734,6 +3766,162 @@
3734 3766 /// [`max_length`]: Field::max_length
3735 3767 /// [`min`]: Field::min
3736 3768 /// [`max`]: Field::max
3769 + /// How a slider's position becomes its value, and how finely it moves.
3770 + ///
3771 + /// **The data of a slider is a fraction and a function taking numbers to
3772 + /// numbers.** Stated by Max 2026-08-21, and it corrects a reading this crate
3773 + /// had carried since [`FieldKind::Range`] arrived at 0.28.0:
3774 + /// [`min`](Field::min) and [`max`](Field::max) were never the control's extent.
3775 + /// A slider's extent is always 0 to 1 — a thumb at 40% of a track — and the
3776 + /// bounds are `f(0)` and `f(1)`. Linear is the constant-slope case, which is
3777 + /// exactly why nobody noticed the function was there: when `f` is
3778 + /// `min + t * (max - min)` the extent and the bounds coincide numerically and
3779 + /// the mapping is invisible.
3780 + ///
3781 + /// So this is not a scale flag bolted onto a range. Every range described
3782 + /// before it had a mapping, and four renderers each hard-coded the same one.
3783 + ///
3784 + /// # Why a closed family and not a function
3785 + ///
3786 + /// `fn(f64) -> f64` is the literal reading and it does not survive the
3787 + /// description boundary. A fn pointer cannot be emitted into a browser, and it
3788 + /// cannot be compared or hashed in a way that means anything, which this struct
3789 + /// needs. A named family is the same semantics with arbitrary closures given
3790 + /// up, and nothing measured wants one: the tree has a single non-linear shape
3791 + /// across five controls and no second shape at all.
3792 + ///
3793 + /// # Why the step is here
3794 + ///
3795 + /// Max, in the same breath: if the family is prescriptive anyway, the step
3796 + /// spacing belongs in it. On a slider the granularity and the mapping are one
3797 + /// decision — a curve chosen without saying how finely it moves is half an
3798 + /// answer — and holding them apart is what let a 0-to-1 threshold ship as a
3799 + /// two-position control, since the host default of 1 was applied to a mapping
3800 + /// nobody had named. It also un-overloads [`Field::step`], which stays as it
3801 + /// was for a *typed* value, where there is no mapping and the granularity is a
3802 + /// plain fact about the number.
3803 + ///
3804 + /// A future curve carrying a fact of its own — an exponent, an inflection —
3805 + /// puts it in its own variant rather than on the struct, which is the second
3806 + /// reason this shape is right.
3807 + ///
3808 + /// **The step is in the value's own units under every curve.** What a curve
3809 + /// changes is the mapping, not the units the granularity is measured in: a step
3810 + /// of `0.001` on an envelope time is three decimals whether the track is
3811 + /// logarithmic or not, and a renderer that reads the step for display precision
3812 + /// keeps reading it the same way.
3813 + ///
3814 + /// Added 0.32.0, from audiofiles' ADSR envelope and its storage cap picker.
3815 + #[non_exhaustive]
3816 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3817 + pub enum Curve<'a> {
3818 + /// Constant slope: `f(t) = min + t * (max - min)`.
3819 + ///
3820 + /// What every described range meant before this enum existed, and the
3821 + /// default, so a site that says nothing is correct unchanged.
3822 + Linear {
3823 + /// The granularity, in the value's own units. `None` is the host's own.
3824 + step: Option<&'a str>,
3825 + },
3826 + /// Constant ratio: `f(t) = min * (max / min).powf(t)`.
3827 + ///
3828 + /// The mapping for a question whose extent spans orders of magnitude and
3829 + /// whose interesting half is the small end. audiofiles' envelope times run
3830 + /// 0.001 to 5 seconds, where a 5 ms attack and a 50 ms attack are audibly
3831 + /// different instruments and a linear track puts both inside its first one
3832 + /// percent.
3833 + ///
3834 + /// # It needs positive bounds
3835 + ///
3836 + /// A constant ratio is undefined across zero, so this asks for `min > 0`.
3837 + /// A range that does not have that is mapped [`Linear`](Self::Linear)ly
3838 + /// instead — see [`value_at`](Self::value_at). Stated rather than enforced,
3839 + /// the way every other constraint in this crate is, and it is not a
3840 + /// hypothetical: an envelope's sustain is a 0-to-1 level and is linear for
3841 + /// this reason rather than by oversight.
3842 + Logarithmic {
3843 + /// The granularity, in the value's own units. `None` is the host's own.
3844 + step: Option<&'a str>,
3845 + },
3846 + }
3847 +
3848 + impl Default for Curve<'_> {
3849 + fn default() -> Self {
3850 + Self::Linear { step: None }
3851 + }
3852 + }
3853 +
3854 + impl<'a> Curve<'a> {
3855 + /// The granularity this curve moves in, whichever curve it is.
3856 + ///
3857 + /// Every variant carries one, so reading it does not need a match at each
3858 + /// of the four renderers.
3859 + #[must_use]
3860 + pub const fn step(self) -> Option<&'a str> {
3861 + // No wildcard: `#[non_exhaustive]` binds downstream, not here, so a
3862 + // curve added later has to answer this rather than fall through to a
3863 + // granularity nobody chose.
3864 + match self {
3865 + Self::Linear { step } | Self::Logarithmic { step } => step,
3866 + }
3867 + }
3868 +
3869 + /// Whether this curve maps as a constant ratio *given these bounds*.
3870 + ///
3871 + /// The bounds are the argument because [`Logarithmic`](Self::Logarithmic)
3872 + /// is a request rather than a guarantee: it needs `0 < min < max`, and a
3873 + /// range that does not have that is drawn linearly. A renderer asks this
3874 + /// instead of matching on the variant, so the fallback is decided in one
3875 + /// place rather than four.
3876 + #[must_use]
3877 + pub fn is_ratio(self, min: f64, max: f64) -> bool {
3878 + matches!(self, Self::Logarithmic { .. }) && min > 0.0 && max > min
3879 + }
3880 +
3881 + /// The value at a position along the track, where `position` is 0 to 1.
3882 + ///
3883 + /// `f`. The whole point of the type, and it lives here rather than in each
3884 + /// renderer so that a terminal's bar, an egui slider and a browser's input
3885 + /// cannot disagree about where a value sits.
3886 + ///
3887 + /// A position outside 0 to 1 is clamped, and bounds that are equal or
3888 + /// inverted give `min` back: a track with no extent has one value on it.
3889 + #[must_use]
3890 + pub fn value_at(self, position: f64, min: f64, max: f64) -> f64 {
3891 + let position = position.clamp(0.0, 1.0);
3892 + // NaN named rather than fallen through: `max <= min` is false for a NaN
3893 + // bound, so without it a track with no numbers on it would be mapped as
3894 + // if it had two.
3895 + if max <= min || min.is_nan() || max.is_nan() {
3896 + return min;
3897 + }
3898 + if self.is_ratio(min, max) {
3899 + min * (max / min).powf(position)
3900 + } else {
3901 + position.mul_add(max - min, min)
3902 + }
3903 + }
3904 +
3905 + /// The position a value sits at, where the answer is 0 to 1.
3906 + ///
3907 + /// `f` inverted, which is what a renderer needs to *draw* a value it was
3908 + /// handed. Same clamping and the same degenerate answer as
3909 + /// [`value_at`](Self::value_at).
3910 + #[must_use]
3911 + pub fn position_of(self, value: f64, min: f64, max: f64) -> f64 {
3912 + if max <= min || min.is_nan() || max.is_nan() {
3913 + return 0.0;
3914 + }
3915 + let value = value.clamp(min, max);
3916 + let position = if self.is_ratio(min, max) {
3917 + (value / min).ln() / (max / min).ln()
3918 + } else {
3919 + (value - min) / (max - min)
3920 + };
3921 + position.clamp(0.0, 1.0)
3922 + }
3923 + }
3924 +
3737 3925 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3738 3926 pub struct Field<'a> {
3739 3927 /// What kind of value it takes.
@@ -3821,13 +4009,30 @@
3821 4009 /// Absent means the host's own granularity, which is the honest default
3822 4010 /// rather than a missing value: a webview's `<input>` steps by 1 unless told
3823 4011 /// otherwise, and that is the browser's rule and not this crate's to
3824 - /// restate. It matters most to [`FieldKind::Range`], where the host default
3825 - /// turns a 0-to-1 threshold into a two-position control, and it is not
3826 - /// exclusive to it: a stepped [`Number`](FieldKind::Number) is the same fact
3827 - /// about a typed value.
4012 + /// restate.
3828 4013 ///
3829 - /// Added 0.28.0 with [`FieldKind::Range`].
4014 + /// # It is the granularity of a *typed* value
4015 + ///
4016 + /// [`FieldKind::Range`] reads its own from [`curve`](Self::curve) and
4017 + /// ignores this, as of 0.32.0. Until then this member served both, and
4018 + /// serving both is what the split fixes: on a slider the granularity and
4019 + /// the mapping are one decision, and on a typed number there is no mapping
4020 + /// to decide with. See [`Curve`], "Why the step is here".
4021 + ///
4022 + /// Added 0.28.0 with [`FieldKind::Range`], and narrowed away from it at
4023 + /// 0.32.0.
3830 4024 pub step: Option<&'a str>,
4025 + /// How a slider's position becomes its value, and how finely it moves.
4026 + ///
4027 + /// [`FieldKind::Range`]'s, and nothing else reads it: a typed number has a
4028 + /// granularity but no mapping, and takes [`step`](Self::step) instead.
4029 + ///
4030 + /// Defaults to [`Curve::Linear`] with no step, which is what every range
4031 + /// described before 0.32.0 meant, so this member is additive and no
4032 + /// existing site changes meaning.
4033 + ///
4034 + /// Added 0.32.0.
4035 + pub curve: Curve<'a>,
3831 4036 /// Whether the field lives behind a "more options" disclosure.
3832 4037 pub extended: bool,
3833 4038 }
@@ -3851,6 +4056,7 @@
3851 4056 min: None,
3852 4057 max: None,
3853 4058 step: None,
4059 + curve: Curve::Linear { step: None },
3854 4060 extended: false,
3855 4061 }
3856 4062 }
@@ -3863,9 +4069,9 @@
3863 4069 /// has a slider with nothing to slide across. Taking them as arguments is
3864 4070 /// what makes that unsayable.
3865 4071 ///
3866 - /// [`step`](Self::step) stays a field rather than a fourth argument. It is
3867 - /// genuinely optional — the host's granularity is a real answer — and the
3868 - /// two bounds are not.
4072 + /// The granularity stays a field rather than a fourth argument, and since
4073 + /// 0.32.0 it is [`curve`](Self::curve)'s: it is genuinely optional — the
4074 + /// host's own is a real answer — and the two bounds are not.
3869 4075 #[must_use]
3870 4076 pub const fn range(name: &'a str, label: &'a str, min: &'a str, max: &'a str) -> Self {
3871 4077 Self {
@@ -6235,4 +6441,135 @@
6235 6441 assert!(Awaiting::of(40 * 1024 * 1024).is_determinate());
6236 6442 assert_eq!(Awaiting::of(7).amount, Some(7));
6237 6443 }
6444 +
6445 + // A slider is a fraction and a mapping
6446 + //
6447 + // `Curve` is the one thing in this crate that computes rather than
6448 + // describes, and it does so because four renderers would otherwise each
6449 + // write these two formulas and drift. So the formulas are pinned here.
6450 +
6451 + /// The bounds of audiofiles' envelope attack, the curve's first consumer.
6452 + const ATTACK: (f64, f64) = (0.001, 5.0);
6453 +
6454 + #[test]
6455 + fn a_curve_is_linear_with_no_step_until_a_field_says_otherwise() {
6456 + assert_eq!(Curve::default(), Curve::Linear { step: None });
6457 + let plain = Field::range("t", "T", "0", "1");
6458 + assert_eq!(plain.curve, Curve::Linear { step: None });
6459 + assert_eq!(plain.curve.step(), None);
6460 + }
6461 +
6462 + #[test]
6463 + fn every_curve_carries_its_own_granularity() {
6464 + assert_eq!(Curve::Linear { step: Some("0.01") }.step(), Some("0.01"));
6465 + assert_eq!(
6466 + Curve::Logarithmic {
6467 + step: Some("0.001")
6468 + }
6469 + .step(),
6470 + Some("0.001")
6471 + );
6472 + }
6473 +
6474 + #[test]
6475 + fn both_ends_of_the_track_are_the_bounds_under_either_curve() {
6476 + // `min` and `max` are `f(0)` and `f(1)`. That is the whole reframe, and
6477 + // it has to hold for a mapping that is not the identity or the bounds
6478 + // have stopped meaning what the field says they mean.
6479 + let (min, max) = ATTACK;
6480 + for curve in [
6481 + Curve::Linear { step: None },
6482 + Curve::Logarithmic { step: None },
6483 + ] {
6484 + assert!((curve.value_at(0.0, min, max) - min).abs() < 1e-12);
6485 + assert!((curve.value_at(1.0, min, max) - max).abs() < 1e-12);
6486 + }
6487 + }
6488 +
6489 + #[test]
6490 + fn a_linear_midpoint_is_the_average_and_a_ratio_midpoint_is_the_geometric_mean() {
6491 + let (min, max) = ATTACK;
6492 + let linear = Curve::Linear { step: None }.value_at(0.5, min, max);
6493 + assert!((linear - 2.5005).abs() < 1e-9);
6494 +
6495 + // The reason the envelope is not linear: half way along a log track is
6496 + // 70 ms, and half way along a linear one is 2.5 seconds. Every attack a
6497 + // sampler is actually played with lives below the first.
6498 + let ratio = Curve::Logarithmic { step: None }.value_at(0.5, min, max);
6499 + assert!((ratio - (min * max).sqrt()).abs() < 1e-12);
6500 + assert!(ratio < 0.08);
6501 + }
6502 +
6503 + #[test]
6504 + fn a_position_and_a_value_round_trip_under_either_curve() {
6505 + let (min, max) = ATTACK;
6506 + for curve in [
6507 + Curve::Linear { step: None },
6508 + Curve::Logarithmic { step: None },
6509 + ] {
6510 + for position in [0.0, 0.1, 0.25, 0.5, 0.75, 0.99, 1.0] {
6511 + let back = curve.position_of(curve.value_at(position, min, max), min, max);
6512 + assert!(
6513 + (back - position).abs() < 1e-9,
6514 + "{curve:?} lost {position} (got {back})"
6515 + );
6516 + }
6517 + }
6518 + }
6519 +
6520 + #[test]
6521 + fn a_ratio_curve_across_zero_is_drawn_linearly_rather_than_refused() {
6522 + // An envelope's sustain is a 0-to-1 level. A constant ratio is
6523 + // undefined there, and the answer is the linear mapping rather than a
6524 + // NaN reaching a renderer that would paint it.
6525 + let curve = Curve::Logarithmic { step: None };
6526 + assert!(!curve.is_ratio(0.0, 1.0));
6527 + assert!((curve.value_at(0.5, 0.0, 1.0) - 0.5).abs() < 1e-12);
6528 + assert!(curve.value_at(0.5, -96.0, -20.0).is_finite());
6529 + assert!(curve.is_ratio(ATTACK.0, ATTACK.1));
6530 + }
6531 +
6532 + #[test]
6533 + fn a_track_with_no_extent_has_one_value_on_it() {
6534 + for curve in [
6535 + Curve::Linear { step: None },
6536 + Curve::Logarithmic { step: None },
6537 + ] {
6538 + assert!((curve.value_at(0.7, 4.0, 4.0) - 4.0).abs() < f64::EPSILON);
6539 + assert!(curve.position_of(4.0, 4.0, 4.0).abs() < f64::EPSILON);
6540 + // Inverted bounds are the same degenerate answer, not a negative
6541 + // extent a renderer would draw backwards.
6542 + assert!((curve.value_at(0.7, 9.0, 2.0) - 9.0).abs() < f64::EPSILON);
6543 + }
6544 + }
6545 +
6546 + #[test]
6547 + fn a_position_or_a_value_outside_the_track_is_clamped_to_it() {
6548 + let (min, max) = ATTACK;
6549 + let curve = Curve::Logarithmic { step: None };
6550 + assert!((curve.value_at(-3.0, min, max) - min).abs() < 1e-12);
6551 + assert!((curve.value_at(4.0, min, max) - max).abs() < 1e-12);
6552 + assert!(curve.position_of(0.0, min, max).abs() < 1e-12);
6553 + assert!((curve.position_of(500.0, min, max) - 1.0).abs() < 1e-12);
6554 + }
6555 +
6556 + #[test]
6557 + fn a_typed_number_keeps_its_own_step_and_a_range_reads_its_curve() {
6558 + // The split the 0.32.0 narrowing is: two granularities that were one
6559 + // member, and the kinds that take them do not overlap.
6560 + let typed = Field {
6561 + step: Some("5"),
6562 + ..Field::new(FieldKind::Number, "port", "Port")
6563 + };
6564 + assert_eq!(typed.step, Some("5"));
6565 +
6566 + let slid = Field {
6567 + curve: Curve::Logarithmic {
6568 + step: Some("0.001"),
6569 + },
6570 + ..Field::range("attack", "Attack", "0.001", "5")
6571 + };
6572 + assert_eq!(slid.step, None);
6573 + assert_eq!(slid.curve.step(), Some("0.001"));
6574 + }
6238 6575 }