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}