diff --git a/flake.nix b/flake.nix index 771a691..42dbd30 100644 --- a/flake.nix +++ b/flake.nix @@ -62,6 +62,7 @@ rustfmt protobuf + grpcurl opencode ]; diff --git a/src/app.rs b/src/app.rs index 2b8a4fc..7de3384 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, pub notifications: NotificationManager, + pub notifications_open: bool, + pub notifications_scroll: usize, + pub notifications_expanded: Option, topbar_area: Rect, main_area: Rect, statusbar_area: Rect, tab_areas: Vec, + 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(¬if.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(¬if.title, Style::default().fg(theme::FG1))), + ]; + + if let Some(detail) = ¬if.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 = 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 { + 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; diff --git a/src/data/models.rs b/src/data/models.rs index c4d4598..0eb8511 100644 --- a/src/data/models.rs +++ b/src/data/models.rs @@ -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, diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs index 494aa1d..9797084 100644 --- a/src/grpc/mod.rs +++ b/src/grpc/mod.rs @@ -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), + Album { album: AlbumDetail, tracks: Vec }, 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), 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() { diff --git a/src/main.rs b/src/main.rs index a1be067..b62c052 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 6f29876..04e76d1 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,3 +1,4 @@ +#[allow(clippy::enum_variant_names)] pub mod music_agregator_v1 { include!("music_agregator.v1.rs"); } diff --git a/src/ui/library.rs b/src/ui/library.rs index dece034..a9542bd 100644 --- a/src/ui/library.rs +++ b/src/ui/library.rs @@ -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, + pub tracks: Vec, pub focus: LibraryFocus, pub artist_state: ListState, pub album_state: ListState, pub track_state: ListState, + tracks_cache: HashMap>, + pending_album_id: Option, } 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 = tracks + let items: Vec = 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 { - 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) { + 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> { + self.tracks_cache.get(album_id) + } + + pub fn needs_fetch(&mut self) -> Option { + 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(¤t_album_id) { + if self.pending_album_id.as_ref() != Some(¤t_album_id) { + self.load_tracks_from_cache(¤t_album_id); + } + return None; + } + + if self.pending_album_id.as_ref() == Some(¤t_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 { + 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 { - 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() -} diff --git a/src/ui/notifications.rs b/src/ui/notifications.rs index 9951284..1cf68c5 100644 --- a/src/ui/notifications.rs +++ b/src/ui/notifications.rs @@ -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, + active: Vec, + history: Vec, 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) = ¬if.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(¬if.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) = ¬if.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!("{}…", ¬if.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) } } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 03b8515..9e00bbc 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -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); } } - } } } diff --git a/src/ui/progress_bar.rs b/src/ui/progress_bar.rs index ba2715f..b8a5891 100644 --- a/src/ui/progress_bar.rs +++ b/src/ui/progress_bar.rs @@ -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); diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index edc5a23..76728cb 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -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) diff --git a/src/ui/topbar.rs b/src/ui/topbar.rs index aeaf5de..7625127 100644 --- a/src/ui/topbar.rs +++ b/src/ui/topbar.rs @@ -9,13 +9,20 @@ use ratatui::{ use crate::app::Tab; use crate::theme; +pub struct TopbarAreas { + pub tabs: Vec, + pub notifications: Rect, +} + pub fn render_topbar( frame: &mut Frame, area: Rect, active_tab: Tab, queue_count: usize, wanted_count: usize, -) -> Vec { + 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(¬if_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, + } } diff --git a/src/ui/views/calendar.rs b/src/ui/views/calendar.rs index ae667ec..dded1ba 100644 --- a/src/ui/views/calendar.rs +++ b/src/ui/views/calendar.rs @@ -70,12 +70,11 @@ pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry] let events: Vec = calendar .iter() .filter(|e| { - if let Some(day_str) = e.date.split('-').nth(2) { - if let Ok(day) = day_str.parse::() { + if let Some(day_str) = e.date.split('-').nth(2) + && let Ok(day) = day_str.parse::() { let month_match = e.date.contains(&format!("{:04}-{:02}", year, month)); return month_match && day == d; } - } false }) .cloned()