Skip to main content

waterkit_core/
units.rs

1//! Typed scalar wrappers with validated value ranges.
2//!
3//! Each newtype enforces its bounds at construction time and exposes a
4//! plain getter. Use these instead of bare `f32` / `f64` for parameters
5//! that have a real source of truth (display brightness in `0..=1`, audio
6//! pan in `-1..=1`, geographic latitude in `-90..=90`, etc.).
7//!
8//! All wrappers in this module:
9//! - implement `Debug`, `Clone`, `Copy`, `PartialEq`, `PartialOrd`;
10//! - expose `MIN` / `MAX` consts;
11//! - return `Err(OutOfRange)` from `new` for `NaN` / out-of-range input;
12//! - expose `new_unchecked` for compile-time constants.
13
14use core::fmt;
15use thiserror::Error;
16
17/// Error returned by unit constructors when the input is out of range.
18#[derive(Debug, Clone, Error)]
19#[error("value {value} is out of range {range}")]
20pub struct OutOfRange {
21    /// The offending value (widened to `f64` for uniform display).
22    pub value: f64,
23    /// Human description of the valid range.
24    pub range: &'static str,
25}
26
27macro_rules! ranged_unit {
28    (
29        $(#[$meta:meta])*
30        name = $name:ident,
31        repr = $repr:ty,
32        min = $min:expr,
33        max = $max:expr,
34        range_doc = $range_doc:literal $(,)?
35    ) => {
36        $(#[$meta])*
37        #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
38        pub struct $name($repr);
39
40        impl $name {
41            /// Lower bound of the valid range.
42            pub const MIN: Self = Self($min);
43            /// Upper bound of the valid range.
44            pub const MAX: Self = Self($max);
45
46            /// Constructs a new value, validating the range.
47            ///
48            /// # Errors
49            ///
50            /// Returns [`OutOfRange`] if `value` is `NaN` or outside the
51            /// valid range.
52            pub fn new(value: $repr) -> Result<Self, OutOfRange> {
53                if !value.is_finite() || value < $min || value > $max {
54                    return Err(OutOfRange {
55                        value: f64::from(value),
56                        range: $range_doc,
57                    });
58                }
59                Ok(Self(value))
60            }
61
62            /// Constructs without validation.
63            ///
64            /// The caller must ensure `value` lies within the valid range.
65            #[must_use]
66            pub const fn new_unchecked(value: $repr) -> Self {
67                Self(value)
68            }
69
70            /// Inner numeric value.
71            #[must_use]
72            pub const fn get(self) -> $repr {
73                self.0
74            }
75        }
76
77        impl fmt::Display for $name {
78            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79                fmt::Display::fmt(&self.0, f)
80            }
81        }
82    };
83}
84
85ranged_unit! {
86    /// Display brightness, normalized `0.0..=1.0`.
87    name = Brightness,
88    repr = f32,
89    min = 0.0,
90    max = 1.0,
91    range_doc = "0.0..=1.0",
92}
93
94ranged_unit! {
95    /// Audio output volume, normalized `0.0..=1.0`.
96    name = Volume,
97    repr = f32,
98    min = 0.0,
99    max = 1.0,
100    range_doc = "0.0..=1.0",
101}
102
103ranged_unit! {
104    /// Stereo panning, `-1.0` (full left) ..= `1.0` (full right).
105    name = Pan,
106    repr = f32,
107    min = -1.0,
108    max = 1.0,
109    range_doc = "-1.0..=1.0",
110}
111
112ranged_unit! {
113    /// Audio playback rate multiplier; `1.0` is normal speed. Values
114    /// `0.25..=4.0` cover all reasonable use cases on every platform.
115    name = PlaybackRate,
116    repr = f32,
117    min = 0.25,
118    max = 4.0,
119    range_doc = "0.25..=4.0",
120}
121
122ranged_unit! {
123    /// Pitch multiplier; `1.0` is unchanged.
124    name = Pitch,
125    repr = f32,
126    min = 0.5,
127    max = 2.0,
128    range_doc = "0.5..=2.0",
129}
130
131ranged_unit! {
132    /// Camera or display zoom factor; `1.0` is no zoom. Upper bound `100.0`
133    /// covers known device caps.
134    name = Zoom,
135    repr = f32,
136    min = 1.0,
137    max = 100.0,
138    range_doc = "1.0..=100.0",
139}
140
141ranged_unit! {
142    /// Geographic latitude in degrees, `-90.0..=90.0`.
143    name = Latitude,
144    repr = f64,
145    min = -90.0,
146    max = 90.0,
147    range_doc = "-90.0..=90.0",
148}
149
150ranged_unit! {
151    /// Geographic longitude in degrees, `-180.0..=180.0`.
152    name = Longitude,
153    repr = f64,
154    min = -180.0,
155    max = 180.0,
156    range_doc = "-180.0..=180.0",
157}
158
159ranged_unit! {
160    /// Display refresh rate in Hz, `1.0..=480.0`. Upper bound exceeds any
161    /// shipping consumer panel.
162    name = RefreshRate,
163    repr = f32,
164    min = 1.0,
165    max = 480.0,
166    range_doc = "1.0..=480.0",
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn brightness_accepts_in_range() {
175        assert!(Brightness::new(0.0).is_ok());
176        assert!(Brightness::new(1.0).is_ok());
177        assert!(Brightness::new(0.5).is_ok());
178    }
179
180    #[test]
181    fn brightness_rejects_out_of_range() {
182        assert!(Brightness::new(-0.1).is_err());
183        assert!(Brightness::new(1.1).is_err());
184        assert!(Brightness::new(f32::NAN).is_err());
185        assert!(Brightness::new(f32::INFINITY).is_err());
186    }
187
188    #[test]
189    fn pan_accepts_negative_range() {
190        assert!(Pan::new(-1.0).is_ok());
191        assert!(Pan::new(0.0).is_ok());
192        assert!(Pan::new(1.0).is_ok());
193        assert!(Pan::new(-1.001).is_err());
194    }
195
196    #[test]
197    fn min_max_consts_are_consistent() {
198        assert!((Brightness::MIN.get() - 0.0).abs() < f32::EPSILON);
199        assert!((Brightness::MAX.get() - 1.0).abs() < f32::EPSILON);
200        assert!((Pan::MIN.get() - -1.0).abs() < f32::EPSILON);
201        assert!((Pan::MAX.get() - 1.0).abs() < f32::EPSILON);
202    }
203}