use std::rc::Rc;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect, Size},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Tabs, Wrap},
};
use ratatui_image::{FilterType, Resize, StatefulImage, protocol::StatefulProtocol};
use crate::{
app::{AppMode, AppState},
config::LayoutPreference,
models::SongData,
};
const HORIZONTAL_MARGIN: u16 = 2;
const VERTICAL_MARGIN: u16 = 1;
const ART_COLUMN_MIN: u16 = 10;
const ART_COLUMN_MAX: u16 = 45;
const ART_TEXT_GAP: u16 = 2;
const MIN_TEXT_COLUMN: u16 = 22;
const MIN_INFO_HEIGHT: u16 = 5;
const FOOTER_SPLIT_WIDTH: u16 = 38;
const CONTENT_MAX_WIDTH: u16 = 90;
const MIN_GAUGE_WIDTH: u16 = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LayoutMode {
Horizontal,
Vertical,
}
fn art_column_width(content_height: u16) -> u16 {
content_height.saturating_mul(2).clamp(ART_COLUMN_MIN, ART_COLUMN_MAX)
}
fn resolve_layout(area: Rect, preference: LayoutPreference) -> LayoutMode {
match preference {
LayoutPreference::Horizontal => LayoutMode::Horizontal,
LayoutPreference::Vertical => LayoutMode::Vertical,
LayoutPreference::Auto => {
let content_height = area.height.saturating_sub(HORIZONTAL_MARGIN * 2 + 1);
let needed = HORIZONTAL_MARGIN * 2 + art_column_width(content_height) + ART_TEXT_GAP + MIN_TEXT_COLUMN;
let is_wide_and_short = area.width as u32 * 2 >= area.height as u32 * 5;
if area.width < needed || !is_wide_and_short { LayoutMode::Vertical } else { LayoutMode::Horizontal }
},
}
}
pub fn help_hint_rect(area: Rect, preference: LayoutPreference) -> Rect {
let (margin, row) = match resolve_layout(area, preference) {
LayoutMode::Horizontal => (HORIZONTAL_MARGIN, area.y + area.height.saturating_sub(3)),
LayoutMode::Vertical => (VERTICAL_MARGIN, area.y + area.height.saturating_sub(2)),
};
Rect { x: area.x + margin, y: row, width: 9, height: area.height.min(1) }
}
fn center_rect(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(r.height.saturating_sub(height) / 2),
Constraint::Length(height.min(r.height)),
Constraint::Min(0),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(r.width.saturating_sub(width) / 2),
Constraint::Length(width.min(r.width)),
Constraint::Min(0),
])
.split(popup_layout[1])[1]
}
fn vertical_art_height(content: Rect, min_info: u16, max_share_percent: u16) -> u16 {
if content.width < 8 || content.height <= min_info + 1 {
return 0;
}
let square = content.width / 2;
let share = content.height.saturating_mul(max_share_percent) / 100;
let headroom = content.height.saturating_sub(min_info);
let height = square.min(share).min(headroom);
if height < 4 { 0 } else { height }
}
fn is_known(value: &str) -> bool {
!value.is_empty() && value != "Unknown" && value != "-"
}
fn fitted_art_size(protocol: Option<&StatefulProtocol>, width: u16, max_height: u16) -> Size {
if width == 0 || max_height == 0 {
return Size::new(width, max_height);
}
match protocol {
Some(protocol) => {
let size = protocol.size_for(Resize::Fit(Some(FilterType::CatmullRom)), Size::new(width, max_height));
Size::new(size.width.min(width), size.height.min(max_height))
},
None => Size::new(width, max_height),
}
}
fn selected_has_art(state: &AppState) -> bool {
state
.liked_list_state
.selected()
.and_then(|i| state.filtered_likes.get(i))
.is_some_and(|s| state.art_path(s.id).exists())
}
pub fn draw_ui(f: &mut Frame, state: &mut AppState, hide_art: bool) {
if let AppMode::ConfigError(ref warnings) = state.mode {
let warnings = warnings.clone();
let scroll = state.config_scroll;
draw_config_error(f, &warnings, scroll);
return;
}
match resolve_layout(f.area(), state.config.get_layout_preference()) {
LayoutMode::Horizontal => draw_horizontal(f, state, hide_art),
LayoutMode::Vertical => draw_vertical(f, state, hide_art),
}
}
fn build_nav(state: &AppState) -> Line<'_> {
Line::from(vec![
Span::styled(
"[player]",
if state.mode == AppMode::Playing {
Style::default().fg(state.theme_color).bold()
} else {
Style::default().fg(state.secondary_color)
},
),
Span::raw(" "),
Span::styled(
"[library]",
if state.mode == AppMode::LikedSongs {
Style::default().fg(state.theme_color).bold()
} else {
Style::default().fg(state.secondary_color)
},
),
])
}
fn build_status_bar(state: &AppState) -> Line<'_> {
let mut status_spans = vec![Span::styled("[?] Help", Style::default().fg(state.secondary_color))];
if state.song.id != 0 && state.likes.contains_key(&state.song.id) {
status_spans.push(Span::styled(" [Liked]", Style::default().fg(state.theme_color)));
}
if state.is_muted || state.volume == 0 {
status_spans.push(Span::styled(" [Muted]", Style::default().fg(state.error_color)));
}
if !state.status_message.is_empty() {
let msg_color = if state.status_message.starts_with("failed")
|| state.status_message.contains("dropped")
|| state.status_message.contains("error")
{
state.error_color
} else {
state.theme_color
};
status_spans.push(Span::styled(format!(" [{}]", state.status_message), Style::default().fg(msg_color)));
} else if !(state.is_muted || state.volume == 0) && state.volume_time.is_some() {
let vol_color = if state.volume >= 66 {
state.theme_color
} else if state.volume >= 33 {
state.warning_color
} else {
state.error_color
};
status_spans.push(Span::styled(format!(" [Vol: {}%]", state.volume), Style::default().fg(vol_color)));
}
Line::from(status_spans)
}
fn listeners_line(state: &AppState) -> Line<'_> {
Line::from(vec![Span::styled(
if state.song.listeners > 0 { format!("{} Listeners ", state.song.listeners) } else { "LIVE ".to_string() },
Style::default().fg(state.theme_color).bold(),
)])
}
fn draw_footer(f: &mut Frame, state: &AppState, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
if area.width >= FOOTER_SPLIT_WIDTH {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(12), Constraint::Length(18)])
.split(area);
f.render_widget(Paragraph::new(build_status_bar(state)), chunks[0]);
f.render_widget(Paragraph::new(listeners_line(state)).alignment(Alignment::Right), chunks[1]);
} else {
f.render_widget(Paragraph::new(build_status_bar(state)), area);
}
}
fn draw_art(
f: &mut Frame,
protocol: &mut Option<StatefulProtocol>,
area: Rect,
hide_art: bool,
anchor_center: bool,
placeholder: &str,
placeholder_color: Color,
) {
if area.width == 0 || area.height == 0 {
return;
}
if hide_art {
f.render_widget(Clear, area);
return;
}
if let Some(protocol) = protocol.as_mut() {
let size = protocol.size_for(Resize::Fit(Some(FilterType::CatmullRom)), area.as_size());
let target = if anchor_center {
center_rect(area, size.width, size.height)
} else {
let width = size.width.min(area.width);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y,
width,
height: size.height.min(area.height),
}
};
f.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(Some(FilterType::CatmullRom))),
target,
protocol,
);
} else {
let y = if anchor_center { area.y + area.height / 2 } else { area.y };
let line = Rect { x: area.x, y, width: area.width, height: 1 };
f.render_widget(
Paragraph::new(placeholder).alignment(Alignment::Center).style(Style::default().fg(placeholder_color)),
line,
);
}
}
fn draw_sakuya(f: &mut Frame, state: &AppState, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let total = state.song.duration;
let elapsed = state.song_start_time.elapsed().as_secs();
let (ratio, label) = if total > 0 {
(
(elapsed as f64 / total as f64).clamp(0.0, 1.0),
format!("{:02}:{:02} / {:02}:{:02}", elapsed / 60, elapsed % 60, total / 60, total % 60),
)
} else {
(1.0, format!("{:02}:{:02} / LIVE", elapsed / 60, elapsed % 60))
};
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(state.theme_color).bg(state.secondary_color))
.ratio(ratio)
.label(label),
area,
);
}
fn meta_lines(song: &SongData, secondary: Color, column_width: u16) -> Vec<Line<'_>> {
let key = Style::default().bold().fg(secondary);
let val = Style::default().fg(secondary);
let mut lines = Vec::new();
if is_known(&song.circle) {
let mut spans = vec![Span::styled("Circle: ", key), Span::styled(song.circle.as_str(), val)];
let link = song.circle_link.as_str();
let link_fits = !link.is_empty()
&& "Circle: ".len() + song.circle.chars().count() + " ()".len() + link.chars().count()
<= column_width as usize;
if link_fits {
spans.push(Span::styled(format!(" ({link})"), val));
}
lines.push(Line::from(spans));
}
if is_known(&song.vocal) {
lines.push(Line::from(vec![Span::styled("Vocal: ", key), Span::styled(song.vocal.as_str(), val)]));
}
if is_known(&song.original) {
lines.push(Line::from(vec![Span::styled("Original: ", key), Span::styled(song.original.as_str(), val)]));
}
if is_known(&song.year) {
lines.push(Line::from(vec![Span::styled("Year: ", key), Span::styled(song.year.as_str(), val)]));
}
if is_known(&song.rating) {
lines.push(Line::from(vec![
Span::styled("Rating: ", key),
Span::styled(format!("{} ({})", song.rating, song.times_rated), val),
]));
}
lines
}
fn draw_library_body(f: &mut Frame, state: &mut AppState, list_area: Rect, bottom: Rect) {
let sorted = &state.filtered_likes;
let mut items: Vec<ListItem> = sorted
.iter()
.enumerate()
.map(|(i, s)| {
if Some(i) == state.liked_list_state.selected() {
ListItem::new(Line::from(vec![Span::styled(
format!("{} - {}", s.title, s.artist),
Style::default().fg(state.highlight_fg_color).bold(),
)]))
} else {
ListItem::new(Line::from(vec![
Span::styled(format!("{} ", s.title), Style::default().fg(state.theme_color).bold()),
Span::styled(format!("- {}", s.artist), Style::default().fg(state.secondary_color)),
]))
}
})
.collect();
if items.is_empty() {
items.push(ListItem::new(Line::from(vec![Span::styled(
if state.search_query.is_empty() { "No liked songs yet." } else { "No search results." },
Style::default().fg(state.secondary_color),
)])));
}
f.render_stateful_widget(
List::new(items).highlight_style(Style::default().bg(state.highlight_bg_color)).highlight_symbol("> "),
list_area,
&mut state.liked_list_state,
);
if state.is_searching {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Search: ", Style::default().fg(state.secondary_color)),
Span::styled(&state.search_query, Style::default().fg(state.highlight_fg_color).bold()),
Span::styled("█", Style::default().fg(state.theme_color).bold()),
]))
.alignment(Alignment::Left),
bottom,
);
} else {
draw_footer(f, state, bottom);
}
}
fn draw_horizontal(f: &mut Frame, state: &mut AppState, hide_art: bool) {
let mut constraints = vec![];
if state.nav_time.is_some() {
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(3));
}
constraints.push(Constraint::Min(10));
constraints.push(Constraint::Length(1));
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.margin(HORIZONTAL_MARGIN)
.split(f.area());
let (content_chunk, bottom_chunk) = if state.nav_time.is_some() {
f.render_widget(Paragraph::new(build_nav(state)).alignment(Alignment::Center), main_chunks[0]);
(main_chunks[2], main_chunks[3])
} else {
(main_chunks[0], main_chunks[1])
};
let dynamic_width = art_column_width(content_chunk.height);
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(dynamic_width), Constraint::Length(ART_TEXT_GAP), Constraint::Min(20)])
.split(content_chunk);
match state.mode {
AppMode::Playing => {
draw_player(f, state, content_chunks.clone(), bottom_chunk, content_chunk.height, hide_art);
},
AppMode::LikedSongs => {
draw_library(f, state, content_chunks.clone(), bottom_chunk, hide_art);
},
AppMode::ConfigError(_) => {},
}
if state.show_help_overlay {
draw_help_popup(f, state, f.area());
}
if state.show_unlike_confirm.is_some() {
draw_confirm_popup(f, state, f.area());
}
}
fn draw_player(
f: &mut Frame,
state: &mut AppState,
content: Rc<[Rect]>,
bottom: Rect,
available_height: u16,
hide_art: bool,
) {
let image_height = content[0].width / 2;
let left_col = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(image_height), Constraint::Min(0)])
.split(content[0]);
let art_size = fitted_art_size(state.art_protocol.as_ref(), content[0].width, image_height);
let placeholder_color = state.secondary_color;
draw_art(f, &mut state.art_protocol, left_col[0], hide_art, false, "Loading Art...", placeholder_color);
let basic_text = vec![
Line::from(vec![Span::styled(&state.song.title, Style::default().fg(state.theme_color).bold())]),
Line::from(vec![Span::styled("Artist: ", Style::default().bold()), Span::raw(&state.song.artist)]),
Line::from(vec![Span::styled("Album: ", Style::default().bold()), Span::raw(&state.song.album)]),
];
let mut ext_text = vec![];
if available_height >= 16 {
if !state.song.circle.is_empty() && state.song.circle != "Unknown" {
let mut spans = vec![
Span::styled("Circle: ", Style::default().bold().fg(state.secondary_color)),
Span::styled(&state.song.circle, Style::default().fg(state.secondary_color)),
];
if !state.song.circle_link.is_empty() {
spans.push(Span::styled(
format!(" ({})", state.song.circle_link),
Style::default().fg(state.secondary_color),
));
}
ext_text.push(Line::from(spans));
}
if !state.song.vocal.is_empty() && state.song.vocal != "Unknown" && state.song.vocal != "-" {
ext_text.push(Line::from(vec![
Span::styled("Vocal: ", Style::default().bold().fg(state.secondary_color)),
Span::styled(&state.song.vocal, Style::default().fg(state.secondary_color)),
]));
}
if !state.song.original.is_empty() && state.song.original != "Unknown" && state.song.original != "-" {
ext_text.push(Line::from(vec![
Span::styled("Original: ", Style::default().bold().fg(state.secondary_color)),
Span::styled(&state.song.original, Style::default().fg(state.secondary_color)),
]));
}
if !state.song.year.is_empty() && state.song.year != "Unknown" {
ext_text.push(Line::from(vec![
Span::styled("Year: ", Style::default().bold().fg(state.secondary_color)),
Span::styled(&state.song.year, Style::default().fg(state.secondary_color)),
]));
}
if !state.song.rating.is_empty() && state.song.rating != "Unknown" {
ext_text.push(Line::from(vec![
Span::styled("Rating: ", Style::default().bold().fg(state.secondary_color)),
Span::styled(
format!("{} ({})", state.song.rating, state.song.times_rated),
Style::default().fg(state.secondary_color),
),
]));
}
}
let refresh_height = state.is_refreshing() as u16;
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(ext_text.len() as u16),
Constraint::Length(1),
Constraint::Length(refresh_height),
Constraint::Min(2),
])
.split(content[2]);
f.render_widget(Paragraph::new(basic_text), right_chunks[0]);
let gauge_width = art_size.width.max(MIN_GAUGE_WIDTH).min(right_chunks[2].width);
let gauge_area = Rect { x: right_chunks[2].x, y: right_chunks[2].y, width: gauge_width, height: 1 };
draw_sakuya(f, state, gauge_area);
if !ext_text.is_empty() {
f.render_widget(Paragraph::new(ext_text), right_chunks[4]);
}
if refresh_height > 0 {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled("少女祈祷中…", Style::default().fg(state.theme_color))])),
right_chunks[6],
);
}
draw_footer(f, state, bottom);
}
fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect, hide_art: bool) {
let image_height = content[0].width / 2;
let left_col = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(image_height), Constraint::Min(0)])
.split(content[0]);
let placeholder = if selected_has_art(state) { "Loading Art..." } else { "No Art Saved" };
let placeholder_color = state.secondary_color;
draw_art(f, &mut state.selected_art, left_col[0], hide_art, false, placeholder, placeholder_color);
draw_library_body(f, state, content[2], bottom);
}
fn draw_vertical(f: &mut Frame, state: &mut AppState, hide_art: bool) {
let mut constraints = vec![];
if state.nav_time.is_some() {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Min(3));
constraints.push(Constraint::Length(1));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.margin(VERTICAL_MARGIN)
.split(f.area());
let (content, bottom) = if state.nav_time.is_some() {
f.render_widget(Paragraph::new(build_nav(state)).alignment(Alignment::Center), chunks[0]);
(chunks[1], chunks[2])
} else {
(chunks[0], chunks[1])
};
match state.mode {
AppMode::Playing => draw_player_vertical(f, state, content, bottom, hide_art),
AppMode::LikedSongs => draw_library_vertical(f, state, content, bottom, hide_art),
AppMode::ConfigError(_) => {},
}
if state.show_help_overlay {
draw_help_popup(f, state, f.area());
}
if state.show_unlike_confirm.is_some() {
draw_confirm_popup(f, state, f.area());
}
}
fn draw_player_vertical(f: &mut Frame, state: &mut AppState, content: Rect, bottom: Rect, hide_art: bool) {
let column = center_rect(content, content.width.min(CONTENT_MAX_WIDTH), content.height);
let title_rows = (state.song.title.chars().count() > column.width.max(1) as usize) as u16 + 1;
let album_rows = (is_known(&state.song.album) && column.width >= 16) as u16;
let fixed = title_rows + 1 + album_rows + 2;
let art_max = vertical_art_height(column, fixed + 1, 58);
let art_size = fitted_art_size(state.art_protocol.as_ref(), column.width, art_max);
let gap = (art_size.height > 0) as u16;
let meta_width = if art_size.height > 0 { art_size.width } else { column.width };
let meta = meta_lines(&state.song, state.secondary_color, meta_width);
let info_headroom = column.height.saturating_sub(art_size.height + gap);
let meta_h = (meta.len() as u16).min(info_headroom.saturating_sub(fixed + 1));
let info_h = fixed + if meta_h > 0 { 1 + meta_h } else { 0 };
let top_pad = column.height.saturating_sub(art_size.height + gap + info_h) / 2;
let art_area = Rect { x: column.x, y: column.y + top_pad, width: column.width, height: art_size.height };
let info_y = art_area.y + art_size.height + gap;
let info_area = Rect {
x: column.x,
y: info_y,
width: column.width,
height: info_h.min((column.y + column.height).saturating_sub(info_y)),
};
let placeholder_color = state.secondary_color;
draw_art(f, &mut state.art_protocol, art_area, hide_art, true, "Loading Art...", placeholder_color);
let mut rows = vec![Constraint::Length(title_rows), Constraint::Length(1)];
if album_rows > 0 {
rows.push(Constraint::Length(album_rows));
}
rows.push(Constraint::Length(1));
rows.push(Constraint::Length(1));
if meta_h > 0 {
rows.push(Constraint::Length(1));
rows.push(Constraint::Length(meta_h));
}
let chunks = Layout::default().direction(Direction::Vertical).constraints(rows).split(info_area);
let mut i = 0;
let title_area = chunks[i];
i += 1;
let artist_area = chunks[i];
i += 1;
let album_area = if album_rows > 0 {
let area = chunks[i];
i += 1;
Some(area)
} else {
None
};
i += 1;
let gauge_row = chunks[i];
i += 1;
let meta_area = if meta_h > 0 {
i += 1;
Some(chunks[i])
} else {
None
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
state.song.title.as_str(),
Style::default().fg(state.theme_color).bold(),
)))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
title_area,
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(state.song.artist.as_str(), Style::default())))
.alignment(Alignment::Center),
artist_area,
);
if let Some(area) = album_area {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
state.song.album.as_str(),
Style::default().fg(state.secondary_color),
)))
.alignment(Alignment::Center),
area,
);
}
let gauge_width = art_size.width.max(MIN_GAUGE_WIDTH).min(column.width);
draw_sakuya(f, state, center_rect(gauge_row, gauge_width, 1));
if let Some(area) = meta_area {
f.render_widget(Paragraph::new(meta), center_rect(area, meta_width, area.height));
}
draw_footer(f, state, bottom);
}
fn draw_library_vertical(f: &mut Frame, state: &mut AppState, content: Rect, bottom: Rect, hide_art: bool) {
let column = center_rect(content, content.width.min(CONTENT_MAX_WIDTH), content.height);
let art_max =
if column.height >= 16 && column.width >= 16 { vertical_art_height(column, MIN_INFO_HEIGHT, 40) } else { 0 };
let art_h = fitted_art_size(state.selected_art.as_ref(), column.width, art_max).height;
let (art_area, list_area) = if art_h > 0 {
let art = Rect { x: column.x, y: column.y, width: column.width, height: art_h };
let list = Rect {
x: column.x,
y: column.y + art_h + 1,
width: column.width,
height: column.height.saturating_sub(art_h + 1),
};
(art, list)
} else {
(Rect { x: column.x, y: column.y, width: column.width, height: 0 }, column)
};
if art_h > 0 {
let placeholder = if selected_has_art(state) { "Loading Art..." } else { "No Art Saved" };
let placeholder_color = state.secondary_color;
draw_art(f, &mut state.selected_art, art_area, hide_art, true, placeholder, placeholder_color);
}
draw_library_body(f, state, list_area, bottom);
}
fn draw_config_error(f: &mut Frame, warnings: &[String], scroll: u16) {
let area = centered_rect(58, 14, f.area());
f.render_widget(Clear, area);
let mut lines = vec![
Line::from(""),
Line::from(vec![Span::styled(" Invalid Configuration Detected ", Style::default().fg(Color::Red).bold())]),
Line::from(""),
Line::from(vec![Span::styled(
" The following issues were found in config.toml:",
Style::default().fg(Color::White),
)]),
Line::from(""),
];
let mut has_color_error = false;
for w in warnings {
if w.contains("valid color") {
has_color_error = true;
}
lines.push(Line::from(vec![Span::styled(format!(" - {w}"), Style::default().fg(Color::Yellow))]));
}
if has_color_error {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
" Supported color formats:",
Style::default().fg(Color::White).bold(),
)]));
lines.push(Line::from(vec![Span::styled(
" - Base16 names (e.g., \"red\", \"cyan\", \"darkgray\")",
Style::default().fg(Color::White),
)]));
lines.push(Line::from(vec![Span::styled(
" - Hex codes (e.g., \"#ffffff\", \"#5bc0ae\")",
Style::default().fg(Color::White),
)]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(" Press 'q' or 'Esc' to quit.", Style::default().fg(Color::DarkGray))]));
f.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.scroll((scroll, 0))
.block(Block::default().borders(Borders::ALL).style(Style::default().fg(Color::Red)))
.alignment(Alignment::Center),
area,
);
}
fn draw_help_popup(f: &mut Frame, state: &AppState, area: Rect) {
let popup_area = centered_rect(44, 14, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Controls ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.style(Style::default().fg(state.theme_color));
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.margin(1)
.split(inner_area);
let tab_titles: Vec<_> = vec![" General ", " Library "].into_iter().map(Line::from).collect();
let tabs = Tabs::new(tab_titles)
.select(state.help_tab)
.highlight_style(Style::default().fg(Color::Black).bg(state.theme_color).bold())
.divider("│");
f.render_widget(tabs, chunks[0]);
let rows = match state.help_tab {
0 => vec![
Row::new(vec![
Span::styled("[Tab]", Style::default().bold().fg(state.theme_color)),
Span::raw("Cycle tabs"),
]),
Row::new(vec![
Span::styled("[m]", Style::default().bold().fg(state.theme_color)),
Span::raw("Mute / Unmute stream"),
]),
Row::new(vec![
Span::styled("[-/=]", Style::default().bold().fg(state.theme_color)),
Span::raw("Volume down / up"),
]),
Row::new(vec![
Span::styled("[h]", Style::default().bold().fg(state.theme_color)),
Span::raw("Open history in browser"),
]),
Row::new(vec![
Span::styled("[?]", Style::default().bold().fg(state.theme_color)),
Span::raw("Close help menu"),
]),
Row::new(vec![
Span::styled("[q/^c]", Style::default().bold().fg(state.theme_color)),
Span::raw("Quit application"),
]),
],
1 => vec![
Row::new(vec![
Span::styled("[l]", Style::default().bold().fg(state.theme_color)),
Span::raw("Like / Unlike song"),
]),
Row::new(vec![
Span::styled("[c/C]", Style::default().bold().fg(state.theme_color)),
Span::raw("Copy short / full metadata"),
]),
Row::new(vec![
Span::styled("[o/Enter]", Style::default().bold().fg(state.theme_color)),
Span::raw("Open album info"),
]),
Row::new(vec![
Span::styled("[/]", Style::default().bold().fg(state.theme_color)),
Span::raw("Search library"),
]),
Row::new(vec![
Span::styled("[j/k]", Style::default().bold().fg(state.theme_color)),
Span::raw("Scroll list down / up"),
]),
],
_ => vec![],
};
let table = Table::new(rows, [Constraint::Length(12), Constraint::Min(20)]).column_spacing(2);
f.render_widget(table, chunks[1]);
}
fn draw_confirm_popup(f: &mut Frame, state: &AppState, area: Rect) {
let popup_area = centered_rect(36, 7, area);
f.render_widget(Clear, popup_area);
f.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(vec![Span::raw(" Remove from library? ")]),
Line::from(""),
Line::from(vec![
Span::styled(" [y] ", Style::default().fg(state.theme_color).bold()),
Span::raw("Yes "),
Span::styled(" [n] ", Style::default().fg(state.error_color).bold()),
Span::raw("No"),
]),
])
.block(Block::default().title(" Confirm ").borders(Borders::ALL).style(Style::default().fg(state.error_color)))
.alignment(Alignment::Center),
popup_area,
);
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, path::PathBuf, time::Instant};
use ratatui::{Terminal, backend::TestBackend, widgets::ListState};
use super::*;
use crate::{
config::AppConfig,
models::{LikedSong, MpvSong, SongData},
};
fn sample_song() -> SongData {
SongData {
id: 7,
title: "Sakuya's Theme ~ Flowering Night".to_string(),
artist: "Touhou Project".to_string(),
album: "Retrospective".to_string(),
circle: "Example Circle".to_string(),
circle_link: "example.fm".to_string(),
vocal: "Nana Takahashi".to_string(),
original: "Flowering Night".to_string(),
year: "2024".to_string(),
duration: 314,
played: 42,
art_url: String::new(),
listeners: 137,
rating: "4.6".to_string(),
times_rated: 58,
}
}
fn sample_like(id: i64, title: &str) -> LikedSong {
LikedSong {
id,
title: title.to_string(),
artist: "Example Artist".to_string(),
album: "Example Album".to_string(),
duration: 180,
timestamp: id.unsigned_abs(),
circle: "Example Circle".to_string(),
vocal: "Example Vocal".to_string(),
original: "Example Original".to_string(),
year: "2023".to_string(),
}
}
fn test_state(mode: AppMode) -> AppState {
let mut liked_list_state = ListState::default();
liked_list_state.select(Some(0));
AppState {
config: AppConfig::default(),
song: sample_song(),
song_start_time: Instant::now(),
art_protocol: None,
live_art_bytes: None,
likes: HashMap::new(),
filtered_likes: vec![sample_like(1, "First Song"), sample_like(2, "Second Song")],
likes_file: PathBuf::from("/tmp/grrs-test/likes.json"),
status_message: "connected".to_string(),
status_message_time: None,
is_muted: false,
mode,
is_searching: false,
search_query: String::new(),
liked_list_state,
selected_art: None,
show_help_overlay: false,
show_unlike_confirm: None,
help_tab: 0,
theme_color: Color::Green,
error_color: Color::Red,
warning_color: Color::Yellow,
highlight_bg_color: Color::DarkGray,
highlight_fg_color: Color::White,
secondary_color: Color::DarkGray,
mpv_song: MpvSong::default(),
refresh_until: None,
volume: 70,
volume_time: None,
config_scroll: 0,
notified_song_id: 0,
nav_time: None,
mpv_retries: 3,
}
}
#[test]
fn wide_and_short_terminals_use_the_side_by_side_layout() {
for (w, h) in [(80, 24), (120, 30), (200, 60), (160, 40)] {
assert_eq!(
resolve_layout(Rect::new(0, 0, w, h), LayoutPreference::Auto),
LayoutMode::Horizontal,
"{w}x{h} should stay wide"
);
}
}
#[test]
fn narrow_terminals_stack_vertically() {
for (w, h) in [(40, 24), (60, 30), (30, 12), (24, 20)] {
assert_eq!(
resolve_layout(Rect::new(0, 0, w, h), LayoutPreference::Auto),
LayoutMode::Vertical,
"{w}x{h} is too narrow for the wide layout"
);
}
}
#[test]
fn portrait_and_near_square_terminals_stack_vertically() {
for (w, h) in [(80, 120), (100, 101), (50, 51), (200, 400), (94, 41), (100, 50), (120, 60)] {
assert_eq!(
resolve_layout(Rect::new(0, 0, w, h), LayoutPreference::Auto),
LayoutMode::Vertical,
"{w}x{h} should use the centered layout"
);
}
}
#[test]
fn explicit_preference_overrides_the_aspect_ratio() {
assert_eq!(resolve_layout(Rect::new(0, 0, 400, 40), LayoutPreference::Vertical), LayoutMode::Vertical);
assert_eq!(resolve_layout(Rect::new(0, 0, 12, 200), LayoutPreference::Horizontal), LayoutMode::Horizontal);
}
#[test]
fn help_hint_tracks_the_layout_margins() {
let wide = help_hint_rect(Rect::new(0, 0, 80, 24), LayoutPreference::Horizontal);
assert_eq!(wide, Rect::new(2, 21, 9, 1));
let tall = help_hint_rect(Rect::new(0, 0, 40, 24), LayoutPreference::Vertical);
assert_eq!(tall, Rect::new(1, 22, 9, 1));
}
#[test]
fn centered_rect_stays_inside_its_parent() {
let parent = Rect::new(3, 4, 20, 10);
assert_eq!(center_rect(parent, 6, 4), Rect::new(10, 7, 6, 4));
assert_eq!(center_rect(parent, 100, 100), parent);
}
#[test]
fn vertical_art_leaves_room_for_the_details() {
let content = Rect::new(0, 0, 40, 40);
let art = vertical_art_height(content, 6, 58);
assert!(art <= content.height - 6, "art must leave the info block intact");
assert!(art <= content.width / 2, "art is never taller than a square of the width");
assert_eq!(vertical_art_height(Rect::new(0, 0, 40, 4), 6, 58), 0);
}
#[test]
fn draws_every_composition_without_panicking() {
let sizes = [
(1u16, 1u16),
(4, 3),
(10, 6),
(16, 10),
(20, 12),
(30, 14),
(40, 20),
(60, 24),
(80, 24),
(80, 40),
(100, 30),
(120, 50),
(45, 120),
(60, 90),
];
for (w, h) in sizes {
for library in [false, true] {
for overlay in 0..3 {
let mode = if library { AppMode::LikedSongs } else { AppMode::Playing };
let mut state = test_state(mode);
state.show_help_overlay = overlay == 1;
state.show_unlike_confirm = (overlay == 2).then_some(1);
state.nav_time = (overlay == 1).then(Instant::now);
state.is_searching = library && overlay == 2;
let mut terminal = Terminal::new(TestBackend::new(w, h)).expect("test backend");
terminal.draw(|f| draw_ui(f, &mut state, false)).expect("draw");
terminal.draw(|f| draw_ui(f, &mut state, true)).expect("draw without art");
}
}
}
}
#[test]
fn draws_album_art_in_both_compositions() {
let picker = ratatui_image::picker::Picker::halfblocks();
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(64, 64, |x, y| {
image::Rgb([(x * 4) as u8, (y * 4) as u8, 128])
}));
for (w, h) in [(80u16, 24u16), (30, 60)] {
let mut state = test_state(AppMode::Playing);
state.art_protocol = Some(picker.new_resize_protocol(image.clone()));
state.selected_art = Some(picker.new_resize_protocol(image.clone()));
let mut terminal = Terminal::new(TestBackend::new(w, h)).expect("test backend");
terminal.draw(|f| draw_ui(f, &mut state, false)).expect("draw player");
state.mode = AppMode::LikedSongs;
terminal.draw(|f| draw_ui(f, &mut state, false)).expect("draw library");
}
}
#[test]
fn draws_the_config_error_overlay() {
let mut state = test_state(AppMode::ConfigError(vec!["theme_color is not a valid color".to_string()]));
let mut terminal = Terminal::new(TestBackend::new(50, 16)).expect("test backend");
terminal.draw(|f| draw_ui(f, &mut state, false)).expect("draw");
}
}