pub mod bindings; mod help; mod library; mod list; mod now_playing; mod queue; use flume::Sender; use ratatui::{ layout::{Constraint, Direction, Layout}, style::Color, Frame, }; use crabidy_core::proto::crabidy::{ get_update_stream_response::Update as StreamUpdate, InitResponse as InitialData, LibraryNode, }; pub use list::StatefulList; use bindings::Action; use library::Library; use now_playing::NowPlaying; use queue::Queue; #[derive(Clone, Copy)] pub enum UiFocus { Library, Queue, } #[derive(Clone, Copy)] enum UiItemKind { Node, Track, } struct UiItem { path: String, title: String, kind: UiItemKind, marked: bool, is_queable: bool, } pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); // const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); pub const COLOR_RED: Color = Color::Rgb(191, 97, 106); pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140); // const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); // const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); // FIXME: Rename this pub enum MessageToUi { Init(InitialData), ReplaceLibraryNode(LibraryNode), Update(StreamUpdate), } // FIXME: Rename this pub enum MessageFromUi { GetLibraryNode(String), AppendTracks(Vec), QueueTracks(Vec), InsertTracks(Vec, usize), RemoveTracks(Vec), ReplaceQueue(Vec), ClearQueue(bool), NextTrack, PrevTrack, RestartTrack, SetCurrentTrack(usize), TogglePlay, ChangeVolume(f32), ToggleMute, ToggleShuffle, ToggleRepeat, } /// What the event loop should do after dispatching an action. /// /// Quitting stays a loop-level concern: `App::dispatch` never tears down the /// terminal itself, it only reports that the loop should end. #[must_use] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DispatchResult { Continue, Quit, } pub struct App { pub focus: UiFocus, /// Whether the help modal is open. While true, `bindings::lookup` only /// admits `Scope::Help` actions and `render` draws the overlay last. pub show_help: bool, pub library: Library, pub now_playing: NowPlaying, pub queue: Queue, tx: Sender, } impl App { pub fn new(tx: Sender) -> App { let library = Library::new(tx.clone()); let queue = Queue::new(tx.clone()); let now_playing = NowPlaying::default(); App { focus: UiFocus::Library, show_help: false, library, now_playing, queue, tx, } } /// Execute one [`Action`] against the app: mutate UI state (focus, help /// modal, list selections) or send the matching [`MessageFromUi`] via /// `tx`. Send failures are ignored like everywhere else in this module — /// the orchestrator side owns error reporting. pub fn dispatch(&mut self, action: Action) -> DispatchResult { match action { Action::Quit => return DispatchResult::Quit, Action::OpenHelp => self.show_help = true, Action::CloseHelp => self.show_help = false, Action::CycleFocus => self.cycle_active(), Action::TogglePlay => { let _ = self.tx.send(MessageFromUi::TogglePlay); } Action::RestartTrack => { let _ = self.tx.send(MessageFromUi::RestartTrack); } Action::VolumeUp => { let _ = self.tx.send(MessageFromUi::ChangeVolume(0.1)); } Action::VolumeDown => { let _ = self.tx.send(MessageFromUi::ChangeVolume(-0.1)); } Action::ToggleMute => { let _ = self.tx.send(MessageFromUi::ToggleMute); } Action::ToggleShuffle => { let _ = self.tx.send(MessageFromUi::ToggleShuffle); } Action::ToggleRepeat => { let _ = self.tx.send(MessageFromUi::ToggleRepeat); } Action::NextTrack => self.queue.play_next(), Action::PrevTrack => self.queue.play_prev(), Action::LibraryFirst => self.library.first(), Action::LibraryLast => self.library.last(), Action::LibraryNext => self.library.next(), Action::LibraryPrev => self.library.prev(), Action::LibraryJumpDown => self.library.down(), Action::LibraryJumpUp => self.library.up(), Action::LibraryAscend => self.library.ascend(), Action::LibraryDive => self.library.dive(), Action::LibraryQueueNext => self.library.queue_queue(), Action::LibraryQueueAppend => self.library.queue_append(), Action::LibraryQueueReplace => self.library.queue_replace(), Action::LibraryToggleMark => self.library.toggle_mark(), Action::QueueInsertHere => { if let Some(selected) = self.queue.selected() { self.library.queue_insert(selected); } } Action::QueueFirst => self.queue.first(), Action::QueueLast => self.queue.last(), Action::QueueNext => self.queue.next(), Action::QueuePrev => self.queue.prev(), Action::QueueJumpDown => self.queue.down(), Action::QueueJumpUp => self.queue.up(), Action::QueueSelectCurrent => self.queue.select_current(), Action::QueuePlaySelected => self.queue.play_selected(), Action::QueueRemoveTrack => self.queue.remove_track(), Action::QueueClearKeepCurrent => { let _ = self.tx.send(MessageFromUi::ClearQueue(true)); } Action::QueueClearAll => { let _ = self.tx.send(MessageFromUi::ClearQueue(false)); } } DispatchResult::Continue } pub fn cycle_active(&mut self) { self.focus = match (self.focus, self.queue.is_empty()) { (UiFocus::Library, false) => UiFocus::Queue, (UiFocus::Library, true) => UiFocus::Library, (UiFocus::Queue, _) => UiFocus::Library, }; } pub fn render(&mut self, f: &mut Frame) { let _full_screen = f.area(); let library_focused = matches!(self.focus, UiFocus::Library); let queue_focused = matches!(self.focus, UiFocus::Queue); let main = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(f.area()); self.library.render(f, main[0], library_focused); let right_side = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(70), Constraint::Max(10)].as_ref()) .split(main[1]); self.queue.render(f, right_side[0], queue_focused); self.now_playing.render(f, right_side[1]); // The help modal renders last so it overlays every pane. if self.show_help { help::render(f); } } } #[cfg(test)] mod tests { use super::*; use flume::Receiver; fn app() -> (App, Receiver) { let (tx, rx) = flume::unbounded(); (App::new(tx), rx) } #[test] fn quit_is_reported_to_the_loop_not_executed() { let (mut app, _rx) = app(); assert_eq!(app.dispatch(Action::Quit), DispatchResult::Quit); } #[test] fn open_and_close_help_toggle_the_flag() { let (mut app, _rx) = app(); assert!(!app.show_help); assert_eq!(app.dispatch(Action::OpenHelp), DispatchResult::Continue); assert!(app.show_help); assert_eq!(app.dispatch(Action::CloseHelp), DispatchResult::Continue); assert!(!app.show_help); } #[test] fn cycle_focus_respects_empty_queue() { let (mut app, _rx) = app(); // The queue starts empty, so focus must stay on the library. assert_eq!(app.dispatch(Action::CycleFocus), DispatchResult::Continue); assert!(matches!(app.focus, UiFocus::Library)); } #[test] fn playback_actions_send_the_matching_message() { let (mut app, rx) = app(); let _ = app.dispatch(Action::TogglePlay); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::TogglePlay))); let _ = app.dispatch(Action::RestartTrack); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RestartTrack))); let _ = app.dispatch(Action::ToggleMute); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleMute))); let _ = app.dispatch(Action::ToggleShuffle); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleShuffle))); let _ = app.dispatch(Action::ToggleRepeat); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleRepeat))); } #[test] fn volume_actions_send_signed_deltas() { let (mut app, rx) = app(); let _ = app.dispatch(Action::VolumeUp); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ChangeVolume(d)) if d > 0.0)); let _ = app.dispatch(Action::VolumeDown); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ChangeVolume(d)) if d < 0.0)); } #[test] fn queue_clear_actions_carry_the_keep_current_flag() { let (mut app, rx) = app(); let _ = app.dispatch(Action::QueueClearKeepCurrent); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ClearQueue(true)))); let _ = app.dispatch(Action::QueueClearAll); assert!(matches!( rx.try_recv(), Ok(MessageFromUi::ClearQueue(false)) )); } }