1#![allow(clippy::too_many_arguments)]
6
7use std::io::{stdout, Write};
8
9const COL_RESET: &str = "\x1b[0m";
15const COL_DIM: &str = "\x1b[90m";
16
17const COL_METAL: &str = "\x1b[38;5;240m";
19const COL_METAL_LIGHT: &str = "\x1b[38;5;245m";
20
21const COL_ACCENT_CYAN: &str = "\x1b[1;36m";
23const COL_ACCENT_GREEN: &str = "\x1b[1;32m";
24const COL_ACCENT_AMBER: &str = "\x1b[1;33m";
25const COL_ACCENT_RED: &str = "\x1b[1;31m";
26
27const COL_TEXT_BRIGHT: &str = "\x1b[1;37m";
29
30const COL_VU_LOW: &str = "\x1b[38;5;46m"; const COL_VU_MID: &str = "\x1b[38;5;226m"; const COL_VU_HIGH: &str = "\x1b[38;5;214m"; const COL_VU_PEAK: &str = "\x1b[1;31m"; const CH_FULL_BLOCK: char = '█';
38const CH_HALF_TOP: char = '▀';
39const CH_SOLID_BAR: char = '▓';
40const CH_DOT: char = '•';
41const CH_CIRCLE_FILLED: &str = "⬤";
42const CH_ARROW_UP: &str = "▲";
43
44pub struct TerminalCanvas {
45 pub width: usize,
46 pub height: usize,
47}
48
49impl TerminalCanvas {
50 pub fn new(width: usize, height: usize) -> Self {
51 Self { width, height }
52 }
53
54 pub fn clear_screen(&self) {
59 print!("\x1b[2J\x1b[H\x1b[?25l");
60 let _ = stdout().flush();
61 }
62
63 pub fn restore_cursor(&self) {
64 println!("\x1b[?25h\x1b[0m");
65 let _ = stdout().flush();
66 }
67
68 pub fn show_cursor(&self) {
69 print!("\x1b[?25h");
70 let _ = stdout().flush();
71 }
72
73 pub fn move_to(&self, row: usize, col: usize) {
74 print!("\x1b[{};{}H", row, col);
75 }
76
77 pub fn clear_region(&self, top: usize, left: usize, w: usize, h: usize) {
78 let blank = " ".repeat(w);
79 for r in top..top + h {
80 self.move_to(r, left);
81 print!("{blank}");
82 }
83 }
84
85 pub fn draw_panel(
92 &self,
93 top: usize,
94 left: usize,
95 w: usize,
96 h: usize,
97 title: &str,
98 status: Option<&str>,
99 ) {
100 self.move_to(top, left);
102 print!("┌─ ");
103 print!("{COL_ACCENT_CYAN}{title}{COL_RESET}");
104
105 let title_width = title.chars().count() + 3;
106 if w > title_width + 2 {
107 print!("{COL_METAL}{}", "─".repeat(w - title_width));
108 }
109 print!("┐");
110
111 if let Some(st) = status {
113 let st_width = st.chars().count();
114 if left + w > st_width + 3 {
115 self.move_to(top, left + w.saturating_sub(st_width + 3));
116 print!("{COL_DIM}{st}{COL_RESET}");
117 }
118 }
119
120 for r in (top + 1)..(top + h - 1) {
122 self.move_to(r, left);
123 print!("{COL_METAL}│{COL_RESET}");
124 self.move_to(r, left + w - 1);
125 print!("{COL_METAL}│{COL_RESET}");
126 }
127
128 self.move_to(top + h - 1, left);
130 print!("└─{COL_METAL}{}", "─".repeat(w - 2));
131 print!("┘{COL_RESET}");
132 }
133
134 pub fn draw_vu_meter(
144 &self,
145 row: usize,
146 col: usize,
147 label: &str,
148 current_linear: f32,
149 peak_linear: f32,
150 db_value: f32,
151 width: usize,
152 ) {
153 self.move_to(row, col);
154
155 let clamped = current_linear.min(1.0_f32.max(current_linear));
156 let filled = ((clamped) * (width as f32)).round() as usize;
157 let empty = width.saturating_sub(filled);
158
159 let mut bar = String::new();
161 for i in 0..filled {
162 let ratio = i as f32 / width as f32;
163 bar.push_str(match ratio {
164 0.0..=0.55 => COL_VU_LOW,
165 0.55..=0.80 => COL_VU_MID,
166 0.80..=0.95 => COL_VU_HIGH,
167 _ => COL_VU_PEAK,
168 });
169 bar.push(CH_FULL_BLOCK);
170 bar.push_str(COL_RESET);
171 }
172 for _ in 0..empty {
174 bar.push('░');
175 }
176
177 let peak_indicator = if peak_linear > 1e-4 && peak_linear <= 1.0 {
179 format!("{COL_ACCENT_RED}{CH_CIRCLE_FILLED}{COL_RESET}")
180 } else {
181 "".to_string()
182 };
183
184 let arrow_db = 20.0 * current_linear.log10();
186 let arrow = if arrow_db > -1.0 {
187 format!("{COL_ACCENT_RED}{CH_ARROW_UP}{COL_RESET}")
188 } else if arrow_db < -40.0 {
189 format!("{COL_DIM}▼{COL_RESET}")
190 } else {
191 format!("{COL_DIM}▲{COL_RESET}")
192 };
193
194 print!(
195 "{COL_TEXT_BRIGHT}{label}{COL_RESET} [{bar}] {peak_indicator} {arrow} \x1b[1;37m{db_value:>6.1} dBFS\x1b[0m"
196 );
197 }
198
199 pub fn draw_vu_simple(
201 &self,
202 row: usize,
203 col: usize,
204 label: &str,
205 level_linear: f32,
206 width: usize,
207 ) {
208 let db = if level_linear < 1e-4 {
209 -90.0
210 } else {
211 20.0 * level_linear.log10()
212 };
213 self.draw_vu_meter(row, col, label, level_linear, 0.0, db, width);
214 }
215
216 pub fn draw_smooth_spectrum(
225 &self,
226 top: usize,
227 left: usize,
228 height: usize,
229 bands: &[(f32, &str)],
230 ) {
231 let max_mag = bands.iter().map(|b| b.0).fold(1e-4_f32, |a, b| a.max(b));
232
233 for row in 0..height {
234 let y_frac = 1.0 - (row as f32 / height as f32);
236 self.move_to(top + row, left);
237
238 for &(mag, _) in bands {
239 let norm = mag / max_mag;
240 if norm >= y_frac {
241 let color = match norm {
243 x if x <= 0.4 => COL_VU_LOW,
244 x if x <= 0.65 => COL_VU_MID,
245 x if x <= 0.85 => COL_VU_HIGH,
246 _ => COL_VU_PEAK,
247 };
248 print!("{color}{CH_FULL_BLOCK}{COL_RESET}");
249 } else if (norm - y_frac).abs() < 0.18 && norm > 0.05 {
250 print!("{COL_METAL}{CH_HALF_TOP}{COL_RESET}");
252 } else {
253 print!("{COL_METAL}·{COL_RESET}");
255 }
256 }
257 }
258
259 self.move_to(top + height, left);
261 for &(mag, lbl) in bands {
262 let norm = mag / max_mag;
263 let color = if norm > 0.85 {
264 COL_ACCENT_RED
265 } else if norm > 0.65 {
266 COL_VU_HIGH
267 } else if norm > 0.4 {
268 COL_VU_MID
269 } else {
270 COL_DIM
271 };
272 print!("{color}{lbl:^5}{COL_RESET}");
273 }
274 }
275
276 pub fn draw_waveform(
283 &self,
284 top: usize,
285 left: usize,
286 width: usize,
287 height: usize,
288 samples: &[f32],
289 ) {
290 if samples.is_empty() {
291 return;
292 }
293
294 let step = samples.len().max(1) / (width + 1);
296 let mid_row = top + height / 2;
297
298 for col in 0..width.min(samples.len()) {
299 let si = col * step;
300 if si >= samples.len() {
301 break;
302 }
303 let val = samples[si].clamp(-1.0, 1.0).abs();
304 let offset = (val * (height as f32 / 2.0)).round() as isize;
305 let target_row = (mid_row as isize) - offset;
306
307 if target_row >= top as isize && target_row < (top + height) as isize {
308 self.move_to(target_row as usize, left + col);
309 print!("{COL_ACCENT_CYAN}{CH_DOT}{COL_RESET}");
310 }
311 }
312 }
313
314 pub fn draw_rolling_meter(
321 &self,
322 top: usize,
323 left: usize,
324 width: usize,
325 height: usize,
326 value: f32,
327 peak_value: f32,
328 label: &str,
329 ) {
330 self.move_to(top, left);
331 print!("{COL_TEXT_BRIGHT}{label}{COL_RESET}");
332
333 let cur_filled = (value * height as f32).round() as usize;
334 let pk_filled = (peak_value * height as f32).round() as usize;
335
336 for row in 0..height {
337 self.move_to(top + height - row, left + width.saturating_sub(12));
338 let is_peak = row == pk_filled || row == pk_filled + 1;
339
340 if is_peak {
341 print!("{COL_ACCENT_RED}{CH_CIRCLE_FILLED}{COL_RESET}");
342 } else if row <= cur_filled {
343 let ratio = row as f32 / height as f32;
344 let c = match ratio {
345 x if x <= 0.55 => "\x1b[38;5;46m",
346 x if x <= 0.80 => "\x1b[38;5;226m",
347 _ => "\x1b[1;31m",
348 };
349 print!("{c}{CH_SOLID_BAR}{COL_RESET}");
350 } else if row <= cur_filled + 2 {
351 print!("{COL_METAL}░{COL_RESET}");
352 } else {
353 print!("{COL_METAL}·{COL_RESET}");
354 }
355 }
356
357 let db = if value < 1e-4 {
358 "-inf".to_string()
359 } else {
360 format!("{:.1}", 20.0 * value.log10())
361 };
362 self.move_to(top + height + 1, left + width.saturating_sub(12));
363 print!("{COL_TEXT_BRIGHT}{db:>5} dB{COL_RESET}");
364 }
365
366 pub fn draw_stereo_pan(
372 &self,
373 row: usize,
374 col: usize,
375 pan_left: f32,
376 _pan_right: f32,
377 width: usize,
378 ) {
379 self.move_to(row, col);
380 let center = width / 2;
381
382 for i in 0..width {
383 if i == center {
384 print!("{COL_TEXT_BRIGHT}┼{COL_RESET}");
385 } else if i < center {
386 let dist_from_center = center - i;
388 let intensity = if pan_left > 0.0 {
389 (pan_left * dist_from_center as f32 / center as f32).min(1.0)
390 } else {
391 0.0
392 };
393 if intensity > 0.3 {
394 print!("{COL_ACCENT_GREEN}{CH_SOLID_BAR}{COL_RESET}");
395 } else {
396 print!("{COL_METAL}·{COL_RESET}");
397 }
398 } else {
399 let dist_from_center = i - center;
401 let intensity = if pan_left < 0.0 {
402 (-pan_left * dist_from_center as f32 / center as f32).min(1.0)
403 } else {
404 0.0
405 };
406 if intensity > 0.3 {
407 print!("{COL_ACCENT_CYAN}{CH_SOLID_BAR}{COL_RESET}");
408 } else {
409 print!("{COL_METAL}·{COL_RESET}");
410 }
411 }
412 }
413 }
414
415 pub fn draw_h_slider(
421 &self,
422 row: usize,
423 col: usize,
424 value_pct: f32, min_val: f32,
426 max_val: f32,
427 label: &str,
428 width: usize,
429 ) {
430 self.move_to(row, col);
431 let pct = value_pct.clamp(0.0, 100.0) / 100.0;
432 let filled = (pct * (width - 2) as f32).round() as usize;
433
434 print!("{COL_METAL}[{COL_RESET}");
436 print!("{COL_ACCENT_CYAN}{}", "━".repeat(filled));
438 print!("{}", "·".repeat((width - 2).saturating_sub(filled)));
440 print!("{COL_METAL}]{COL_RESET}");
442
443 let val = min_val + (max_val - min_val) * pct;
445 let db_or_val = if label.contains("dB") || label.contains("Gain") || label.contains("EQ") {
446 format!("{val:+6.1} dB")
447 } else {
448 format!("{val:.0}")
449 };
450 self.move_to(row, col + width + 2);
451 print!("{COL_TEXT_BRIGHT}{db_or_val}{COL_RESET}");
452
453 self.move_to(row, col.saturating_sub(label.chars().count() + 2));
455 print!("{COL_DIM}{label}{COL_RESET}");
456 }
457
458 pub fn draw_processing_status(&self, row: usize, col: usize, active_tags: &[&str]) {
464 self.move_to(row, col);
465 for tag in active_tags {
466 let (text, color) = match tag.to_ascii_lowercase().as_str() {
467 "comp" | "compressor" => ("COMPRESS".to_string(), COL_ACCENT_AMBER),
468 "limit" | "limiter" => ("LIMIT".to_string(), COL_ACCENT_RED),
469 "eq" | "equalizer" => ("EQ".to_string(), COL_ACCENT_CYAN),
470 _ => (tag.to_ascii_uppercase(), COL_METAL_LIGHT),
471 };
472 print!("{color}[{text}]{COL_RESET} ");
473 }
474 }
475
476 pub fn draw_header(
482 &self,
483 row: usize,
484 col: usize,
485 title: &str,
486 sample_rate: u32,
487 channels: usize,
488 ) {
489 self.move_to(row, col);
491 print!("{COL_ACCENT_CYAN}╔═╗{COL_RESET}");
492
493 for (i, ch) in title.chars().enumerate() {
495 self.move_to(row, col + 5 + i);
496 print!("{COL_TEXT_BRIGHT}{ch}{COL_RESET}");
497 }
498
499 let after_title = col + 5 + title.chars().count();
501 self.move_to(row, after_title);
502 print!("{COL_METAL}╚═══════════════════════════════════════════════════{COL_RESET}");
503
504 self.move_to(row + 1, col + 5);
506 print!(
507 "{COL_DIM}Sample Rate: {COL_TEXT_BRIGHT}{sample_rate} Hz{COL_RESET} │ Channels: {COL_TEXT_BRIGHT}{channels}{COL_RESET} │ Format: {COL_TEXT_BRIGHT}f32[-1..1]{COL_RESET}"
508 );
509 }
510
511 pub fn draw_stat(&self, row: usize, col: usize, label: &str, value: &str) {
516 self.move_to(row, col);
517 print!("{COL_DIM}{label}:{COL_RESET} {COL_TEXT_BRIGHT}{value}{COL_RESET}");
518 }
519
520 pub fn draw_separator(&self, row: usize, col: usize, width: usize) {
521 self.move_to(row, col);
522 print!("{COL_METAL}{}{COL_RESET}", "─".repeat(width));
523 }
524
525 pub fn draw_box(&self, top: usize, left: usize, w: usize, h: usize, title: &str) {
531 self.draw_panel(top, left, w, h, title, None);
532 }
533
534 pub fn draw_level_meter(
535 &self,
536 row: usize,
537 col: usize,
538 label: &str,
539 peak: f32,
540 rms: f32,
541 width: usize,
542 ) {
543 self.draw_vu_simple(row, col, label, peak, width);
544 let label_cols = label.chars().count();
545 self.move_to(row, col + label_cols + width + 20);
547 let rms_db = if rms < 1e-4 {
548 -90.0
549 } else {
550 20.0 * rms.log10()
551 };
552 print!("{COL_DIM}RMS {:>5.1} dBFS{COL_RESET}", rms_db);
553 }
554
555 pub fn draw_fft_spectrum(&self, top: usize, left: usize, height: usize, bands: &[(f32, &str)]) {
556 self.draw_smooth_spectrum(top, left, height, bands);
557 }
558}