Skip to main content

rescale_core/tui/
canvas.rs

1//! Terminal UI Renderer — **DAW Plugin Dark Theme** dengan zero dependency.
2//! Komponen visual bergaya hardware audio: VU meter LED, smooth spectrum analyzer,
3//! panel bergaya rack unit, dan processing status indicator.
4
5#![allow(clippy::too_many_arguments)]
6
7use std::io::{stdout, Write};
8
9// ═══════════════════════════════════════════
10//  PALET WARNA DAW (ANSI 256 warna)
11// ═══════════════════════════════════════════
12
13/// Warna dasar / reset
14const COL_RESET: &str = "\x1b[0m";
15const COL_DIM: &str = "\x1b[90m";
16
17/// Metal gray untuk border/rack panel — kesan hardware outboard
18const COL_METAL: &str = "\x1b[38;5;240m";
19const COL_METAL_LIGHT: &str = "\x1b[38;5;245m";
20
21/// Aksen neon cyan — judul, highlights
22const 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
27/// Teks putih terang
28const COL_TEXT_BRIGHT: &str = "\x1b[1;37m";
29
30/// Gradien hijau–kuning–merah untuk VU meter (mode 256-color)
31const COL_VU_LOW: &str = "\x1b[38;5;46m"; // Hijau lembut
32const COL_VU_MID: &str = "\x1b[38;5;226m"; // Kuning terang
33const COL_VU_HIGH: &str = "\x1b[38;5;214m"; // Orange
34const COL_VU_PEAK: &str = "\x1b[1;31m"; // Merah menyala
35
36// Karakter visual utama
37const 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    // ────────────────────────────────────────
55    //  UTILITAS LAYAR
56    // ────────────────────────────────────────
57
58    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    // ═══════════════════════════════════════
86    //  PANEL PLUGIN — Frame Bergaya Rack Unit
87    // ═══════════════════════════════════════
88
89    /// Gambar panel dengan border metal, title bar, dan optional status teks.
90    /// Mirip tampilan hardware compressor/limiter di studio profesional.
91    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        // Bilah judul
101        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        // Status text di kanan atas panel
112        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        // Border samping — garis metal
121        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        // Border bawah
129        self.move_to(top + h - 1, left);
130        print!("└─{COL_METAL}{}", "─".repeat(w - 2));
131        print!("┘{COL_RESET}");
132    }
133
134    // ═══════════════════════════════════════
135    //  VU METER LED — Gaya Hardware Outboard
136    // ═══════════════════════════════════════
137
138    /// Render VU Meter bergaya plugin DAW lengkap:
139    /// - Bar gradien hijau → kuning → merah (segmen LED)
140    /// - Peak hold indicator (⬤) yang persist antar-frame
141    /// - Panah arah (▲/▼) menunjukkan level naik/turun
142    /// - Nilai dBFS digital di samping
143    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        // Bangun bar VU gradien per segmen
160        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        // Bagian belakang kosong
173        for _ in 0..empty {
174            bar.push('░');
175        }
176
177        // Indikator peak hold
178        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        // Panah arah
185        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    /// Versi ringkas tanpa peak hold & arrow
200    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    // ═══════════════════════════════════════
217    //  ANALISIS SPEKTRUM MULUS — Spektrum Bergerak
218    // ═══════════════════════════════════════
219
220    /// Spectrum analyzer vertikal dengan tepi mulus (antialiasing).
221    /// Setiap kolom merepresentasikan satu band frekuensi.
222    /// Menggunakan █ penuh + ▀ setengah atas untuk kesan halus seperti
223    /// hardware spectrum analyzer analog.
224    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            // y_frac = 1.0 di puncak, turun ke 0 di bawah
235            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                    // Bar aktif — warna berdasarkan ketinggian
242                    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                    // Setengah blok di tepi atas = efek anti-aliasing
251                    print!("{COL_METAL}{CH_HALF_TOP}{COL_RESET}");
252                } else {
253                    // Titik grid latar
254                    print!("{COL_METAL}·{COL_RESET}");
255                }
256            }
257        }
258
259        // Label frekuensi
260        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    // ═══════════════════════════════════════
277    //  TAMPILAN WAVEFORM — Mini Time-Domain
278    // ═══════════════════════════════════════
279
280    /// Render mini waveform horizontal dalam area tertentu.
281    /// Setiap sample direpresentasikan sebagai titik • pada posisi Y sesuai amplitude.
282    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        // Resample-down agar muat lebar
295        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    // ═══════════════════════════════════════
315    //  METER ISIAN BERGULIR — Hardware Outboard
316    // ═══════════════════════════════════════════
317
318    /// Rolling/falling level meter bergaya hardware mixer.
319    /// Level turun secara gradual memberi kesan "live".
320    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    // ═══════════════════════════════════════
367    //  METER PAN STEREO
368    // ═══════════════════════════════════════
369
370    /// Indikator stereo pan/phase balance horizontal.
371    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                // Sisi kiri
387                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                // Sisi kanan
400                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    // ═══════════════════════════════════════
416    //  VISUALISER SLIDER — Kontrol Parameter
417    // ═══════════════════════════════════════
418
419    /// Visualisasi slider/knob horizontal bergaya mixer untuk parameter EQ atau gain.
420    pub fn draw_h_slider(
421        &self,
422        row: usize,
423        col: usize,
424        value_pct: f32, // [0..100]
425        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        // Kurung track
435        print!("{COL_METAL}[{COL_RESET}");
436        // Isian
437        print!("{COL_ACCENT_CYAN}{}", "━".repeat(filled));
438        // Kosong
439        print!("{}", "·".repeat((width - 2).saturating_sub(filled)));
440        // Kurung penutup
441        print!("{COL_METAL}]{COL_RESET}");
442
443        // Nilai numerik
444        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        // Label kiri
454        self.move_to(row, col.saturating_sub(label.chars().count() + 2));
455        print!("{COL_DIM}{label}{COL_RESET}");
456    }
457
458    // ═══════════════════════════════════════
459    //  INDIKATOR STATUS PEMROSESAN
460    // ═══════════════════════════════════════
461
462    /// Badge status proses DSP aktif — tampil seperti indikator di hardware compressor.
463    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    // ═══════════════════════════════════════
477    //  HEADER / BILAH JUDUL
478    // ═══════════════════════════════════════
479
480    /// Header bergaya judul plugin DAW dengan info teknis sistem.
481    pub fn draw_header(
482        &self,
483        row: usize,
484        col: usize,
485        title: &str,
486        sample_rate: u32,
487        channels: usize,
488    ) {
489        // Icon box kiri
490        self.move_to(row, col);
491        print!("{COL_ACCENT_CYAN}╔═╗{COL_RESET}");
492
493        // Cetak judul karakter per karakter (hindari masalah hitung multi-byte)
494        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        // Garis pemisah
500        let after_title = col + 5 + title.chars().count();
501        self.move_to(row, after_title);
502        print!("{COL_METAL}╚═══════════════════════════════════════════════════{COL_RESET}");
503
504        // Bilah sub-info
505        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    // ═══════════════════════════════════════
512    //  RENDERER UTILITAS
513    // ═══════════════════════════════════════
514
515    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    // ────────────────────────────────────────
526    //  KOMPATIBILITAS MUNDUR — API LEGACY
527    // ────────────────────────────────────────
528    // Method lama masih tersedia agar tidak pecah saat build.
529
530    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        // Sisakan dua kolom pemisah dari bacaan peak dBFS di atasnya.
546        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}