feature: vertical layout
added a centered, stacked layout for narrow and portrait terminals and made it the default, since it reads a lot better on tall windows. the original side-by-side view now only shows up in short, wide windows where stacking would waste the height. also added a `layout` config option ("auto", "horizontal", "vertical") for overriding the auto behavior.
while i was in there i fixed up the horizontal view: the art now lines up with the track title instead of floating in the middle of its column (a mistake introduced when bringing in the vertical layout), the progress bar matches the width of the art instead of stretching across the whole interface, and the metadata block lines up with the cover. popups are centered now, and the layout flips immediately on resize. closes 72db3e8
diff --git a/README.md b/README.md
@@ -8,6 +8,7 @@ A minimal terminal user interface for [Gensokyo Radio](https://gensokyoradio.net
- Streams Gensokyo Radio with album art in your terminal
- Lets you save liked tracks and keeps local copies of their metadata and covers
- Supports media keys (MPRIS) and volume controls
+- Uses a centered, portrait-friendly layout by default and switches to a side-by-side view in short, wide windows
- Rudimentary theming support via config file
## Dependencies
diff --git a/config.toml.example b/config.toml.example
@@ -11,6 +11,10 @@ stream_url = "https://stream.gensokyoradio.net/3"
[ui]
# "playing" (default player view) or "library" (liked songs)
default_mode = "playing"
+# "auto" keeps the artwork stacked above the track details and only switches to
+# the side-by-side view in short, wide windows; force one with "horizontal" or
+# "vertical"
+layout = "auto"
# accepts base16 color names ("green", "red", "magenta") or hex codes ("#5bc0ae")
theme_color = "green"
error_color = "red"
diff --git a/src/config.rs b/src/config.rs
@@ -36,6 +36,7 @@ impl Default for PlayerConfig {
#[serde(default, deny_unknown_fields)]
pub struct UiConfig {
pub default_mode: String,
+ pub layout: String,
pub theme_color: String,
pub error_color: String,
pub warning_color: String,
@@ -48,6 +49,7 @@ impl Default for UiConfig {
fn default() -> Self {
Self {
default_mode: "playing".to_string(),
+ layout: "auto".to_string(),
theme_color: "green".to_string(),
error_color: "red".to_string(),
warning_color: "yellow".to_string(),
@@ -58,6 +60,13 @@ impl Default for UiConfig {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LayoutPreference {
+ Auto,
+ Horizontal,
+ Vertical,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SystemConfig {
@@ -122,9 +131,22 @@ impl AppConfig {
warnings.push("default_mode must be 'playing' or 'library'".to_string());
}
+ let layout = self.ui.layout.to_lowercase();
+ if layout != "auto" && layout != "horizontal" && layout != "vertical" {
+ warnings.push("layout must be 'auto', 'horizontal' or 'vertical'".to_string());
+ }
+
warnings
}
+ pub fn get_layout_preference(&self) -> LayoutPreference {
+ match self.ui.layout.to_lowercase().as_str() {
+ "horizontal" => LayoutPreference::Horizontal,
+ "vertical" => LayoutPreference::Vertical,
+ _ => LayoutPreference::Auto,
+ }
+ }
+
pub fn get_theme_color(&self) -> Color {
parse_color(&self.ui.theme_color).unwrap_or(Color::Green)
}
@@ -184,3 +206,30 @@ pub fn parse_color(c: &str) -> Option<Color> {
_ => None,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn config_without_layout_defaults_to_auto() {
+ let config: AppConfig = toml::from_str("[ui]\ntheme_color = \"green\"\n").expect("parse config");
+ assert_eq!(config.get_layout_preference(), LayoutPreference::Auto);
+ assert!(config.get_warnings().is_empty());
+ }
+
+ #[test]
+ fn explicit_layout_is_case_insensitive() {
+ let config: AppConfig = toml::from_str("[ui]\nlayout = \"Vertical\"\n").expect("parse config");
+ assert_eq!(config.get_layout_preference(), LayoutPreference::Vertical);
+
+ let config: AppConfig = toml::from_str("[ui]\nlayout = \"horizontal\"\n").expect("parse config");
+ assert_eq!(config.get_layout_preference(), LayoutPreference::Horizontal);
+ }
+
+ #[test]
+ fn invalid_layout_produces_a_warning() {
+ let config: AppConfig = toml::from_str("[ui]\nlayout = \"diagonal\"\n").expect("parse config");
+ assert!(config.get_warnings().iter().any(|w| w.contains("layout")), "expected a layout warning");
+ }
+}
diff --git a/src/input.rs b/src/input.rs
@@ -1,10 +1,14 @@
use std::{fs, io::Write, os::unix::net::UnixStream};
use crossterm::event::{Event as CEvent, KeyCode, KeyModifiers, MouseButton, MouseEventKind};
+use ratatui::layout::{Position, Rect};
use ratatui_image::picker::Picker;
use tokio::sync::mpsc;
-use crate::app::{AppEvent, AppMode, AppState};
+use crate::{
+ app::{AppEvent, AppMode, AppState},
+ ui::help_hint_rect,
+};
pub struct InputOutcome {
pub should_quit: bool,
@@ -334,14 +338,15 @@ pub async fn handle_input(
},
CEvent::Mouse(mouse) => {
if mouse.kind == MouseEventKind::Down(MouseButton::Left)
- && let Ok(size) = crossterm::terminal::size()
- && mouse.row == size.1.saturating_sub(3)
- && mouse.column >= 2
- && mouse.column <= 10
+ && let Ok((width, height)) = crossterm::terminal::size()
{
- state.show_help_overlay = !state.show_help_overlay;
- if !state.show_help_overlay {
- clear = true;
+ let area = Rect::new(0, 0, width, height);
+ let hint = help_hint_rect(area, state.config.get_layout_preference());
+ if hint.contains(Position::new(mouse.column, mouse.row)) {
+ state.show_help_overlay = !state.show_help_overlay;
+ if !state.show_help_overlay {
+ clear = true;
+ }
}
}
},
diff --git a/src/main.rs b/src/main.rs
@@ -115,7 +115,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
loop {
if event::poll(Duration::from_millis(250)).unwrap_or(false)
&& let Ok(evt) = event::read()
- && matches!(evt, CEvent::Key(_) | CEvent::Mouse(_))
+ && matches!(evt, CEvent::Key(_) | CEvent::Mouse(_) | CEvent::Resize(_, _))
&& tx_input.blocking_send(AppEvent::Input(evt)).is_err()
{
break;
diff --git a/src/ui.rs b/src/ui.rs
@@ -2,14 +2,71 @@ use std::rc::Rc;
use ratatui::{
Frame,
- layout::{Alignment, Constraint, Direction, Layout, Rect},
+ 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};
+use ratatui_image::{FilterType, Resize, StatefulImage, protocol::StatefulProtocol};
-use crate::app::{AppMode, AppState};
+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()
@@ -31,6 +88,78 @@ fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
.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) {
@@ -65,105 +194,205 @@ fn build_status_bar(state: &AppState) -> Line<'_> {
Line::from(status_spans)
}
-pub fn draw_ui(f: &mut Frame, state: &mut AppState, hide_art: bool) {
- if let AppMode::ConfigError(ref warnings) = state.mode {
- let area = centered_rect(58, 14, f.area());
- f.render_widget(Clear, area);
+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(),
+ )])
+}
- 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(""),
- ];
+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);
+ }
+}
- let mut has_color_error = false;
- for w in warnings {
- if w.contains("valid color") {
- has_color_error = true;
+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),
}
- lines.push(Line::from(vec![Span::styled(format!(" - {w}"), Style::default().fg(Color::Yellow))]));
- }
+ };
+ 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,
+ );
+ }
+}
- 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),
- )]));
+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
+}
- lines.push(Line::from(""));
- lines
- .push(Line::from(vec![Span::styled(" Press 'q' or 'Esc' to quit.", Style::default().fg(Color::DarkGray))]));
+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(lines)
- .wrap(Wrap { trim: true })
- .scroll((state.config_scroll, 0))
- .block(Block::default().borders(Borders::ALL).style(Style::default().fg(Color::Red)))
- .alignment(Alignment::Center),
- area,
+ 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,
);
- return;
+ } 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).margin(2).constraints(constraints).split(f.area());
+ 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() {
- let nav_text = 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)
- },
- ),
- ]);
- f.render_widget(Paragraph::new(nav_text).alignment(Alignment::Center), main_chunks[0]);
-
+ 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 = (content_chunk.height * 2).clamp(10, 45);
+ let dynamic_width = art_column_width(content_chunk.height);
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
- .constraints([Constraint::Length(dynamic_width), Constraint::Length(2), Constraint::Min(20)])
+ .constraints([Constraint::Length(dynamic_width), Constraint::Length(ART_TEXT_GAP), Constraint::Min(20)])
.split(content_chunk);
match state.mode {
@@ -173,14 +402,14 @@ pub fn draw_ui(f: &mut Frame, state: &mut AppState, hide_art: bool) {
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, content_chunks[2]);
+ draw_help_popup(f, state, f.area());
}
if state.show_unlike_confirm.is_some() {
- draw_confirm_popup(f, state, content_chunks[2]);
+ draw_confirm_popup(f, state, f.area());
}
}
@@ -198,17 +427,9 @@ fn draw_player(
.constraints([Constraint::Length(image_height), Constraint::Min(0)])
.split(content[0]);
- if hide_art {
- f.render_widget(Clear, left_col[0]);
- } else if let Some(ref mut protocol) = state.art_protocol {
- f.render_stateful_widget(
- StatefulImage::default().resize(Resize::Fit(Some(FilterType::CatmullRom))),
- left_col[0],
- protocol,
- );
- } else {
- f.render_widget(Paragraph::new("Loading Art...").alignment(Alignment::Center), left_col[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())]),
@@ -276,23 +497,9 @@ fn draw_player(
.split(content[2]);
f.render_widget(Paragraph::new(basic_text), right_chunks[0]);
- 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),
- right_chunks[2],
- );
+ 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]);
}
@@ -304,20 +511,7 @@ fn draw_player(
);
}
- let bottom_layout = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Min(40), Constraint::Length(25)])
- .split(bottom);
-
- f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom_layout[0]);
- f.render_widget(
- Paragraph::new(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(),
- )]))
- .alignment(Alignment::Right),
- bottom_layout[1],
- );
+ draw_footer(f, state, bottom);
}
fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect, hide_art: bool) {
@@ -327,71 +521,225 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
.constraints([Constraint::Length(image_height), Constraint::Min(0)])
.split(content[0]);
- let sorted = &state.filtered_likes;
- let has_art = state
- .liked_list_state
- .selected()
- .and_then(|i| sorted.get(i))
- .map(|s| state.art_path(s.id).exists())
- .unwrap_or(false);
+ 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);
- if hide_art {
- f.render_widget(Clear, left_col[0]);
- } else if let Some(ref mut protocol) = state.selected_art {
- f.render_stateful_widget(
- StatefulImage::default().resize(Resize::Fit(Some(FilterType::CatmullRom))),
- left_col[0],
- protocol,
- );
- } else if has_art {
- f.render_widget(Paragraph::new("Loading Art...").alignment(Alignment::Center), left_col[0]);
+ 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 {
- f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), left_col[0]);
+ (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(_) => {},
}
- 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 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());
+ }
+}
- 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),
- )])));
+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));
}
- f.render_stateful_widget(
- List::new(items).highlight_style(Style::default().bg(state.highlight_bg_color)).highlight_symbol("> "),
- content[2],
- &mut state.liked_list_state,
- );
- if state.is_searching {
+ 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(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,
+ 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 {
- f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
+ (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) {
@@ -499,3 +847,221 @@ fn draw_confirm_popup(f: &mut Frame, state: &AppState, area: Rect) {
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");
+ }
+}