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, 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> { 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 = 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"); } }