feature: initial implementation of history tab
implemented history tab with cached artwork. supported liking and opening tracks directly from history tab, as well as the metadata copying feature. updated help popup to reflect history controls. closes 2ac5c14
diff --git a/pkg/grrs-git/PKGBUILD b/pkg/grrs-git/PKGBUILD
@@ -1,7 +1,7 @@
# Maintainer: Myn <hello@myn.sh>
pkgname=grrs-git
-pkgver=r30.b907e00
+pkgver=r31.be93c4f
pkgrel=1
pkgdesc="A minimal TUI for Gensokyo Radio"
arch=('x86_64')
diff --git a/src/app.rs b/src/app.rs
@@ -1,6 +1,8 @@
use std::{
collections::HashMap,
+ collections::hash_map::DefaultHasher,
env, fs,
+ hash::{Hash, Hasher},
path::PathBuf,
time::{Instant, SystemTime, UNIX_EPOCH},
};
@@ -12,7 +14,7 @@ use souvlaki::MediaControlEvent;
use crate::{
config::AppConfig,
- models::{LikedSong, MpvSong, SongData},
+ models::{HistorySong, LikedSong, MpvSong, SongData},
};
pub const REFRESH_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
@@ -34,6 +36,8 @@ pub enum AppEvent {
LocalArtLoaded(i64, Option<StatefulProtocol>),
MediaControl(MediaControlEvent),
ApiUpdate { listeners: u64, circle_link: String, rating: String, times_rated: u64 },
+ HistoryUpdate(Vec<HistorySong>),
+ HistoryArtLoaded(usize, Option<StatefulProtocol>),
MpvUpdate(MpvSong),
KosuzuBusy(bool),
}
@@ -46,6 +50,9 @@ pub struct AppState {
pub live_art_bytes: Option<Vec<u8>>,
pub likes: HashMap<i64, LikedSong>,
pub likes_file: PathBuf,
+ pub history: Vec<HistorySong>,
+ pub history_list_state: ListState,
+ pub selected_history_art: Option<StatefulProtocol>,
pub status_message: String,
pub status_message_time: Option<Instant>,
pub is_muted: bool,
@@ -68,6 +75,7 @@ pub struct AppState {
pub config_scroll: u16,
pub notified_song_id: i64,
pub nav_time: Option<Instant>,
+ pub cache_dir: PathBuf,
}
impl AppState {
@@ -109,6 +117,7 @@ impl AppState {
fs::create_dir_all(&data_dir).ok();
fs::create_dir_all(&cache_dir).ok();
+ fs::create_dir_all(cache_dir.join("history_art")).ok();
let old_likes = config_dir.join("likes.json");
let new_likes = data_dir.join("likes.json");
@@ -154,6 +163,9 @@ impl AppState {
live_art_bytes: None,
likes,
likes_file,
+ history: Vec::new(),
+ history_list_state: ListState::default(),
+ selected_history_art: None,
status_message: "Connecting to Gensokyo Radio...".to_string(),
status_message_time: Some(Instant::now()),
is_muted: false,
@@ -176,9 +188,17 @@ impl AppState {
config_scroll: 0,
notified_song_id: 0,
nav_time: None,
+ cache_dir,
}
}
+ pub fn generate_id(title: &str, artist: &str) -> i64 {
+ let mut hasher = DefaultHasher::new();
+ title.hash(&mut hasher);
+ artist.hash(&mut hasher);
+ hasher.finish() as i64
+ }
+
pub fn toggle_like(&mut self) {
if self.song.id == 0 {
return;
diff --git a/src/input.rs b/src/input.rs
@@ -1,10 +1,16 @@
-use std::{fs, io::Write, os::unix::net::UnixStream};
+use std::{
+ fs,
+ io::Write,
+ os::unix::net::UnixStream,
+ time::{SystemTime, UNIX_EPOCH},
+};
use crossterm::event::{Event as CEvent, KeyCode, KeyModifiers, MouseButton, MouseEventKind};
use ratatui_image::picker::Picker;
use tokio::sync::mpsc;
use crate::app::{AppEvent, AppMode, AppState};
+use crate::models::LikedSong;
pub struct InputOutcome {
pub should_quit: bool,
@@ -198,7 +204,22 @@ pub async fn handle_input(
if key.code == KeyCode::Tab {
state.mode = AppMode::History;
state.selected_art = None;
+ state.selected_history_art = None;
state.nav_time = Some(std::time::Instant::now());
+ if !state.history.is_empty() {
+ if state.history_list_state.selected().is_none() {
+ state.history_list_state.select(Some(0));
+ }
+ let idx = state.history_list_state.selected().unwrap_or(0);
+ fetch_history_art(
+ idx,
+ state.history[idx].album_art.clone(),
+ state.cache_dir.join("history_art"),
+ tx.clone(),
+ picker.clone(),
+ http_client.clone(),
+ );
+ }
} else if key.code == KeyCode::Esc {
state.mode = AppMode::Playing;
state.selected_art = None;
@@ -210,6 +231,7 @@ pub async fn handle_input(
let next = if current == 0 { sorted.len() - 1 } else { current - 1 };
state.liked_list_state.select(Some(next));
if current != next {
+ state.selected_art = None;
load_local_art(
sorted[next].id,
state.art_path(sorted[next].id),
@@ -225,6 +247,7 @@ pub async fn handle_input(
let next = if current == sorted.len() - 1 { 0 } else { current + 1 };
state.liked_list_state.select(Some(next));
if current != next {
+ state.selected_art = None;
load_local_art(
sorted[next].id,
state.art_path(sorted[next].id),
@@ -282,6 +305,137 @@ pub async fn handle_input(
if key.code == KeyCode::Tab || key.code == KeyCode::Esc {
state.mode = AppMode::Playing;
state.nav_time = Some(std::time::Instant::now());
+ state.selected_history_art = None;
+ } else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') {
+ if !state.history.is_empty() {
+ let current = state.history_list_state.selected().unwrap_or(0);
+ let next = if current == 0 { state.history.len() - 1 } else { current - 1 };
+ state.history_list_state.select(Some(next));
+ if current != next {
+ state.selected_history_art = None;
+ fetch_history_art(
+ next,
+ state.history[next].album_art.clone(),
+ state.cache_dir.join("history_art"),
+ tx.clone(),
+ picker.clone(),
+ http_client.clone(),
+ );
+ }
+ }
+ } else if key.code == KeyCode::Down || key.code == KeyCode::Char('j') {
+ if !state.history.is_empty() {
+ let current = state.history_list_state.selected().unwrap_or(0);
+ let next = if current == state.history.len() - 1 { 0 } else { current + 1 };
+ state.history_list_state.select(Some(next));
+ if current != next {
+ state.selected_history_art = None;
+ fetch_history_art(
+ next,
+ state.history[next].album_art.clone(),
+ state.cache_dir.join("history_art"),
+ tx.clone(),
+ picker.clone(),
+ http_client.clone(),
+ );
+ }
+ }
+ } else if key.code == KeyCode::Char('l') {
+ if let Some(idx) = state.history_list_state.selected() {
+ if let Some(hist_song) = state.history.get(idx).cloned() {
+ let fake_id = AppState::generate_id(&hist_song.title, &hist_song.artist);
+ if state.likes.contains_key(&fake_id) {
+ state.show_unlike_confirm = Some(fake_id);
+ } else {
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system time went backwards")
+ .as_secs();
+ state.likes.insert(
+ fake_id,
+ LikedSong {
+ id: fake_id,
+ title: hist_song.title.clone(),
+ artist: hist_song.artist.clone(),
+ album: hist_song.album.clone(),
+ duration: 0,
+ timestamp: ts,
+ circle: hist_song.circle.clone(),
+ vocal: String::new(),
+ original: String::new(),
+ year: String::new(),
+ },
+ );
+ if let Ok(data) = serde_json::to_string_pretty(&state.likes) {
+ fs::write(&state.likes_file, data).ok();
+ }
+
+ state.status_message = "song liked!".to_string();
+ state.status_message_time = Some(std::time::Instant::now());
+
+ if !hist_song.album_art.is_empty() {
+ let art_dir = state
+ .likes_file
+ .parent()
+ .expect("likes_file has no parent directory")
+ .join("art");
+ fs::create_dir_all(&art_dir).ok();
+ let dest_path = state.art_path(fake_id);
+ let src_path = state.cache_dir.join("history_art").join(&hist_song.album_art);
+ let client = http_client.clone();
+ let art_url = hist_song.album_art.clone();
+ tokio::spawn(async move {
+ if src_path.exists() {
+ let _ = tokio::fs::copy(src_path, dest_path).await;
+ } else {
+ let url =
+ format!("https://gensokyoradio.net/images/albums/500/{}", art_url);
+ if let Ok(resp) = client.get(&url).send().await
+ && let Ok(bytes) = resp.bytes().await
+ {
+ let _ = tokio::fs::write(dest_path, bytes).await;
+ }
+ }
+ });
+ }
+ }
+ }
+ }
+ } else if key.code == KeyCode::Char('c') || key.code == KeyCode::Char('C') {
+ if let Some(idx) = state.history_list_state.selected() {
+ if let Some(song) = state.history.get(idx)
+ && let Ok(mut clipboard) = arboard::Clipboard::new()
+ {
+ let text = if key.code == KeyCode::Char('c') {
+ format!("{} - {}", song.artist, song.title)
+ } else {
+ format!(
+ "Title: {}\nArtist: {}\nAlbum: {}\nCircle: {}",
+ song.title, song.artist, song.album, song.circle
+ )
+ };
+ let _ = clipboard.set_text(text);
+ state.status_message = if key.code == KeyCode::Char('c') {
+ "copied track/artist!".to_string()
+ } else {
+ "copied full metadata!".to_string()
+ };
+ state.status_message_time = Some(std::time::Instant::now());
+ }
+ }
+ } else if (key.code == KeyCode::Char('o') || key.code == KeyCode::Enter)
+ && let Some(idx) = state.history_list_state.selected()
+ {
+ if let Some(song) = state.history.get(idx) {
+ let query = urlencoding::encode(&song.album);
+ let url = format!("https://touhoudb.com/Search?searchType=Album&filter={query}");
+ if webbrowser::open(&url).is_ok() {
+ state.status_message = "opened touhoudb in browser!".to_string();
+ } else {
+ state.status_message = "failed to open browser".to_string();
+ }
+ state.status_message_time = Some(std::time::Instant::now());
+ }
}
}
},
@@ -326,3 +480,56 @@ fn load_local_art(id: i64, path: std::path::PathBuf, tx: mpsc::Sender<AppEvent>,
let _ = tx.send(AppEvent::LocalArtLoaded(id, None)).await;
});
}
+
+pub fn fetch_history_art(
+ idx: usize,
+ filename: String,
+ cache_dir: std::path::PathBuf,
+ tx: mpsc::Sender<AppEvent>,
+ picker: Picker,
+ client: reqwest::Client,
+) {
+ if filename.is_empty() {
+ tokio::spawn(async move {
+ let _ = tx.send(AppEvent::HistoryArtLoaded(idx, None)).await;
+ });
+ return;
+ }
+
+ tokio::spawn(async move {
+ let path = cache_dir.join(&filename);
+ let mut bytes = vec![];
+
+ if path.exists() {
+ if let Ok(b) = tokio::fs::read(&path).await {
+ bytes = b;
+ }
+ }
+
+ if bytes.is_empty() {
+ let url = format!("https://gensokyoradio.net/images/albums/500/{}", filename);
+ if let Ok(resp) = client.get(&url).send().await
+ && let Ok(b) = resp.bytes().await
+ {
+ bytes = b.to_vec();
+ let _ = tokio::fs::write(&path, &bytes).await;
+ }
+ }
+
+ if !bytes.is_empty() {
+ let p_clone = picker.clone();
+ let decode_result = tokio::task::spawn_blocking(move || {
+ if let Ok(img) = image::load_from_memory(&bytes) {
+ return Some(p_clone.new_resize_protocol(img));
+ }
+ None
+ })
+ .await
+ .unwrap_or(None);
+
+ let _ = tx.send(AppEvent::HistoryArtLoaded(idx, decode_result)).await;
+ return;
+ }
+ let _ = tx.send(AppEvent::HistoryArtLoaded(idx, None)).await;
+ });
+}
diff --git a/src/main.rs b/src/main.rs
@@ -30,7 +30,7 @@ use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use crate::{
app::{AppEvent, AppMode, AppState, REFRESH_WINDOW},
input::handle_input,
- models::{ApiInfo, SongData, WsPayload, parse_mpv_media_title},
+ models::{ApiInfo, HistorySong, SongData, WsPayload, parse_mpv_media_title},
ui::draw_ui,
};
@@ -50,7 +50,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut mpv_process = None;
- // Hard stop: Only spawn mpv if the config is valid
if !is_config_error {
let _ = fs::remove_file(&socket_path);
@@ -103,8 +102,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let picker = Picker::from_query_stdio().expect("failed to query terminal for image support");
let loop_picker = picker.clone();
- let http_client =
- reqwest::Client::builder().timeout(Duration::from_secs(5)).build().expect("failed to build http client");
+
+ let http_client = reqwest::Client::builder()
+ .user_agent("curl/8.4.0")
+ .timeout(Duration::from_secs(5))
+ .build()
+ .expect("failed to build http client");
let tx_input = tx.clone();
std::thread::spawn(move || {
@@ -126,8 +129,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
watch::channel::<(i64, String, String, String)>((0, String::new(), String::new(), String::new()));
let (ws_nudge_tx, mut ws_nudge_rx) = watch::channel(());
- // Hard stop: Only spawn tokio tasks if the config is valid
if !is_config_error {
+ let tx_hist_init = tx.clone();
+ let hist_client_init = http_client.clone();
+ tokio::spawn(async move {
+ if let Ok(resp) = hist_client_init.get("https://gensokyoradio.net/api/station/history/").send().await
+ && let Ok(text) = resp.text().await
+ {
+ if let Ok(payloads) = serde_json::from_str::<Vec<serde_json::Value>>(&text) {
+ let hist: Vec<HistorySong> = payloads
+ .into_iter()
+ .map(|p| HistorySong {
+ title: p.get("TITLE").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ artist: p.get("ARTIST").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ album: p.get("ALBUM").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ circle: p.get("CIRCLE").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ album_art: p.get("ALBUMART").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ })
+ .collect();
+ if !hist.is_empty() {
+ let _ = tx_hist_init.send(AppEvent::HistoryUpdate(hist)).await;
+ }
+ }
+ }
+ });
+
let tx_api = tx.clone();
let api_client = http_client.clone();
tokio::spawn(async move {
@@ -145,7 +171,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
})
.await;
}
- tokio::time::sleep(Duration::from_secs(15)).await;
+
+ tokio::time::sleep(Duration::from_secs(2)).await;
+
+ if let Ok(resp) = api_client.get("https://gensokyoradio.net/api/station/history/").send().await
+ && let Ok(text) = resp.text().await
+ {
+ if let Ok(payloads) = serde_json::from_str::<Vec<serde_json::Value>>(&text) {
+ let hist: Vec<HistorySong> = payloads
+ .into_iter()
+ .map(|p| HistorySong {
+ title: p.get("TITLE").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ artist: p.get("ARTIST").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ album: p.get("ALBUM").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ circle: p.get("CIRCLE").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ album_art: p.get("ALBUMART").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ })
+ .collect();
+ if !hist.is_empty() {
+ let _ = tx_api.send(AppEvent::HistoryUpdate(hist)).await;
+ }
+ }
+ }
+
+ tokio::time::sleep(Duration::from_secs(13)).await;
}
});
@@ -338,6 +387,77 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break;
}
},
+ AppEvent::HistoryUpdate(history) => {
+ let first_load = state.history.is_empty() && !history.is_empty();
+ let old_first = state.history.first().map(|s| s.title.clone());
+ let new_first = history.first().map(|s| s.title.clone());
+
+ let shifted = old_first != new_first && !first_load;
+
+ state.history = history.clone();
+
+ if shifted {
+ if let Some(idx) = state.history_list_state.selected() {
+ if idx + 1 < state.history.len() {
+ state.history_list_state.select(Some(idx + 1));
+ }
+ }
+ }
+
+ if first_load && state.mode == AppMode::History {
+ if state.history_list_state.selected().is_none() {
+ state.history_list_state.select(Some(0));
+ }
+ let idx = state.history_list_state.selected().unwrap_or(0);
+ crate::input::fetch_history_art(
+ idx,
+ history[idx].album_art.clone(),
+ state.cache_dir.join("history_art"),
+ tx.clone(),
+ picker.clone(),
+ http_client.clone(),
+ );
+ }
+
+ if first_load || shifted {
+ let cache_dir = state.cache_dir.join("history_art");
+ let hist_client = http_client.clone();
+
+ tokio::spawn(async move {
+ let mut current_files = std::collections::HashSet::new();
+ for song in history {
+ if song.album_art.is_empty() {
+ continue;
+ }
+ current_files.insert(song.album_art.clone());
+ let path = cache_dir.join(&song.album_art);
+ if !path.exists() {
+ let url = format!("https://gensokyoradio.net/images/albums/500/{}", song.album_art);
+ if let Ok(resp) = hist_client.get(&url).send().await
+ && let Ok(bytes) = resp.bytes().await
+ {
+ let _ = tokio::fs::write(&path, bytes).await;
+ }
+ }
+ }
+
+ if let Ok(mut entries) = tokio::fs::read_dir(&cache_dir).await {
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ if let Ok(name) = entry.file_name().into_string() {
+ if !current_files.contains(&name) {
+ let _ = tokio::fs::remove_file(entry.path()).await;
+ }
+ }
+ }
+ }
+ });
+ }
+ },
+ AppEvent::HistoryArtLoaded(idx, art) => {
+ if state.history_list_state.selected() == Some(idx) {
+ state.selected_history_art = art;
+ }
+ },
AppEvent::SongUpdate(song) => {
let cached_listeners = state.song.listeners;
if song.id != state.song.id {
diff --git a/src/models.rs b/src/models.rs
@@ -106,3 +106,12 @@ pub struct LikedSong {
#[serde(default)]
pub year: String,
}
+
+#[derive(Default, Clone, Debug)]
+pub struct HistorySong {
+ pub title: String,
+ pub artist: String,
+ pub album: String,
+ pub circle: String,
+ pub album_art: String,
+}
diff --git a/src/ui.rs b/src/ui.rs
@@ -180,19 +180,19 @@ pub fn draw_ui(f: &mut Frame, state: &mut AppState, hide_art: bool) {
draw_player(f, state, content_chunks.clone(), bottom_chunk, content_chunk.height, hide_art);
},
AppMode::LikedSongs => {
- draw_library(f, state, content_chunks, bottom_chunk, hide_art);
+ draw_library(f, state, content_chunks.clone(), bottom_chunk, hide_art);
},
AppMode::History => {
- draw_history(f, state, content_chunks, bottom_chunk);
+ draw_history(f, state, content_chunks.clone(), bottom_chunk, hide_art);
},
_ => {},
}
if state.show_help_overlay {
- draw_help_popup(f, state);
+ draw_help_popup(f, state, content_chunks[2]);
}
if state.show_unlike_confirm.is_some() {
- draw_confirm_popup(f, state);
+ draw_confirm_popup(f, state, content_chunks[2]);
}
}
@@ -335,15 +335,24 @@ 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.get_sorted_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);
+
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::new(), left_col[0], protocol);
+ } else if has_art {
+ f.render_widget(Paragraph::new("Loading Art...").alignment(Alignment::Center), left_col[0]);
} else {
f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), left_col[0]);
}
- let sorted = state.get_sorted_likes();
let mut items: Vec<ListItem> = sorted
.iter()
.enumerate()
@@ -376,20 +385,66 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
}
-fn draw_history(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect) {
- f.render_widget(Clear, content[0]);
- f.render_widget(
- Paragraph::new("History tab coming soon...")
- .alignment(Alignment::Center)
- .style(Style::default().fg(state.secondary_color)),
+fn draw_history(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 has_art = state
+ .history_list_state
+ .selected()
+ .and_then(|i| state.history.get(i))
+ .map(|s| !s.album_art.is_empty())
+ .unwrap_or(false);
+
+ if hide_art {
+ f.render_widget(Clear, left_col[0]);
+ } else if let Some(ref mut protocol) = state.selected_history_art {
+ f.render_stateful_widget(StatefulImage::new(), left_col[0], protocol);
+ } else if has_art {
+ f.render_widget(Paragraph::new("Loading Art...").alignment(Alignment::Center), left_col[0]);
+ } else {
+ f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), left_col[0]);
+ }
+
+ let mut items: Vec<ListItem> = state
+ .history
+ .iter()
+ .enumerate()
+ .map(|(i, s)| {
+ if Some(i) == state.history_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(
+ "Fetching history...",
+ Style::default().fg(state.secondary_color),
+ )])));
+ }
+ f.render_stateful_widget(
+ List::new(items).highlight_style(Style::default().bg(state.highlight_bg_color)).highlight_symbol("> "),
content[2],
+ &mut state.history_list_state,
);
f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
}
-fn draw_help_popup(f: &mut Frame, state: &AppState) {
- let area = centered_rect(44, 14, f.area());
- f.render_widget(Clear, 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 ")
@@ -398,8 +453,8 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
.border_type(ratatui::widgets::BorderType::Rounded)
.style(Style::default().fg(state.theme_color));
- let inner_area = block.inner(area);
- f.render_widget(block, area);
+ let inner_area = block.inner(popup_area);
+ f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -407,7 +462,7 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
.margin(1)
.split(inner_area);
- let tab_titles: Vec<_> = vec![" General ", " Library "].into_iter().map(Line::from).collect();
+ let tab_titles: Vec<_> = vec![" General ", " Lib / Hist "].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())
@@ -419,7 +474,7 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
0 => vec![
Row::new(vec![
Span::styled("[Tab]", Style::default().bold().fg(state.theme_color)),
- Span::raw("Cycle player / library / history"),
+ Span::raw("Cycle tabs"),
]),
Row::new(vec![
Span::styled("[m]", Style::default().bold().fg(state.theme_color)),
@@ -453,7 +508,7 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
]),
Row::new(vec![
Span::styled("[j/k]", Style::default().bold().fg(state.theme_color)),
- Span::raw("Scroll library down / up"),
+ Span::raw("Scroll list down / up"),
]),
],
_ => vec![],
@@ -464,9 +519,9 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
f.render_widget(table, chunks[1]);
}
-fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
- let area = centered_rect(36, 7, f.area());
- f.render_widget(Clear, area);
+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(""),
@@ -481,6 +536,6 @@ fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
])
.block(Block::default().title(" Confirm ").borders(Borders::ALL).style(Style::default().fg(state.error_color)))
.alignment(Alignment::Center),
- area,
+ popup_area,
);
}