Files
rumus/src/ui.rs

491 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use ratatui::prelude::*;
use ratatui::widgets::{
Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Wrap,
};
use crate::app::{App, InputMode, Panel};
use crate::library::{RightRow, SortMode};
use crate::state::RepeatMode;
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(3),
])
.split(f.area());
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[0]);
let left_col = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(8),
])
.split(top[0]);
draw_left_panel(f, app, left_col[0]);
draw_equalizer(f, app, left_col[1]);
draw_right_panel(f, app, top[1]);
draw_status(f, app, chunks[1]);
if app.show_help {
draw_help(f, f.area());
}
if app.show_dirs {
draw_dirs(f, app, f.area());
}
}
fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
f.render_widget(Clear, area);
let items: Vec<ListItem> = app
.library
.artists
.iter()
.map(|a| ListItem::new(a.name.clone()))
.collect();
let highlight = if app.active_panel == Panel::Left {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default().add_modifier(Modifier::DIM)
};
let sel = if app.library.artists.is_empty() {
None
} else {
Some(app.library.artist_selected)
};
app.library.left_state.select(sel);
let title_text = match app.library.sort_mode {
SortMode::ByArtist => " Исполнители ",
SortMode::ByAlbum => " Альбомы ",
SortMode::ByFilename => " Источник ",
};
let album_color = Color::Rgb(100, 149, 237);
let title = Span::styled(title_text, Style::default().fg(album_color));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(highlight)
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.library.left_state);
}
fn fmt_dur(d: std::time::Duration) -> String {
if d.is_zero() {
"--:--".into()
} else {
format!("{}:{:02}", d.as_secs() / 60, d.as_secs() % 60)
}
}
fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
f.render_widget(Clear, area);
let rows = app.library.current_right_rows();
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(idx, row)| match row {
RightRow::AlbumHeader { name } => {
let mut album_total = std::time::Duration::ZERO;
let mut i = idx + 1;
while i < rows.len() {
if let RightRow::Track { index_in_library } = rows[i] {
if let Some(t) = app.library.track(index_in_library) {
album_total += t.duration;
}
i += 1;
} else {
break;
}
}
let total_str = fmt_dur(album_total);
let inner_width = area.width.saturating_sub(6) as usize;
let pad = inner_width
.saturating_sub(name.len() + total_str.len() + 4)
.max(1);
let line = format!("{} ] {} {}", name, "─".repeat(pad), total_str);
let album_color = Color::Rgb(100, 149, 237);
ListItem::new(Line::from(vec![
Span::styled("[ ", Style::default().fg(album_color)),
Span::styled(
line,
Style::default()
.fg(album_color)
.add_modifier(Modifier::BOLD),
),
]))
}
RightRow::Track { index_in_library } => {
let is_playing = Some(*index_in_library) == app.playing_index;
let marker = if is_playing { "♪ " } else { " " };
let (title, num, dur) = match app.library.track(*index_in_library) {
Some(t) => (
t.title.clone(),
t.track_number
.map(|n| format!("{:02}. ", n))
.unwrap_or_default(),
fmt_dur(t.duration),
),
None => ("<unknown>".into(), String::new(), "--:--".into()),
};
let style = if is_playing {
Style::default().fg(Color::Green)
} else {
Style::default()
};
let left = format!(" {}{}{}", marker, num, title);
let inner_width = area.width.saturating_sub(4) as usize;
let left_len = left.chars().count();
let dur_len = dur.chars().count();
let pad = inner_width
.saturating_sub(left_len + dur_len)
.max(1);
ListItem::new(Line::from(vec![
Span::styled(left, style),
Span::raw(" ".repeat(pad)),
Span::styled(dur, style),
]))
}
})
.collect();
let selected_name = app
.library
.artists
.get(app.library.artist_selected)
.map(|a| a.name.as_str())
.unwrap_or("—");
let title_text = match app.library.sort_mode {
SortMode::ByArtist => {
if app.library.filter.is_empty() {
format!(" Альбомы и треки - {} ", selected_name)
} else {
format!(
" Альбомы и треки - {} [{}] ",
selected_name, app.library.filter
)
}
}
SortMode::ByAlbum => {
if app.library.filter.is_empty() {
format!(" Исполнители и треки - {} ", selected_name)
} else {
format!(
" Исполнители и треки - {} [{}] ",
selected_name, app.library.filter
)
}
}
SortMode::ByFilename => {
if app.library.filter.is_empty() {
" Альбомы и треки ".into()
} else {
format!(" Альбомы и треки [{}] ", app.library.filter)
}
}
};
let album_color = Color::Rgb(100, 149, 237);
let title = Span::styled(title_text, Style::default().fg(album_color));
let highlight = if app.active_panel == Panel::Right {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default().add_modifier(Modifier::DIM)
};
let sel = if rows.is_empty() {
None
} else {
Some(app.library.right_selected)
};
app.library.right_state.select(sel);
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(highlight)
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.library.right_state);
}
fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
let inner_width = area.width.saturating_sub(1) as usize;
if inner_width == 0 {
return;
}
let num_bars = (inner_width / 2).max(4);
let bars_data = app.player.spectrum.bars();
let step = (bars_data.len() / num_bars).max(1);
let mut display: Vec<f32> = Vec::with_capacity(num_bars);
for i in 0..num_bars {
let start = i * step;
let end = (start + step).min(bars_data.len());
if start >= bars_data.len() {
display.push(0.0);
continue;
}
let sum: f32 = bars_data[start..end].iter().sum();
display.push(sum / (end - start).max(1) as f32);
}
let bars: Vec<Bar> = display
.iter()
.map(|&v| {
Bar::default()
.value((v * 100.0) as u64)
.style(Style::default().fg(Color::Rgb(100, 149, 237)))
})
.collect();
let chart = BarChart::default()
.data(BarGroup::default().bars(&bars))
.bar_width(1)
.bar_gap(1)
.max(100)
.bar_set(ratatui::symbols::bar::NINE_LEVELS)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Эквалайзер ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237))),
);
f.render_widget(chart, area);
}
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
let inner = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(1),
Constraint::Length(18),
Constraint::Length(16),
Constraint::Length(12),
])
.split(area);
let (status_text, status_title) = match app.input_mode {
InputMode::Search => (format!(">{}", app.search_buffer), " Поиск "),
InputMode::Command => (format!(":{}", app.command_buffer), " Команда "),
InputMode::Normal => {
let prefix = if app.player.is_paused() { "|| " } else { " " };
(format!("{}{}", prefix, app.status), " Статус ")
}
};
let status = Paragraph::new(status_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(status_title)
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(status, inner[0]);
let (pos, total) = app.progress();
let time_text = format!(
"{:02}:{:02} / {:02}:{:02}",
pos.as_secs() / 60,
pos.as_secs() % 60,
total.as_secs() / 60,
total.as_secs() % 60,
);
let time = Paragraph::new(time_text)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Время ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(time, inner[1]);
let mode_str = match (app.repeat, app.shuffle) {
(RepeatMode::Off, false) => "".to_string(),
(RepeatMode::Off, true) => "SHUF".to_string(),
(RepeatMode::All, false) => "RPT ALL".to_string(),
(RepeatMode::One, false) => "RPT ONE".to_string(),
(RepeatMode::All, true) => "RPT+SHUF".to_string(),
(RepeatMode::One, true) => "ONE+SHUF".to_string(),
};
let mode = Paragraph::new(mode_str)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Режим ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(mode, inner[2]);
let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32);
let vol = Paragraph::new(vol_text)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Звук ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(vol, inner[3]);
}
fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) {
let count = app.sources().len();
// Строки: заголовок (1) + пустая (1) + "Список" (1) + N + пустая (1) + подсказка (1)
let content_lines = 5 + count;
let desired_height = (content_lines + 2) as u16;
let width = 70u16.min(screen.width.saturating_sub(4));
let max_h = screen.height.saturating_sub(4);
let height = desired_height.min(max_h).max(5);
let x = (screen.width.saturating_sub(width)) / 2;
let y = (screen.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height);
f.render_widget(Clear, area);
let title_color = Style::default().fg(Color::Rgb(100, 149, 237));
let header = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(Color::DarkGray);
let mut lines = vec![
Line::from(Span::styled(
" Источники библиотеки ",
title_color.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(" Список", header)),
];
for (i, src) in app.sources().iter().enumerate() {
lines.push(Line::from(vec![
Span::styled(format!(" [{}] ", i), dim),
Span::raw(src.clone()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" :rm <N> — удалить, Esc — закрыть",
dim,
)));
let para = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(title_color)
.title(" Библиотека "),
)
.wrap(Wrap { trim: false });
f.render_widget(para, area);
}
fn draw_help(f: &mut Frame, screen: Rect) {
let width = 62u16.min(screen.width.saturating_sub(4));
let height = 44u16.min(screen.height.saturating_sub(4));
let x = (screen.width.saturating_sub(width)) / 2;
let y = (screen.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height);
f.render_widget(Clear, area);
let header = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let title_color = Style::default().fg(Color::Rgb(100, 149, 237));
let help_text = vec![
Line::from(Span::styled(
" Управление плеером ",
title_color.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(" Навигация", header)),
Line::from(" w / ↑ вверх по списку"),
Line::from(" s / ↓ вниз по списку"),
Line::from(" Tab переключить панель"),
Line::from(" g / G в начало / в конец списка"),
Line::from(" f к играющему треку"),
Line::from(""),
Line::from(Span::styled(" Воспроизведение", header)),
Line::from(" Enter играть выделенный трек"),
Line::from(" Space пауза / продолжить"),
Line::from(" z стоп"),
Line::from(" Ctrl+→ / Ctrl+d следующий трек"),
Line::from(" Ctrl+← / Ctrl+a предыдущий трек"),
Line::from(" r repeat: off → all → one"),
Line::from(" x shuffle вкл/выкл"),
Line::from(""),
Line::from(Span::styled(" Перемотка", header)),
Line::from(" a / ← −5 секунд"),
Line::from(" d / → +5 секунд"),
Line::from(" 1..9 перейти к 10%..90% трека"),
Line::from(""),
Line::from(Span::styled(" Команды", header)),
Line::from(" :add <путь> добавить в сессию"),
Line::from(" :load <путь> заменить сессию"),
Line::from(" :reload пересканировать всё"),
Line::from(" :save сохранить библиотеку"),
Line::from(" :savepl <файл> экспорт в M3U/PLS"),
Line::from(" :dirs список источников"),
Line::from(" :rm <N> удалить источник"),
Line::from(" :clear очистить источники"),
Line::from(" :help эта справка"),
Line::from(" :q выход"),
Line::from(""),
Line::from(Span::styled(" Звук и прочее", header)),
Line::from(" + / - громкость"),
Line::from(" / поиск"),
Line::from(" Del удалить из списка"),
Line::from(" t сортировка: артист → альбом → файл"),
Line::from(" F1 эта справка"),
Line::from(" q / Esc выход"),
Line::from(""),
Line::from(Span::styled(
" F1 или Esc - закрыть ",
Style::default().fg(Color::DarkGray),
)),
];
let help = Paragraph::new(help_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(title_color)
.title(" Помощь "),
);
f.render_widget(help, area);
}