feat: add notification history dropdown and track fetching

- Add notifications dropdown in topbar with click-to-expand details
- Implement GetAlbum gRPC call for fetching track details
- Add track caching to avoid duplicate requests
- Guard against albums with empty IDs from server
- Increase notification TTL from 4s to 6s
- Add grpcurl to flake.nix for debugging
This commit is contained in:
Alexander
2026-05-09 11:19:24 +02:00
parent e77e854d2e
commit f7660436c2
13 changed files with 508 additions and 139 deletions
+1
View File
@@ -62,6 +62,7 @@
rustfmt
protobuf
grpcurl
opencode
];
+244 -9
View File
@@ -9,9 +9,10 @@ use ratatui::{
};
use crate::data::{
Album, AlbumStatus, Artist, CalendarEntry, HistoryEntry, MonitorState, QueueEntry, WantedEntry,
Album, AlbumStatus, Artist, CalendarEntry, HistoryEntry, MonitorState, QueueEntry, Track,
WantedEntry,
};
use crate::grpc::{AlbumDetail, ArtistSummary, GrpcResponse};
use crate::grpc::{AlbumDetail, ArtistSummary, GrpcResponse, TrackDetail};
use crate::theme;
use crate::ui::library::{LibraryFocus, LibraryState, render_library};
use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal};
@@ -80,10 +81,15 @@ pub struct App {
pub history_state: ListState,
pub calendar: Vec<CalendarEntry>,
pub notifications: NotificationManager,
pub notifications_open: bool,
pub notifications_scroll: usize,
pub notifications_expanded: Option<u64>,
topbar_area: Rect,
main_area: Rect,
statusbar_area: Rect,
tab_areas: Vec<Rect>,
notifications_btn_area: Rect,
notifications_dropdown_area: Rect,
}
impl Default for App {
@@ -112,10 +118,15 @@ impl Default for App {
history_state,
calendar,
notifications: NotificationManager::new(),
notifications_open: false,
notifications_scroll: 0,
notifications_expanded: None,
topbar_area: Rect::default(),
main_area: Rect::default(),
statusbar_area: Rect::default(),
tab_areas: Vec::new(),
notifications_btn_area: Rect::default(),
notifications_dropdown_area: Rect::default(),
}
}
}
@@ -147,8 +158,19 @@ impl App {
let queue_count = self.queue.len();
let wanted_count = self.wanted.len();
let notification_count = self.notifications.history_count();
self.tab_areas = render_topbar(frame, chunks[0], self.tab, queue_count, wanted_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]);
@@ -163,6 +185,123 @@ impl App {
}
self.notifications.render(frame, area);
if self.notifications_open {
self.render_notifications_dropdown(frame);
}
}
fn render_notifications_dropdown(&mut self, frame: &mut Frame) {
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::ui::notifications::render_notification_item;
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::text::{Line, Span};
use ratatui::style::{Style, Modifier};
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) {
@@ -221,6 +360,10 @@ impl App {
}
pub fn handle_escape(&mut self) {
if self.notifications_open {
self.notifications_open = false;
return;
}
if self.modal.is_some() {
self.modal = None;
}
@@ -236,6 +379,27 @@ impl App {
return;
}
if self.notifications_open {
let in_dropdown = x >= self.notifications_dropdown_area.x
&& x < self.notifications_dropdown_area.x + self.notifications_dropdown_area.width
&& y >= self.notifications_dropdown_area.y
&& y < self.notifications_dropdown_area.y + self.notifications_dropdown_area.height;
let in_btn = x >= self.notifications_btn_area.x
&& x < self.notifications_btn_area.x + self.notifications_btn_area.width
&& y == self.notifications_btn_area.y;
if in_btn {
self.notifications_open = false;
self.notifications_expanded = None;
} else if in_dropdown {
self.handle_notification_click(x, y);
} else {
self.notifications_open = false;
self.notifications_expanded = None;
}
return;
}
if y == self.topbar_area.y {
self.handle_topbar_click(x);
return;
@@ -251,6 +415,15 @@ impl App {
}
fn handle_topbar_click(&mut self, x: u16) {
if x >= self.notifications_btn_area.x
&& x < self.notifications_btn_area.x + self.notifications_btn_area.width
{
self.notifications_open = !self.notifications_open;
self.notifications_scroll = 0;
self.notifications_expanded = None;
return;
}
for (i, area) in self.tab_areas.iter().enumerate() {
if x >= area.x && x < area.x + area.width {
if let Some(tab) = Tab::ALL.get(i) {
@@ -261,6 +434,25 @@ impl App {
}
}
fn handle_notification_click(&mut self, _x: u16, y: u16) {
if self.notifications_expanded.is_some() {
self.notifications_expanded = None;
return;
}
let inner_y = self.notifications_dropdown_area.y + 1;
let rel_y = y.saturating_sub(inner_y) as usize;
let item_height = 3usize;
let clicked_idx = rel_y / item_height;
let history = self.notifications.history();
let actual_idx = self.notifications_scroll + clicked_idx;
if let Some(notif) = history.iter().rev().nth(actual_idx) {
self.notifications_expanded = Some(notif.id);
}
}
fn handle_main_click(&mut self, x: u16, y: u16) {
let rel_y = y.saturating_sub(self.main_area.y) as usize;
@@ -309,21 +501,19 @@ impl App {
let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT;
if rel_y < tracks_start_row {
if let Some(artist) = self.library.selected_artist() {
if album_row < artist.albums.len() {
if let Some(artist) = self.library.selected_artist()
&& album_row < artist.albums.len() {
self.library.album_state.select(Some(album_row));
self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Albums;
}
}
} else {
let track_row = rel_y - tracks_start_row;
if let Some(album) = self.library.selected_album() {
if track_row < album.total as usize {
if let Some(album) = self.library.selected_album()
&& track_row < album.total as usize {
self.library.track_state.select(Some(track_row));
self.library.focus = LibraryFocus::Tracks;
}
}
}
}
}
@@ -335,6 +525,16 @@ impl App {
return;
}
if self.notifications_open {
let max_scroll = self.notifications.history_count().saturating_sub(8);
if delta > 0 {
self.notifications_scroll = (self.notifications_scroll + 1).min(max_scroll);
} else {
self.notifications_scroll = self.notifications_scroll.saturating_sub(1);
}
return;
}
match self.tab {
Tab::Library => {
self.scroll_library_list(delta);
@@ -432,11 +632,19 @@ impl App {
"",
);
}
GrpcResponse::Album { album, tracks } => {
let converted: Vec<Track> = tracks.into_iter().map(convert_track).collect();
self.library.cache_tracks(album.id, converted);
}
GrpcResponse::Error(msg) => {
self.set_error(msg);
}
}
}
pub fn pending_album_fetch(&mut self) -> Option<String> {
self.library.needs_fetch()
}
}
fn convert_artist(summary: ArtistSummary) -> Artist {
@@ -512,6 +720,33 @@ fn parse_year(date_str: &str) -> u16 {
.unwrap_or(0)
}
fn convert_track(detail: TrackDetail) -> Track {
let have = detail.file.is_some();
let quality = detail
.file
.as_ref()
.map(|f| f.format.clone())
.unwrap_or_else(|| "".to_string());
let duration = format_duration(detail.duration_ms);
Track {
id: detail.id,
number: detail.track_number as u16,
disc: detail.disc_number as u16,
title: detail.title,
duration,
have,
quality,
}
}
fn format_duration(ms: i32) -> String {
let total_secs = ms / 1000;
let mins = total_secs / 60;
let secs = total_secs % 60;
format!("{}:{:02}", mins, secs)
}
fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) {
if len == 0 {
return;
+2 -1
View File
@@ -60,10 +60,11 @@ pub struct Album {
pub status: AlbumStatus,
}
/// A track on an album.
#[derive(Debug, Clone)]
pub struct Track {
pub id: String,
pub number: u16,
pub disc: u16,
pub title: String,
pub duration: String,
pub have: bool,
+25 -1
View File
@@ -3,16 +3,21 @@ use tonic::transport::Channel;
use crate::proto::music_agregator_v1::music_agregator_service_client::MusicAgregatorServiceClient;
pub use crate::proto::music_agregator_v1::{AlbumDetail, ArtistSummary, GetArtistsRequest};
pub use crate::proto::music_agregator_v1::{
AlbumDetail, ArtistSummary, GetAlbumRequest, GetArtistsRequest, TrackDetail,
};
#[derive(Debug)]
pub enum GrpcRequest {
GetArtists,
GetAlbum { album_id: String },
}
#[derive(Debug)]
#[allow(dead_code, clippy::large_enum_variant)]
pub enum GrpcResponse {
Artists(Vec<ArtistSummary>),
Album { album: AlbumDetail, tracks: Vec<TrackDetail> },
Error(String),
}
@@ -36,6 +41,21 @@ impl GrpcClient {
let response = self.music.get_artists(GetArtistsRequest {}).await?;
Ok(response.into_inner().artists)
}
pub async fn get_album(
&mut self,
album_id: String,
) -> Result<(AlbumDetail, Vec<TrackDetail>), tonic::Status> {
let response = self
.music
.get_album(GetAlbumRequest { album_id })
.await?;
let inner = response.into_inner();
let album = inner.album.ok_or_else(|| {
tonic::Status::not_found("Album not found in response")
})?;
Ok((album, inner.tracks))
}
}
pub fn spawn_grpc_worker(
@@ -61,6 +81,10 @@ pub fn spawn_grpc_worker(
Ok(artists) => GrpcResponse::Artists(artists),
Err(e) => GrpcResponse::Error(e.to_string()),
},
GrpcRequest::GetAlbum { album_id } => match client.get_album(album_id).await {
Ok((album, tracks)) => GrpcResponse::Album { album, tracks },
Err(e) => GrpcResponse::Error(e.to_string()),
}
};
if resp_tx.send(response).await.is_err() {
+7 -2
View File
@@ -69,7 +69,7 @@ async fn run() -> Result<()> {
let (grpc_tx, mut grpc_rx) = spawn_grpc_worker(config.grpc_addr());
if grpc_tx.send(GrpcRequest::GetArtists).await.is_err() {
if grpc_tx.try_send(GrpcRequest::GetArtists).is_err() {
app.set_error("Failed to send initial request".to_string());
}
@@ -80,6 +80,10 @@ async fn run() -> Result<()> {
app.handle_grpc_response(response);
}
if let Some(album_id) = app.pending_album_fetch() {
let _ = grpc_tx.try_send(GrpcRequest::GetAlbum { album_id });
}
if event::poll(TICK_RATE)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
@@ -92,7 +96,8 @@ async fn run() -> Result<()> {
} else if key.code == KeyCode::Char('r')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
let _ = grpc_tx.send(GrpcRequest::GetArtists).await;
app.library.clear_cache();
let _ = grpc_tx.try_send(GrpcRequest::GetArtists);
}
}
Event::Mouse(mouse) => match mouse.kind {
+1
View File
@@ -1,3 +1,4 @@
#[allow(clippy::enum_variant_names)]
pub mod music_agregator_v1 {
include!("music_agregator.v1.rs");
}
+82 -85
View File
@@ -1,5 +1,7 @@
#![allow(dead_code)]
use std::collections::HashMap;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
@@ -23,10 +25,13 @@ pub enum LibraryFocus {
pub struct LibraryState {
pub artists: Vec<Artist>,
pub tracks: Vec<Track>,
pub focus: LibraryFocus,
pub artist_state: ListState,
pub album_state: ListState,
pub track_state: ListState,
tracks_cache: HashMap<String, Vec<Track>>,
pending_album_id: Option<String>,
}
impl LibraryState {
@@ -45,10 +50,13 @@ impl LibraryState {
Self {
artists,
tracks: Vec::new(),
focus: LibraryFocus::Artists,
artist_state,
album_state,
track_state,
tracks_cache: HashMap::new(),
pending_album_id: None,
}
}
@@ -66,27 +74,24 @@ impl LibraryState {
pub fn move_up(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if let Some(i) = self.artist_state.selected() {
if i > 0 {
if let Some(i) = self.artist_state.selected()
&& 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 {
if let Some(i) = self.album_state.selected()
&& 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 {
if let Some(i) = self.track_state.selected()
&& i > 0 {
self.track_state.select(Some(i - 1));
}
}
}
}
}
@@ -95,32 +100,29 @@ impl LibraryState {
match self.focus {
LibraryFocus::Artists => {
let max = self.artists.len().saturating_sub(1);
if let Some(i) = self.artist_state.selected() {
if i < max {
if let Some(i) = self.artist_state.selected()
&& 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 {
if let Some(i) = self.album_state.selected()
&& 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 {
if let Some(i) = self.track_state.selected()
&& i < max {
self.track_state.select(Some(i + 1));
}
}
}
}
}
@@ -383,13 +385,12 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
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) {
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()
@@ -536,9 +537,8 @@ fn render_albums_list(
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() {
if state.tracks.is_empty() {
let msg = Paragraph::new(Span::styled(
"(no album selected)",
Style::default().fg(theme::GRAY),
@@ -547,7 +547,8 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
return;
}
let items: Vec<ListItem> = tracks
let items: Vec<ListItem> = state
.tracks
.iter()
.map(|track| {
let (icon_char, icon_style) = track_icon(track.have);
@@ -569,11 +570,11 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
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::styled(track.title.clone(), title_style),
Span::raw(" "),
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(&track.quality, quality_style),
Span::styled(track.quality.clone(), quality_style),
])
.into()
})
@@ -590,64 +591,60 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
}
impl LibraryState {
pub fn get_tracks(&self) -> Vec<Track> {
let Some(album) = self.selected_album() else {
return Vec::new();
};
pub fn get_tracks(&self) -> &[Track] {
&self.tracks
}
tracks_for(album)
pub fn cache_tracks(&mut self, album_id: String, tracks: Vec<Track>) {
self.tracks_cache.insert(album_id.clone(), tracks);
if self.pending_album_id.as_ref() == Some(&album_id) {
self.pending_album_id = None;
self.load_tracks_from_cache(&album_id);
}
}
pub fn load_tracks_from_cache(&mut self, album_id: &str) {
if let Some(tracks) = self.tracks_cache.get(album_id) {
self.tracks = tracks.clone();
self.track_state.select(if self.tracks.is_empty() { None } else { Some(0) });
}
}
pub fn get_cached_tracks(&self, album_id: &str) -> Option<&Vec<Track>> {
self.tracks_cache.get(album_id)
}
pub fn needs_fetch(&mut self) -> Option<String> {
let current_album_id = self.selected_album().map(|a| a.id.clone())?;
if current_album_id.is_empty() {
return None;
}
if self.tracks_cache.contains_key(&current_album_id) {
if self.pending_album_id.as_ref() != Some(&current_album_id) {
self.load_tracks_from_cache(&current_album_id);
}
return None;
}
if self.pending_album_id.as_ref() == Some(&current_album_id) {
return None;
}
self.pending_album_id = Some(current_album_id.clone());
self.tracks.clear();
Some(current_album_id)
}
pub fn selected_album_id(&self) -> Option<String> {
self.selected_album().map(|a| a.id.clone())
}
pub fn clear_cache(&mut self) {
self.tracks_cache.clear();
self.pending_album_id = None;
self.tracks.clear();
}
}
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()
}
+98 -24
View File
@@ -12,8 +12,9 @@ use ratatui::{
use crate::theme;
const NOTIFICATION_TTL_SECS: u64 = 4;
const NOTIFICATION_TTL_SECS: u64 = 6;
const MAX_VISIBLE: usize = 5;
const MAX_HISTORY: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotifKind {
@@ -24,7 +25,7 @@ pub enum NotifKind {
}
impl NotifKind {
fn color(self) -> ratatui::style::Color {
pub fn color(self) -> ratatui::style::Color {
match self {
NotifKind::Info => theme::BLUE,
NotifKind::Success => theme::GREEN,
@@ -32,8 +33,18 @@ impl NotifKind {
NotifKind::Error => theme::RED,
}
}
pub fn label(self) -> &'static str {
match self {
NotifKind::Info => "Info",
NotifKind::Success => "Success",
NotifKind::Warn => "Warning",
NotifKind::Error => "Error",
}
}
}
#[derive(Clone)]
pub struct Notification {
pub id: u64,
pub title: String,
@@ -44,7 +55,8 @@ pub struct Notification {
}
pub struct NotificationManager {
notifications: Vec<Notification>,
active: Vec<Notification>,
history: Vec<Notification>,
next_id: u64,
}
@@ -57,7 +69,8 @@ impl Default for NotificationManager {
impl NotificationManager {
pub fn new() -> Self {
Self {
notifications: Vec::new(),
active: Vec::new(),
history: Vec::new(),
next_id: 1,
}
}
@@ -78,22 +91,28 @@ impl NotificationManager {
created_at: Instant::now(),
};
self.next_id += 1;
self.notifications.push(notification);
while self.notifications.len() > MAX_VISIBLE * 2 {
self.notifications.remove(0);
self.history.push(notification.clone());
if self.history.len() > MAX_HISTORY {
self.history.remove(0);
}
self.active.push(notification);
}
pub fn tick(&mut self) {
let now = Instant::now();
self.notifications
self.active
.retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS);
}
pub fn history(&self) -> &[Notification] {
&self.history
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let visible: Vec<&Notification> = self
.notifications
.active
.iter()
.rev()
.take(MAX_VISIBLE)
@@ -106,7 +125,7 @@ impl NotificationManager {
return;
}
let notif_width = 36u16.min(area.width);
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);
@@ -150,16 +169,13 @@ impl NotificationManager {
])];
if let Some(detail) = &notif.detail {
let mut d = detail.clone();
let max_len = inner.width.saturating_sub(4) as usize;
if d.len() > max_len {
d.truncate(max_len.saturating_sub(1));
d.push('…');
}
lines.push(Line::from(Span::styled(
d,
Style::default().fg(theme::GRAY),
)));
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);
@@ -167,11 +183,69 @@ impl NotificationManager {
}
}
pub fn len(&self) -> usize {
self.notifications.len()
pub fn active_count(&self) -> usize {
self.active.len()
}
pub fn is_empty(&self) -> bool {
self.notifications.is_empty()
pub fn history_count(&self) -> usize {
self.history.len()
}
}
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);
}
fn format_elapsed(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
}
}
+2 -3
View File
@@ -79,8 +79,8 @@ impl Widget for Pane<'_> {
let block = self.build_block();
block.render(area, buf);
if let Some(footer) = self.footer {
if area.height > 2 {
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);
@@ -89,7 +89,6 @@ impl Widget for Pane<'_> {
buf.set_line(footer_x, footer_y, &footer, footer_width);
}
}
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) ->
let filled_count = if total == 0 {
0
} else {
((have as usize * width) + total as usize - 1) / total as usize
(have as usize * width).div_ceil(total as usize)
};
let empty_count = width.saturating_sub(filled_count);
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::theme;
fn get_free_space() -> String {
match statvfs("/") {
Ok(stat) => {
let free_bytes = stat.blocks_available() as u64 * stat.fragment_size() as u64;
let free_bytes = stat.blocks_available() * stat.fragment_size();
let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
if free_gb >= 1000.0 {
format!("{:.1} TB free", free_gb / 1024.0)
+41 -8
View File
@@ -9,13 +9,20 @@ use ratatui::{
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,
) -> Vec<Rect> {
notification_count: usize,
notifications_open: bool,
) -> TopbarAreas {
let mut spans = Vec::new();
let mut tab_areas = Vec::new();
let mut current_x = area.x;
@@ -99,16 +106,42 @@ pub fn render_topbar(
tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1));
}
let used_width = current_x - area.x;
let remaining = area.width.saturating_sub(used_width) as usize;
spans.push(Span::styled(
" ".repeat(remaining),
Style::default().bg(theme::BG1),
));
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);
tab_areas
TopbarAreas {
tabs: tab_areas,
notifications: notifications_area,
}
}
+2 -3
View File
@@ -70,12 +70,11 @@ pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]
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>() {
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()