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
59pub 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#[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#[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#[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}