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:
@@ -0,0 +1,206 @@
|
||||
//! Calendar view - upcoming releases.
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Build calendar grid for May 2026
|
||||
let today_day = 8u8; // May 8, 2026
|
||||
let year = 2026u16;
|
||||
let month = 5u8;
|
||||
|
||||
// May 2026 starts on Friday (day_of_week = 5)
|
||||
let start_dow = 5u8;
|
||||
let days_in_month = 31u8;
|
||||
let days_in_prev = 30u8; // April has 30 days
|
||||
|
||||
let mut cells: Vec<CalendarCell> = Vec::new();
|
||||
|
||||
// Previous month days
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
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) {
|
||||
if 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Next month days to fill 6 weeks (42 cells)
|
||||
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;
|
||||
}
|
||||
|
||||
// Layout: header + day-of-week + 6 rows of days
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Month header
|
||||
Constraint::Length(1), // Day of week labels
|
||||
Constraint::Fill(1), // Calendar grid
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Month header: "◀ May 2026 ▶"
|
||||
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],
|
||||
);
|
||||
|
||||
// Day of week labels
|
||||
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]);
|
||||
|
||||
// Calendar grid - 6 rows x 7 cols
|
||||
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();
|
||||
|
||||
// First line: day number with optional today marker
|
||||
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);
|
||||
|
||||
// Event lines
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user