refactor: migrate to DDD layered architecture

Split the monolithic app.rs (762 lines) into four DDD layers:
- domain/: models, navigation enums (Tab, ModalKind), conversions, aggregates
- application/: App state, LibraryState, NotificationManager, event handlers
- infrastructure/: config, gRPC client, system utilities
- presentation/: all render functions, widgets, views, modals

Original modules (app, data, ui, config, grpc) preserved as thin
re-export facades for backward compatibility. All 13 insta snapshot
tests pass without modification.
This commit is contained in:
Alexander
2026-05-09 12:25:10 +02:00
parent 5bee7092d3
commit c1205e5fb0
45 changed files with 3983 additions and 2248 deletions
+263
View File
@@ -0,0 +1,263 @@
#![allow(dead_code)]
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::Style,
widgets::Paragraph,
};
use crate::application::app_state::App;
use crate::domain::navigation::Tab;
use crate::theme;
use crate::ui::library::render_library;
use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal};
use crate::ui::statusbar::render_statusbar;
use crate::ui::topbar::render_topbar;
use crate::ui::views::{
render_calendar, render_history, render_queue, render_settings, render_wanted,
};
impl App {
pub fn draw(&mut self, frame: &mut Frame) {
self.size = frame.area();
let area = frame.area();
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme::BG0)),
area,
);
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
self.topbar_area = chunks[0];
self.main_area = chunks[1];
self.statusbar_area = chunks[2];
let queue_count = self.queue.len();
let wanted_count = self.wanted.len();
let notification_count = self.notifications.history_count();
let topbar = render_topbar(
frame,
chunks[0],
self.tab,
queue_count,
wanted_count,
notification_count,
self.notifications_open,
);
self.tab_areas = topbar.tabs;
self.notifications_btn_area = topbar.notifications;
self.render_main_content(frame, chunks[1]);
let position = self.get_position();
render_statusbar(frame, chunks[2], position, queue_count, wanted_count);
if let Some(modal) = &self.modal {
match modal {
ModalKind::Help => render_help_modal(frame, area),
ModalKind::Quit => render_quit_modal(frame, area, self.queue.len()),
}
}
self.notifications.render(frame, area);
if self.notifications_open {
self.render_notifications_dropdown(frame);
}
}
fn render_notifications_dropdown(&mut self, frame: &mut Frame) {
use crate::ui::notifications::render_notification_item;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
let history = self.notifications.history();
if history.is_empty() {
return;
}
let dropdown_width = 52u16;
let max_items = 8usize;
let item_height = 3u16;
if let Some(expanded_id) = self.notifications_expanded {
if let Some(notif) = history.iter().find(|n| n.id == expanded_id) {
let detail_lines = notif
.detail
.as_ref()
.map(|d| d.lines().count())
.unwrap_or(1);
let dropdown_height = (6 + detail_lines as u16).min(20);
let x = self
.notifications_btn_area
.x
.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let y = self.topbar_area.y + 1;
let dropdown_area = Rect::new(
x.max(0),
y,
dropdown_width.min(self.size.width),
dropdown_height.min(self.size.height - y),
);
self.notifications_dropdown_area = dropdown_area;
frame.render_widget(Clear, dropdown_area);
let border_color = notif.kind.color();
let block = Block::default()
.borders(Borders::ALL)
.border_style(ratatui::style::Style::default().fg(border_color))
.style(ratatui::style::Style::default().bg(theme::BG1));
let inner = block.inner(dropdown_area);
frame.render_widget(block, dropdown_area);
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
let elapsed = notif.created_at.elapsed().as_secs();
let time_str = if elapsed < 60 {
format!("{}s ago", elapsed)
} else if elapsed < 3600 {
format!("{}m ago", elapsed / 60)
} else {
format!("{}h ago", elapsed / 3600)
};
let mut lines = vec![
Line::from(vec![
Span::styled("", Style::default().fg(theme::GRAY)),
Span::styled(&notif.icon, Style::default().fg(border_color)),
Span::raw(" "),
Span::styled(
notif.kind.label(),
Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(time_str, Style::default().fg(theme::GRAY)),
]),
Line::from(""),
Line::from(Span::styled(&notif.title, Style::default().fg(theme::FG1))),
];
if let Some(detail) = &notif.detail {
lines.push(Line::from(""));
for line in detail.lines() {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(theme::FG2),
)));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(para, inner);
return;
}
}
let visible_count = history.len().min(max_items);
let dropdown_height = (visible_count as u16 * item_height) + 2;
let x = self
.notifications_btn_area
.x
.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let y = self.topbar_area.y + 1;
let dropdown_area = Rect::new(
x.max(0),
y,
dropdown_width.min(self.size.width),
dropdown_height.min(self.size.height - y),
);
self.notifications_dropdown_area = dropdown_area;
frame.render_widget(Clear, dropdown_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(ratatui::style::Style::default().fg(theme::GRAY))
.style(ratatui::style::Style::default().bg(theme::BG1));
let inner = block.inner(dropdown_area);
frame.render_widget(block, dropdown_area);
let start_idx = self.notifications_scroll;
let end_idx = (start_idx + max_items).min(history.len());
for (i, notif) in history
.iter()
.rev()
.skip(start_idx)
.take(end_idx - start_idx)
.enumerate()
{
let item_y = inner.y + (i as u16 * item_height);
let item_area = Rect::new(inner.x, item_y, inner.width, item_height);
render_notification_item(frame, item_area, notif);
}
}
fn render_main_content(&mut self, frame: &mut Frame, area: Rect) {
match self.tab {
Tab::Library => {
render_library(frame, area, &mut self.library);
}
Tab::Wanted => {
render_wanted(frame, area, &self.wanted, &mut self.wanted_state);
}
Tab::Queue => {
render_queue(frame, area, &self.queue, &mut self.queue_state);
}
Tab::History => {
render_history(frame, area, &self.history, &mut self.history_state);
}
Tab::Calendar => {
render_calendar(frame, area, &self.calendar);
}
Tab::Settings => {
render_settings(frame, area);
}
}
}
fn get_position(&self) -> Option<(usize, usize)> {
match self.tab {
Tab::Library => {
let idx = self.library.selected_artist_index().unwrap_or(0) + 1;
let total = self.library.artist_count();
if total > 0 { Some((idx, total)) } else { None }
}
Tab::Wanted => {
if self.wanted.is_empty() {
return None;
}
let idx = self.wanted_state.selected().unwrap_or(0) + 1;
Some((idx, self.wanted.len()))
}
Tab::Queue => {
if self.queue.is_empty() {
return None;
}
let idx = self.queue_state.selected().unwrap_or(0) + 1;
Some((idx, self.queue.len()))
}
Tab::History => {
if self.history.is_empty() {
return None;
}
let idx = self.history_state.selected().unwrap_or(0) + 1;
Some((idx, self.history.len()))
}
Tab::Calendar | Tab::Settings => None,
}
}
}
+404
View File
@@ -0,0 +1,404 @@
#![allow(dead_code)]
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, ListState, Paragraph},
};
use crate::application::library_state::{LibraryFocus, LibraryState};
use crate::data::{Album, AlbumStatus, Artist, MonitorState};
use crate::domain::aggregates::artist_status;
use crate::theme;
use crate::ui::pane::{Pane, section_divider};
use crate::ui::progress_bar::progress_bar;
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 monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) {
match state {
MonitorState::Monitored => 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)),
},
MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
MonitorState::Excluded => ('x', Style::default().fg(theme::RED)),
MonitorState::Unspecified => ('?', 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 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) = monitor_state_icon(artist.monitor_state, status);
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();
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 = if artist.is_some() {
Line::from(vec![Span::styled(
format!("{}/{} tracks", have_tracks, total_tracks),
Style::default().fg(theme::GRAY),
)])
} else {
Line::from("")
};
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 Some(artist) = artist else {
let msg = Paragraph::new(Span::styled(
"No artist selected",
Style::default().fg(theme::GRAY),
));
frame.render_widget(msg, inner);
return;
};
let chunks = Layout::vertical([
Constraint::Length(6),
Constraint::Length(1),
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(inner);
render_artist_header(frame, chunks[0], artist);
let albums_count = artist.albums.len();
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
&& 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) = match artist.monitor_state {
MonitorState::Monitored => (
Span::styled("", Style::default().fg(theme::GREEN)),
"Monitored",
Style::default().fg(theme::FG2),
),
MonitorState::Unmonitored => (
Span::styled("", Style::default().fg(theme::GRAY)),
"Unmonitored",
Style::default().fg(theme::GRAY),
),
MonitorState::Excluded => (
Span::styled("", Style::default().fg(theme::RED)),
"Excluded",
Style::default().fg(theme::RED),
),
MonitorState::Unspecified => (
Span::styled("? ", Style::default().fg(theme::GRAY)),
"Unknown",
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;
if state.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> = state
.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.clone(), title_style),
Span::raw(" "),
Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(track.quality.clone(), 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);
}
+9
View File
@@ -0,0 +1,9 @@
pub mod app_renderer;
pub mod library;
pub mod notifications;
pub mod topbar;
pub mod progress_bar;
pub mod pane;
pub mod statusbar;
pub mod modals;
pub mod views;
+148
View File
@@ -0,0 +1,148 @@
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::theme;
fn keybind_row<'a>(key: &'a str, desc: &'a str) -> Line<'a> {
Line::from(vec![
Span::styled(format!("{:<14}", key), Style::default().fg(theme::YELLOW)),
Span::styled(desc, Style::default().fg(theme::FG2)),
])
}
fn section_header(title: &str) -> Line<'static> {
Line::from(Span::styled(
title.to_string(),
Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD),
))
}
pub fn render_help_modal(frame: &mut Frame, area: Rect) {
let modal_width = 96u16.min(area.width.saturating_sub(4));
let modal_height = 28u16.min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::YELLOW))
.title(Line::from(vec![
Span::styled("", Style::default().fg(theme::YELLOW)),
Span::styled(
"Keybindings · evil-mode (Doom Emacs)",
Style::default().fg(theme::YELLOW),
),
Span::styled("", Style::default().fg(theme::YELLOW)),
]))
.style(Style::default().bg(theme::BG0));
let inner = block.inner(modal_area);
frame.render_widget(block, modal_area);
let cols = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(inner);
render_col1(frame, cols[0]);
render_col2(frame, cols[1]);
render_col3(frame, cols[2]);
let footer_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let footer = Paragraph::new(Line::from(vec![Span::styled(
"harmony · v0.4.2 · canonical evil-mode bindings (Vim/Doom Emacs)",
Style::default().fg(theme::GRAY),
)]));
frame.render_widget(footer, footer_area);
}
fn render_col1(frame: &mut Frame, area: Rect) {
let lines = vec![
section_header("Motion · char/line"),
keybind_row("h j k l", "left / down / up / right"),
keybind_row("w / W", "next word / WORD"),
keybind_row("b / B", "prev word / WORD"),
keybind_row("e / E", "end of word / WORD"),
keybind_row("ge / gE", "back to end of (W)ORD"),
keybind_row("0 / ^", "line start (focus left)"),
keybind_row("$", "line end (focus right)"),
keybind_row("{N}<motion>", "repeat motion N times"),
Line::from(""),
section_header("Motion · file/page"),
keybind_row("g g", "first line"),
keybind_row("G", "last line"),
keybind_row("{N} G", "go to line N"),
keybind_row("g t / g T", "next / prev tab"),
keybind_row("C-d / C-u", "½ page down/up"),
keybind_row("C-f / C-b", "page down/up"),
keybind_row("C-e / C-y", "scroll line down/up"),
keybind_row("H / M / L", "viewport top / mid / bot"),
keybind_row("{ / }", "paragraph back/fwd"),
keybind_row("[[ / ]]", "section back/fwd"),
keybind_row("[c / ]c", "prev / next change"),
];
let para = Paragraph::new(lines);
frame.render_widget(para, area);
}
fn render_col2(frame: &mut Frame, area: Rect) {
let lines = vec![
section_header("Search & jumps"),
keybind_row("/ pat", "filter library"),
keybind_row("? pat", "search backward"),
keybind_row("n / N", "next / prev match"),
keybind_row("* / #", "search word fwd/back"),
keybind_row("C-o / C-i", "jumplist back / fwd"),
keybind_row("m{a-z}", "set mark"),
keybind_row("'{a-z}", "jump to mark line"),
keybind_row("`{a-z}", "jump to mark exact"),
keybind_row("''", "jump to last position"),
Line::from(""),
section_header("Center · z_"),
keybind_row("z z / z .", "center cursor"),
keybind_row("z t", "cursor → top"),
keybind_row("z b / z -", "cursor → bottom"),
];
let para = Paragraph::new(lines);
frame.render_widget(para, area);
}
fn render_col3(frame: &mut Frame, area: Rect) {
let lines = vec![
section_header("SPC leader (Doom)"),
keybind_row("SPC SPC", "M-x command"),
keybind_row("SPC b", "+buffer (tabs)"),
keybind_row("SPC f", "+file / library"),
keybind_row("SPC s", "+search"),
keybind_row("SPC w", "+window / pane"),
keybind_row("SPC t", "+toggle / theme"),
keybind_row("SPC n", "+notifications"),
keybind_row("SPC a", "+actions / artist"),
keybind_row("SPC q", "+quit"),
keybind_row("SPC h", "+help"),
keybind_row("SPC l/w/h/c", "→ tab quick"),
Line::from(""),
section_header("Modes & ex commands"),
keybind_row(":w / :sync", "save library"),
keybind_row(":q", "quit"),
keybind_row(":theme", "dark | light"),
keybind_row("a · t · s · r", "add·toggle·search·refresh"),
keybind_row("1‥6", "switch tab"),
keybind_row("Enter / Esc", "open / back"),
keybind_row("?", "this help"),
];
let para = Paragraph::new(lines);
frame.render_widget(para, area);
}
+7
View File
@@ -0,0 +1,7 @@
pub mod help;
pub mod quit;
pub use help::render_help_modal;
pub use quit::render_quit_modal;
pub use crate::domain::navigation::ModalKind;
+59
View File
@@ -0,0 +1,59 @@
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::theme;
pub fn render_quit_modal(frame: &mut Frame, area: Rect, downloads_in_progress: usize) {
let modal_width = 48u16.min(area.width.saturating_sub(4));
let modal_height = 7u16.min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::YELLOW))
.title(Line::from(vec![
Span::styled("", Style::default().fg(theme::YELLOW)),
Span::styled("quit harmony?", Style::default().fg(theme::YELLOW)),
Span::styled("", Style::default().fg(theme::YELLOW)),
]))
.style(Style::default().bg(theme::BG0));
let inner = block.inner(modal_area);
frame.render_widget(block, modal_area);
let msg = if downloads_in_progress > 0 {
format!(
"{} downloads in progress will continue.",
downloads_in_progress
)
} else {
"No downloads in progress.".to_string()
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(msg, Style::default().fg(theme::FG2))),
Line::from(""),
Line::from(vec![
Span::styled("press ", Style::default().fg(theme::GRAY)),
Span::styled("y", Style::default().fg(theme::YELLOW)),
Span::styled(" to confirm, ", Style::default().fg(theme::GRAY)),
Span::styled("n", Style::default().fg(theme::YELLOW)),
Span::styled(" to cancel", Style::default().fg(theme::GRAY)),
]),
];
let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
frame.render_widget(para, inner);
}
+146
View File
@@ -0,0 +1,146 @@
#![allow(dead_code)]
use std::time::Instant;
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::application::notification_state::{format_elapsed, Notification, NotificationManager, MAX_VISIBLE};
use crate::theme;
impl NotificationManager {
pub fn render(&self, frame: &mut Frame, area: Rect) {
let visible: Vec<&Notification> = self
.active()
.iter()
.rev()
.take(MAX_VISIBLE)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if visible.is_empty() {
return;
}
let notif_width = 50u16.min(area.width.saturating_sub(4));
let notif_height = 3u16;
let spacing = 1u16;
let total_height = visible.len() as u16 * (notif_height + spacing);
let start_y = area.y + area.height.saturating_sub(total_height + 1);
let start_x = area.x + area.width.saturating_sub(notif_width + 2);
for (i, notif) in visible.iter().enumerate() {
let y = start_y + (i as u16) * (notif_height + spacing);
let notif_area = Rect::new(start_x, y, notif_width, notif_height);
frame.render_widget(Clear, notif_area);
let border_color = notif.kind.color();
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme::BG1));
let inner = block.inner(notif_area);
frame.render_widget(block, notif_area);
let elapsed = Instant::now().duration_since(notif.created_at).as_secs();
let timestamp = if elapsed == 0 {
"now".to_string()
} else {
format!("{}s", elapsed)
};
let mut lines = vec![Line::from(vec![
Span::styled(&notif.icon, Style::default().fg(border_color)),
Span::raw(" "),
Span::styled(
&notif.title,
Style::default()
.fg(theme::FG1)
.add_modifier(ratatui::style::Modifier::BOLD),
),
Span::raw(" "),
Span::styled(timestamp, Style::default().fg(theme::GRAY)),
])];
if let Some(detail) = &notif.detail {
let max_len = inner.width.saturating_sub(2) as usize;
let d = if detail.len() > max_len {
format!("{}", &detail[..max_len.saturating_sub(1)])
} else {
detail.clone()
};
lines.push(Line::from(Span::styled(
d,
Style::default().fg(theme::GRAY),
)));
}
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
}
}
pub fn render_notification_item(frame: &mut Frame, area: Rect, notif: &Notification) {
let border_color = notif.kind.color();
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme::BG1));
let inner = block.inner(area);
frame.render_widget(block, area);
let elapsed = Instant::now().duration_since(notif.created_at).as_secs();
let timestamp = format_elapsed(elapsed);
let mut lines = vec![Line::from(vec![
Span::styled(&notif.icon, Style::default().fg(border_color)),
Span::raw(" "),
Span::styled(
notif.kind.label(),
Style::default()
.fg(theme::FG1)
.add_modifier(ratatui::style::Modifier::BOLD),
),
Span::raw(" "),
Span::styled(timestamp, Style::default().fg(theme::GRAY)),
])];
if let Some(detail) = &notif.detail {
let max_len = inner.width.saturating_sub(2) as usize;
let d = if detail.len() > max_len {
format!("{}", &detail[..max_len.saturating_sub(1)])
} else {
detail.clone()
};
lines.push(Line::from(Span::styled(
d,
Style::default().fg(theme::GRAY),
)));
} else {
let max_len = inner.width.saturating_sub(2) as usize;
let title = if notif.title.len() > max_len {
format!("{}", &notif.title[..max_len.saturating_sub(1)])
} else {
notif.title.clone()
};
lines.push(Line::from(Span::styled(
title,
Style::default().fg(theme::GRAY),
)));
}
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
+111
View File
@@ -0,0 +1,111 @@
//! Reusable Pane widget with styled borders and title.
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Widget},
};
use crate::theme;
pub struct Pane<'a> {
title: &'a str,
meta: Option<&'a str>,
focused: bool,
footer: Option<Line<'a>>,
}
impl<'a> Pane<'a> {
pub fn new(title: &'a str) -> Self {
Self {
title,
meta: None,
focused: false,
footer: None,
}
}
pub fn meta(mut self, meta: &'a str) -> Self {
self.meta = Some(meta);
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn footer(mut self, footer: Line<'a>) -> Self {
self.footer = Some(footer);
self
}
pub fn build_block(&self) -> Block<'a> {
let border_color = if self.focused {
theme::YELLOW
} else {
theme::BG3
};
let title_color = if self.focused {
theme::YELLOW
} else {
theme::GRAY
};
let mut title_spans = vec![
Span::styled("─[ ", Style::default().fg(border_color)),
Span::styled(self.title, Style::default().fg(title_color)),
];
if let Some(meta) = self.meta {
title_spans.push(Span::styled(" · ", Style::default().fg(theme::GRAY)));
title_spans.push(Span::styled(meta, Style::default().fg(theme::GRAY)));
}
title_spans.push(Span::styled(" ]─", Style::default().fg(border_color)));
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Line::from(title_spans))
.style(Style::default().bg(theme::BG0))
}
}
impl Widget for Pane<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = self.build_block();
block.render(area, buf);
if let Some(footer) = self.footer
&& area.height > 2
{
let footer_y = area.y + area.height - 1;
let footer_x = area.x + 2;
let footer_width = area.width.saturating_sub(4);
if footer_width > 0 {
buf.set_line(footer_x, footer_y, &footer, footer_width);
}
}
}
}
pub fn section_divider<'a>(label: &'a str, right: Option<&'a str>) -> Line<'a> {
let mut spans = vec![
Span::styled("", Style::default().fg(theme::BG3)),
Span::styled(label, Style::default().fg(theme::GRAY)),
Span::styled("", Style::default().fg(theme::BG3)),
];
if let Some(r) = right {
spans.push(Span::styled(
format!(" {}", r),
Style::default().fg(theme::GRAY),
));
}
Line::from(spans)
}
+39
View File
@@ -0,0 +1,39 @@
//! Unicode progress bar widget.
use ratatui::{
style::Style,
text::{Line, Span},
};
use crate::data::AlbumStatus;
use crate::theme;
/// Renders a unicode progress bar using ▰ (filled) and ▱ (empty).
/// Returns a Line with colored spans based on status:
/// - Complete: green filled
/// - Partial: yellow filled
/// - Wanted: red filled
/// - Unmonitored: gray filled
pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) -> Line<'static> {
let filled_count = if total == 0 {
0
} else {
(have as usize * width).div_ceil(total as usize)
};
let empty_count = width.saturating_sub(filled_count);
let filled_color = match status {
AlbumStatus::Complete => theme::GREEN,
AlbumStatus::Partial => theme::YELLOW,
AlbumStatus::Wanted => theme::RED,
AlbumStatus::Unmonitored => theme::GRAY,
};
let filled_str: String = "".repeat(filled_count);
let empty_str: String = "".repeat(empty_count);
Line::from(vec![
Span::styled(filled_str, Style::default().fg(filled_color)),
Span::styled(empty_str, Style::default().fg(theme::BG3)),
])
}
+79
View File
@@ -0,0 +1,79 @@
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::infrastructure::system::get_free_space;
use crate::theme;
pub fn render_statusbar(
frame: &mut Frame,
area: Rect,
position: Option<(usize, usize)>,
queue_count: usize,
wanted_count: usize,
) {
let mut spans = Vec::new();
spans.push(Span::styled(" ", Style::default().bg(theme::BG2)));
let left_width: usize = spans.iter().map(|s| s.content.len()).sum();
let mut right_spans = Vec::new();
if let Some((current, total)) = position {
right_spans.push(Span::styled(
format!(" {}/{} ", current, total),
Style::default().fg(theme::FG2).bg(theme::BG2),
));
}
if queue_count > 0 {
right_spans.push(Span::styled(
format!(" {} {} ", '\u{2193}', queue_count),
Style::default()
.fg(theme::BG0)
.bg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
));
}
if wanted_count > 0 {
right_spans.push(Span::styled(
format!(" ! {} ", wanted_count),
Style::default()
.fg(theme::BG0)
.bg(theme::BLUE)
.add_modifier(Modifier::BOLD),
));
}
right_spans.push(Span::styled(
format!(" {} ", get_free_space()),
Style::default().fg(theme::GRAY).bg(theme::BG2),
));
right_spans.push(Span::styled(
" harmony 0.4.2 ",
Style::default().fg(theme::GRAY).bg(theme::BG2),
));
let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum();
let spacer_width = area
.width
.saturating_sub(left_width as u16)
.saturating_sub(right_width as u16) as usize;
spans.push(Span::styled(
" ".repeat(spacer_width),
Style::default().bg(theme::BG2),
));
spans.extend(right_spans);
let line = Line::from(spans);
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG2));
frame.render_widget(paragraph, area);
}
+147
View File
@@ -0,0 +1,147 @@
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::app::Tab;
use crate::theme;
pub struct TopbarAreas {
pub tabs: Vec<Rect>,
pub notifications: Rect,
}
pub fn render_topbar(
frame: &mut Frame,
area: Rect,
active_tab: Tab,
queue_count: usize,
wanted_count: usize,
notification_count: usize,
notifications_open: bool,
) -> TopbarAreas {
let mut spans = Vec::new();
let mut tab_areas = Vec::new();
let mut current_x = area.x;
let logo = " ▲ harmony ";
spans.push(Span::styled(
logo,
Style::default()
.fg(theme::BG0)
.bg(theme::ORANGE)
.add_modifier(Modifier::BOLD),
));
current_x += logo.len() as u16;
spans.push(Span::raw(" "));
current_x += 1;
let tabs = [
(Tab::Library, "Library", None),
(
Tab::Wanted,
"Wanted",
if wanted_count > 0 {
Some(wanted_count)
} else {
None
},
),
(
Tab::Queue,
"Queue",
if queue_count > 0 {
Some(queue_count)
} else {
None
},
),
(Tab::History, "History", None),
(Tab::Calendar, "Calendar", None),
(Tab::Settings, "Settings", None),
];
for (tab, label, badge) in tabs.iter() {
let is_active = *tab == active_tab;
let tab_start = current_x;
let text = format!(" {} ", label);
let mut tab_width = text.len() as u16;
if is_active {
spans.push(Span::styled(
text,
Style::default()
.fg(theme::YELLOW)
.bg(theme::BG0)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
text,
Style::default().fg(theme::FG3).bg(theme::BG1),
));
}
current_x += tab_width;
if let Some(count) = badge {
let badge_text = format!(" {} ", count);
let badge_width = badge_text.len() as u16;
spans.push(Span::styled(
badge_text,
Style::default()
.fg(theme::BG0)
.bg(theme::RED)
.add_modifier(Modifier::BOLD),
));
tab_width += badge_width;
current_x += badge_width;
}
tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1));
}
let notif_text = if notification_count > 0 {
format!(" ● Notifications ({}) ", notification_count)
} else {
" ● Notifications ".to_string()
};
let notif_width = notif_text.len() as u16;
let notif_x = area.x + area.width - notif_width;
let remaining = (notif_x - current_x) as usize;
if remaining > 0 {
spans.push(Span::styled(
" ".repeat(remaining),
Style::default().bg(theme::BG1),
));
}
let notif_style = if notifications_open {
Style::default()
.fg(theme::YELLOW)
.bg(theme::BG0)
.add_modifier(Modifier::BOLD)
} else if notification_count > 0 {
Style::default().fg(theme::YELLOW).bg(theme::BG1)
} else {
Style::default().fg(theme::FG3).bg(theme::BG1)
};
spans.push(Span::styled(&notif_text, notif_style));
let notifications_area = Rect::new(notif_x, area.y, notif_width, 1);
let line = Line::from(spans);
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1));
frame.render_widget(paragraph, area);
TopbarAreas {
tabs: tab_areas,
notifications: notifications_area,
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Calendar view - upcoming releases.
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::data::CalendarEntry;
use crate::theme;
use crate::ui::pane::Pane;
struct CalendarCell {
day: u8,
dim: bool,
is_today: bool,
events: Vec<CalendarEntry>,
}
pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]) {
let meta = "upcoming releases · May 2026";
let footer = Line::from(vec![
Span::styled("[h/l]", Style::default().fg(theme::GRAY)),
Span::styled(" month · ", Style::default().fg(theme::FG2)),
Span::styled("[Enter]", Style::default().fg(theme::GRAY)),
Span::styled(" details", Style::default().fg(theme::FG2)),
Span::raw(" "),
Span::styled(
format!("{} upcoming", calendar.len()),
Style::default().fg(theme::GRAY),
),
]);
let pane = Pane::new("Calendar")
.meta(meta)
.focused(true)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let today_day = 8u8;
let year = 2026u16;
let month = 5u8;
let start_dow = 5u8;
let days_in_month = 31u8;
let days_in_prev = 30u8;
let mut cells: Vec<CalendarCell> = Vec::new();
for i in 0..start_dow {
cells.push(CalendarCell {
day: days_in_prev - start_dow + i + 1,
dim: true,
is_today: false,
events: Vec::new(),
});
}
for d in 1..=days_in_month {
let events: Vec<CalendarEntry> = calendar
.iter()
.filter(|e| {
if let Some(day_str) = e.date.split('-').nth(2)
&& let Ok(day) = day_str.parse::<u8>()
{
let month_match = e.date.contains(&format!("{:04}-{:02}", year, month));
return month_match && day == d;
}
false
})
.cloned()
.collect();
cells.push(CalendarCell {
day: d,
dim: false,
is_today: d == today_day,
events,
});
}
let mut next_day = 1u8;
while cells.len() < 42 {
cells.push(CalendarCell {
day: next_day,
dim: true,
is_today: false,
events: Vec::new(),
});
next_day += 1;
}
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(inner);
let header = Line::from(vec![
Span::styled("", Style::default().fg(theme::GRAY)),
Span::styled(
"May 2026",
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
),
Span::styled("", Style::default().fg(theme::GRAY)),
]);
frame.render_widget(
Paragraph::new(header).alignment(ratatui::layout::Alignment::Center),
chunks[0],
);
let dow_labels = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
let cell_width = chunks[2].width / 7;
let dow_spans: Vec<Span> = dow_labels
.iter()
.map(|&d| {
let pad = cell_width.saturating_sub(3) as usize;
Span::styled(
format!("{}{}", d, " ".repeat(pad)),
Style::default().fg(theme::GRAY),
)
})
.collect();
frame.render_widget(Paragraph::new(Line::from(dow_spans)), chunks[1]);
let row_height = chunks[2].height / 6;
let grid_rows = Layout::vertical(vec![Constraint::Length(row_height); 6]).split(chunks[2]);
for (row_idx, row_area) in grid_rows.iter().enumerate() {
let col_areas = Layout::horizontal(vec![Constraint::Ratio(1, 7); 7]).split(*row_area);
for (col_idx, col_area) in col_areas.iter().enumerate() {
let cell_idx = row_idx * 7 + col_idx;
if cell_idx < cells.len() {
render_calendar_cell(frame, *col_area, &cells[cell_idx]);
}
}
}
}
fn render_calendar_cell(frame: &mut Frame, area: Rect, cell: &CalendarCell) {
if area.height == 0 || area.width == 0 {
return;
}
let day_style = if cell.dim {
Style::default().fg(theme::BG4)
} else if cell.is_today {
Style::default().fg(theme::FG1)
} else {
Style::default().fg(theme::FG2)
};
let mut lines: Vec<Line> = Vec::new();
let day_line = if cell.is_today {
Line::from(vec![
Span::styled(format!("{}", cell.day), day_style),
Span::styled("", Style::default().fg(theme::ORANGE)),
])
} else {
Line::from(Span::styled(format!("{}", cell.day), day_style))
};
lines.push(day_line);
let max_events = (area.height.saturating_sub(1)) as usize;
for event in cell.events.iter().take(max_events) {
let event_style = match event.status.as_str() {
"announced" => Style::default().fg(theme::YELLOW),
"monitored" => Style::default().fg(theme::GREEN),
_ => Style::default().fg(theme::FG2),
};
let mut display = event.artist.clone();
let max_len = area.width.saturating_sub(1) as usize;
if display.len() > max_len {
display.truncate(max_len.saturating_sub(1));
display.push('…');
}
lines.push(Line::from(Span::styled(display, event_style)));
}
let para = Paragraph::new(lines);
frame.render_widget(para, area);
}
+91
View File
@@ -0,0 +1,91 @@
//! History view - recent activity.
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{List, ListItem, ListState},
};
use crate::data::HistoryEntry;
use crate::theme;
use crate::ui::pane::Pane;
fn event_style(event: &str) -> (char, &'static str, Style) {
match event {
"imported" => ('✓', "imported", Style::default().fg(theme::GREEN)),
"downloaded" => ('↓', "downloaded", Style::default().fg(theme::AQUA)),
"grabbed" => ('⤓', "grabbed", Style::default().fg(theme::BLUE)),
"search" => ('?', "search", Style::default().fg(theme::YELLOW)),
"refreshed" => ('↻', "refreshed", Style::default().fg(theme::PURPLE)),
"failed" => ('✗', "failed", Style::default().fg(theme::RED)),
_ => ('·', "unknown", Style::default().fg(theme::GRAY)),
}
}
pub fn render_history(
frame: &mut Frame,
area: Rect,
history: &[HistoryEntry],
state: &mut ListState,
) {
let meta = format!("{} events", history.len());
let footer = Line::from(vec![
Span::styled("[d]", Style::default().fg(theme::GRAY)),
Span::styled(" clear · ", Style::default().fg(theme::FG2)),
Span::styled("[r]", Style::default().fg(theme::GRAY)),
Span::styled(" retry failed", Style::default().fg(theme::FG2)),
Span::raw(" "),
Span::styled("last sync 12:04", Style::default().fg(theme::GRAY)),
]);
let pane = Pane::new("History")
.meta(&meta)
.focused(true)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let items: Vec<ListItem> = history
.iter()
.map(|entry| {
let (icon, label, style) = event_style(&entry.event);
let total_fixed = 11 + 12 + 2 + 22;
let detail_width = (inner.width as usize).saturating_sub(total_fixed);
let mut artist = entry.artist.clone();
if artist.len() > 20 {
artist.truncate(19);
artist.push('…');
}
let artist_pad = 22_usize.saturating_sub(artist.len());
let mut detail = entry.detail.clone();
if detail.len() > detail_width {
detail.truncate(detail_width.saturating_sub(1));
detail.push('…');
}
Line::from(vec![
Span::styled(
format!("{:<11}", entry.when),
Style::default().fg(theme::GRAY),
),
Span::styled(format!("{:<12}", label), style),
Span::styled(format!("{} ", icon), style),
Span::styled(artist, Style::default().fg(theme::FG1)),
Span::raw(" ".repeat(artist_pad)),
Span::styled(detail, Style::default().fg(theme::GRAY)),
])
.into()
})
.collect();
let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
frame.render_stateful_widget(list, inner, state);
}
+13
View File
@@ -0,0 +1,13 @@
//! Tab view modules.
pub mod calendar;
pub mod history;
pub mod queue;
pub mod settings;
pub mod wanted;
pub use calendar::render_calendar;
pub use history::render_history;
pub use queue::render_queue;
pub use settings::render_settings;
pub use wanted::render_wanted;
+135
View File
@@ -0,0 +1,135 @@
//! Queue view - active downloads.
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{List, ListItem, ListState},
};
use crate::data::QueueEntry;
use crate::theme;
use crate::ui::pane::Pane;
fn progress_bar_aqua(progress: f64, width: usize) -> Vec<Span<'static>> {
let filled = ((progress * width as f64).round() as usize).min(width);
let empty = width.saturating_sub(filled);
vec![
Span::styled("".repeat(filled), Style::default().fg(theme::AQUA)),
Span::styled("".repeat(empty), Style::default().fg(theme::BG3)),
]
}
pub fn render_queue(frame: &mut Frame, area: Rect, queue: &[QueueEntry], state: &mut ListState) {
let total_speed: f64 = queue
.iter()
.filter_map(|q| q.speed.trim_end_matches(" MB/s").parse::<f64>().ok())
.sum();
let meta = format!(
"{} active · {:.1}%",
queue.len(),
queue.iter().map(|q| q.progress * 100.0).sum::<f64>() / queue.len().max(1) as f64
);
let footer = Line::from(vec![
Span::styled("[x]", Style::default().fg(theme::GRAY)),
Span::styled(" remove · ", Style::default().fg(theme::FG2)),
Span::styled("[p]", Style::default().fg(theme::GRAY)),
Span::styled(" pause · ", Style::default().fg(theme::FG2)),
Span::styled("[Enter]", Style::default().fg(theme::GRAY)),
Span::styled(" details", Style::default().fg(theme::FG2)),
Span::raw(" "),
Span::styled(
format!("{:.1} MB/s", total_speed),
Style::default().fg(theme::AQUA),
),
]);
let pane = Pane::new("Download queue")
.meta(&meta)
.focused(true)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let header = Line::from(vec![
Span::styled(" ", Style::default().fg(theme::GRAY)),
Span::styled("RELEASE", Style::default().fg(theme::GRAY)),
Span::raw(" ".repeat(inner.width.saturating_sub(65) as usize)),
Span::styled("INDEXER ", Style::default().fg(theme::GRAY)),
Span::styled("PROGRESS ", Style::default().fg(theme::GRAY)),
Span::styled("SPEED ", Style::default().fg(theme::GRAY)),
Span::styled("ETA", Style::default().fg(theme::GRAY)),
]);
let header_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
frame.render_widget(ratatui::widgets::Paragraph::new(header), header_area);
let list_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
let items: Vec<ListItem> = queue
.iter()
.map(|entry| {
let total_fixed = 3 + 18 + 18 + 8 + 7;
let title_width = (inner.width as usize).saturating_sub(total_fixed);
let title_artist = format!("{} · {}", entry.title, entry.artist);
let mut display_title = title_artist.clone();
if display_title.len() > title_width {
display_title.truncate(title_width.saturating_sub(1));
display_title.push('…');
}
let title_pad = title_width.saturating_sub(display_title.len());
let mut indexer = entry.indexer.clone();
if indexer.len() > 16 {
indexer.truncate(15);
indexer.push('…');
}
let indexer_pad = 18_usize.saturating_sub(indexer.len());
let pct = (entry.progress * 100.0).round() as u8;
let mut spans = vec![
Span::styled("", Style::default().fg(theme::AQUA)),
Span::styled(display_title, Style::default().fg(theme::FG1)),
Span::raw(" ".repeat(title_pad)),
Span::styled(indexer, Style::default().fg(theme::GRAY)),
Span::raw(" ".repeat(indexer_pad)),
];
spans.extend(progress_bar_aqua(entry.progress, 12));
spans.push(Span::styled(
format!(" {:>3}%", pct),
Style::default().fg(theme::GRAY),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{:>8}", entry.speed),
Style::default().fg(theme::GREEN),
));
spans.push(Span::styled(
format!("{:>7}", entry.eta),
Style::default().fg(theme::YELLOW),
));
Line::from(spans).into()
})
.collect();
let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
frame.render_stateful_widget(list, list_area, state);
}
+294
View File
@@ -0,0 +1,294 @@
//! Settings view - configuration display.
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::theme;
use crate::ui::pane::Pane;
struct SettingRow<'a> {
label: &'a str,
value: &'a str,
value_style: Style,
tag: &'a str,
tag_style: Style,
}
struct Indexer<'a> {
name: &'a str,
priority: u8,
formats: &'a str,
state: &'a str,
tag_style: Style,
enabled: bool,
}
pub fn render_settings(frame: &mut Frame, area: Rect) {
let footer = Line::from(vec![
Span::styled("[Tab]", Style::default().fg(theme::GRAY)),
Span::styled(" next section · ", Style::default().fg(theme::FG2)),
Span::styled("[Enter]", Style::default().fg(theme::GRAY)),
Span::styled(" edit · ", Style::default().fg(theme::FG2)),
Span::styled("[:w]", Style::default().fg(theme::GRAY)),
Span::styled(" save", Style::default().fg(theme::FG2)),
Span::raw(" "),
Span::styled("config saved 12:04", Style::default().fg(theme::GRAY)),
]);
let pane = Pane::new("Settings")
.meta("config · /etc/harmony.toml")
.focused(true)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let rows =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
let top_cols =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[0]);
let bot_cols =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
render_library_section(frame, top_cols[0]);
render_quality_section(frame, top_cols[1]);
render_indexers_section(frame, bot_cols[0]);
render_appearance_section(frame, bot_cols[1]);
}
fn section_block(title: &str) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::BG3))
.title(Line::from(vec![
Span::styled("─[ ", Style::default().fg(theme::BG3)),
Span::styled(title, Style::default().fg(theme::GRAY)),
Span::styled(" ]─", Style::default().fg(theme::BG3)),
]))
.style(Style::default().bg(theme::BG0))
}
fn render_setting_row(row: &SettingRow) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{:<18}", row.label),
Style::default().fg(theme::GRAY),
),
Span::styled(row.value.to_string(), row.value_style),
Span::raw(" "),
Span::styled(format!("[{}]", row.tag), row.tag_style),
])
}
fn render_library_section(frame: &mut Frame, area: Rect) {
let block = section_block("Library");
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = [
SettingRow {
label: "root folder",
value: "/home/usr/music",
value_style: Style::default().fg(theme::AQUA),
tag: "edit",
tag_style: Style::default().fg(theme::GREEN),
},
SettingRow {
label: "naming",
value: "{artist}/{year} - {album}/…",
value_style: Style::default().fg(theme::FG1),
tag: "edit",
tag_style: Style::default().fg(theme::FG2),
},
SettingRow {
label: "cover art",
value: "embed + folder.jpg",
value_style: Style::default().fg(theme::FG1),
tag: "on",
tag_style: Style::default().fg(theme::GREEN),
},
SettingRow {
label: "file types",
value: "flac, mp3, m4a, ogg, opus",
value_style: Style::default().fg(theme::FG1),
tag: "edit",
tag_style: Style::default().fg(theme::FG2),
},
SettingRow {
label: "replace existing",
value: "if higher quality",
value_style: Style::default().fg(theme::FG1),
tag: "cycle",
tag_style: Style::default().fg(theme::YELLOW),
},
];
let lines: Vec<Line> = rows.iter().map(render_setting_row).collect();
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn render_quality_section(frame: &mut Frame, area: Rect) {
let block = section_block("Quality Profile");
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = [
SettingRow {
label: "profile",
value: "FLAC ▸ MP3 320 ▸ MP3 V0",
value_style: Style::default().fg(theme::YELLOW),
tag: "cycle",
tag_style: Style::default().fg(theme::GREEN),
},
SettingRow {
label: "cutoff",
value: "FLAC 16-44",
value_style: Style::default().fg(theme::FG1),
tag: "cycle",
tag_style: Style::default().fg(theme::FG2),
},
SettingRow {
label: "min size",
value: "12 MB / track",
value_style: Style::default().fg(theme::FG1),
tag: "edit",
tag_style: Style::default().fg(theme::FG2),
},
SettingRow {
label: "max size",
value: "300 MB / track",
value_style: Style::default().fg(theme::FG1),
tag: "edit",
tag_style: Style::default().fg(theme::FG2),
},
SettingRow {
label: "prefer single album",
value: "true",
value_style: Style::default().fg(theme::FG1),
tag: "on",
tag_style: Style::default().fg(theme::GREEN),
},
];
let lines: Vec<Line> = rows.iter().map(render_setting_row).collect();
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn render_indexers_section(frame: &mut Frame, area: Rect) {
let block = section_block("Indexers");
let inner = block.inner(area);
frame.render_widget(block, area);
let indexers = [
Indexer {
name: "redacted.ch",
priority: 25,
formats: "FLAC, MP3",
state: "ok",
tag_style: Style::default().fg(theme::GREEN),
enabled: true,
},
Indexer {
name: "orpheus.network",
priority: 25,
formats: "FLAC",
state: "ok",
tag_style: Style::default().fg(theme::GREEN),
enabled: true,
},
Indexer {
name: "rutracker",
priority: 10,
formats: "FLAC, MP3",
state: "slow",
tag_style: Style::default().fg(theme::YELLOW),
enabled: true,
},
Indexer {
name: "nzbgeek",
priority: 5,
formats: "usenet",
state: "off",
tag_style: Style::default().fg(theme::RED),
enabled: false,
},
];
let lines: Vec<Line> = indexers
.iter()
.map(|ix| {
let name_style = if ix.enabled {
Style::default().fg(theme::FG1)
} else {
Style::default().fg(theme::GRAY)
};
Line::from(vec![
Span::styled(format!("{:<18}", ix.name), name_style),
Span::styled(
format!("priority {} ", ix.priority),
Style::default().fg(theme::AQUA),
),
Span::styled(
format!("· {}", ix.formats),
Style::default().fg(theme::GRAY),
),
Span::raw(" "),
Span::styled(format!("[{}]", ix.state), ix.tag_style),
])
})
.collect();
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn render_appearance_section(frame: &mut Frame, area: Rect) {
let block = section_block("Appearance");
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
Line::from(vec![
Span::styled("theme ", Style::default().fg(theme::GRAY)),
Span::styled("[x] gruvbox dark", Style::default().fg(theme::YELLOW)),
Span::styled(" [ ] gruvbox light", Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled("[cycle]", Style::default().fg(theme::GREEN)),
]),
Line::from(vec![
Span::styled("font size ", Style::default().fg(theme::GRAY)),
Span::styled("JetBrains Mono · 14px", Style::default().fg(theme::FG1)),
Span::raw(" "),
Span::styled("[edit]", Style::default().fg(theme::FG2)),
]),
Line::from(vec![
Span::styled("scanlines (CRT) ", Style::default().fg(theme::GRAY)),
Span::styled(
"[ ] subtle scanline overlay",
Style::default().fg(theme::GRAY),
),
Span::raw(" "),
Span::styled("[off]", Style::default().fg(theme::FG2)),
]),
Line::from(vec![
Span::styled("unicode glyphs ", Style::default().fg(theme::GRAY)),
Span::styled(
"box-drawing · powerline · nerd",
Style::default().fg(theme::FG1),
),
Span::raw(" "),
Span::styled("[on]", Style::default().fg(theme::GREEN)),
]),
];
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
+123
View File
@@ -0,0 +1,123 @@
//! Wanted view - missing albums and tracks.
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{List, ListItem, ListState},
};
use crate::data::{AlbumStatus, WantedEntry};
use crate::theme;
use crate::ui::pane::Pane;
fn status_icon(status: AlbumStatus) -> (char, Style) {
match status {
AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)),
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
_ => ('●', Style::default().fg(theme::GREEN)),
}
}
pub fn render_wanted(frame: &mut Frame, area: Rect, wanted: &[WantedEntry], state: &mut ListState) {
let total_missing: u16 = wanted.iter().map(|w| w.missing).sum();
let count_str = format!("{} missing or partial", wanted.len());
let footer = Line::from(vec![
Span::styled("[s]", Style::default().fg(theme::GRAY)),
Span::styled(" search · ", Style::default().fg(theme::FG2)),
Span::styled("[m]", Style::default().fg(theme::GRAY)),
Span::styled(" unmonitor · ", Style::default().fg(theme::FG2)),
Span::styled("[Enter]", Style::default().fg(theme::GRAY)),
Span::styled(" open", Style::default().fg(theme::FG2)),
Span::raw(" "),
Span::styled(
format!("{} missing tracks", total_missing),
Style::default().fg(theme::GRAY),
),
]);
let pane = Pane::new("Wanted")
.meta(&count_str)
.focused(true)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let header = Line::from(vec![
Span::styled(" ", Style::default().fg(theme::GRAY)),
Span::styled("ALBUM", Style::default().fg(theme::GRAY)),
Span::raw(" ".repeat(inner.width.saturating_sub(70) as usize)),
Span::styled(
"ARTIST ",
Style::default().fg(theme::GRAY),
),
Span::styled("YEAR ", Style::default().fg(theme::GRAY)),
Span::styled("MISSING ", Style::default().fg(theme::GRAY)),
Span::styled("RELEASE DATE", Style::default().fg(theme::GRAY)),
]);
let header_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
frame.render_widget(ratatui::widgets::Paragraph::new(header), header_area);
let list_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
let items: Vec<ListItem> = wanted
.iter()
.map(|entry| {
let (icon_char, icon_style) = status_icon(entry.status);
let total_fixed = 3 + 28 + 6 + 7 + 12;
let album_width = (inner.width as usize).saturating_sub(total_fixed);
let mut album = entry.album.clone();
if album.len() > album_width {
album.truncate(album_width.saturating_sub(1));
album.push('…');
}
let album_pad = album_width.saturating_sub(album.len());
let mut artist = entry.artist.clone();
if artist.len() > 26 {
artist.truncate(25);
artist.push('…');
}
let artist_pad = 28_usize.saturating_sub(artist.len());
Line::from(vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(album, Style::default().fg(theme::FG1)),
Span::raw(" ".repeat(album_pad)),
Span::styled(artist, Style::default().fg(theme::GRAY)),
Span::raw(" ".repeat(artist_pad)),
Span::styled(
format!("{:<6}", entry.year),
Style::default().fg(theme::GRAY),
),
Span::styled(
format!("{:>7}", entry.missing),
Style::default().fg(theme::RED),
),
Span::raw(" "),
Span::styled(&entry.release_date, Style::default().fg(theme::GRAY)),
])
.into()
})
.collect();
let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
frame.render_stateful_widget(list, list_area, state);
}