use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{
Axis, Block, Borders, Chart, Dataset, Gauge, GraphType, Paragraph,
canvas::{Canvas, Context},
},
};
use super::app::{App, ViewMode, WaveformDisplayMode};
use super::save_dialog_ui::draw_save_dialog;
const MIN_HEIGHT_FOR_OSCILLOSCOPE: u16 = 20;
const LED_LEVEL_THRESHOLDS: [(f32, &str); 3] = [
(0.3, "●"), (0.05, "◐"), (0.0, "○"), ];
const LED_CLIPPING_LEVEL: f32 = 0.9;
const LED_HIGH_LEVEL: f32 = 0.3;
const LED_LOW_LEVEL: f32 = 0.05;
const GRID_COLOR: Color = Color::Rgb(0, 60, 30);
const GRID_VERTICAL_STEP: usize = 10;
const GRID_HORIZONTAL_LINES: [f64; 7] = [-0.75, -0.5, -0.25, 0.0, 0.25, 0.5, 0.75];
fn format_time(seconds: u64) -> String {
let mins = seconds / 60;
let secs = seconds % 60;
format!("{mins:02}:{secs:02}")
}
fn format_duration(duration: std::time::Duration) -> String {
format_time(duration.as_secs())
}
fn create_control_button(key: &str, style: Style) -> Span<'static> {
Span::styled(format!("[{key}]"), style)
}
fn create_control(key: &str, label: &str, style: Style) -> Vec<Span<'static>> {
vec![
create_control_button(key, style),
Span::raw(format!(" {label} ")),
]
}
pub fn draw(f: &mut Frame, app: &App) {
let size = f.area();
match app.view_mode {
ViewMode::Player => {
draw_main_ui(f, app);
}
ViewMode::Browser => {
draw_integrated_browser(f, app);
}
}
if let Some(ref save_dialog) = app.save_dialog {
draw_save_dialog(f, size, save_dialog);
}
}
fn draw_main_ui(f: &mut Frame, app: &App) {
let size = f.area();
let show_oscilloscope = size.height > MIN_HEIGHT_FOR_OSCILLOSCOPE;
let constraints = if show_oscilloscope {
vec![
Constraint::Length(2), Constraint::Length(3), Constraint::Length(3), Constraint::Min(7), Constraint::Length(4), ]
} else {
vec![
Constraint::Length(2), Constraint::Length(3), Constraint::Length(3), Constraint::Length(4), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(constraints)
.split(size);
let title_text = if let Some(file) = &app.current_file {
let filename = std::path::Path::new(file)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(file);
if let Some(position) = app.get_playlist_position() {
format!("🎵 {filename} ({position})")
} else {
format!("🎵 {filename}")
}
} else {
"🎵 ZIM Player".to_string()
};
let title = Paragraph::new(title_text)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center);
f.render_widget(title, chunks[0]);
draw_file_info_with_leds(f, chunks[1], app);
draw_progress_bar(f, chunks[2], app);
if show_oscilloscope {
draw_oscilloscope(f, chunks[3], app);
}
let controls_idx = if show_oscilloscope { 4 } else { 3 };
let control_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(chunks[controls_idx]);
let play_color = if app.is_playing {
Color::Yellow
} else {
Color::Green
};
let controls_row1 = vec![
create_control_button("space", Style::default().fg(play_color)),
Span::raw(if app.is_playing {
" pause "
} else {
" play "
}),
create_control_button("←→", Style::default().fg(Color::Magenta)),
Span::raw(" seek "),
create_control_button("/", Style::default().fg(Color::Blue)),
Span::raw(" browse "),
create_control_button("q", Style::default().fg(Color::Red)),
Span::raw(" quit"),
];
let loop_style = if app.is_looping {
Style::default().fg(Color::Magenta).bg(Color::DarkGray)
} else {
Style::default().fg(Color::Magenta)
};
let mut controls_row2 = Vec::new();
controls_row2.extend(create_control("i", "in", Style::default().fg(Color::Green)));
controls_row2.extend(create_control(
"o",
"out",
Style::default().fg(Color::Green),
));
controls_row2.extend(create_control(
"x",
"clear",
Style::default().fg(Color::Yellow),
));
controls_row2.push(create_control_button("l", loop_style));
controls_row2.push(Span::raw(if app.is_looping {
" loop ● "
} else {
" loop "
}));
if app.is_playing && app.timeline_waveform.is_some() {
let waveform_style = if app.show_timeline_while_playing {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
controls_row2.push(create_control_button("w", waveform_style));
controls_row2.push(Span::raw(if app.show_timeline_while_playing {
" timeline ● "
} else {
" timeline "
}));
}
let mode_style = Style::default().fg(Color::Yellow);
controls_row2.push(create_control_button("m", mode_style));
controls_row2.push(Span::raw(format!(
" {} ",
app.waveform_display_mode.label()
)));
controls_row2.extend(create_control(
"s",
"save",
Style::default().fg(Color::Cyan),
));
controls_row2.extend(create_control(
"e",
"edit",
Style::default().fg(Color::Magenta),
));
if app.playlist.is_some() {
controls_row2.extend(create_control(
"p",
"prev",
Style::default().fg(Color::Blue),
));
controls_row2.extend(create_control(
"n",
"next",
Style::default().fg(Color::Blue),
));
}
controls_row2.push(create_control_button(
"t",
Style::default().fg(Color::Yellow),
));
controls_row2.push(Span::raw(if app.telemetry.config().enabled {
" telemetry ●"
} else {
" telemetry"
}));
let controls_widget1 = Paragraph::new(Line::from(controls_row1)).alignment(Alignment::Center);
let controls_widget2 = Paragraph::new(Line::from(controls_row2)).alignment(Alignment::Center);
let border_widget = Block::default().borders(Borders::TOP);
f.render_widget(border_widget, chunks[controls_idx]);
f.render_widget(controls_widget1, control_chunks[0]);
f.render_widget(controls_widget2, control_chunks[1]);
}
fn draw_file_info_with_leds(f: &mut Frame, area: Rect, app: &App) {
if let Some(ref progress) = app.waveform_progress {
let progress_text = format!("Calculating waveform... {:.0}%", progress.percentage);
let progress_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::ITALIC);
let progress_widget = Paragraph::new(progress_text)
.style(progress_style)
.alignment(Alignment::Center);
f.render_widget(progress_widget, area);
return;
}
if let Some(ref message) = app.editor_message {
let msg_style = Style::default()
.fg(Color::Yellow)
.bg(Color::Black)
.add_modifier(Modifier::ITALIC);
let msg = Paragraph::new(message.as_str())
.style(msg_style)
.alignment(Alignment::Center);
f.render_widget(msg, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(20), Constraint::Length(12), ])
.split(area);
let file_info = if app.current_file.is_some() {
"Ready".to_string()
} else {
"No file selected - Pass a file path to play".to_string()
};
let file_widget = Paragraph::new(file_info).style(Style::default().fg(Color::White));
f.render_widget(file_widget, chunks[0]);
draw_leds(f, chunks[1], app);
let border = Block::default().borders(Borders::BOTTOM);
f.render_widget(border, area);
}
fn draw_leds(f: &mut Frame, area: Rect, app: &App) {
let led_text = if app.current_file.is_some() {
let l_char = get_led_char(app.left_level);
let r_char = get_led_char(app.right_level);
let l_color = get_led_color(app.left_level, true);
let r_color = get_led_color(app.right_level, false);
vec![
Span::raw("L"),
Span::styled(l_char, Style::default().fg(l_color)),
Span::raw(" R"),
Span::styled(r_char, Style::default().fg(r_color)),
]
} else {
vec![
Span::raw("L"),
Span::styled("○", Style::default().fg(Color::DarkGray)),
Span::raw(" R"),
Span::styled("○", Style::default().fg(Color::DarkGray)),
]
};
let led_widget = Paragraph::new(Line::from(led_text)).alignment(Alignment::Right);
f.render_widget(led_widget, area);
}
fn get_led_char(level: f32) -> &'static str {
for (threshold, symbol) in LED_LEVEL_THRESHOLDS.iter() {
if level >= *threshold {
return symbol;
}
}
"○" }
fn get_led_color(level: f32, is_left: bool) -> Color {
match (is_left, level) {
(_, l) if l > LED_CLIPPING_LEVEL => Color::Rgb(255, 100, 100),
(true, l) if l > LED_HIGH_LEVEL => Color::Rgb(100, 255, 100),
(true, l) if l > LED_LOW_LEVEL => Color::Rgb(50, 200, 50),
(true, _) => Color::Rgb(20, 100, 20),
(false, l) if l > LED_HIGH_LEVEL => Color::Rgb(255, 150, 0),
(false, l) if l > LED_LOW_LEVEL => Color::Rgb(200, 100, 0),
(false, _) => Color::Rgb(100, 50, 0),
}
}
fn draw_oscilloscope(f: &mut Frame, area: Rect, app: &App) {
match app.waveform_display_mode {
WaveformDisplayMode::Vectorscope => {
draw_vectorscope(f, area, app);
}
_ => {
let grid_canvas = Canvas::default()
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.paint(|ctx| {
draw_grid(ctx, area);
})
.x_bounds([0.0, area.width as f64])
.y_bounds([-1.0, 1.0]);
f.render_widget(grid_canvas, area);
let inner_area = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
draw_waveform_chart(f, inner_area, app);
}
}
}
fn draw_vectorscope(f: &mut Frame, area: Rect, app: &App) {
let grid_canvas = Canvas::default()
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(" Vectorscope (L/R) "),
)
.paint(|ctx| {
draw_vectorscope_grid(ctx, area);
})
.x_bounds([-1.0, 1.0])
.y_bounds([-1.0, 1.0]);
f.render_widget(grid_canvas, area);
let inner_area = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let points = app
.waveform_buffer
.get_vectorscope_points(inner_area.width as usize * 4);
if points.is_empty() {
return;
}
let datasets = vec![
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Scatter)
.style(Style::default().fg(Color::Rgb(0, 255, 150)))
.data(&points),
];
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.bounds([-1.0, 1.0])
.labels::<Vec<Span>>(vec![]),
)
.y_axis(
Axis::default()
.bounds([-1.0, 1.0])
.labels::<Vec<Span>>(vec![]),
);
f.render_widget(chart, inner_area);
}
fn draw_vectorscope_grid(ctx: &mut Context, _area: Rect) {
let center_x = 0.0;
let center_y = 0.0;
ctx.draw(&ratatui::widgets::canvas::Line {
x1: center_x,
y1: -1.0,
x2: center_x,
y2: 1.0,
color: Color::Rgb(60, 60, 80),
});
ctx.draw(&ratatui::widgets::canvas::Line {
x1: -1.0,
y1: center_y,
x2: 1.0,
y2: center_y,
color: Color::Rgb(60, 60, 80),
});
ctx.draw(&ratatui::widgets::canvas::Line {
x1: -0.9,
y1: -0.9,
x2: 0.9,
y2: 0.9,
color: Color::Rgb(40, 40, 60),
});
ctx.draw(&ratatui::widgets::canvas::Line {
x1: -0.9,
y1: 0.9,
x2: 0.9,
y2: -0.9,
color: Color::Rgb(40, 40, 60),
});
}
fn draw_grid(ctx: &mut Context, area: Rect) {
for x in (0..area.width).step_by(GRID_VERTICAL_STEP) {
ctx.draw(&ratatui::widgets::canvas::Line {
x1: x as f64,
y1: -1.0,
x2: x as f64,
y2: 1.0,
color: GRID_COLOR,
});
}
for y in GRID_HORIZONTAL_LINES {
ctx.draw(&ratatui::widgets::canvas::Line {
x1: 0.0,
y1: y,
x2: area.width as f64,
y2: y,
color: GRID_COLOR,
});
}
}
fn draw_waveform_chart(f: &mut Frame, area: Rect, app: &App) {
let use_timeline =
(!app.is_playing || app.show_timeline_while_playing) && app.timeline_waveform.is_some();
let peaks = if use_timeline {
app.timeline_waveform
.as_ref()
.unwrap()
.get_display_peaks(area.width as usize)
} else {
app.waveform_buffer
.get_triggered_display_peaks(area.width as usize)
};
let has_signal = peaks.iter().any(|(min, max)| *min != 0.0 || *max != 0.0);
let (upper_color, lower_color) = if use_timeline {
(Color::Rgb(100, 150, 255), Color::Rgb(60, 100, 200)) } else {
(Color::Rgb(0, 255, 100), Color::Rgb(0, 200, 80)) };
#[allow(clippy::complexity)]
let (upper_data, lower_data): (Vec<(f64, f64)>, Vec<(f64, f64)>) = if has_signal {
let upper: Vec<(f64, f64)> = peaks
.iter()
.enumerate()
.map(|(i, (_min, max))| {
let x = i as f64;
let y = (*max * 1.5).clamp(-0.95, 0.95) as f64;
(x, y)
})
.collect();
let lower: Vec<(f64, f64)> = peaks
.iter()
.enumerate()
.map(|(i, (min, _max))| {
let x = i as f64;
let y = (*min * 1.5).clamp(-0.95, 0.95) as f64;
(x, y)
})
.collect();
(upper, lower)
} else {
let time_offset = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as f64
/ 1000.0;
let demo: Vec<(f64, f64)> = (0..area.width)
.map(|x| {
let t = x as f64 / area.width as f64 * 4.0 * std::f64::consts::PI;
let y1 = (t + time_offset * 0.5).sin() * 0.8;
let y2 = ((t * 2.0) + time_offset).sin() * 0.4;
let y = (y1 + y2).clamp(-0.95, 0.95);
(x as f64, y)
})
.collect();
(demo.clone(), demo)
};
let (marker, graph_type) = match app.waveform_display_mode {
WaveformDisplayMode::Scatter => (symbols::Marker::Dot, GraphType::Scatter), _ => (symbols::Marker::Braille, GraphType::Line), };
let mut datasets = vec![
Dataset::default()
.marker(marker)
.graph_type(graph_type)
.style(Style::default().fg(upper_color))
.data(&upper_data),
Dataset::default()
.marker(marker)
.graph_type(graph_type)
.style(Style::default().fg(lower_color))
.data(&lower_data),
];
let position_data: Vec<(f64, f64)>;
if use_timeline && has_signal {
let position_x = (app.playback_position * area.width as f32) as f64;
position_data = vec![(position_x, -0.95), (position_x, 0.95)];
datasets.push(
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::Rgb(255, 200, 0)))
.data(&position_data),
);
}
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.bounds([0.0, area.width as f64])
.labels::<Vec<Span>>(vec![]),
)
.y_axis(
Axis::default()
.bounds([-1.0, 1.0])
.labels::<Vec<Span>>(vec![]),
);
f.render_widget(chart, area);
}
fn draw_progress_bar(f: &mut Frame, area: Rect, app: &App) {
let progress = app.playback_position;
let time_info = if let Some(duration) = app.duration {
let current_secs = (duration.as_secs() as f32 * progress) as u64;
let current_time = format_time(current_secs);
let total_time = format_duration(duration);
let mut time_str = format!("{current_time} / {total_time}");
if let Some(selection_duration) = app.get_selection_duration() {
let sel_secs = selection_duration.as_secs_f32();
time_str.push_str(&format!(" [{sel_secs:.1}s]"));
}
time_str
} else {
"00:00 / 00:00".to_string()
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(10), Constraint::Length(20), ])
.split(area);
draw_progress_with_marks(f, chunks[0], app);
let time_widget = Paragraph::new(time_info)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(time_widget, chunks[1]);
}
fn draw_progress_with_marks(f: &mut Frame, area: Rect, app: &App) {
let progress = app.playback_position;
let progress_percent = (progress * 100.0) as u16;
let label_style = if progress_percent >= 50 {
Style::default()
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let progress_widget = Gauge::default()
.block(Block::default().borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Cyan))
.percent(progress_percent)
.label(Span::styled(format!("{progress_percent}%"), label_style));
f.render_widget(progress_widget, area);
let inner_area = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let bar_width = inner_area.width;
if let Some(mark_in) = app.mark_in {
let mark_x = inner_area.x + (mark_in * bar_width as f32) as u16;
if mark_x < inner_area.x + bar_width {
let marker = Paragraph::new("┃").style(
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
);
let marker_area = Rect {
x: mark_x,
y: inner_area.y,
width: 1,
height: 1,
};
f.render_widget(marker, marker_area);
}
}
if let Some(mark_out) = app.mark_out {
let mark_x = inner_area.x + (mark_out * bar_width as f32) as u16;
if mark_x < inner_area.x + bar_width {
let marker = Paragraph::new("┃")
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
let marker_area = Rect {
x: mark_x,
y: inner_area.y,
width: 1,
height: 1,
};
f.render_widget(marker, marker_area);
}
}
if let (Some(mark_in), Some(mark_out)) = (app.mark_in, app.mark_out) {
let start = mark_in.min(mark_out);
let end = mark_in.max(mark_out);
let start_x = (start * bar_width as f32) as u16;
let end_x = (end * bar_width as f32) as u16;
let selection_width = end_x.saturating_sub(start_x).max(1);
if start_x < bar_width {
let selection_area = Rect {
x: inner_area.x + start_x,
y: inner_area.y,
width: selection_width.min(bar_width - start_x),
height: 1,
};
let selection = Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(selection, selection_area);
}
}
}
fn draw_integrated_browser(f: &mut Frame, app: &App) {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Min(10), Constraint::Length(1), Constraint::Length(1), ])
.split(size);
draw_browser_content(f, chunks[0], &app.browser);
draw_mini_player(f, chunks[1], app);
draw_browser_help(f, chunks[2], &app.browser);
if app.browser.search_visible {
draw_floating_search_bar(f, size, &app.browser);
}
}
fn draw_floating_search_bar(f: &mut Frame, area: Rect, browser: &super::browser::Browser) {
let width = area.width.min(60); let height = 3;
let x = (area.width.saturating_sub(width)) / 2;
let y = 2;
let search_area = Rect {
x: area.x + x,
y: area.y + y,
width,
height,
};
f.render_widget(ratatui::widgets::Clear, search_area);
let clear_block = Block::default().style(Style::default().bg(Color::Black));
f.render_widget(clear_block, search_area);
let search_text = if browser.search_query.is_empty() {
"Type to search or use 'title:' or 'tag:'...".to_string()
} else {
log::debug!("Search query to display: {:?}", browser.search_query);
browser.search_query.clone()
};
let title = "Search (Enter or Esc to close)";
let border_style = Style::default().fg(Color::Yellow).bg(Color::Black);
let search = Paragraph::new(search_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style)
.style(Style::default().bg(Color::Black)),
)
.style(if browser.search_query.is_empty() {
Style::default().fg(Color::DarkGray).bg(Color::Black)
} else {
Style::default().fg(Color::White).bg(Color::Black)
});
f.render_widget(search, search_area);
}
fn draw_browser_content(f: &mut Frame, area: Rect, browser: &super::browser::Browser) {
use super::browser::BrowserFocus;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40), Constraint::Percentage(60), ])
.split(area);
let filtered_items = browser.get_filtered_items();
let files: Vec<Line> = filtered_items
.iter()
.enumerate()
.map(|(i, (item, _))| {
let style = if i == browser.selected {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
};
let prefix = if i == browser.selected { "> " } else { " " };
let filename = item
.audio_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown");
let display_text = if let Some(ref project) = item.metadata.project {
format!("{prefix}{filename} [{project}]")
} else {
format!("{prefix}{filename}")
};
Line::from(display_text).style(style)
})
.collect();
let title = if browser.search_visible && browser.focus == BrowserFocus::Search {
"Files - Press Esc to return"
} else {
"Files - j/k to navigate, Enter to select, / to search"
};
let border_style = if browser.focus == BrowserFocus::Files {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let file_list = Paragraph::new(files)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style),
)
.scroll((browser.selected.saturating_sub(10) as u16, 0));
f.render_widget(file_list, chunks[0]);
let preview_area = chunks[1];
let usable_width = preview_area.width.saturating_sub(2) as usize; let usable_height = preview_area.height.saturating_sub(2) as usize; let max_chars = usable_width * usable_height;
let preview_content = if browser.selected < filtered_items.len() {
let (item, _context) = filtered_items[browser.selected];
if !item.metadata.content.is_empty() {
let limit = max_chars.min(2000);
let content: String = item.metadata.content.chars().take(limit).collect();
if item.metadata.content.len() > limit {
content + "..."
} else {
content
}
} else {
"No preview available".to_string()
}
} else {
"No preview available".to_string()
};
let preview_title = if browser.selected < filtered_items.len() {
let (item, _) = filtered_items[browser.selected];
if let Some(ref project) = item.metadata.project {
format!("Preview - Project: {project}")
} else {
"Preview".to_string()
}
} else {
"Preview".to_string()
};
let preview = Paragraph::new(preview_content)
.block(Block::default().borders(Borders::ALL).title(preview_title))
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(preview, chunks[1]);
}
fn draw_mini_player(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(3), Constraint::Min(20), Constraint::Length(12), Constraint::Length(8), ])
.split(area);
let play_icon = if app.is_playing { "▶" } else { "⏸" };
let play_widget = Paragraph::new(play_icon).style(Style::default().fg(if app.is_playing {
Color::Green
} else {
Color::Yellow
}));
f.render_widget(play_widget, chunks[0]);
let progress = (app.playback_position * 100.0) as u16;
let label_style = if progress >= 50 {
Style::default()
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let progress_bar = Gauge::default()
.percent(progress)
.label(Span::styled(format!("{progress}%"), label_style))
.style(Style::default().fg(Color::DarkGray))
.gauge_style(Style::default().fg(Color::Cyan));
f.render_widget(progress_bar, chunks[1]);
let time_text = if let Some(duration) = app.duration {
let current = duration.as_secs_f32() * app.playback_position;
let total = duration.as_secs_f32();
format!("{current:.0}/{total:.0}s")
} else {
"--/--".to_string()
};
let time_widget = Paragraph::new(time_text)
.alignment(Alignment::Center)
.style(Style::default().fg(Color::White));
f.render_widget(time_widget, chunks[2]);
let left_led = get_led_char(app.left_level);
let right_led = get_led_char(app.right_level);
let left_color = get_led_color(app.left_level, true);
let right_color = get_led_color(app.right_level, false);
let leds = Line::from(vec![
Span::styled(left_led, Style::default().fg(left_color)),
Span::raw(" "),
Span::styled(right_led, Style::default().fg(right_color)),
]);
let led_widget = Paragraph::new(leds).alignment(Alignment::Center);
f.render_widget(led_widget, chunks[3]);
}
fn draw_browser_help(f: &mut Frame, area: Rect, browser: &super::browser::Browser) {
use super::browser::BrowserFocus;
let help_text = if browser.search_visible {
match browser.focus {
BrowserFocus::Search => {
"Type to search | Try: 'title: my song' or 'tag: ambient' | Enter/Esc: Hide search | ←→: Seek"
}
BrowserFocus::Files => {
"j/k or ↑↓: Navigate | Enter: Select | /: Search | Esc: Back | Space: Play/Pause | h/l or ←→: Seek"
}
}
} else {
"j/k or ↑↓: Navigate | Enter: Select | /: Search | Esc: Back | Space: Play/Pause | h/l or ←→: Seek"
};
let help_style = Style::default().fg(Color::DarkGray);
let help = Paragraph::new(help_text)
.style(help_style)
.alignment(Alignment::Center);
f.render_widget(help, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time() {
assert_eq!(format_time(0), "00:00");
assert_eq!(format_time(59), "00:59");
assert_eq!(format_time(60), "01:00");
assert_eq!(format_time(3661), "61:01");
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(std::time::Duration::from_secs(0)), "00:00");
assert_eq!(
format_duration(std::time::Duration::from_secs(125)),
"02:05"
);
}
#[test]
fn test_get_led_char() {
assert_eq!(get_led_char(0.0), "○");
assert_eq!(get_led_char(0.04), "○");
assert_eq!(get_led_char(0.05), "◐");
assert_eq!(get_led_char(0.2), "◐");
assert_eq!(get_led_char(0.3), "●");
assert_eq!(get_led_char(1.0), "●");
}
#[test]
fn test_get_led_color_left_channel() {
assert_eq!(get_led_color(0.01, true), Color::Rgb(20, 100, 20));
assert_eq!(get_led_color(0.1, true), Color::Rgb(50, 200, 50));
assert_eq!(get_led_color(0.5, true), Color::Rgb(100, 255, 100));
assert_eq!(get_led_color(0.95, true), Color::Rgb(255, 100, 100));
}
#[test]
fn test_get_led_color_right_channel() {
assert_eq!(get_led_color(0.01, false), Color::Rgb(100, 50, 0));
assert_eq!(get_led_color(0.1, false), Color::Rgb(200, 100, 0));
assert_eq!(get_led_color(0.5, false), Color::Rgb(255, 150, 0));
assert_eq!(get_led_color(0.95, false), Color::Rgb(255, 100, 100));
}
#[test]
fn test_led_clipping_color() {
assert_eq!(
get_led_color(LED_CLIPPING_LEVEL + 0.01, true),
Color::Rgb(255, 100, 100)
);
assert_eq!(
get_led_color(LED_CLIPPING_LEVEL + 0.01, false),
Color::Rgb(255, 100, 100)
);
}
#[test]
fn test_create_control_button() {
let button = create_control_button("q", Style::default().fg(Color::Red));
assert_eq!(button.content, "[q]");
assert_eq!(button.style.fg, Some(Color::Red));
}
}