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:
@@ -0,0 +1,79 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::application::library_state::LibraryState;
|
||||
use crate::application::notification_state::NotificationManager;
|
||||
use crate::data::{Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry};
|
||||
use crate::domain::navigation::{ModalKind, Tab};
|
||||
|
||||
pub struct App {
|
||||
pub running: bool,
|
||||
pub tab: Tab,
|
||||
pub size: Rect,
|
||||
pub library: LibraryState,
|
||||
pub modal: Option<ModalKind>,
|
||||
pub wanted: Vec<WantedEntry>,
|
||||
pub wanted_state: ListState,
|
||||
pub queue: Vec<QueueEntry>,
|
||||
pub queue_state: ListState,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
pub history_state: ListState,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub notifications: NotificationManager,
|
||||
pub notifications_open: bool,
|
||||
pub notifications_scroll: usize,
|
||||
pub notifications_expanded: Option<u64>,
|
||||
pub(crate) topbar_area: Rect,
|
||||
pub(crate) main_area: Rect,
|
||||
pub(crate) statusbar_area: Rect,
|
||||
pub(crate) tab_areas: Vec<Rect>,
|
||||
pub(crate) notifications_btn_area: Rect,
|
||||
pub(crate) notifications_dropdown_area: Rect,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
let artists: Vec<Artist> = Vec::new();
|
||||
let wanted: Vec<WantedEntry> = Vec::new();
|
||||
let queue: Vec<QueueEntry> = Vec::new();
|
||||
let history: Vec<HistoryEntry> = Vec::new();
|
||||
let calendar: Vec<CalendarEntry> = Vec::new();
|
||||
|
||||
let wanted_state = ListState::default();
|
||||
let queue_state = ListState::default();
|
||||
let history_state = ListState::default();
|
||||
|
||||
Self {
|
||||
running: true,
|
||||
tab: Tab::Library,
|
||||
size: Rect::default(),
|
||||
library: LibraryState::new(artists),
|
||||
modal: None,
|
||||
wanted,
|
||||
wanted_state,
|
||||
queue,
|
||||
queue_state,
|
||||
history,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crossterm::event::MouseButton;
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::application::app_state::App;
|
||||
use crate::data::{Artist, Track};
|
||||
use crate::domain::conversions::{convert_artist, convert_track};
|
||||
use crate::domain::navigation::Tab;
|
||||
use crate::grpc::GrpcResponse;
|
||||
use crate::ui::library::{LibraryFocus, LibraryState};
|
||||
use crate::ui::notifications::NotifKind;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) {
|
||||
if button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.modal.is_some() {
|
||||
self.handle_modal_click(x, y);
|
||||
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;
|
||||
}
|
||||
|
||||
if y >= self.main_area.y && y < self.main_area.y + self.main_area.height {
|
||||
self.handle_main_click(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_modal_click(&mut self, _x: u16, _y: u16) {
|
||||
self.modal = None;
|
||||
}
|
||||
|
||||
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) {
|
||||
self.tab = *tab;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
match self.tab {
|
||||
Tab::Library => {
|
||||
self.handle_library_click(x, rel_y);
|
||||
}
|
||||
Tab::Wanted => {
|
||||
self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y);
|
||||
if rel_y < self.wanted.len() {
|
||||
self.wanted_state.select(Some(rel_y));
|
||||
}
|
||||
}
|
||||
Tab::Queue => {
|
||||
if rel_y < self.queue.len() {
|
||||
self.queue_state.select(Some(rel_y));
|
||||
}
|
||||
}
|
||||
Tab::History => {
|
||||
if rel_y < self.history.len() {
|
||||
self.history_state.select(Some(rel_y));
|
||||
}
|
||||
}
|
||||
Tab::Calendar | Tab::Settings => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_library_click(&mut self, x: u16, rel_y: usize) {
|
||||
const ARTISTS_PANE_WIDTH: u16 = 32;
|
||||
const BORDER_TOP: usize = 1;
|
||||
const HEADER_HEIGHT: usize = 6;
|
||||
const DIVIDER_HEIGHT: usize = 1;
|
||||
const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT;
|
||||
|
||||
if x < ARTISTS_PANE_WIDTH {
|
||||
if rel_y > 0 && rel_y <= self.library.artists.len() {
|
||||
self.library.artist_state.select(Some(rel_y - 1));
|
||||
self.library.album_state.select(Some(0));
|
||||
self.library.track_state.select(Some(0));
|
||||
self.library.focus = LibraryFocus::Artists;
|
||||
}
|
||||
} else if rel_y >= ALBUMS_START_ROW {
|
||||
let album_row = rel_y - ALBUMS_START_ROW;
|
||||
let content_height = self.main_area.height.saturating_sub(10) as usize;
|
||||
let albums_section_height = (content_height * 40) / 100;
|
||||
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()
|
||||
&& 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()
|
||||
&& track_row < album.total as usize
|
||||
{
|
||||
self.library.track_state.select(Some(track_row));
|
||||
self.library.focus = LibraryFocus::Tracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
|
||||
|
||||
pub fn handle_scroll(&mut self, delta: i32) {
|
||||
if self.modal.is_some() {
|
||||
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);
|
||||
}
|
||||
Tab::Wanted => {
|
||||
let len = self.wanted.len();
|
||||
scroll_list_state(&mut self.wanted_state, len, delta);
|
||||
}
|
||||
Tab::Queue => {
|
||||
let len = self.queue.len();
|
||||
scroll_list_state(&mut self.queue_state, len, delta);
|
||||
}
|
||||
Tab::History => {
|
||||
let len = self.history.len();
|
||||
scroll_list_state(&mut self.history_state, len, delta);
|
||||
}
|
||||
Tab::Calendar | Tab::Settings => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_library_list(&mut self, delta: i32) {
|
||||
match self.library.focus {
|
||||
LibraryFocus::Artists => {
|
||||
let len = self.library.artists.len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = self.library.artist_state.selected().unwrap_or(0);
|
||||
let new_idx = if delta > 0 {
|
||||
(current + 1).min(len - 1)
|
||||
} else {
|
||||
current.saturating_sub(1)
|
||||
};
|
||||
if new_idx != current {
|
||||
self.library.artist_state.select(Some(new_idx));
|
||||
self.library.album_state.select(Some(0));
|
||||
self.library.track_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
if let Some(artist) = self.library.selected_artist() {
|
||||
let len = artist.albums.len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = self.library.album_state.selected().unwrap_or(0);
|
||||
let new_idx = if delta > 0 {
|
||||
(current + 1).min(len - 1)
|
||||
} else {
|
||||
current.saturating_sub(1)
|
||||
};
|
||||
if new_idx != current {
|
||||
self.library.album_state.select(Some(new_idx));
|
||||
self.library.track_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Tracks => {
|
||||
if let Some(album) = self.library.selected_album() {
|
||||
let len = album.total as usize;
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = self.library.track_state.selected().unwrap_or(0);
|
||||
let new_idx = if delta > 0 {
|
||||
(current + 1).min(len - 1)
|
||||
} else {
|
||||
current.saturating_sub(1)
|
||||
};
|
||||
self.library.track_state.select(Some(new_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_tick(&mut self) {
|
||||
self.notifications.tick();
|
||||
}
|
||||
|
||||
pub fn set_error(&mut self, msg: String) {
|
||||
self.notifications
|
||||
.push("Error", Some(msg), NotifKind::Error, "✗");
|
||||
}
|
||||
|
||||
pub fn handle_grpc_response(&mut self, response: GrpcResponse) {
|
||||
match response {
|
||||
GrpcResponse::Artists(artists) => {
|
||||
let converted: Vec<Artist> = artists.into_iter().map(convert_artist).collect();
|
||||
let count = converted.len();
|
||||
self.library = LibraryState::new(converted);
|
||||
self.notifications.push(
|
||||
"Library loaded",
|
||||
Some(format!("{} artists", count)),
|
||||
NotifKind::Success,
|
||||
"✓",
|
||||
);
|
||||
}
|
||||
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 scroll_list_state(state: &mut ListState, len: usize, delta: i32) {
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let current = state.selected().unwrap_or(0);
|
||||
let new_idx = if delta > 0 {
|
||||
(current + 1).min(len - 1)
|
||||
} else {
|
||||
current.saturating_sub(1)
|
||||
};
|
||||
state.select(Some(new_idx));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::data::{Album, Artist, Track};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LibraryFocus {
|
||||
#[default]
|
||||
Artists,
|
||||
Albums,
|
||||
Tracks,
|
||||
}
|
||||
|
||||
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 {
|
||||
pub fn new(artists: Vec<Artist>) -> Self {
|
||||
let mut artist_state = ListState::default();
|
||||
let mut album_state = ListState::default();
|
||||
let mut track_state = ListState::default();
|
||||
|
||||
if !artists.is_empty() {
|
||||
artist_state.select(Some(0));
|
||||
if !artists[0].albums.is_empty() {
|
||||
album_state.select(Some(0));
|
||||
track_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
artists,
|
||||
tracks: Vec::new(),
|
||||
focus: LibraryFocus::Artists,
|
||||
artist_state,
|
||||
album_state,
|
||||
track_state,
|
||||
tracks_cache: HashMap::new(),
|
||||
pending_album_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_artist(&self) -> Option<&Artist> {
|
||||
self.artist_state
|
||||
.selected()
|
||||
.and_then(|i| self.artists.get(i))
|
||||
}
|
||||
|
||||
pub fn selected_album(&self) -> Option<&Album> {
|
||||
self.selected_artist()
|
||||
.and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i)))
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
if let Some(i) = self.artist_state.selected()
|
||||
&& i > 0
|
||||
{
|
||||
self.artist_state.select(Some(i - 1));
|
||||
self.reset_album_selection();
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
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()
|
||||
&& i > 0
|
||||
{
|
||||
self.track_state.select(Some(i - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
let max = self.artists.len().saturating_sub(1);
|
||||
if let Some(i) = self.artist_state.selected()
|
||||
&& 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()
|
||||
&& 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()
|
||||
&& i < max
|
||||
{
|
||||
self.track_state.select(Some(i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn track_count(&self) -> usize {
|
||||
self.selected_album().map(|a| a.total as usize).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn focus_left(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {}
|
||||
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
|
||||
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_right(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
if self.selected_artist().is_some() {
|
||||
self.focus = LibraryFocus::Albums;
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
if self.selected_album().is_some() {
|
||||
self.focus = LibraryFocus::Tracks;
|
||||
}
|
||||
}
|
||||
LibraryFocus::Tracks => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
LibraryFocus::Artists => LibraryFocus::Albums,
|
||||
LibraryFocus::Albums => LibraryFocus::Tracks,
|
||||
LibraryFocus::Tracks => LibraryFocus::Artists,
|
||||
};
|
||||
}
|
||||
|
||||
fn reset_album_selection(&mut self) {
|
||||
if let Some(artist) = self.selected_artist() {
|
||||
if !artist.albums.is_empty() {
|
||||
self.album_state.select(Some(0));
|
||||
} else {
|
||||
self.album_state.select(None);
|
||||
}
|
||||
}
|
||||
self.reset_track_selection();
|
||||
}
|
||||
|
||||
fn reset_track_selection(&mut self) {
|
||||
if self.selected_album().is_some() {
|
||||
self.track_state.select(Some(0));
|
||||
} else {
|
||||
self.track_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn artist_count(&self) -> usize {
|
||||
self.artists.len()
|
||||
}
|
||||
|
||||
pub fn selected_artist_index(&self) -> Option<usize> {
|
||||
self.artist_state.selected()
|
||||
}
|
||||
|
||||
pub fn get_tracks(&self) -> &[Track] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
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(¤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<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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod app_state;
|
||||
pub mod handlers;
|
||||
pub mod library_state;
|
||||
pub mod notification_state;
|
||||
@@ -0,0 +1,128 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
pub const NOTIFICATION_TTL_SECS: u64 = 6;
|
||||
pub const MAX_VISIBLE: usize = 5;
|
||||
pub const MAX_HISTORY: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotifKind {
|
||||
Info,
|
||||
Success,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NotifKind {
|
||||
pub fn color(self) -> Color {
|
||||
match self {
|
||||
NotifKind::Info => theme::BLUE,
|
||||
NotifKind::Success => theme::GREEN,
|
||||
NotifKind::Warn => theme::YELLOW,
|
||||
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,
|
||||
pub detail: Option<String>,
|
||||
pub kind: NotifKind,
|
||||
pub icon: String,
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
pub struct NotificationManager {
|
||||
active: Vec<Notification>,
|
||||
history: Vec<Notification>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Default for NotificationManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: Vec::new(),
|
||||
history: Vec::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&mut self,
|
||||
title: impl Into<String>,
|
||||
detail: Option<String>,
|
||||
kind: NotifKind,
|
||||
icon: impl Into<String>,
|
||||
) {
|
||||
let notification = Notification {
|
||||
id: self.next_id,
|
||||
title: title.into(),
|
||||
detail,
|
||||
kind,
|
||||
icon: icon.into(),
|
||||
created_at: Instant::now(),
|
||||
};
|
||||
self.next_id += 1;
|
||||
|
||||
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.active
|
||||
.retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS);
|
||||
}
|
||||
|
||||
pub fn history(&self) -> &[Notification] {
|
||||
&self.history
|
||||
}
|
||||
|
||||
pub fn active(&self) -> &[Notification] {
|
||||
&self.active
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
pub fn history_count(&self) -> usize {
|
||||
self.history.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user