303 lines
9.7 KiB
Rust
303 lines
9.7 KiB
Rust
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<String>),
|
|
QueueTracks(Vec<String>),
|
|
InsertTracks(Vec<String>, usize),
|
|
RemoveTracks(Vec<usize>),
|
|
ReplaceQueue(Vec<String>),
|
|
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<MessageFromUi>,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(tx: Sender<MessageFromUi>) -> 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<MessageFromUi>) {
|
|
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))
|
|
));
|
|
}
|
|
}
|