Skip to main content

player_scope/
ui.rs

1use std::{
2    io,
3    path::Path,
4    sync::atomic::{AtomicU8, Ordering},
5    time::{Duration, Instant},
6};
7
8use anyhow::Result;
9use crossterm::{
10    cursor::{SetCursorStyle, Show},
11    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
12    execute,
13    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
14};
15use ratatui::{
16    Frame, Terminal,
17    backend::CrosstermBackend,
18    layout::{Alignment, Constraint, Direction, Layout, Rect},
19    style::{Color, Modifier, Style},
20    symbols::{Marker as GraphMarker, border},
21    text::{Line, Span},
22    widgets::{
23        Block, Borders, Cell, Chart, Clear, Gauge, List, ListItem, Paragraph, Row, Scrollbar,
24        ScrollbarOrientation, ScrollbarState, Table, block::Title,
25    },
26};
27use scope::{
28    display::{
29        Dimension, DisplayMode, GraphConfig, oscilloscope::Oscilloscope,
30        spectroscope::Spectroscope, vectorscope::Vectorscope,
31    },
32    input::Matrix,
33};
34
35#[path = "cmd_boxes.rs"]
36mod cmd_boxes;
37
38use crate::control;
39
40const SPINNER: [&str; 8] = ["⢈", "⡈", "⡐", "⡠", "⣀", "⢄", "⢂", "⢁"];
41const HOTKEYS_ENABLED: bool = false;
42static HOTKEY_STATE: AtomicU8 = AtomicU8::new(0);
43const APP_BG: Color = Color::Rgb(255, 255, 255);
44const ACCENT: Color = Color::LightCyan;
45const COMMAND_FG: Color = Color::Rgb(0, 0, 0);
46const FLASH_FG: Color = Color::Rgb(255, 255, 255);
47const FLASH_BG: Color = Color::Rgb(0, 0, 0);
48const HOTKEY_FINAL_BG: Color = Color::Rgb(255, 255, 255);
49const SYSTEM_BG: Color = Color::Rgb(0, 0, 0);
50const SYSTEM_FG: Color = Color::Rgb(255, 255, 255);
51const BAR_TIME_FG: Color = Color::Indexed(15);
52const BAR_FILL: Color = Color::Rgb(0, 130, 150);
53const IDLE_CURSOR_DELAY_MS: u128 = 10_000;
54const IDLE_CURSOR_SWEEP_MS: u128 = 3_600;
55const PROMPT_PREFIX: &str = "┠╴╶╴ ─╴ ╶── ╶───Prompt ───┤";
56const SYSTEM_SUFFIX: &str = "├─── System───╴ ──╴ ╶─ ╶╴╶";
57const SYSTEM_EDGE: &str = "┨";
58
59/// Runs the terminal UI with the provided data/configuration.
60pub fn run(config: UiConfig) -> Result<()> {
61    let mut terminal = setup_terminal()?;
62    let result = App::new(config).run(&mut terminal);
63    restore_terminal(&mut terminal)?;
64    result
65}
66
67/// Initial state and demo data used by the terminal UI.
68#[derive(Debug, Clone)]
69pub struct UiConfig {
70    pub ready_response: String,
71    pub default_file_path: String,
72    pub next_file_path: String,
73    pub default_track: TrackData,
74    pub next_track: TrackData,
75    pub initial_volume: u16,
76    pub initial_gain: i16,
77    pub initial_pitch: i16,
78    pub initial_progress_secs: u64,
79    pub next_progress_secs: u64,
80    pub default_duration_secs: u64,
81    pub next_duration_secs: u64,
82    pub initial_loop_range: Option<(u64, u64)>,
83    pub logs: Vec<String>,
84    pub playlist_entries: Vec<PlaylistEntryData>,
85}
86
87/// Track metadata displayed in the Playback panel.
88#[derive(Debug, Clone)]
89pub struct TrackData {
90    pub file: String,
91    pub album: String,
92    pub artist: String,
93    pub codec: String,
94    pub bitrate: String,
95    pub sample_rate: String,
96    pub channels: String,
97    pub size: String,
98}
99
100/// One row in the playlist demo table.
101#[derive(Debug, Clone)]
102pub struct PlaylistEntryData {
103    pub icon: String,
104    pub name: String,
105    pub kind: String,
106    pub duration: String,
107    pub size: String,
108}
109
110fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
111    enable_raw_mode()?;
112    let mut stdout = io::stdout();
113    execute!(
114        stdout,
115        EnterAlternateScreen,
116        Show,
117        SetCursorStyle::BlinkingBlock
118    )?;
119    let backend = CrosstermBackend::new(stdout);
120    Ok(Terminal::new(backend)?)
121}
122
123fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
124    disable_raw_mode()?;
125    execute!(
126        terminal.backend_mut(),
127        SetCursorStyle::DefaultUserShape,
128        Show,
129        LeaveAlternateScreen
130    )?;
131    terminal.show_cursor()?;
132    Ok(())
133}
134
135#[derive(Debug, Clone)]
136struct App {
137    input: String,
138    cursor: usize,
139    last_response: String,
140    file_path: String,
141    track: Track,
142    playing: bool,
143    muted: bool,
144    volume: u16,
145    gain: i16,
146    pitch: i16,
147    progress_secs: u64,
148    duration_secs: u64,
149    labels: Vec<Marker>,
150    loop_range: Option<(u64, u64)>,
151    logs: Vec<String>,
152    spinner_idx: usize,
153    recording: bool,
154    playlist_visible: bool,
155    playlist_scroll: usize,
156    playlist_entries: Vec<PlaylistEntry>,
157    scope_mode: Option<ScopeMode>,
158    scope_phase: f64,
159    started_at: Instant,
160    last_input_at: Instant,
161    idle_cursor_sweep_started_at: Option<Instant>,
162    idle_cursor_sweep_done: bool,
163    last_tick: Instant,
164    last_progress_tick: Instant,
165    should_quit: bool,
166    default_file_path: String,
167    next_file_path: String,
168    default_track: Track,
169    next_track: Track,
170    default_progress_secs: u64,
171    next_progress_secs: u64,
172    default_duration_secs: u64,
173    next_duration_secs: u64,
174}
175
176#[derive(Debug, Clone, Copy)]
177enum ScopeMode {
178    Dual,
179    Spectroscope,
180}
181
182impl App {
183    fn new(config: UiConfig) -> Self {
184        let now = Instant::now();
185        let default_track: Track = config.default_track.into();
186        let next_track: Track = config.next_track.into();
187        Self {
188            input: String::new(),
189            cursor: 0,
190            last_response: config.ready_response,
191            file_path: config.default_file_path.clone(),
192            track: default_track.clone(),
193            playing: false,
194            muted: false,
195            volume: config.initial_volume,
196            gain: config.initial_gain,
197            pitch: config.initial_pitch,
198            progress_secs: config.initial_progress_secs,
199            duration_secs: config.default_duration_secs,
200            labels: default_labels(config.default_duration_secs),
201            loop_range: config.initial_loop_range,
202            logs: config.logs,
203            spinner_idx: 0,
204            recording: false,
205            playlist_visible: false,
206            playlist_scroll: 0,
207            playlist_entries: config
208                .playlist_entries
209                .into_iter()
210                .map(Into::into)
211                .collect(),
212            scope_mode: None,
213            scope_phase: 0.0,
214            started_at: now,
215            last_input_at: now,
216            idle_cursor_sweep_started_at: None,
217            idle_cursor_sweep_done: false,
218            last_tick: now,
219            last_progress_tick: now,
220            should_quit: false,
221            default_file_path: config.default_file_path,
222            next_file_path: config.next_file_path,
223            default_track,
224            next_track,
225            default_progress_secs: config.initial_progress_secs,
226            next_progress_secs: config.next_progress_secs,
227            default_duration_secs: config.default_duration_secs,
228            next_duration_secs: config.next_duration_secs,
229        }
230    }
231
232    fn run(mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
233        while !self.should_quit {
234            terminal.draw(|frame| self.draw(frame))?;
235
236            let timeout = Duration::from_millis(80);
237            if event::poll(timeout)? {
238                if let Event::Key(key) = event::read()? {
239                    self.handle_key(key);
240                }
241            }
242
243            self.tick();
244        }
245
246        Ok(())
247    }
248
249    fn tick(&mut self) {
250        if self.last_tick.elapsed() < Duration::from_millis(90) {
251            return;
252        }
253
254        self.last_tick = Instant::now();
255        self.tick_idle_cursor_sweep();
256        if self.scope_mode.is_some() {
257            self.scope_phase = (self.scope_phase + 0.18) % std::f64::consts::TAU;
258        }
259        if self.playing {
260            self.spinner_idx = (self.spinner_idx + 1) % SPINNER.len();
261            if self.last_progress_tick.elapsed() < Duration::from_secs(1) {
262                return;
263            }
264
265            self.last_progress_tick = Instant::now();
266            self.progress_secs = self.progress_secs.saturating_add(1);
267
268            if let Some((start, end)) = self.loop_range {
269                if self.progress_secs >= end {
270                    self.progress_secs = start;
271                    self.log(format!(
272                        "loop: wrapped to {} from {}",
273                        fmt_time(start),
274                        fmt_time(end)
275                    ));
276                }
277            } else if self.progress_secs >= self.duration_secs {
278                self.progress_secs = self.duration_secs;
279                self.playing = false;
280                self.respond("complete: demo timeline reached the end");
281            }
282        }
283    }
284
285    fn tick_idle_cursor_sweep(&mut self) {
286        if let Some(started_at) = self.idle_cursor_sweep_started_at {
287            if started_at.elapsed().as_millis() >= IDLE_CURSOR_SWEEP_MS {
288                self.idle_cursor_sweep_started_at = None;
289                self.idle_cursor_sweep_done = true;
290            }
291            return;
292        }
293
294        if !self.idle_cursor_sweep_done
295            && self.last_input_at.elapsed().as_millis() >= IDLE_CURSOR_DELAY_MS
296        {
297            self.idle_cursor_sweep_started_at = Some(Instant::now());
298        }
299    }
300
301    fn record_user_input(&mut self) {
302        self.last_input_at = Instant::now();
303        self.idle_cursor_sweep_started_at = None;
304        self.idle_cursor_sweep_done = false;
305    }
306
307    fn handle_key(&mut self, key: KeyEvent) {
308        self.record_user_input();
309        match key.code {
310            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
311                self.shutdown("quit: ctrl-c")
312            }
313            KeyCode::Char('q') if HOTKEYS_ENABLED && self.input.is_empty() => {
314                self.shutdown("quit: q")
315            }
316            KeyCode::Esc => {
317                self.input.clear();
318                self.cursor = 0;
319            }
320            KeyCode::Enter => self.submit(),
321            KeyCode::Backspace => self.backspace(),
322            KeyCode::Delete => self.delete(),
323            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
324            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.chars().count()),
325            KeyCode::Up if self.playlist_visible && self.input.is_empty() => {
326                self.scroll_playlist(-1)
327            }
328            KeyCode::Down if self.playlist_visible && self.input.is_empty() => {
329                self.scroll_playlist(1)
330            }
331            KeyCode::PageUp if self.playlist_visible && self.input.is_empty() => {
332                self.scroll_playlist(-12)
333            }
334            KeyCode::PageDown if self.playlist_visible && self.input.is_empty() => {
335                self.scroll_playlist(12)
336            }
337            KeyCode::Home => self.cursor = 0,
338            KeyCode::End => self.cursor = self.input.chars().count(),
339            KeyCode::Char(' ') if HOTKEYS_ENABLED && self.input.is_empty() => {
340                self.toggle_playback()
341            }
342            KeyCode::Char('+') if HOTKEYS_ENABLED && self.input.is_empty() => self.adjust_volume(5),
343            KeyCode::Char('-') if HOTKEYS_ENABLED && self.input.is_empty() => {
344                self.adjust_volume(-5)
345            }
346            KeyCode::Char('m') if HOTKEYS_ENABLED && self.input.is_empty() => self.toggle_mute(),
347            KeyCode::Char('n') if HOTKEYS_ENABLED && self.input.is_empty() => self.next_track(),
348            KeyCode::Char('p') if HOTKEYS_ENABLED && self.input.is_empty() => self.prev_track(),
349            KeyCode::Char(ch) => self.insert(ch),
350            _ => {}
351        }
352    }
353
354    fn submit(&mut self) {
355        let command = self.input.trim().to_string();
356        if command.is_empty() {
357            return;
358        }
359
360        self.log(format!("> {command}"));
361        self.run_command(&command);
362        self.input.clear();
363        self.cursor = 0;
364    }
365
366    fn run_command(&mut self, command: &str) {
367        match control::parse_command(command) {
368            Ok(event) => control::dispatch(self, &event),
369            Err(control::ParseCommandError::Empty) => {}
370            Err(control::ParseCommandError::Unknown(action)) => {
371                self.respond(format!("{action}: command logged but not wired"));
372            }
373            Err(control::ParseCommandError::Ambiguous { input, matches }) => {
374                self.respond(format!("{input}: ambiguous, matches {}", matches.join("/")));
375            }
376        }
377    }
378
379    fn draw(&self, frame: &mut Frame) {
380        HOTKEY_STATE.store(self.hotkey_state(), Ordering::Relaxed);
381        let area = frame.area();
382        frame.render_widget(Clear, area);
383        frame.render_widget(Paragraph::new("").style(Style::default().bg(APP_BG)), area);
384
385        let loaded_file = Path::new(&self.file_path)
386            .file_name()
387            .and_then(|name| name.to_str())
388            .unwrap_or(self.file_path.as_str());
389        let outer = Block::default()
390            .title(Title::from(Line::from(vec![
391                Span::styled("━", Style::default().fg(Color::DarkGray)),
392                icon_span("🎞"),
393                Span::styled(
394                    " Player ",
395                    Style::default()
396                        .fg(Color::Cyan)
397                        .add_modifier(Modifier::BOLD),
398                ),
399                Span::styled("╺  ╺ ╺╸ ━╺━━", Style::default().fg(Color::DarkGray)),
400            ])))
401            .title(Title::from(
402                Line::from(vec![
403                    Span::styled("━━╋  ", Style::default().fg(Color::DarkGray)),
404                    Span::styled(
405                        loaded_file.to_string(),
406                        Style::default().fg(COMMAND_FG).add_modifier(Modifier::BOLD),
407                    ),
408                    Span::styled("  ╋━━", Style::default().fg(Color::DarkGray)),
409                ])
410                .centered(),
411            ))
412            .borders(Borders::ALL)
413            .border_set(border::THICK)
414            .border_style(Style::default().fg(Color::DarkGray));
415        let inner = outer.inner(area);
416        frame.render_widget(outer, area);
417
418        let compact = inner.width < 112 || inner.height < 28;
419        let labels_visible = self.labels_visible();
420        if compact {
421            if labels_visible {
422                let root = Layout::default()
423                    .direction(Direction::Vertical)
424                    .constraints([
425                        Constraint::Length(7),
426                        Constraint::Length(2),
427                        Constraint::Length(3),
428                        Constraint::Min(10),
429                    ])
430                    .split(inner);
431
432                cmd_boxes::draw_compact(frame, root[0]);
433                self.draw_prompt(frame, root[1]);
434                self.draw_labels(frame, root[2]);
435                self.draw_main(frame, root[3]);
436            } else {
437                let root = Layout::default()
438                    .direction(Direction::Vertical)
439                    .constraints([
440                        Constraint::Length(7),
441                        Constraint::Length(2),
442                        Constraint::Min(10),
443                    ])
444                    .split(inner);
445
446                cmd_boxes::draw_compact(frame, root[0]);
447                self.draw_prompt(frame, root[1]);
448                self.draw_main(frame, root[2]);
449            }
450        } else {
451            if labels_visible {
452                let root = Layout::default()
453                    .direction(Direction::Vertical)
454                    .constraints([
455                        Constraint::Length(6),
456                        Constraint::Length(1),
457                        Constraint::Length(3),
458                        Constraint::Min(13),
459                    ])
460                    .split(inner);
461
462                cmd_boxes::draw(frame, root[0]);
463                self.draw_prompt(frame, root[1]);
464                self.draw_labels(frame, root[2]);
465                self.draw_main(frame, root[3]);
466            } else {
467                let root = Layout::default()
468                    .direction(Direction::Vertical)
469                    .constraints([
470                        Constraint::Length(6),
471                        Constraint::Length(1),
472                        Constraint::Min(13),
473                    ])
474                    .split(inner);
475
476                cmd_boxes::draw(frame, root[0]);
477                self.draw_prompt(frame, root[1]);
478                self.draw_main(frame, root[2]);
479            }
480        }
481    }
482
483    fn draw_prompt(&self, frame: &mut Frame, area: Rect) {
484        let prompt_bg = APP_BG;
485        let prompt_input_bg = Color::Rgb(238, 238, 238);
486        let system_bg = SYSTEM_BG;
487        let prompt_label = Style::default()
488            .fg(Color::DarkGray)
489            .bg(prompt_bg)
490            .add_modifier(Modifier::BOLD);
491        let prompt_input = Style::default().fg(COMMAND_FG).bg(prompt_input_bg);
492        let system_label = Style::default()
493            .fg(COMMAND_FG)
494            .bg(APP_BG)
495            .add_modifier(Modifier::BOLD);
496        let system_edge = Style::default()
497            .fg(Color::DarkGray)
498            .bg(APP_BG)
499            .add_modifier(Modifier::BOLD);
500        let system_text = Style::default().fg(SYSTEM_FG).bg(system_bg);
501
502        if area.height <= 1 {
503            let columns = Layout::default()
504                .direction(Direction::Horizontal)
505                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
506                .split(area);
507            let prompt_area = extend_left(columns[0], 1);
508
509            frame.render_widget(
510                Paragraph::new("").style(Style::default().bg(prompt_bg)),
511                columns[0],
512            );
513            frame.render_widget(
514                Paragraph::new("").style(Style::default().bg(system_bg)),
515                columns[1],
516            );
517
518            let sweep_col = self.idle_cursor_sweep_col(prompt_area.width);
519            let prompt = self.prompt_line(prompt_label, prompt_input);
520            self.draw_prompt_input_bg(frame, prompt_area, prompt_input_bg);
521            frame.render_widget(Paragraph::new(prompt), prompt_area);
522            self.place_prompt_cursor(frame, prompt_area, sweep_col);
523
524            let response = Line::from(vec![
525                Span::styled(&self.last_response, system_text),
526                Span::styled(SYSTEM_SUFFIX, system_label),
527                Span::styled(SYSTEM_EDGE, system_edge),
528            ])
529            .right_aligned();
530            frame.render_widget(Paragraph::new(response), extend_right(columns[1], 1));
531
532            return;
533        }
534
535        let rows = Layout::default()
536            .direction(Direction::Vertical)
537            .constraints([Constraint::Length(1), Constraint::Length(1)])
538            .split(area);
539        let prompt_area = extend_left(rows[0], 1);
540
541        frame.render_widget(
542            Paragraph::new("").style(Style::default().bg(prompt_bg)),
543            rows[0],
544        );
545        frame.render_widget(
546            Paragraph::new("").style(Style::default().bg(system_bg)),
547            rows[1],
548        );
549
550        let sweep_col = self.idle_cursor_sweep_col(prompt_area.width);
551        let prompt = self.prompt_line(prompt_label, prompt_input);
552        self.draw_prompt_input_bg(frame, prompt_area, prompt_input_bg);
553        frame.render_widget(Paragraph::new(prompt), prompt_area);
554        self.place_prompt_cursor(frame, prompt_area, sweep_col);
555
556        let response = Line::from(vec![
557            Span::styled(&self.last_response, system_text),
558            Span::styled(SYSTEM_SUFFIX, system_label),
559            Span::styled(SYSTEM_EDGE, system_edge),
560        ])
561        .right_aligned();
562        frame.render_widget(Paragraph::new(response), extend_right(rows[1], 1));
563    }
564
565    fn prompt_line<'a>(&'a self, prompt_label: Style, prompt_input: Style) -> Line<'a> {
566        let cursor_byte = self.byte_index(self.cursor);
567        let (before, after) = self.input.split_at(cursor_byte);
568
569        Line::from(vec![
570            Span::styled(PROMPT_PREFIX, prompt_label),
571            Span::styled(before, prompt_input),
572            Span::styled(after, prompt_input),
573        ])
574    }
575
576    fn draw_prompt_input_bg(&self, frame: &mut Frame, area: Rect, input_bg: Color) {
577        let start = self.prompt_cursor_col().saturating_sub(self.cursor as u16);
578        if start >= area.width {
579            return;
580        }
581
582        let input_area = Rect {
583            x: area.x + start,
584            width: area.width.saturating_sub(start),
585            ..area
586        };
587        frame.render_widget(
588            Paragraph::new("").style(Style::default().bg(input_bg)),
589            input_area,
590        );
591    }
592
593    fn place_prompt_cursor(&self, frame: &mut Frame, area: Rect, sweep_col: Option<u16>) {
594        if area.width == 0 {
595            return;
596        }
597
598        let col = match sweep_col {
599            Some(col) => col,
600            None => self.prompt_cursor_col(),
601        };
602
603        frame.set_cursor_position((area.x + col.min(area.width.saturating_sub(1)), area.y));
604    }
605
606    fn draw_labels(&self, frame: &mut Frame, area: Rect) {
607        let compact = area.width < 90;
608        let mut line = Vec::new();
609        for (idx, marker) in self.labels.iter().enumerate() {
610            if idx > 0 {
611                line.push(Span::styled(
612                    if compact { "  " } else { "  |  " },
613                    Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
614                ));
615            }
616
617            if !compact {
618                line.push(icon_span("⏱"));
619                line.push(accent_span(" "));
620            }
621            line.push(accent_span_owned(marker.name.clone()));
622            line.push(Span::styled(
623                format!(" {}", fmt_time(marker.seconds)),
624                plain_style().fg(ACCENT),
625            ));
626        }
627
628        let block = Block::default()
629            .title("─Labels ╾")
630            .title_style(block_title_style())
631            .borders(Borders::ALL)
632            .border_set(border::ROUNDED)
633            .border_style(Style::default().fg(Color::DarkGray));
634        frame.render_widget(Paragraph::new(Line::from(line)).block(block), area);
635    }
636
637    fn draw_main(&self, frame: &mut Frame, area: Rect) {
638        if let Some(mode) = self.scope_mode {
639            self.draw_scope(frame, area, mode);
640            return;
641        }
642
643        if self.playlist_visible {
644            self.draw_playlist(frame, area);
645            return;
646        }
647
648        let columns = Layout::default()
649            .direction(Direction::Horizontal)
650            .constraints([Constraint::Length(19), Constraint::Min(40)])
651            .split(area);
652
653        self.draw_track(frame, columns[0]);
654        self.draw_status(frame, columns[1]);
655    }
656
657    fn draw_scope(&self, frame: &mut Frame, area: Rect, mode: ScopeMode) {
658        let cfg = GraphConfig {
659            samples: 256,
660            sampling_rate: 48_000,
661            scale: 1.2,
662            width: area.width as u32,
663            scatter: false,
664            references: true,
665            show_ui: false,
666            marker_type: GraphMarker::Braille,
667            palette: vec![Color::LightCyan, Color::LightMagenta],
668            labels_color: Color::Yellow,
669            axis_color: Color::DarkGray,
670            ..GraphConfig::default()
671        };
672        let data = self.scope_data(cfg.samples as usize);
673
674        match mode {
675            ScopeMode::Dual => {
676                let panes = Layout::default()
677                    .direction(Direction::Horizontal)
678                    .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
679                    .split(area);
680                let mut oscillo = Oscilloscope::default();
681                self.draw_scope_chart(
682                    frame,
683                    panes[0],
684                    &cfg,
685                    &data,
686                    &mut oscillo,
687                    "Scope",
688                    Some("time"),
689                );
690                let mut scope = Vectorscope::default();
691                self.draw_scope_chart(frame, panes[1], &cfg, &data, &mut scope, "Vector", None);
692            }
693            ScopeMode::Spectroscope => {
694                let mut scope = Spectroscope {
695                    sampling_rate: cfg.sampling_rate,
696                    buffer_size: cfg.samples,
697                    average: 1,
698                    buf: Vec::new(),
699                    window: true,
700                    log_y: true,
701                    phase_diff: false,
702                };
703                self.draw_scope_chart(frame, area, &cfg, &data, &mut scope, "Spectro", None);
704            }
705        }
706    }
707
708    fn draw_scope_chart<M: DisplayMode>(
709        &self,
710        frame: &mut Frame,
711        area: Rect,
712        cfg: &GraphConfig,
713        data: &Matrix<f64>,
714        mode: &mut M,
715        title: &str,
716        x_label: Option<&str>,
717    ) {
718        let mut series = mode.references(cfg);
719        series.extend(mode.process(cfg, data));
720        let datasets = series.iter().map(Into::into).collect::<Vec<_>>();
721
722        let block = Block::default()
723            .title(format!("─{title} ╾"))
724            .title_style(block_title_style())
725            .borders(Borders::ALL)
726            .border_set(border::ROUNDED)
727            .border_style(Style::default().fg(Color::DarkGray));
728
729        let chart = Chart::new(datasets)
730            .block(block)
731            .x_axis(mode.axis(cfg, Dimension::X))
732            .y_axis(mode.axis(cfg, Dimension::Y));
733        frame.render_widget(chart, area);
734
735        if let Some(label) = x_label {
736            let label_area = Rect {
737                x: area.x.saturating_add(1),
738                y: area.bottom().saturating_sub(2),
739                width: area.width.saturating_sub(2),
740                height: 1,
741            };
742            frame.render_widget(
743                Paragraph::new(label)
744                    .alignment(Alignment::Center)
745                    .style(Style::default().fg(COMMAND_FG)),
746                label_area,
747            );
748        }
749    }
750
751    fn draw_playlist(&self, frame: &mut Frame, area: Rect) {
752        let visible_rows = area.height.saturating_sub(3) as usize;
753        let max_scroll = self
754            .playlist_entries
755            .len()
756            .saturating_sub(visible_rows.max(1));
757        let scroll = self.playlist_scroll.min(max_scroll);
758        let end = (scroll + visible_rows).min(self.playlist_entries.len());
759        let rows = self.playlist_entries[scroll..end]
760            .iter()
761            .enumerate()
762            .map(|(idx, entry)| {
763                let absolute = scroll + idx + 1;
764                Row::new(vec![
765                    Cell::from(format!("{absolute:03}")),
766                    Cell::from(entry.icon.as_str()),
767                    Cell::from(entry.name.as_str()),
768                    Cell::from(entry.kind.as_str()),
769                    Cell::from(entry.duration.as_str()),
770                    Cell::from(entry.size.as_str()),
771                ])
772            })
773            .collect::<Vec<_>>();
774
775        let block = Block::default()
776            .title(format!(
777                "─Playlist  /apps/scope/tui/folder  {} entries ╾",
778                self.playlist_entries.len()
779            ))
780            .title_style(block_title_style())
781            .borders(Borders::ALL)
782            .border_set(border::ROUNDED)
783            .border_style(Style::default().fg(Color::DarkGray));
784        let inner = inset_x(block.inner(area), 1);
785
786        let table = Table::new(
787            rows,
788            [
789                Constraint::Length(5),
790                Constraint::Length(3),
791                Constraint::Min(22),
792                Constraint::Length(10),
793                Constraint::Length(9),
794                Constraint::Length(9),
795            ],
796        )
797        .header(
798            Row::new(vec!["#", "", "name", "kind", "duration", "size"]).style(
799                Style::default()
800                    .fg(Color::Cyan)
801                    .add_modifier(Modifier::BOLD),
802            ),
803        )
804        .block(block)
805        .style(Style::default().fg(Color::Gray))
806        .column_spacing(1);
807        frame.render_widget(table, area);
808
809        let mut scrollbar_state = ScrollbarState::new(self.playlist_entries.len()).position(scroll);
810        let scrollbar = Scrollbar::default()
811            .orientation(ScrollbarOrientation::VerticalRight)
812            .thumb_style(Style::default().fg(Color::Yellow))
813            .track_style(Style::default().fg(Color::DarkGray));
814        frame.render_stateful_widget(scrollbar, inner, &mut scrollbar_state);
815    }
816
817    fn draw_track(&self, frame: &mut Frame, area: Rect) {
818        let fields = [
819            ("file", self.track.file.clone()),
820            ("album", self.track.album.clone()),
821            ("artist", self.track.artist.clone()),
822            ("codec", self.track.codec.clone()),
823            ("bitrate", self.track.bitrate.clone()),
824            ("sample", self.track.sample_rate.clone()),
825            ("channels", self.track.channels.clone()),
826            ("size", self.track.size.clone()),
827            ("gain", format!("{} dB", signed(self.gain))),
828            ("pitch", format!("{} st", signed(self.pitch))),
829        ];
830        let block = Block::default()
831            .title("─Playback ╾")
832            .title_style(block_title_style())
833            .borders(Borders::ALL)
834            .border_set(border::ROUNDED)
835            .border_style(Style::default().fg(Color::DarkGray));
836        let inner = inset_x(block.inner(area), 1);
837
838        let mut lines = Vec::new();
839        for (label, value) in fields {
840            lines.push(Line::from(Span::styled(
841                label,
842                plain_style().fg(Color::Gray),
843            )));
844            lines.extend(wrapped_track_value_lines(&value, inner.width));
845        }
846
847        frame.render_widget(block, area);
848        frame.render_widget(Paragraph::new(lines), inner);
849    }
850
851    fn draw_status(&self, frame: &mut Frame, area: Rect) {
852        let rows = Layout::default()
853            .direction(Direction::Vertical)
854            .constraints([
855                Constraint::Length(6),
856                Constraint::Length(4),
857                Constraint::Min(2),
858            ])
859            .split(area);
860
861        let status_columns = Layout::default()
862            .direction(Direction::Horizontal)
863            .constraints([Constraint::Length(12), Constraint::Min(12)])
864            .split(rows[0]);
865
866        self.draw_transport_slot(frame, status_columns[0]);
867        self.draw_status_panel(frame, status_columns[1]);
868        self.draw_meters(frame, rows[1]);
869        self.draw_log(frame, rows[2]);
870    }
871
872    fn draw_transport_slot(&self, frame: &mut Frame, area: Rect) {
873        let symbol = if self.playing { "▶" } else { "⏸" };
874        let (top_title, bottom_title) = transport_titles(
875            symbol,
876            area.width.saturating_sub(2) as usize,
877            (self.started_at.elapsed().as_millis() / 180) as usize,
878        );
879        let block = Block::default()
880            .title_top(top_title)
881            .title_bottom(bottom_title)
882            .borders(Borders::ALL)
883            .border_set(border::ROUNDED)
884            .border_style(Style::default().fg(Color::DarkGray));
885        let inner = block.inner(area);
886        frame.render_widget(block, area);
887
888        let hot = Style::default().fg(SYSTEM_BG).add_modifier(Modifier::BOLD);
889        let lines = if self.playing {
890            vec![
891                Line::from(Span::styled("   █▄     ", hot)),
892                Line::from(Span::styled("   ████▄  ", hot)),
893                Line::from(Span::styled("   ████▀  ", hot)),
894                Line::from(Span::styled("   █▀     ", hot)),
895            ]
896        } else {
897            vec![
898                Line::from(Span::styled(" ▐██  ██▌ ", hot)),
899                Line::from(Span::styled(" ▐██  ██▌ ", hot)),
900                Line::from(Span::styled(" ▐██  ██▌ ", hot)),
901                Line::from(Span::styled(" ▝▀▀  ▀▀▘ ", hot)),
902            ]
903        };
904
905        frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner);
906    }
907
908    fn draw_status_panel(&self, frame: &mut Frame, area: Rect) {
909        let block = Block::default()
910            .title("─Status ╾")
911            .title_style(block_title_style())
912            .borders(Borders::ALL)
913            .border_set(border::ROUNDED)
914            .border_style(Style::default().fg(Color::DarkGray));
915        let inner = inset_x(block.inner(area), 1);
916        frame.render_widget(block, area);
917
918        let rows = Layout::default()
919            .direction(Direction::Vertical)
920            .constraints([
921                Constraint::Length(1),
922                Constraint::Length(1),
923                Constraint::Length(2),
924            ])
925            .split(inner);
926
927        self.draw_status_header(frame, rows[0]);
928        self.draw_progress(frame, rows[1]);
929        self.draw_marker_row(frame, rows[2]);
930    }
931
932    fn draw_status_header(&self, frame: &mut Frame, area: Rect) {
933        let spinner = if self.playing {
934            SPINNER[self.spinner_idx]
935        } else {
936            "·"
937        };
938        let samples_total = 10_833_502_u64;
939        let samples_now =
940            samples_total.saturating_mul(self.progress_secs) / self.duration_secs.max(1);
941        let record = if self.recording { "  REC armed" } else { "" };
942
943        let content = if area.width < 60 {
944            let left = vec![Span::styled(
945                format!("{spinner} "),
946                Style::default()
947                    .fg(Color::LightGreen)
948                    .add_modifier(Modifier::BOLD),
949            )];
950            let right = vec![
951                Span::styled(
952                    samples_now.to_string(),
953                    Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
954                ),
955                Span::raw("  "),
956                Span::styled(
957                    format!("{}%", self.progress_percent()),
958                    Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
959                ),
960                Span::styled(
961                    record,
962                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
963                ),
964                Span::raw(" "),
965            ];
966            justified_line(left, right, area.width)
967        } else {
968            let left = vec![Span::styled(
969                format!("{spinner} "),
970                Style::default()
971                    .fg(Color::LightGreen)
972                    .add_modifier(Modifier::BOLD),
973            )];
974            let right = vec![
975                Span::styled(
976                    format!("{samples_now}/{samples_total}"),
977                    Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
978                ),
979                Span::raw("  "),
980                Span::styled(
981                    format!("{}%", self.progress_percent()),
982                    Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
983                ),
984                Span::styled(
985                    record,
986                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
987                ),
988                Span::raw(" "),
989            ];
990            justified_line(left, right, area.width)
991        };
992
993        frame.render_widget(Paragraph::new(content), area);
994    }
995
996    fn draw_progress(&self, frame: &mut Frame, area: Rect) {
997        let area = Rect {
998            x: area.x.saturating_add(1),
999            width: area.width.saturating_sub(2),
1000            ..area
1001        };
1002        let width = usize::from(area.width);
1003        if width == 0 {
1004            return;
1005        }
1006        if width == 1 {
1007            frame.render_widget(
1008                Paragraph::new(Span::styled("█", Style::default().fg(BAR_FILL))),
1009                area,
1010            );
1011            return;
1012        }
1013
1014        let ratio = self.progress_secs as f64 / self.duration_secs.max(1) as f64;
1015        let filled = ((width.saturating_sub(2) as f64) * ratio).round() as usize;
1016        let empty = width.saturating_sub(2).saturating_sub(filled);
1017        let right_cap = if ratio >= 1.0 {
1018            BAR_FILL
1019        } else {
1020            Color::DarkGray
1021        };
1022
1023        let mut cells = Vec::with_capacity(width);
1024        cells.push(BarCell::new('▌', BAR_FILL));
1025        cells.extend((0..filled).map(|_| BarCell::new('█', BAR_FILL)));
1026        cells.extend((0..empty).map(|_| BarCell::new('█', Color::DarkGray)));
1027        cells.push(BarCell::new('▐', right_cap));
1028
1029        let elapsed = fmt_time(self.progress_secs);
1030        let duration = fmt_time(self.duration_secs);
1031        let duration_start = width.saturating_sub(1 + duration.chars().count());
1032        place_bar_text(&mut cells, duration_start, &duration, Color::DarkGray);
1033
1034        let elapsed_start = if width > 2 {
1035            let elapsed_width = elapsed.chars().count();
1036            let pos = 1 + filled.saturating_sub(elapsed_width);
1037            let max_start = duration_start.saturating_sub(elapsed.chars().count() + 1);
1038            pos.min(max_start).max(1)
1039        } else {
1040            0
1041        };
1042        place_bar_text(&mut cells, elapsed_start, &elapsed, BAR_FILL);
1043
1044        let line = Line::from(
1045            cells
1046                .into_iter()
1047                .map(|cell| Span::styled(cell.text, cell.style))
1048                .collect::<Vec<_>>(),
1049        );
1050        frame.render_widget(Paragraph::new(line), area);
1051    }
1052
1053    fn draw_marker_row(&self, frame: &mut Frame, area: Rect) {
1054        let area = Rect {
1055            x: area.x.saturating_add(1),
1056            width: area.width.saturating_sub(2),
1057            ..area
1058        };
1059        let width = area.width.max(1) as usize;
1060        let mut arrows = vec![' '; width];
1061        let mut names = vec![' '; width];
1062
1063        for marker in &self.labels {
1064            let pos = marker_position(marker.seconds, self.duration_secs, width);
1065            if pos < arrows.len() {
1066                arrows[pos] = '⬆';
1067                place_centered(&mut names, pos, &marker.name);
1068            }
1069        }
1070
1071        let content = vec![
1072            Line::from(Span::styled(
1073                arrows.iter().collect::<String>(),
1074                Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
1075            )),
1076            Line::from(Span::styled(
1077                names.iter().collect::<String>(),
1078                Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
1079            )),
1080        ];
1081        frame.render_widget(Paragraph::new(content).alignment(Alignment::Left), area);
1082    }
1083
1084    fn draw_meters(&self, frame: &mut Frame, area: Rect) {
1085        let (output_icon, output_text, output_color) = if self.muted {
1086            ("🔇", "muted", Color::Red)
1087        } else {
1088            ("🔈", "live", Color::LightGreen)
1089        };
1090        let output_title = Title::from(
1091            Line::from(vec![
1092                Span::styled("──┤", Style::default().fg(Color::DarkGray)),
1093                icon_span(output_icon),
1094                Span::styled(
1095                    output_text,
1096                    Style::default()
1097                        .fg(output_color)
1098                        .add_modifier(Modifier::BOLD),
1099                ),
1100                Span::styled("├───", Style::default().fg(Color::DarkGray)),
1101            ])
1102            .centered(),
1103        );
1104        let block = Block::default()
1105            .title("─Volume ╾")
1106            .title(output_title)
1107            .title_style(block_title_style())
1108            .borders(Borders::ALL)
1109            .border_set(border::ROUNDED)
1110            .border_style(Style::default().fg(Color::DarkGray));
1111        let inner = inset_x(block.inner(area), 1);
1112        frame.render_widget(block, area);
1113
1114        let l = if self.muted { 0 } else { self.volume as u64 };
1115        let r = if self.muted {
1116            0
1117        } else {
1118            self.volume.saturating_sub(3) as u64
1119        };
1120        let channel_rows = Layout::default()
1121            .direction(Direction::Vertical)
1122            .constraints([Constraint::Length(1), Constraint::Length(1)])
1123            .split(inner);
1124        self.draw_channel_meter(frame, channel_rows[0], "L", l);
1125        self.draw_channel_meter(frame, channel_rows[1], "R", r);
1126    }
1127
1128    fn draw_channel_meter(&self, frame: &mut Frame, area: Rect, label: &str, value: u64) {
1129        let columns = Layout::default()
1130            .direction(Direction::Horizontal)
1131            .constraints([Constraint::Length(3), Constraint::Min(8)])
1132            .split(area);
1133
1134        frame.render_widget(
1135            Paragraph::new(label).style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)),
1136            columns[0],
1137        );
1138        let gauge = Gauge::default()
1139            .gauge_style(Style::default().fg(BAR_FILL).bg(Color::DarkGray))
1140            .ratio(value as f64 / 100.0)
1141            .label("");
1142        frame.render_widget(gauge, columns[1]);
1143
1144        let percent = format!("{}%", value);
1145        let percent_area = Rect {
1146            x: columns[1].x.saturating_add(1),
1147            width: columns[1].width.saturating_sub(1),
1148            ..columns[1]
1149        };
1150        frame.render_widget(
1151            Paragraph::new(Span::styled(
1152                percent,
1153                Style::reset().fg(BAR_TIME_FG).bg(BAR_FILL),
1154            )),
1155            percent_area,
1156        );
1157
1158        let db = if self.muted {
1159            "-inf dB".to_string()
1160        } else {
1161            format!("{:.1} dB", value as f64 * 0.48 - 30.0)
1162        };
1163        let db_width = db.chars().count() as u16;
1164        let db_area = Rect {
1165            x: columns[1].right().saturating_sub(db_width + 1),
1166            width: db_width,
1167            ..columns[1]
1168        };
1169        frame.render_widget(
1170            Paragraph::new(Span::styled(
1171                db,
1172                Style::reset().fg(BAR_TIME_FG).bg(Color::DarkGray),
1173            )),
1174            db_area,
1175        );
1176    }
1177
1178    fn draw_log(&self, frame: &mut Frame, area: Rect) {
1179        let block = Block::default()
1180            .title("─Log ╾")
1181            .title_style(block_title_style())
1182            .borders(Borders::ALL)
1183            .border_set(border::ROUNDED)
1184            .border_style(Style::default().fg(Color::DarkGray));
1185        let inner = inset_x(block.inner(area), 1);
1186        if inner.height == 0 {
1187            return;
1188        }
1189
1190        frame.render_widget(block, area);
1191
1192        let visible = inner.height as usize;
1193        let start = self.logs.len().saturating_sub(visible);
1194        let items = self.logs[start..]
1195            .iter()
1196            .map(|item| ListItem::new(item.as_str()))
1197            .collect::<Vec<_>>();
1198
1199        let list = List::new(items).style(Style::default().fg(Color::Gray));
1200        frame.render_widget(list, inner);
1201    }
1202
1203    fn insert(&mut self, ch: char) {
1204        let byte = self.byte_index(self.cursor);
1205        self.input.insert(byte, ch);
1206        self.cursor += 1;
1207    }
1208
1209    fn backspace(&mut self) {
1210        if self.cursor == 0 {
1211            return;
1212        }
1213
1214        let byte = self.byte_index(self.cursor);
1215        let prev = self.byte_index(self.cursor - 1);
1216        self.input.replace_range(prev..byte, "");
1217        self.cursor -= 1;
1218    }
1219
1220    fn delete(&mut self) {
1221        if self.cursor >= self.input.chars().count() {
1222            return;
1223        }
1224
1225        let start = self.byte_index(self.cursor);
1226        let end = self.byte_index(self.cursor + 1);
1227        self.input.replace_range(start..end, "");
1228    }
1229
1230    fn byte_index(&self, char_index: usize) -> usize {
1231        self.input
1232            .char_indices()
1233            .nth(char_index)
1234            .map(|(index, _)| index)
1235            .unwrap_or(self.input.len())
1236    }
1237
1238    fn toggle_playback(&mut self) {
1239        self.playing = !self.playing;
1240        if self.playing {
1241            self.respond("play: visual timeline running; audio engine not attached");
1242            self.last_progress_tick = Instant::now();
1243        } else {
1244            self.respond("pause: timeline held");
1245        }
1246    }
1247
1248    fn toggle_mute(&mut self) {
1249        self.muted = !self.muted;
1250        if self.muted {
1251            self.respond("mute: output marked silent");
1252        } else {
1253            self.respond("unmute: output restored");
1254        }
1255    }
1256
1257    fn adjust_volume(&mut self, delta: i16) {
1258        self.set_volume(self.volume as i16 + delta);
1259    }
1260
1261    fn set_volume(&mut self, volume: i16) {
1262        self.volume = volume.clamp(0, 100) as u16;
1263        self.respond(format!("volume: {}%", self.volume));
1264    }
1265
1266    fn show_mainview(&mut self) {
1267        self.playlist_visible = false;
1268        self.scope_mode = None;
1269        self.respond("mainview: playback view restored");
1270    }
1271
1272    fn scroll_playlist(&mut self, delta: isize) {
1273        let max_scroll = self.playlist_entries.len().saturating_sub(1);
1274        self.playlist_scroll = self
1275            .playlist_scroll
1276            .saturating_add_signed(delta)
1277            .min(max_scroll);
1278    }
1279
1280    fn scope_data(&self, samples: usize) -> Matrix<f64> {
1281        let mut left = Vec::with_capacity(samples);
1282        let mut right = Vec::with_capacity(samples);
1283
1284        for index in 0..samples {
1285            let t = index as f64 / samples.max(1) as f64;
1286            let sweep = self.scope_phase + (t * std::f64::consts::TAU);
1287            let l = (sweep * 3.0).sin() * 0.70 + (sweep * 11.0).sin() * 0.18;
1288            let r = (sweep * 2.0 + 0.8).sin() * 0.55 + (sweep * 7.0).cos() * 0.24;
1289            left.push(l);
1290            right.push(r);
1291        }
1292
1293        vec![left, right]
1294    }
1295
1296    fn seek(&mut self, seconds: u64) {
1297        self.progress_secs = seconds.min(self.duration_secs);
1298        self.respond(format!("goto: {}", fmt_time(self.progress_secs)));
1299    }
1300
1301    fn next_track(&mut self) {
1302        self.track = self.next_track.clone();
1303        self.file_path.clone_from(&self.next_file_path);
1304        self.progress_secs = self.next_progress_secs;
1305        self.duration_secs = self.next_duration_secs;
1306        self.labels = default_labels(self.next_duration_secs);
1307        self.respond("next: loaded demo metadata only");
1308    }
1309
1310    fn prev_track(&mut self) {
1311        self.track = self.default_track.clone();
1312        self.file_path.clone_from(&self.default_file_path);
1313        self.progress_secs = self.default_progress_secs;
1314        self.duration_secs = self.default_duration_secs;
1315        self.labels = default_labels(self.default_duration_secs);
1316        self.respond("prev: restored first demo metadata");
1317    }
1318
1319    fn load_path(&mut self, path: String) {
1320        let file = Path::new(&path)
1321            .file_name()
1322            .and_then(|name| name.to_str())
1323            .unwrap_or(path.as_str())
1324            .to_string();
1325
1326        self.file_path = path;
1327        self.track.file = file;
1328        self.progress_secs = 0;
1329        self.playing = false;
1330        self.respond("load: metadata mocked; playback backend still disconnected");
1331    }
1332
1333    fn shutdown(&mut self, message: impl Into<String>) {
1334        self.respond(message);
1335        self.should_quit = true;
1336    }
1337
1338    fn respond(&mut self, message: impl Into<String>) {
1339        let message = message.into();
1340        self.last_response = compact_response(&message);
1341        self.log(message);
1342    }
1343
1344    fn log(&mut self, message: impl Into<String>) {
1345        self.logs.push(message.into());
1346        if self.logs.len() > 200 {
1347            self.logs.remove(0);
1348        }
1349    }
1350
1351    fn progress_percent(&self) -> u64 {
1352        self.progress_secs.saturating_mul(100) / self.duration_secs.max(1)
1353    }
1354
1355    fn labels_visible(&self) -> bool {
1356        self.scope_mode.is_none()
1357            && !self.playlist_visible
1358            && self.labels.iter().any(|marker| {
1359                let is_start = marker.name == "S" && marker.seconds == 0;
1360                let is_end = marker.name == "E" && marker.seconds == self.duration_secs;
1361                !(is_start || is_end)
1362            })
1363    }
1364
1365    fn hotkey_state(&self) -> u8 {
1366        let elapsed = self.started_at.elapsed();
1367        let elapsed_ms = elapsed.as_millis();
1368
1369        if elapsed_ms < 1500 {
1370            return 0;
1371        }
1372
1373        let blink_ms = elapsed_ms - 1500;
1374        if blink_ms < 900 {
1375            return if (blink_ms / 150) % 2 == 0 { 1 } else { 0 };
1376        }
1377
1378        2
1379    }
1380
1381    fn idle_cursor_sweep_col(&self, width: u16) -> Option<u16> {
1382        let started_at = self.idle_cursor_sweep_started_at?;
1383        if width == 0 {
1384            return None;
1385        }
1386
1387        let elapsed = started_at.elapsed().as_millis();
1388        if elapsed >= IDLE_CURSOR_SWEEP_MS {
1389            return None;
1390        }
1391
1392        let start_col = self.prompt_cursor_col().min(width.saturating_sub(1));
1393        let max_col = width.saturating_sub(1);
1394        let travel = u128::from(max_col.saturating_sub(start_col));
1395        let half = IDLE_CURSOR_SWEEP_MS / 2;
1396        let offset = if elapsed <= half {
1397            elapsed.saturating_mul(travel) / half
1398        } else {
1399            IDLE_CURSOR_SWEEP_MS
1400                .saturating_sub(elapsed)
1401                .saturating_mul(travel)
1402                / half
1403        };
1404
1405        Some(start_col.saturating_add(offset as u16))
1406    }
1407
1408    fn prompt_cursor_col(&self) -> u16 {
1409        PROMPT_PREFIX
1410            .chars()
1411            .count()
1412            .try_into()
1413            .unwrap_or(u16::MAX)
1414            .saturating_add(self.cursor as u16)
1415    }
1416}
1417
1418impl control::ControlEventHandler for App {
1419    fn on_load(&mut self, event: &control::ParsedCommand) {
1420        let path = event.rest();
1421        if path.is_empty() {
1422            self.respond("load: missing path");
1423        } else {
1424            self.load_path(path);
1425        }
1426    }
1427
1428    fn on_play(&mut self, _event: &control::ParsedCommand) {
1429        self.playing = true;
1430        self.last_progress_tick = Instant::now();
1431        self.respond("play: visual timeline running; audio engine not attached");
1432    }
1433
1434    fn on_pause(&mut self, _event: &control::ParsedCommand) {
1435        self.playing = false;
1436        self.respond("pause: timeline held");
1437    }
1438
1439    fn on_quit(&mut self, _event: &control::ParsedCommand) {
1440        self.shutdown("shutdown: leaving tui demo");
1441    }
1442
1443    fn on_terminate(&mut self, _event: &control::ParsedCommand) {
1444        self.shutdown("shutdown: leaving tui demo");
1445    }
1446
1447    fn on_mainview(&mut self, _event: &control::ParsedCommand) {
1448        self.show_mainview();
1449    }
1450
1451    fn on_next(&mut self, _event: &control::ParsedCommand) {
1452        self.next_track();
1453    }
1454
1455    fn on_prev(&mut self, _event: &control::ParsedCommand) {
1456        self.prev_track();
1457    }
1458
1459    fn on_goto(&mut self, event: &control::ParsedCommand) {
1460        match event.arg(0).and_then(parse_time) {
1461            Some(seconds) => self.seek(seconds),
1462            None => self.respond("goto: use mm:ss or seconds"),
1463        }
1464    }
1465
1466    fn on_label(&mut self, event: &control::ParsedCommand) {
1467        let (name, seconds) = match (event.arg(0), event.arg(1)) {
1468            (None, _) => ("L", self.progress_secs),
1469            (Some(raw_time), None) if raw_time.starts_with('-') => {
1470                self.respond(format!(
1471                    "label: time must be between 00:00 and {}",
1472                    fmt_time(self.duration_secs)
1473                ));
1474                return;
1475            }
1476            (Some(raw_time), None) if raw_time.contains(':') || raw_time.parse::<u64>().is_ok() => {
1477                let Some(seconds) = parse_time(raw_time) else {
1478                    self.respond(format!(
1479                        "label: time must be between 00:00 and {}",
1480                        fmt_time(self.duration_secs)
1481                    ));
1482                    return;
1483                };
1484                ("L", seconds)
1485            }
1486            (Some(name), None) => (name, self.progress_secs),
1487            (Some(name), Some(raw_time)) => {
1488                let Some(seconds) = parse_time(raw_time) else {
1489                    self.respond(format!(
1490                        "label: time must be between 00:00 and {}",
1491                        fmt_time(self.duration_secs)
1492                    ));
1493                    return;
1494                };
1495                (name, seconds)
1496            }
1497        };
1498
1499        if seconds > self.duration_secs {
1500            self.respond(format!(
1501                "label: time must be between 00:00 and {}",
1502                fmt_time(self.duration_secs)
1503            ));
1504            return;
1505        }
1506
1507        self.labels.push(Marker::new(name, seconds));
1508        self.respond(format!("label: {name} at {}", fmt_time(seconds)));
1509    }
1510
1511    fn on_loop(&mut self, event: &control::ParsedCommand) {
1512        let start = event.arg(0).and_then(parse_time);
1513        let end = event.arg(1).and_then(parse_time);
1514
1515        match (start, end) {
1516            (Some(start), Some(end)) if start < end => {
1517                self.loop_range = Some((start, end.min(self.duration_secs)));
1518                self.respond(format!("loop: {} to {}", fmt_time(start), fmt_time(end)));
1519            }
1520            _ => self.respond("loop: use loop <start> <end>, for example loop 0:12 1:05"),
1521        }
1522    }
1523
1524    fn on_clear_loop(&mut self, _event: &control::ParsedCommand) {
1525        self.loop_range = None;
1526        self.respond("loop: cleared");
1527    }
1528
1529    fn on_volume(&mut self, event: &control::ParsedCommand) {
1530        let Some(value) = event.arg(0) else {
1531            self.respond(format!("volume: {}%", self.volume));
1532            return;
1533        };
1534
1535        match value {
1536            "+" | "up" => self.adjust_volume(5),
1537            "-" | "down" => self.adjust_volume(-5),
1538            raw => match raw.parse::<i16>() {
1539                Ok(value) => self.set_volume(value),
1540                Err(_) => self.respond("volume: use vol +, vol -, or vol <0-100>"),
1541            },
1542        }
1543    }
1544
1545    fn on_volume_up(&mut self, _event: &control::ParsedCommand) {
1546        self.adjust_volume(5);
1547    }
1548
1549    fn on_volume_down(&mut self, _event: &control::ParsedCommand) {
1550        self.adjust_volume(-5);
1551    }
1552
1553    fn on_mute(&mut self, _event: &control::ParsedCommand) {
1554        self.muted = true;
1555        self.respond("mute: output marked silent");
1556    }
1557
1558    fn on_unmute(&mut self, _event: &control::ParsedCommand) {
1559        self.muted = false;
1560        self.respond("unmute: output restored");
1561    }
1562
1563    fn on_gain(&mut self, event: &control::ParsedCommand) {
1564        match event.arg(0).and_then(|value| value.parse::<i16>().ok()) {
1565            Some(value) => {
1566                self.gain = value.clamp(-24, 24);
1567                self.respond(format!("gain: {} dB", signed(self.gain)));
1568            }
1569            None => self.respond("gain: use gain <-24..24>"),
1570        }
1571    }
1572
1573    fn on_pitch(&mut self, event: &control::ParsedCommand) {
1574        match event.arg(0).and_then(|value| value.parse::<i16>().ok()) {
1575            Some(value) => {
1576                self.pitch = value.clamp(-12, 12);
1577                self.respond(format!("pitch: {} semitones", signed(self.pitch)));
1578            }
1579            None => self.respond("pitch: use pitch <-12..12>"),
1580        }
1581    }
1582
1583    fn on_cut(&mut self, event: &control::ParsedCommand) {
1584        let start = event.arg(0).and_then(parse_time);
1585        let end = event.arg(1).and_then(parse_time);
1586
1587        match (start, end) {
1588            (Some(start), Some(end)) if start < end => {
1589                self.respond(format!(
1590                    "cut: staged {}..{}; no file has been changed",
1591                    fmt_time(start),
1592                    fmt_time(end)
1593                ));
1594            }
1595            _ => self.respond("cut: use cut <start> <end>"),
1596        }
1597    }
1598
1599    fn on_rec(&mut self, _event: &control::ParsedCommand) {
1600        self.recording = !self.recording;
1601        if self.recording {
1602            self.respond("record: armed visually; microphone is not connected");
1603        } else {
1604            self.respond("record: disarmed");
1605        }
1606    }
1607
1608    fn on_mic(&mut self, _event: &control::ParsedCommand) {
1609        self.respond("mic: demo input meter only; no capture device opened");
1610    }
1611
1612    fn on_save(&mut self, event: &control::ParsedCommand) {
1613        let path = event.rest();
1614        if path.is_empty() {
1615            self.respond("save: missing path");
1616        } else {
1617            self.respond(format!("save: staged export to {path}; no bytes written"));
1618        }
1619    }
1620
1621    fn on_playlist(&mut self, _event: &control::ParsedCommand) {
1622        self.playlist_visible = true;
1623        self.scope_mode = None;
1624        self.respond(format!(
1625            "playlist: showing folder demo with {} entries",
1626            self.playlist_entries.len()
1627        ));
1628    }
1629
1630    fn on_playlist_top(&mut self, _event: &control::ParsedCommand) {
1631        self.playlist_scroll = 0;
1632        self.respond("playlist: top");
1633    }
1634
1635    fn on_playlist_bottom(&mut self, _event: &control::ParsedCommand) {
1636        self.playlist_scroll = self.playlist_entries.len().saturating_sub(1);
1637        self.respond("playlist: bottom");
1638    }
1639
1640    fn on_playlist_up(&mut self, _event: &control::ParsedCommand) {
1641        self.scroll_playlist(-1);
1642        self.respond(format!("playlist: row {}", self.playlist_scroll + 1));
1643    }
1644
1645    fn on_playlist_down(&mut self, _event: &control::ParsedCommand) {
1646        self.scroll_playlist(1);
1647        self.respond(format!("playlist: row {}", self.playlist_scroll + 1));
1648    }
1649
1650    fn on_webradio(&mut self, _event: &control::ParsedCommand) {
1651        self.respond("webradio: stream list is not wired yet");
1652    }
1653
1654    fn on_scope(&mut self, _event: &control::ParsedCommand) {
1655        self.scope_mode = Some(ScopeMode::Dual);
1656        self.playlist_visible = false;
1657        self.respond("scope: showing scope-tui oscillo/vector demo");
1658    }
1659
1660    fn on_spectro(&mut self, _event: &control::ParsedCommand) {
1661        self.scope_mode = Some(ScopeMode::Spectroscope);
1662        self.playlist_visible = false;
1663        self.respond("scope: showing scope-tui spectroscope demo");
1664    }
1665
1666    fn on_transcribe(&mut self, _event: &control::ParsedCommand) {
1667        self.respond("transcribe: would listen and append words to log/file");
1668    }
1669
1670    fn on_help(&mut self, _event: &control::ParsedCommand) {
1671        self.respond(format!("commands: {}", control::command_names()));
1672    }
1673}
1674
1675#[derive(Debug, Clone)]
1676struct Track {
1677    file: String,
1678    album: String,
1679    artist: String,
1680    codec: String,
1681    bitrate: String,
1682    sample_rate: String,
1683    channels: String,
1684    size: String,
1685}
1686
1687impl From<TrackData> for Track {
1688    fn from(track: TrackData) -> Self {
1689        Self {
1690            file: track.file,
1691            album: track.album,
1692            artist: track.artist,
1693            codec: track.codec,
1694            bitrate: track.bitrate,
1695            sample_rate: track.sample_rate,
1696            channels: track.channels,
1697            size: track.size,
1698        }
1699    }
1700}
1701
1702#[derive(Debug, Clone)]
1703struct Marker {
1704    name: String,
1705    seconds: u64,
1706}
1707
1708impl Marker {
1709    fn new(name: impl Into<String>, seconds: u64) -> Self {
1710        Self {
1711            name: name.into(),
1712            seconds,
1713        }
1714    }
1715}
1716
1717fn default_labels(duration_secs: u64) -> Vec<Marker> {
1718    vec![Marker::new("S", 0), Marker::new("E", duration_secs)]
1719}
1720
1721struct BarCell {
1722    text: String,
1723    style: Style,
1724}
1725
1726impl BarCell {
1727    fn new(ch: char, color: Color) -> Self {
1728        Self {
1729            text: ch.to_string(),
1730            style: Style::default().fg(color),
1731        }
1732    }
1733
1734    fn set_text(&mut self, ch: char, bg: Color) {
1735        self.text = ch.to_string();
1736        self.style = Style::reset().fg(BAR_TIME_FG).bg(bg);
1737    }
1738}
1739
1740fn place_bar_text(cells: &mut [BarCell], start: usize, text: &str, bg: Color) {
1741    for (idx, ch) in text.chars().enumerate() {
1742        if let Some(cell) = cells.get_mut(start + idx) {
1743            cell.set_text(ch, bg);
1744        }
1745    }
1746}
1747
1748fn compact_response(message: &str) -> String {
1749    let summary = message
1750        .split_once(':')
1751        .map(|(head, _)| head)
1752        .unwrap_or(message)
1753        .trim();
1754    let words = summary.split_whitespace().take(5).collect::<Vec<_>>();
1755
1756    if words.is_empty() {
1757        "ready".into()
1758    } else {
1759        words.join(" ")
1760    }
1761}
1762
1763fn wrapped_track_value_lines(value: &str, width: u16) -> Vec<Line<'static>> {
1764    const INDENT: usize = 2;
1765    const MAX_LINES: usize = 3;
1766
1767    let text_width = usize::from(width).saturating_sub(INDENT).max(1);
1768    let mut chunks = wrap_text(value, text_width);
1769    let overflow = chunks.len() > MAX_LINES;
1770    chunks.truncate(MAX_LINES);
1771
1772    if chunks.is_empty() {
1773        chunks.push(String::new());
1774    }
1775    if overflow {
1776        if let Some(last) = chunks.last_mut() {
1777            *last = ellipsize(last, text_width);
1778        }
1779    }
1780
1781    chunks
1782        .into_iter()
1783        .map(|line| {
1784            Line::from(Span::styled(
1785                format!("  {line}"),
1786                plain_style().fg(Color::Gray).add_modifier(Modifier::ITALIC),
1787            ))
1788        })
1789        .collect()
1790}
1791
1792fn wrap_text(text: &str, width: usize) -> Vec<String> {
1793    let mut lines = Vec::new();
1794    let mut current = String::new();
1795
1796    for word in text.split_whitespace() {
1797        let word_len = word.chars().count();
1798        if word_len > width {
1799            if !current.is_empty() {
1800                lines.push(current);
1801                current = String::new();
1802            }
1803            lines.extend(chunk_word(word, width));
1804            continue;
1805        }
1806
1807        let next_len = if current.is_empty() {
1808            word_len
1809        } else {
1810            current.chars().count() + 1 + word_len
1811        };
1812
1813        if next_len <= width {
1814            if !current.is_empty() {
1815                current.push(' ');
1816            }
1817            current.push_str(word);
1818        } else {
1819            lines.push(current);
1820            current = word.to_string();
1821        }
1822    }
1823
1824    if !current.is_empty() {
1825        lines.push(current);
1826    }
1827
1828    lines
1829}
1830
1831fn chunk_word(word: &str, width: usize) -> Vec<String> {
1832    let chars = word.chars().collect::<Vec<_>>();
1833    chars
1834        .chunks(width.max(1))
1835        .map(|chunk| chunk.iter().collect())
1836        .collect()
1837}
1838
1839fn ellipsize(text: &str, width: usize) -> String {
1840    if width <= 3 {
1841        return ".".repeat(width);
1842    }
1843
1844    let prefix = text.chars().take(width - 3).collect::<String>();
1845    format!("{prefix}...")
1846}
1847
1848#[derive(Debug, Clone)]
1849struct PlaylistEntry {
1850    icon: String,
1851    name: String,
1852    kind: String,
1853    duration: String,
1854    size: String,
1855}
1856
1857impl From<PlaylistEntryData> for PlaylistEntry {
1858    fn from(entry: PlaylistEntryData) -> Self {
1859        Self {
1860            icon: entry.icon,
1861            name: entry.name,
1862            kind: entry.kind,
1863            duration: entry.duration,
1864            size: entry.size,
1865        }
1866    }
1867}
1868
1869fn icon_span(text: &'static str) -> Span<'static> {
1870    Span::styled(text, plain_style())
1871}
1872
1873fn justified_line(
1874    mut left: Vec<Span<'static>>,
1875    right: Vec<Span<'static>>,
1876    width: u16,
1877) -> Line<'static> {
1878    let left_width = Line::from(left.clone()).width();
1879    let right_width = Line::from(right.clone()).width();
1880    let gap = usize::from(width).saturating_sub(left_width + right_width);
1881
1882    left.push(Span::raw(" ".repeat(gap)));
1883    left.extend(right);
1884    Line::from(left)
1885}
1886
1887fn block_title_style() -> Style {
1888    Style::default().fg(COMMAND_FG).add_modifier(Modifier::BOLD)
1889}
1890
1891fn inset_x(area: Rect, margin: u16) -> Rect {
1892    let margin = margin.min(area.width / 2);
1893    Rect {
1894        x: area.x + margin,
1895        width: area.width.saturating_sub(margin * 2),
1896        ..area
1897    }
1898}
1899
1900fn extend_right(area: Rect, amount: u16) -> Rect {
1901    Rect {
1902        width: area.width.saturating_add(amount),
1903        ..area
1904    }
1905}
1906
1907fn extend_left(area: Rect, amount: u16) -> Rect {
1908    let amount = amount.min(area.x);
1909    Rect {
1910        x: area.x.saturating_sub(amount),
1911        width: area.width.saturating_add(amount),
1912        ..area
1913    }
1914}
1915
1916fn transport_titles(symbol: &str, rail_len: usize, phase: usize) -> (String, String) {
1917    if rail_len < 3 {
1918        return (symbol.to_string(), symbol.to_string());
1919    }
1920
1921    let max_pos = rail_len.saturating_sub(2);
1922    let top_pos = 1 + pingpong_phase(phase, max_pos);
1923    let bottom_pos = rail_len.saturating_sub(top_pos + 2);
1924
1925    (
1926        transport_title(symbol, rail_len, top_pos),
1927        transport_title(symbol, rail_len, bottom_pos),
1928    )
1929}
1930
1931fn pingpong_phase(phase: usize, len: usize) -> usize {
1932    if len <= 1 {
1933        return 0;
1934    }
1935
1936    let cycle = len * 2 - 2;
1937    let step = phase % cycle;
1938    if step < len { step } else { cycle - step }
1939}
1940
1941fn transport_title(symbol: &str, rail_len: usize, pos: usize) -> String {
1942    let mut cells = vec!["─".to_string(); rail_len];
1943    if let Some(cell) = cells.get_mut(pos) {
1944        *cell = symbol.to_string();
1945    }
1946    if let Some(cell) = cells.get_mut(pos + 1) {
1947        *cell = " ".to_string();
1948    }
1949
1950    cells.concat()
1951}
1952
1953fn plain_style() -> Style {
1954    Style::default().remove_modifier(Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED)
1955}
1956
1957fn accent_span(text: &'static str) -> Span<'static> {
1958    Span::styled(
1959        text,
1960        Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
1961    )
1962}
1963
1964fn accent_span_owned(text: String) -> Span<'static> {
1965    Span::styled(
1966        text,
1967        Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
1968    )
1969}
1970
1971fn parse_time(input: &str) -> Option<u64> {
1972    let parts = input.split(':').collect::<Vec<_>>();
1973    match parts.as_slice() {
1974        [seconds] => seconds.parse().ok(),
1975        [minutes, seconds] => {
1976            let minutes = minutes.parse::<u64>().ok()?;
1977            let seconds = seconds.parse::<u64>().ok()?;
1978            Some(minutes * 60 + seconds)
1979        }
1980        [hours, minutes, seconds] => {
1981            let hours = hours.parse::<u64>().ok()?;
1982            let minutes = minutes.parse::<u64>().ok()?;
1983            let seconds = seconds.parse::<u64>().ok()?;
1984            Some(hours * 3600 + minutes * 60 + seconds)
1985        }
1986        _ => None,
1987    }
1988}
1989
1990fn marker_position(seconds: u64, duration: u64, width: usize) -> usize {
1991    ((seconds as f64 / duration.max(1) as f64) * width.saturating_sub(1) as f64).round() as usize
1992}
1993
1994fn place_centered(row: &mut [char], center: usize, text: &str) {
1995    let chars = text.chars().collect::<Vec<_>>();
1996    let start = center.saturating_sub(chars.len() / 2);
1997
1998    for (offset, ch) in chars.into_iter().enumerate() {
1999        let index = start + offset;
2000        if index < row.len() {
2001            row[index] = ch;
2002        }
2003    }
2004}
2005
2006fn fmt_time(seconds: u64) -> String {
2007    let minutes = seconds / 60;
2008    let seconds = seconds % 60;
2009    format!("{minutes:02}:{seconds:02}")
2010}
2011
2012fn signed(value: i16) -> String {
2013    if value > 0 {
2014        format!("+{value}")
2015    } else {
2016        value.to_string()
2017    }
2018}