beamer_core/
param_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//! # Example
9//!
10//! ```ignore
11//! use beamer_core::param_format::Formatter;
12//!
13//! let db_formatter = Formatter::Decibel { precision: 1 };
14//! assert_eq!(db_formatter.format(1.0), "0.0 dB");  // 1.0 linear = 0 dB
15//! assert_eq!(db_formatter.format(0.5), "-6.0 dB"); // 0.5 linear ≈ -6 dB
16//!
17//! let hz_formatter = Formatter::Frequency;
18//! assert_eq!(hz_formatter.format(440.0), "440 Hz");
19//! assert_eq!(hz_formatter.format(1500.0), "1.50 kHz");
20//! ```
21
22/// Parameter value formatter.
23///
24/// Defines how plain parameter values are converted to display strings
25/// and parsed back from user input.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum Formatter {
28    /// Generic float with configurable precision (e.g., "1.23").
29    Float {
30        /// Number of decimal places.
31        precision: usize,
32    },
33
34    /// Decibel formatter for gain/level parameters.
35    ///
36    /// Input is linear amplitude (0.0 = silence, 1.0 = unity).
37    /// Display: "-12.0 dB", "-inf dB"
38    Decibel {
39        /// Number of decimal places.
40        precision: usize,
41    },
42
43    /// Direct decibel formatter where input is already in dB.
44    ///
45    /// Used by `FloatParam::db()` where the plain value is stored as dB.
46    /// Display: "+12.0 dB", "-60.0 dB"
47    DecibelDirect {
48        /// Number of decimal places.
49        precision: usize,
50        /// Minimum dB value (below this shows "-inf dB")
51        min_db: f64,
52    },
53
54    /// Frequency formatter with automatic Hz/kHz scaling.
55    ///
56    /// Display: "440 Hz", "1.50 kHz"
57    Frequency,
58
59    /// Milliseconds formatter.
60    ///
61    /// Display: "10.0 ms"
62    Milliseconds {
63        /// Number of decimal places.
64        precision: usize,
65    },
66
67    /// Seconds formatter.
68    ///
69    /// Display: "1.50 s"
70    Seconds {
71        /// Number of decimal places.
72        precision: usize,
73    },
74
75    /// Percentage formatter.
76    ///
77    /// Input is 0.0-1.0, display is 0%-100%.
78    /// Display: "75%"
79    Percent {
80        /// Number of decimal places.
81        precision: usize,
82    },
83
84    /// Pan formatter for stereo position.
85    ///
86    /// Input is -1.0 (left) to +1.0 (right).
87    /// Display: "L50", "C", "R50"
88    Pan,
89
90    /// Ratio formatter for compressors.
91    ///
92    /// Display: "4.0:1", "∞:1"
93    Ratio {
94        /// Number of decimal places.
95        precision: usize,
96    },
97
98    /// Semitones formatter for pitch shifting.
99    ///
100    /// Display: "+12 st", "-7 st", "0 st"
101    Semitones,
102
103    /// Boolean formatter.
104    ///
105    /// Display: "On", "Off"
106    Boolean,
107}
108
109impl Formatter {
110    /// Format a plain value to a display string.
111    ///
112    /// The interpretation of `value` depends on the formatter variant:
113    /// - `Decibel`: linear amplitude (1.0 = 0 dB)
114    /// - `Frequency`: Hz
115    /// - `Milliseconds`: ms
116    /// - `Seconds`: s
117    /// - `Percent`: 0.0-1.0 (displayed as 0%-100%)
118    /// - `Pan`: -1.0 to +1.0
119    /// - `Ratio`: ratio value (4.0 = "4:1")
120    /// - `Semitones`: integer semitones
121    /// - `Boolean`: >0.5 = On, <=0.5 = Off
122    pub fn format(&self, value: f64) -> String {
123        match self {
124            Formatter::Float { precision } => {
125                format!("{:.prec$}", value, prec = *precision)
126            }
127
128            Formatter::Decibel { precision } => {
129                if value < 1e-10 {
130                    "-inf dB".to_string()
131                } else {
132                    let db = 20.0 * value.log10();
133                    if db >= 0.0 {
134                        format!("+{:.prec$} dB", db, prec = *precision)
135                    } else {
136                        format!("{:.prec$} dB", db, prec = *precision)
137                    }
138                }
139            }
140
141            Formatter::DecibelDirect { precision, min_db } => {
142                // Value is already in dB, just format it
143                // Use strict less-than so that min_db itself displays correctly
144                if value < *min_db {
145                    "-inf dB".to_string()
146                } else if value >= 0.0 {
147                    format!("+{:.prec$} dB", value, prec = *precision)
148                } else {
149                    format!("{:.prec$} dB", value, prec = *precision)
150                }
151            }
152
153            Formatter::Frequency => {
154                if value >= 1000.0 {
155                    format!("{:.2} kHz", value / 1000.0)
156                } else if value >= 100.0 {
157                    format!("{:.0} Hz", value)
158                } else {
159                    format!("{:.1} Hz", value)
160                }
161            }
162
163            Formatter::Milliseconds { precision } => {
164                format!("{:.prec$} ms", value, prec = *precision)
165            }
166
167            Formatter::Seconds { precision } => {
168                format!("{:.prec$} s", value, prec = *precision)
169            }
170
171            Formatter::Percent { precision } => {
172                format!("{:.prec$}%", value * 100.0, prec = *precision)
173            }
174
175            Formatter::Pan => {
176                if value.abs() < 0.005 {
177                    "C".to_string()
178                } else if value < 0.0 {
179                    format!("L{:.0}", value.abs() * 100.0)
180                } else {
181                    format!("R{:.0}", value * 100.0)
182                }
183            }
184
185            Formatter::Ratio { precision } => {
186                if value > 100.0 {
187                    "∞:1".to_string()
188                } else {
189                    format!("{:.prec$}:1", value, prec = *precision)
190                }
191            }
192
193            Formatter::Semitones => {
194                let st = value.round() as i64;
195                if st > 0 {
196                    format!("+{} st", st)
197                } else {
198                    format!("{} st", st)
199                }
200            }
201
202            Formatter::Boolean => {
203                if value > 0.5 {
204                    "On".to_string()
205                } else {
206                    "Off".to_string()
207                }
208            }
209        }
210    }
211
212    /// Parse a display string to a plain value.
213    ///
214    /// Returns `None` if the string cannot be parsed.
215    /// Accepts various formats with or without units.
216    pub fn parse(&self, s: &str) -> Option<f64> {
217        let s = s.trim();
218
219        match self {
220            Formatter::Float { .. } => s.parse().ok(),
221
222            Formatter::Decibel { .. } => {
223                let trimmed = s
224                    .trim_end_matches(" dB")
225                    .trim_end_matches("dB")
226                    .trim();
227
228                if trimmed.eq_ignore_ascii_case("-inf")
229                    || trimmed.eq_ignore_ascii_case("-∞")
230                    || trimmed == "-infinity"
231                {
232                    return Some(0.0);
233                }
234
235                let db: f64 = trimmed.parse().ok()?;
236                Some(10.0_f64.powf(db / 20.0))
237            }
238
239            Formatter::DecibelDirect { min_db, .. } => {
240                // Parse dB value directly (no conversion)
241                let trimmed = s
242                    .trim_end_matches(" dB")
243                    .trim_end_matches("dB")
244                    .trim();
245
246                if trimmed.eq_ignore_ascii_case("-inf")
247                    || trimmed.eq_ignore_ascii_case("-∞")
248                    || trimmed == "-infinity"
249                {
250                    return Some(*min_db);
251                }
252
253                trimmed.parse().ok()
254            }
255
256            Formatter::Frequency => {
257                // Try kHz first
258                if let Some(khz_str) = s
259                    .strip_suffix(" kHz")
260                    .or_else(|| s.strip_suffix("kHz"))
261                    .or_else(|| s.strip_suffix(" khz"))
262                    .or_else(|| s.strip_suffix("khz"))
263                {
264                    return khz_str.trim().parse::<f64>().ok().map(|v| v * 1000.0);
265                }
266
267                // Then Hz
268                let hz_str = s
269                    .trim_end_matches(" Hz")
270                    .trim_end_matches("Hz")
271                    .trim_end_matches(" hz")
272                    .trim_end_matches("hz")
273                    .trim();
274
275                hz_str.parse().ok()
276            }
277
278            Formatter::Milliseconds { .. } => {
279                let trimmed = s
280                    .strip_suffix(" ms")
281                    .or_else(|| s.strip_suffix("ms"))
282                    .unwrap_or(s)
283                    .trim();
284                trimmed.parse().ok()
285            }
286
287            Formatter::Seconds { .. } => {
288                let trimmed = s
289                    .strip_suffix(" s")
290                    .or_else(|| s.strip_suffix("s"))
291                    .unwrap_or(s)
292                    .trim();
293                trimmed.parse().ok()
294            }
295
296            Formatter::Percent { .. } => {
297                let trimmed = s.trim_end_matches('%').trim();
298                trimmed.parse::<f64>().ok().map(|v| v / 100.0)
299            }
300
301            Formatter::Pan => {
302                let s_upper = s.to_uppercase();
303                if s_upper == "C" || s_upper == "CENTER" || s_upper == "0" {
304                    return Some(0.0);
305                }
306
307                if let Some(left) = s_upper.strip_prefix('L') {
308                    return left.trim().parse::<f64>().ok().map(|v| -v / 100.0);
309                }
310
311                if let Some(right) = s_upper.strip_prefix('R') {
312                    return right.trim().parse::<f64>().ok().map(|v| v / 100.0);
313                }
314
315                // Try parsing as raw number (-100 to +100 or -1 to +1)
316                if let Ok(v) = s.parse::<f64>() {
317                    if v.abs() > 1.0 {
318                        return Some(v / 100.0); // Assume -100 to +100
319                    }
320                    return Some(v); // Assume -1 to +1
321                }
322
323                None
324            }
325
326            Formatter::Ratio { .. } => {
327                // Handle infinity
328                if s == "∞:1" || s == "inf:1" || s.eq_ignore_ascii_case("infinity:1") {
329                    return Some(f64::INFINITY);
330                }
331
332                // Strip ":1" suffix
333                let trimmed = s.trim_end_matches(":1").trim();
334                trimmed.parse().ok()
335            }
336
337            Formatter::Semitones => {
338                let trimmed = s.trim_end_matches(" st").trim_end_matches("st").trim();
339                trimmed.parse().ok()
340            }
341
342            Formatter::Boolean => match s.to_lowercase().as_str() {
343                "on" | "true" | "yes" | "1" | "enabled" => Some(1.0),
344                "off" | "false" | "no" | "0" | "disabled" => Some(0.0),
345                _ => None,
346            },
347        }
348    }
349
350    /// Get the unit string for this formatter (for ParamInfo).
351    pub fn units(&self) -> &'static str {
352        match self {
353            Formatter::Float { .. } => "",
354            Formatter::Decibel { .. } => "dB",
355            Formatter::DecibelDirect { .. } => "dB",
356            Formatter::Frequency => "Hz",
357            Formatter::Milliseconds { .. } => "ms",
358            Formatter::Seconds { .. } => "s",
359            Formatter::Percent { .. } => "%",
360            Formatter::Pan => "",
361            Formatter::Ratio { .. } => "",
362            Formatter::Semitones => "st",
363            Formatter::Boolean => "",
364        }
365    }
366}
367
368impl Default for Formatter {
369    fn default() -> Self {
370        Formatter::Float { precision: 2 }
371    }
372}