crabidy/cbd-tui/src/app/mod.rs

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))
));
}
}