Skip to main content

beamer_core/
parameter_format.rs

1//! Parameter value formatting and parsing.
2//!
3//! This module provides the [`Formatter`] enum for converting between
4//! plain parameter values and display strings. Each formatter variant
5//! handles a specific unit type (dB, Hz, ms, etc.) with appropriate
6//! formatting and parsing logic.
7//!
8//! # Design
9//!
10//! The formatter separates value formatting from unit strings:
11//! - `text()` returns the bare value without units (e.g., "440", "-6.0")
12//! - `unit()` returns the unit string (e.g., "Hz", "dB")
13//! - The host/UI combines them for display (e.g., "440 Hz", "-6.0 dB")
14//!
15//! This separation allows proper VST3/AU parameter info where the units
16//! field is separate from the formatted value string.
17//!
18//! # Example
19//!
20//! ```ignore
21//! use beamer_core::parameter_format::Formatter;
22//!
23//! let db_formatter = Formatter::Decibel { precision: 1 };
24//! assert_eq!(db_formatter.text(1.0), "0.0");   // Value only
25//! assert_eq!(db_formatter.unit(), "dB");       // Unit separately
26//!
27//! let hz_formatter = Formatter::Frequency;
28//! assert_eq!(hz_formatter.text(440.0), "440");
29//! assert_eq!(hz_formatter.text(1500.0), "1.50k");  // Auto-scaled with SI prefix
30//! assert_eq!(hz_formatter.unit(), "Hz");
31//! ```
32
33/// Parameter value formatter.
34///
35/// Defines how plain parameter values are converted to display strings
36/// and parsed back from user input.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum Formatter {
39    /// Generic float with configurable precision (e.g., "1.23").
40    Float {
41        /// Number of decimal places.
42        precision: usize,
43    },
44
45    /// Decibel formatter for gain/level parameters.
46    ///
47    /// Input is linear amplitude (0.0 = silence, 1.0 = unity).
48    /// Format: "-12.0", "-inf" (unit "dB" via `unit()`)
49    Decibel {
50        /// Number of decimal places.
51        precision: usize,
52    },
53
54    /// Direct decibel formatter where input is already in dB.
55    ///
56    /// Used by `FloatParameter::db()` where the plain value is stored as dB.
57    /// Format: "+12.0", "-60.0" (unit "dB" via `unit()`)
58    DecibelDirect {
59        /// Number of decimal places.
60        precision: usize,
61        /// Minimum dB value (below this shows "-inf")
62        min_db: f64,
63    },
64
65    /// Frequency formatter with automatic Hz/kHz scaling.
66    ///
67    /// Format: "440", "1.50k" (unit "Hz" via `unit()`)
68    Frequency,
69
70    /// Milliseconds formatter.
71    ///
72    /// Format: "10.0" (unit "ms" via `unit()`)
73    Milliseconds {
74        /// Number of decimal places.
75        precision: usize,
76    },
77
78    /// Seconds formatter.
79    ///
80    /// Format: "1.50" (unit "s" via `unit()`)
81    Seconds {
82        /// Number of decimal places.
83        precision: usize,
84    },
85
86    /// Percentage formatter.
87    ///
88    /// Input is 0.0-1.0, display is 0-100.
89    /// Format: "75" (unit "%" via `unit()`)
90    Percent {
91        /// Number of decimal places.
92        precision: usize,
93    },
94
95    /// Pan formatter for stereo position.
96    ///
97    /// Input is -1.0 (left) to +1.0 (right).
98    /// Display: "L50", "C", "R50"
99    Pan,
100
101    /// Ratio formatter for compressors.
102    ///
103    /// Display: "4.0:1", "∞:1"
104    Ratio {
105        /// Number of decimal places.
106        precision: usize,
107    },
108
109    /// Semitones formatter for pitch shifting.
110    ///
111    /// Format: "+12", "-7", "0" (unit "st" via `unit()`)
112    Semitones,
113
114    /// Boolean formatter.
115    ///
116    /// Display: "On", "Off"
117    Boolean,
118}
119
120impl Formatter {
121    /// Convert a plain value to a display string (without unit).
122    ///
123    /// The interpretation of `value` depends on the formatter variant:
124    /// - `Decibel`: linear amplitude (1.0 = 0 dB)
125    /// - `Frequency`: Hz
126    /// - `Milliseconds`: ms
127    /// - `Seconds`: s
128    /// - `Percent`: 0.0-1.0 (displayed as 0-100)
129    /// - `Pan`: -1.0 to +1.0
130    /// - `Ratio`: ratio value (4.0 = "4:1")
131    /// - `Semitones`: integer semitones
132    /// - `Boolean`: >0.5 = On, <=0.5 = Off
133    pub fn text(&self, value: f64) -> String {
134        match self {
135            Formatter::Float { precision } => {
136                format!("{:.prec$}", value, prec = *precision)
137            }
138
139            Formatter::Decibel { precision } => {
140                if value < 1e-10 {
141                    "-inf".to_string()
142                } else {
143                    let db = 20.0 * value.log10();
144                    if db >= 0.0 {
145                        format!("+{:.prec$}", db, prec = *precision)
146                    } else {
147                        format!("{:.prec$}", db, prec = *precision)
148                    }
149                }
150            }
151
152            Formatter::DecibelDirect { precision, min_db } => {
153                // Value is already in dB, just format it
154                // Use strict less-than so that min_db itself displays correctly
155                if value < *min_db {
156                    "-inf".to_string()
157                } else if value >= 0.0 {
158                    format!("+{:.prec$}", value, prec = *precision)
159                } else {
160                    format!("{:.prec$}", value, prec = *precision)
161                }
162            }
163
164            Formatter::Frequency => {
165                if value >= 1000.0 {
166                    format!("{:.2}k", value / 1000.0)
167                } else if value >= 100.0 {
168                    format!("{:.0}", value)
169                } else {
170                    format!("{:.1}", value)
171                }
172            }
173
174            Formatter::Milliseconds { precision } => {
175                format!("{:.prec$}", value, prec = *precision)
176            }
177
178            Formatter::Seconds { precision } => {
179                format!("{:.prec$}", value, prec = *precision)
180            }
181
182            Formatter::Percent { precision } => {
183                format!("{:.prec$}", value * 100.0, prec = *precision)
184            }
185
186            Formatter::Pan => {
187                if value.abs() < 0.005 {
188                    "C".to_string()
189                } else if value < 0.0 {
190                    format!("L{:.0}", value.abs() * 100.0)
191                } else {
192                    format!("R{:.0}", value * 100.0)
193                }
194            }
195
196            Formatter::Ratio { precision } => {
197                if value > 100.0 {
198                    "∞:1".to_string()
199                } else {
200                    format!("{:.prec$}:1", value, prec = *precision)
201                }
202            }
203
204            Formatter::Semitones => {
205                let st = value.round() as i64;
206                if st > 0 {
207                    format!("+{}", st)
208                } else {
209                    format!("{}", st)
210                }
211            }
212
213            Formatter::Boolean => {
214                if value > 0.5 {
215                    "On".to_string()
216                } else {
217                    "Off".to_string()
218                }
219            }
220        }
221    }
222
223    /// Parse a display string to a plain value.
224    ///
225    /// Returns `None` if the string cannot be parsed.
226    /// Accepts various formats with or without units.
227    pub fn parse(&self, s: &str) -> Option<f64> {
228        let s = s.trim();
229
230        match self {
231            Formatter::Float { .. } => s.parse().ok(),
232
233            Formatter::Decibel { .. } => {
234                let trimmed = s
235                    .trim_end_matches(" dB")
236                    .trim_end_matches("dB")
237                    .trim();
238
239                if trimmed.eq_ignore_ascii_case("-inf")
240                    || trimmed.eq_ignore_ascii_case("-∞")
241                    || trimmed == "-infinity"
242                {
243                    return Some(0.0);
244                }
245
246                let db: f64 = trimmed.parse().ok()?;
247                Some(10.0_f64.powf(db / 20.0))
248            }
249
250            Formatter::DecibelDirect { min_db, .. } => {
251                // Parse dB value directly (no conversion)
252                let trimmed = s
253                    .trim_end_matches(" dB")
254                    .trim_end_matches("dB")
255                    .trim();
256
257                if trimmed.eq_ignore_ascii_case("-inf")
258                    || trimmed.eq_ignore_ascii_case("-∞")
259                    || trimmed == "-infinity"
260                {
261                    return Some(*min_db);
262                }
263
264                trimmed.parse().ok()
265            }
266
267            Formatter::Frequency => {
268                // Try kHz first
269                if let Some(khz_str) = s
270                    .strip_suffix(" kHz")
271                    .or_else(|| s.strip_suffix("kHz"))
272                    .or_else(|| s.strip_suffix(" khz"))
273                    .or_else(|| s.strip_suffix("khz"))
274                {
275                    return khz_str.trim().parse::<f64>().ok().map(|v| v * 1000.0);
276                }
277
278                // Then Hz
279                let hz_str = s
280                    .trim_end_matches(" Hz")
281                    .trim_end_matches("Hz")
282                    .trim_end_matches(" hz")
283                    .trim_end_matches("hz")
284                    .trim();
285
286                hz_str.parse().ok()
287            }
288
289            Formatter::Milliseconds { .. } => {
290                let trimmed = s
291                    .strip_suffix(" ms")
292                    .or_else(|| s.strip_suffix("ms"))
293                    .unwrap_or(s)
294                    .trim();
295                trimmed.parse().ok()
296            }
297
298            Formatter::Seconds { .. } => {
299                let trimmed = s
300                    .strip_suffix(" s")
301                    .or_else(|| s.strip_suffix("s"))
302                    .unwrap_or(s)
303                    .trim();
304                trimmed.parse().ok()
305            }
306
307            Formatter::Percent { .. } => {
308                let trimmed = s.trim_end_matches('%').trim();
309                trimmed.parse::<f64>().ok().map(|v| v / 100.0)
310            }
311
312            Formatter::Pan => {
313                let s_upper = s.to_uppercase();
314                if s_upper == "C" || s_upper == "CENTER" || s_upper == "0" {
315                    return Some(0.0);
316                }
317
318                if let Some(left) = s_upper.strip_prefix('L') {
319                    return left.trim().parse::<f64>().ok().map(|v| -v / 100.0);
320                }
321
322                if let Some(right) = s_upper.strip_prefix('R') {
323                    return right.trim().parse::<f64>().ok().map(|v| v / 100.0);
324                }
325
326                // Try parsing as raw number (-100 to +100 or -1 to +1)
327                if let Ok(v) = s.parse::<f64>() {
328                    if v.abs() > 1.0 {
329                        return Some(v / 100.0); // Assume -100 to +100
330                    }
331                    return Some(v); // Assume -1 to +1
332                }
333
334                None
335            }
336
337            Formatter::Ratio { .. } => {
338                // Handle infinity
339                if s == "∞:1" || s == "inf:1" || s.eq_ignore_ascii_case("infinity:1") {
340                    return Some(f64::INFINITY);
341                }
342
343                // Strip ":1" suffix
344                let trimmed = s.trim_end_matches(":1").trim();
345                trimmed.parse().ok()
346            }
347
348            Formatter::Semitones => {
349                let trimmed = s.trim_end_matches(" st").trim_end_matches("st").trim();
350                trimmed.parse().ok()
351            }
352
353            Formatter::Boolean => match s.to_lowercase().as_str() {
354                "on" | "true" | "yes" | "1" | "enabled" => Some(1.0),
355                "off" | "false" | "no" | "0" | "disabled" => Some(0.0),
356                _ => None,
357            },
358        }
359    }
360
361    /// Get the unit string for this formatter.
362    pub fn unit(&self) -> &'static str {
363        match self {
364            Formatter::Float { .. } => "",
365            Formatter::Decibel { .. } => "dB",
366            Formatter::DecibelDirect { .. } => "dB",
367            Formatter::Frequency => "Hz",
368            Formatter::Milliseconds { .. } => "ms",
369            Formatter::Seconds { .. } => "s",
370            Formatter::Percent { .. } => "%",
371            Formatter::Pan => "",
372            Formatter::Ratio { .. } => "",
373            Formatter::Semitones => "st",
374            Formatter::Boolean => "",
375        }
376    }
377}
378
379impl Default for Formatter {
380    fn default() -> Self {
381        Formatter::Float { precision: 2 }
382    }
383}
384
385impl Formatter {
386    /// Return a new `Formatter` with updated precision.
387    ///
388    /// For formatter variants that have a `precision` field, this returns
389    /// a new formatter with the updated precision. For variants without
390    /// precision (e.g., `Pan`, `Boolean`, `Semitones`, `Frequency`), this
391    /// returns `self` unchanged.
392    ///
393    /// # Example
394    ///
395    /// ```ignore
396    /// let formatter = Formatter::DecibelDirect { precision: 1, min_db: -60.0 };
397    /// let high_precision = formatter.with_precision(3);
398    /// // high_precision is DecibelDirect { precision: 3, min_db: -60.0 }
399    ///
400    /// let pan = Formatter::Pan;
401    /// let same_pan = pan.with_precision(2);
402    /// // same_pan is still Pan (no precision field)
403    /// ```
404    pub fn with_precision(self, precision: usize) -> Self {
405        match self {
406            Formatter::Float { .. } => Formatter::Float { precision },
407            Formatter::Decibel { .. } => Formatter::Decibel { precision },
408            Formatter::DecibelDirect { min_db, .. } => {
409                Formatter::DecibelDirect { precision, min_db }
410            }
411            Formatter::Milliseconds { .. } => Formatter::Milliseconds { precision },
412            Formatter::Seconds { .. } => Formatter::Seconds { precision },
413            Formatter::Percent { .. } => Formatter::Percent { precision },
414            Formatter::Ratio { .. } => Formatter::Ratio { precision },
415            // Variants without precision return self unchanged
416            Formatter::Frequency
417            | Formatter::Pan
418            | Formatter::Semitones
419            | Formatter::Boolean => self,
420        }
421    }
422
423    /// Check if this formatter variant supports precision customization.
424    ///
425    /// Returns `true` for variants with a `precision` field, `false` otherwise.
426    pub fn supports_precision(&self) -> bool {
427        matches!(
428            self,
429            Formatter::Float { .. }
430                | Formatter::Decibel { .. }
431                | Formatter::DecibelDirect { .. }
432                | Formatter::Milliseconds { .. }
433                | Formatter::Seconds { .. }
434                | Formatter::Percent { .. }
435                | Formatter::Ratio { .. }
436        )
437    }
438
439    /// Get the current precision, if applicable.
440    ///
441    /// Returns `Some(precision)` for variants with a `precision` field,
442    /// `None` otherwise.
443    pub fn precision(&self) -> Option<usize> {
444        match self {
445            Formatter::Float { precision }
446            | Formatter::Decibel { precision }
447            | Formatter::DecibelDirect { precision, .. }
448            | Formatter::Milliseconds { precision }
449            | Formatter::Seconds { precision }
450            | Formatter::Percent { precision }
451            | Formatter::Ratio { precision } => Some(*precision),
452            Formatter::Frequency | Formatter::Pan | Formatter::Semitones | Formatter::Boolean => {
453                None
454            }
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_with_precision_float() {
465        let formatter = Formatter::Float { precision: 2 };
466        let updated = formatter.with_precision(4);
467        assert_eq!(updated.precision(), Some(4));
468        assert_eq!(updated.text(1.2345), "1.2345");
469    }
470
471    #[test]
472    fn test_with_precision_decibel() {
473        let formatter = Formatter::Decibel { precision: 1 };
474        let updated = formatter.with_precision(2);
475        assert_eq!(updated.precision(), Some(2));
476        assert_eq!(updated.text(1.0), "+0.00"); // 0 dB
477    }
478
479    #[test]
480    fn test_with_precision_decibel_direct() {
481        let formatter = Formatter::DecibelDirect {
482            precision: 1,
483            min_db: -60.0,
484        };
485        let updated = formatter.with_precision(3);
486        assert_eq!(updated.precision(), Some(3));
487        // Verify min_db is preserved
488        if let Formatter::DecibelDirect { min_db, precision } = updated {
489            assert_eq!(min_db, -60.0);
490            assert_eq!(precision, 3);
491        } else {
492            panic!("Expected DecibelDirect variant");
493        }
494        assert_eq!(updated.text(-6.5), "-6.500");
495    }
496
497    #[test]
498    fn test_with_precision_milliseconds() {
499        let formatter = Formatter::Milliseconds { precision: 1 };
500        let updated = formatter.with_precision(0);
501        assert_eq!(updated.precision(), Some(0));
502        assert_eq!(updated.text(10.5), "10"); // Rounded to 0 decimal places
503    }
504
505    #[test]
506    fn test_with_precision_seconds() {
507        let formatter = Formatter::Seconds { precision: 2 };
508        let updated = formatter.with_precision(3);
509        assert_eq!(updated.precision(), Some(3));
510        assert_eq!(updated.text(1.5), "1.500");
511    }
512
513    #[test]
514    fn test_with_precision_percent() {
515        let formatter = Formatter::Percent { precision: 0 };
516        let updated = formatter.with_precision(1);
517        assert_eq!(updated.precision(), Some(1));
518        assert_eq!(updated.text(0.755), "75.5"); // 0.755 * 100 = 75.5
519    }
520
521    #[test]
522    fn test_with_precision_ratio() {
523        let formatter = Formatter::Ratio { precision: 1 };
524        let updated = formatter.with_precision(2);
525        assert_eq!(updated.precision(), Some(2));
526        assert_eq!(updated.text(4.0), "4.00:1");
527    }
528
529    #[test]
530    fn test_with_precision_no_effect_on_frequency() {
531        let formatter = Formatter::Frequency;
532        let updated = formatter.with_precision(5);
533        assert_eq!(updated, Formatter::Frequency);
534        assert_eq!(updated.precision(), None);
535    }
536
537    #[test]
538    fn test_with_precision_no_effect_on_pan() {
539        let formatter = Formatter::Pan;
540        let updated = formatter.with_precision(3);
541        assert_eq!(updated, Formatter::Pan);
542        assert_eq!(updated.precision(), None);
543    }
544
545    #[test]
546    fn test_with_precision_no_effect_on_semitones() {
547        let formatter = Formatter::Semitones;
548        let updated = formatter.with_precision(2);
549        assert_eq!(updated, Formatter::Semitones);
550        assert_eq!(updated.precision(), None);
551    }
552
553    #[test]
554    fn test_with_precision_no_effect_on_boolean() {
555        let formatter = Formatter::Boolean;
556        let updated = formatter.with_precision(1);
557        assert_eq!(updated, Formatter::Boolean);
558        assert_eq!(updated.precision(), None);
559    }
560
561    #[test]
562    fn test_supports_precision() {
563        assert!(Formatter::Float { precision: 2 }.supports_precision());
564        assert!(Formatter::Decibel { precision: 1 }.supports_precision());
565        assert!(Formatter::DecibelDirect {
566            precision: 1,
567            min_db: -60.0
568        }
569        .supports_precision());
570        assert!(Formatter::Milliseconds { precision: 1 }.supports_precision());
571        assert!(Formatter::Seconds { precision: 2 }.supports_precision());
572        assert!(Formatter::Percent { precision: 0 }.supports_precision());
573        assert!(Formatter::Ratio { precision: 1 }.supports_precision());
574
575        assert!(!Formatter::Frequency.supports_precision());
576        assert!(!Formatter::Pan.supports_precision());
577        assert!(!Formatter::Semitones.supports_precision());
578        assert!(!Formatter::Boolean.supports_precision());
579    }
580
581    #[test]
582    fn test_precision_getter() {
583        assert_eq!(Formatter::Float { precision: 3 }.precision(), Some(3));
584        assert_eq!(Formatter::Decibel { precision: 2 }.precision(), Some(2));
585        assert_eq!(
586            Formatter::DecibelDirect {
587                precision: 1,
588                min_db: -60.0
589            }
590            .precision(),
591            Some(1)
592        );
593        assert_eq!(Formatter::Frequency.precision(), None);
594        assert_eq!(Formatter::Pan.precision(), None);
595    }
596}