feat: implement harmony TUI with vim/evil-mode navigation and SPC leader

Full Ratatui implementation of the harmony music library manager prototype:
- 6 tab views (Library 3-pane, Wanted, Queue, History, Calendar, Settings)
- Vim/evil-mode keybindings (hjkl, counts, gg/G, w/b/e, Ctrl-d/u, H/M/L, marks, operator-pending)
- SPC leader key with which-key popup (Doom Emacs style)
- Command mode (:q, :theme, :help) and / search filter
- Help and quit confirmation modals
- Toast notification system with auto-dismiss
- Gruvbox dark theme throughout
This commit is contained in:
Alexander
2026-05-08 13:26:09 +02:00
parent f967256708
commit fcefcc02a0
27 changed files with 4309 additions and 36 deletions
+606
View File
@@ -0,0 +1,606 @@
#![allow(dead_code)]
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, ListState, Paragraph},
Frame,
};
use crate::data::{Album, AlbumStatus, Artist, Track};
use crate::theme;
use crate::ui::pane::{section_divider, Pane};
use crate::ui::progress_bar::progress_bar;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LibraryFocus {
#[default]
Artists,
Albums,
Tracks,
}
pub struct LibraryState {
pub artists: Vec<Artist>,
pub focus: LibraryFocus,
pub artist_state: ListState,
pub album_state: ListState,
pub track_state: ListState,
}
impl LibraryState {
pub fn new(artists: Vec<Artist>) -> Self {
let mut artist_state = ListState::default();
let mut album_state = ListState::default();
let mut track_state = ListState::default();
if !artists.is_empty() {
artist_state.select(Some(0));
if !artists[0].albums.is_empty() {
album_state.select(Some(0));
track_state.select(Some(0));
}
}
Self {
artists,
focus: LibraryFocus::Artists,
artist_state,
album_state,
track_state,
}
}
pub fn selected_artist(&self) -> Option<&Artist> {
self.artist_state.selected().and_then(|i| self.artists.get(i))
}
pub fn selected_album(&self) -> Option<&Album> {
self.selected_artist()
.and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i)))
}
pub fn move_up(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if let Some(i) = self.artist_state.selected() {
if i > 0 {
self.artist_state.select(Some(i - 1));
self.reset_album_selection();
}
}
}
LibraryFocus::Albums => {
if let Some(i) = self.album_state.selected() {
if i > 0 {
self.album_state.select(Some(i - 1));
self.reset_track_selection();
}
}
}
LibraryFocus::Tracks => {
if let Some(i) = self.track_state.selected() {
if i > 0 {
self.track_state.select(Some(i - 1));
}
}
}
}
}
pub fn move_down(&mut self) {
match self.focus {
LibraryFocus::Artists => {
let max = self.artists.len().saturating_sub(1);
if let Some(i) = self.artist_state.selected() {
if i < max {
self.artist_state.select(Some(i + 1));
self.reset_album_selection();
}
}
}
LibraryFocus::Albums => {
let max = self.selected_artist()
.map(|a| a.albums.len().saturating_sub(1))
.unwrap_or(0);
if let Some(i) = self.album_state.selected() {
if i < max {
self.album_state.select(Some(i + 1));
self.reset_track_selection();
}
}
}
LibraryFocus::Tracks => {
let max = self.track_count().saturating_sub(1);
if let Some(i) = self.track_state.selected() {
if i < max {
self.track_state.select(Some(i + 1));
}
}
}
}
}
fn track_count(&self) -> usize {
self.selected_album().map(|a| a.total as usize).unwrap_or(0)
}
pub fn focus_left(&mut self) {
match self.focus {
LibraryFocus::Artists => {}
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
}
}
pub fn focus_right(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if self.selected_artist().is_some() {
self.focus = LibraryFocus::Albums;
}
}
LibraryFocus::Albums => {
if self.selected_album().is_some() {
self.focus = LibraryFocus::Tracks;
}
}
LibraryFocus::Tracks => {}
}
}
pub fn cycle_focus(&mut self) {
self.focus = match self.focus {
LibraryFocus::Artists => LibraryFocus::Albums,
LibraryFocus::Albums => LibraryFocus::Tracks,
LibraryFocus::Tracks => LibraryFocus::Artists,
};
}
fn reset_album_selection(&mut self) {
if let Some(artist) = self.selected_artist() {
if !artist.albums.is_empty() {
self.album_state.select(Some(0));
} else {
self.album_state.select(None);
}
}
self.reset_track_selection();
}
fn reset_track_selection(&mut self) {
if self.selected_album().is_some() {
self.track_state.select(Some(0));
} else {
self.track_state.select(None);
}
}
pub fn artist_count(&self) -> usize {
self.artists.len()
}
pub fn selected_artist_index(&self) -> Option<usize> {
self.artist_state.selected()
}
}
fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
if !monitored {
return ('◌', Style::default().fg(theme::GRAY));
}
match status {
AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)),
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)),
AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)),
}
}
fn track_icon(have: bool) -> (char, Style) {
if have {
('✓', Style::default().fg(theme::GREEN))
} else {
('✗', Style::default().fg(theme::RED))
}
}
fn artist_status(artist: &Artist) -> AlbumStatus {
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
if have == total {
AlbumStatus::Complete
} else if have == 0 {
AlbumStatus::Wanted
} else {
AlbumStatus::Partial
}
}
fn fmt_size(gb: f64) -> String {
if gb >= 1.0 {
format!("{:.1} GB", gb)
} else {
format!("{} MB", (gb * 1024.0).round() as u32)
}
}
pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area);
render_artists_pane(frame, chunks[0], state);
render_detail_pane(frame, chunks[1], state);
}
fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Artists;
let artist_count = state.artists.len();
let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum();
let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum();
let footer = Line::from(vec![
Span::styled(
format!("{} artists · {} alb", artist_count, total_albums),
Style::default().fg(theme::GRAY),
),
Span::raw(" "),
Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)),
]);
let artist_count_str = artist_count.to_string();
let pane = Pane::new("Artists")
.meta(&artist_count_str)
.focused(focused)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let items: Vec<ListItem> = state
.artists
.iter()
.map(|artist| {
let status = artist_status(artist);
let (icon_char, icon_style) = status_icon(status, artist.monitored);
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let mut name_text = artist.name.clone();
if !artist.monitored {
name_text.push_str(" ·unm");
}
let count_str = format!("{}/{}", have, total);
let name_width = inner.width as usize - 2 - count_str.len() - 2;
if name_text.len() > name_width {
name_text.truncate(name_width.saturating_sub(1));
name_text.push('…');
}
let padding = name_width.saturating_sub(name_text.len());
Line::from(vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(name_text, Style::default().fg(theme::FG1)),
Span::raw(" ".repeat(padding)),
Span::styled(count_str, Style::default().fg(theme::GRAY)),
])
.into()
})
.collect();
let highlight_style = if focused {
Style::default().bg(theme::YELLOW).fg(theme::BG0)
} else {
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
};
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, inner, &mut state.artist_state);
}
fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks;
let artist = state.selected_artist();
let meta = artist
.map(|a| format!("{} · {}", a.country, a.genres.first().map(|s| s.as_str()).unwrap_or("")))
.unwrap_or_default();
let have_tracks: u16 = artist
.map(|a| a.albums.iter().map(|al| al.have).sum())
.unwrap_or(0);
let total_tracks: u16 = artist
.map(|a| a.albums.iter().map(|al| al.total).sum())
.unwrap_or(0);
let footer = Line::from(vec![
Span::styled("[a]", Style::default().fg(theme::GRAY)),
Span::styled(" add ", Style::default().fg(theme::FG2)),
Span::styled("[m]", Style::default().fg(theme::GRAY)),
Span::styled(" monitor ", Style::default().fg(theme::FG2)),
Span::styled("[s]", Style::default().fg(theme::GRAY)),
Span::styled(" search ", Style::default().fg(theme::FG2)),
Span::styled("[r]", Style::default().fg(theme::GRAY)),
Span::styled(" refresh", Style::default().fg(theme::FG2)),
Span::raw(" "),
Span::styled(
format!("{}/{} tracks", have_tracks, total_tracks),
Style::default().fg(theme::GRAY),
),
]);
let pane = Pane::new("Detail")
.meta(&meta)
.focused(focused)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(6),
Constraint::Length(1),
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(inner);
if let Some(artist) = artist {
render_artist_header(frame, chunks[0], artist);
}
let albums_count = artist.map(|a| a.albums.len()).unwrap_or(0);
let albums_label = format!("{} releases", albums_count);
let album_divider = section_divider("albums", Some(&albums_label));
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
let selected_artist_idx = state.artist_state.selected();
if let Some(idx) = selected_artist_idx {
if let Some(artist) = state.artists.get(idx) {
let albums = artist.albums.clone();
let focus = state.focus;
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
}
}
let album_title = state
.selected_album()
.map(|a| a.title.clone())
.unwrap_or_default();
let track_counts = state
.selected_album()
.map(|a| format!("{}/{}", a.have, a.total))
.unwrap_or_default();
let track_label = format!("tracks · {}", album_title);
let track_divider = section_divider(&track_label, Some(&track_counts));
frame.render_widget(Paragraph::new(track_divider), chunks[3]);
render_tracks_list(frame, chunks[4], state);
}
fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let (status_icon, status_text, status_style) = if artist.monitored {
(
Span::styled("", Style::default().fg(theme::GREEN)),
"Monitored",
Style::default().fg(theme::FG2),
)
} else {
(
Span::styled("", Style::default().fg(theme::GRAY)),
"Unmonitored",
Style::default().fg(theme::GRAY),
)
};
let lines = vec![
Line::from(Span::styled(
&artist.name,
Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled("status ", Style::default().fg(theme::GRAY)),
status_icon,
Span::styled(status_text, status_style),
Span::raw(" "),
Span::styled("path ", Style::default().fg(theme::GRAY)),
Span::styled(&artist.path, Style::default().fg(theme::AQUA)),
]),
Line::from(vec![
Span::styled("quality ", Style::default().fg(theme::GRAY)),
Span::styled(&artist.quality, Style::default().fg(theme::FG1)),
Span::raw(" "),
Span::styled("size ", Style::default().fg(theme::GRAY)),
Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)),
]),
Line::from(vec![
Span::styled("albums ", Style::default().fg(theme::GRAY)),
Span::styled(artist.albums.len().to_string(), Style::default().fg(theme::FG1)),
Span::raw(" "),
Span::styled("tracks ", Style::default().fg(theme::GRAY)),
Span::styled(have.to_string(), Style::default().fg(theme::FG1)),
Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)),
]),
];
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0));
frame.render_widget(paragraph, area);
}
fn render_albums_list(
frame: &mut Frame,
area: Rect,
albums: &[Album],
focus: LibraryFocus,
album_state: &mut ListState,
) {
let focused = focus == LibraryFocus::Albums;
let items: Vec<ListItem> = albums
.iter()
.map(|album| {
let (icon_char, icon_style) = status_icon(album.status, album.monitored);
let type_str = format!("[{}]", album.album_type);
let year_str = album.year.to_string();
let progress = progress_bar(album.have, album.total, 10, album.status);
let count_str = format!("{}/{}", album.have, album.total);
let quality_style = if album.quality == "" {
Style::default().fg(theme::GRAY)
} else {
Style::default().fg(theme::AQUA)
};
let title_width = area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8;
let mut title = album.title.clone();
if title.len() > title_width {
title.truncate(title_width.saturating_sub(1));
title.push('…');
}
let mut spans = vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(title, Style::default().fg(theme::FG1)),
Span::raw(" "),
Span::styled(type_str, Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(year_str, Style::default().fg(theme::GRAY)),
Span::raw(" "),
];
spans.extend(progress.spans);
spans.push(Span::raw(" "));
spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY)));
spans.push(Span::raw(" "));
spans.push(Span::styled(&album.quality, quality_style));
Line::from(spans).into()
})
.collect();
let highlight_style = if focused {
Style::default().bg(theme::YELLOW).fg(theme::BG0)
} else {
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
};
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, area, album_state);
}
fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Tracks;
let tracks = state.get_tracks();
if tracks.is_empty() {
let msg = Paragraph::new(Span::styled(
"(no album selected)",
Style::default().fg(theme::GRAY),
));
frame.render_widget(msg, area);
return;
}
let items: Vec<ListItem> = tracks
.iter()
.map(|track| {
let (icon_char, icon_style) = track_icon(track.have);
let num_str = format!("{:02}", track.number);
let title_style = if track.have {
Style::default().fg(theme::FG1)
} else {
Style::default().fg(theme::GRAY)
};
let quality_style = if track.have {
Style::default().fg(theme::AQUA)
} else {
Style::default().fg(theme::RED)
};
Line::from(vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(num_str, Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(&track.title, title_style),
Span::raw(" "),
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(&track.quality, quality_style),
])
.into()
})
.collect();
let highlight_style = if focused {
Style::default().bg(theme::YELLOW).fg(theme::BG0)
} else {
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
};
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, area, &mut state.track_state);
}
impl LibraryState {
pub fn get_tracks(&self) -> Vec<Track> {
let Some(album) = self.selected_album() else {
return Vec::new();
};
if album.id == "bush" {
return crate::data::sample_tracks_bush_hall();
}
tracks_for(album)
}
}
fn tracks_for(album: &Album) -> Vec<Track> {
let titles = [
"Opening", "Curtain Call", "Half-Light", "Polaroid", "Switchback",
"Slow Dancer", "The Inheritance", "Glassworks", "Interlude", "Aftermath",
"Static", "Returner", "Dust Bowl", "Postcard", "Late Reply",
"Honeymoon", "Northern Lights", "Cold Open", "Coda", "Reprise",
];
(0..album.total)
.map(|i| {
let idx = i as usize;
let m = 2 + ((idx * 7) % 5);
let s = (idx * 13) % 60;
let title_idx = idx % titles.len();
let title = if idx >= titles.len() {
format!("{} II", titles[title_idx])
} else {
titles[title_idx].to_string()
};
let have = i < album.have;
let quality = if have {
album.quality.clone()
} else {
"".to_string()
};
Track {
number: i + 1,
title,
duration: format!("{}:{:02}", m, s),
have,
quality,
}
})
.collect()
}