use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Position, Rect},
style::{Style, Stylize},
text::Span,
widgets::Block,
Frame,
};
use tokio::sync::mpsc::UnboundedSender;
use crate::state::{
action::{Action, ComponentAction, GeneralAction},
component::ActiveComponent,
};
use super::{
colors::{APP_BORDER, APP_BORDER_TEXT, TEXT_NORMAL},
components::{
content_view::ContentView, control_panel::ControlPanel, queuebar::QueueBar,
sidebar::Sidebar, Component, ComponentRender, RenderProps,
},
widgets::popups::Popup,
AppState,
};
#[must_use]
pub struct App {
pub action_tx: UnboundedSender<Action>,
active_component: ActiveComponent,
sidebar: Sidebar,
queuebar: QueueBar,
control_panel: ControlPanel,
content_view: ContentView,
popup: Option<Box<dyn Popup>>,
}
impl App {
fn get_active_view_component(&self) -> &dyn Component {
match self.active_component {
ActiveComponent::Sidebar => &self.sidebar,
ActiveComponent::QueueBar => &self.queuebar,
ActiveComponent::ControlPanel => &self.control_panel,
ActiveComponent::ContentView => &self.content_view,
}
}
fn get_active_view_component_mut(&mut self) -> &mut dyn Component {
match self.active_component {
ActiveComponent::Sidebar => &mut self.sidebar,
ActiveComponent::QueueBar => &mut self.queuebar,
ActiveComponent::ControlPanel => &mut self.control_panel,
ActiveComponent::ContentView => &mut self.content_view,
}
}
pub fn move_with_search(self, state: &AppState) -> Self {
let new = self.content_view.search_view.move_with_state(state);
Self {
content_view: ContentView {
search_view: new,
..self.content_view
},
..self
}
}
pub fn move_with_audio(self, state: &AppState) -> Self {
Self {
queuebar: self.queuebar.move_with_state(state),
control_panel: self.control_panel.move_with_state(state),
..self
}
}
pub fn move_with_library(self, state: &AppState) -> Self {
let content_view = self.content_view.move_with_state(state);
Self {
content_view,
..self
}
}
pub fn move_with_view(self, state: &AppState) -> Self {
let content_view = self.content_view.move_with_state(state);
Self {
content_view,
..self
}
}
pub fn move_with_component(self, state: &AppState) -> Self {
Self {
active_component: state.active_component,
..self
}
}
pub fn move_with_popup(self, popup: Option<Box<dyn Popup>>) -> Self {
Self { popup, ..self }
}
}
impl Component for App {
fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized,
{
Self {
action_tx: action_tx.clone(),
active_component: state.active_component,
sidebar: Sidebar::new(state, action_tx.clone()),
queuebar: QueueBar::new(state, action_tx.clone()),
control_panel: ControlPanel::new(state, action_tx.clone()),
content_view: ContentView::new(state, action_tx),
popup: None,
}
.move_with_state(state)
}
fn move_with_state(self, state: &AppState) -> Self
where
Self: Sized,
{
Self {
sidebar: self.sidebar.move_with_state(state),
queuebar: self.queuebar.move_with_state(state),
control_panel: self.control_panel.move_with_state(state),
content_view: self.content_view.move_with_state(state),
popup: self.popup.map(|popup| {
let mut popup = popup;
popup.update_with_state(state);
popup
}),
..self
}
}
fn name(&self) -> &str {
self.get_active_view_component().name()
}
fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if let Some(popup) = self.popup.as_mut() {
popup.handle_key_event(key, self.action_tx.clone());
return;
}
match key.code {
KeyCode::Esc => {
self.action_tx
.send(Action::General(GeneralAction::Exit))
.unwrap();
}
KeyCode::Tab => self
.action_tx
.send(Action::ActiveComponent(ComponentAction::Next))
.unwrap(),
KeyCode::BackTab => self
.action_tx
.send(Action::ActiveComponent(ComponentAction::Previous))
.unwrap(),
_ => self.get_active_view_component_mut().handle_key_event(key),
}
}
fn handle_mouse_event(&mut self, mouse: crossterm::event::MouseEvent, area: Rect) {
if let Some(popup) = self.popup.as_mut() {
popup.handle_mouse_event(mouse, popup.area(area), self.action_tx.clone());
return;
}
let area = area.inner(Margin::new(1, 1));
let mouse_position = Position::new(mouse.column, mouse.row);
let Areas {
control_panel,
sidebar,
content_view,
queuebar,
} = split_area(area);
if control_panel.contains(mouse_position) {
self.control_panel.handle_mouse_event(mouse, control_panel);
} else if sidebar.contains(mouse_position) {
self.sidebar.handle_mouse_event(mouse, sidebar);
} else if content_view.contains(mouse_position) {
self.content_view.handle_mouse_event(mouse, content_view);
} else if queuebar.contains(mouse_position) {
self.queuebar.handle_mouse_event(mouse, queuebar);
}
}
}
#[derive(Debug)]
struct Areas {
pub control_panel: Rect,
pub sidebar: Rect,
pub content_view: Rect,
pub queuebar: Rect,
}
fn split_area(area: Rect) -> Areas {
let [main_views, control_panel] = *Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(4)].as_ref())
.split(area)
else {
panic!("Failed to split frame into areas")
};
let [sidebar, content_view, queuebar] = *Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Length(19),
Constraint::Fill(4),
Constraint::Min(25),
]
.as_ref(),
)
.split(main_views)
else {
panic!("Failed to split main views area")
};
Areas {
control_panel,
sidebar,
content_view,
queuebar,
}
}
impl ComponentRender<Rect> for App {
fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
let block = Block::bordered()
.title_top(Span::styled(
"MECOMP",
Style::default().bold().fg(APP_BORDER_TEXT.into()),
))
.title_bottom(Span::styled(
"Tab/Shift+Tab to switch focus | Esc to quit",
Style::default().fg(APP_BORDER_TEXT.into()),
))
.border_style(Style::default().fg(APP_BORDER.into()))
.style(Style::default().fg(TEXT_NORMAL.into()));
let app_area = block.inner(area);
debug_assert_eq!(area.inner(Margin::new(1, 1)), app_area);
frame.render_widget(block, area);
app_area
}
fn render_content(&self, frame: &mut Frame, area: Rect) {
let Areas {
control_panel,
sidebar,
content_view,
queuebar,
} = split_area(area);
let (control_panel_focused, sidebar_focused, content_view_focused, queuebar_focused) =
match self.active_component {
ActiveComponent::ControlPanel => (true, false, false, false),
ActiveComponent::Sidebar => (false, true, false, false),
ActiveComponent::ContentView => (false, false, true, false),
ActiveComponent::QueueBar => (false, false, false, true),
};
self.control_panel.render(
frame,
RenderProps {
area: control_panel,
is_focused: control_panel_focused,
},
);
self.sidebar.render(
frame,
RenderProps {
area: sidebar,
is_focused: sidebar_focused,
},
);
self.content_view.render(
frame,
RenderProps {
area: content_view,
is_focused: content_view_focused,
},
);
self.queuebar.render(
frame,
RenderProps {
area: queuebar,
is_focused: queuebar_focused,
},
);
if let Some(popup) = &self.popup {
popup.render_popup(frame);
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::{
state::action::{GeneralAction, PopupAction},
test_utils::setup_test_terminal,
ui::{
components::{self, content_view::ActiveView},
widgets::popups::notification::Notification,
},
};
use crossterm::event::KeyModifiers;
use mecomp_core::{
rpc::SearchResult,
state::{library::LibraryFull, Percent, RepeatMode, StateAudio, StateRuntime},
};
use mecomp_storage::db::schemas::song::Song;
use one_or_many::OneOrMany;
use pretty_assertions::assert_eq;
use rstest::{fixture, rstest};
use tokio::sync::mpsc::unbounded_channel;
#[fixture]
fn song() -> Song {
Song {
id: Song::generate_id(),
title: "Test Song".into(),
artist: OneOrMany::One("Test Artist".into()),
album_artist: OneOrMany::One("Test Album Artist".into()),
album: "Test Album".into(),
genre: OneOrMany::One("Test Genre".into()),
runtime: Duration::from_secs(180),
track: Some(0),
disc: Some(0),
release_year: Some(2021),
extension: "mp3".into(),
path: "test.mp3".into(),
}
}
#[rstest]
#[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
#[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
#[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&AppState::default(), tx);
app.handle_key_event(KeyEvent::from(key_code));
let action = rx.blocking_recv().unwrap();
assert_eq!(action, expected);
}
#[rstest]
#[case::sidebar(ActiveComponent::Sidebar)]
#[case::content_view(ActiveComponent::ContentView)]
#[case::queuebar(ActiveComponent::QueueBar)]
#[case::control_panel(ActiveComponent::ControlPanel)]
fn smoke_render(#[case] active_component: ActiveComponent) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let app = App::new(
&AppState {
active_component,
..Default::default()
},
tx,
);
let (mut terminal, area) = setup_test_terminal(100, 100);
let completed_frame = terminal.draw(|frame| app.render(frame, area));
assert!(completed_frame.is_ok());
}
#[rstest]
#[case::sidebar(ActiveComponent::Sidebar)]
#[case::content_view(ActiveComponent::ContentView)]
#[case::queuebar(ActiveComponent::QueueBar)]
#[case::control_panel(ActiveComponent::ControlPanel)]
fn test_render_with_popup(#[case] active_component: ActiveComponent) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let app = App::new(
&AppState {
active_component,
..Default::default()
},
tx,
);
let (mut terminal, area) = setup_test_terminal(100, 100);
let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
let app = app.move_with_popup(Some(Box::new(Notification::new(
"Hello, World!".into(),
unbounded_channel().0,
))));
let (mut terminal, area) = setup_test_terminal(100, 100);
let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
}
#[rstest]
#[case::sidebar(ActiveComponent::Sidebar)]
#[case::content_view(ActiveComponent::ContentView)]
#[case::queuebar(ActiveComponent::QueueBar)]
#[case::control_panel(ActiveComponent::ControlPanel)]
#[tokio::test]
async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&AppState {
active_component,
..Default::default()
},
tx,
);
let (mut terminal, area) = setup_test_terminal(100, 100);
let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
let popup = Box::new(Notification::new(
"Hello, World!".into(),
unbounded_channel().0,
));
app = app.move_with_popup(Some(popup));
let (mut terminal, area) = setup_test_terminal(100, 100);
let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
app.handle_key_event(KeyEvent::from(KeyCode::Esc));
let action = rx.recv().await.unwrap();
assert_eq!(action, Action::Popup(PopupAction::Close));
app = app.move_with_popup(None);
let (mut terminal, area) = setup_test_terminal(100, 100);
let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
}
#[rstest]
fn test_move_with_search(song: Song) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let state = AppState::default();
let mut app = App::new(&state, tx);
let state = AppState {
search: SearchResult {
songs: vec![song].into_boxed_slice(),
..Default::default()
},
..state
};
app = app.move_with_search(&state);
assert_eq!(
app.content_view.search_view.props.search_results,
state.search,
);
}
#[rstest]
fn test_move_with_audio(song: Song) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let state = AppState::default();
let mut app = App::new(&state, tx);
let state = AppState {
audio: StateAudio {
queue: vec![song.clone()].into_boxed_slice(),
queue_position: Some(0),
current_song: Some(song.clone()),
repeat_mode: RepeatMode::Once,
runtime: Some(StateRuntime {
seek_position: Duration::from_secs(0),
seek_percent: Percent::new(0.0),
duration: song.runtime,
}),
paused: true,
muted: false,
volume: 1.0,
},
..state
};
app = app.move_with_audio(&state);
let components::queuebar::Props {
queue,
current_position,
repeat_mode,
} = app.queuebar.props;
assert_eq!(queue, state.audio.queue);
assert_eq!(current_position, state.audio.queue_position);
assert_eq!(repeat_mode, state.audio.repeat_mode);
let components::control_panel::Props {
is_playing,
muted,
volume,
song_runtime,
song_title,
song_artist,
} = app.control_panel.props;
assert_eq!(is_playing, !state.audio.paused);
assert_eq!(muted, state.audio.muted);
assert_eq!(volume, state.audio.volume);
assert_eq!(song_runtime, state.audio.runtime);
assert_eq!(
song_title,
state
.audio
.current_song
.as_ref()
.map(|song| song.title.to_string())
);
assert_eq!(
song_artist,
state.audio.current_song.as_ref().map(|song| {
song.artist
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(", ")
})
);
}
#[rstest]
fn test_move_with_library(song: Song) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let state = AppState {
active_component: ActiveComponent::ContentView,
active_view: ActiveView::Songs,
..Default::default()
};
let mut app = App::new(&state, tx);
let state = AppState {
library: LibraryFull {
songs: vec![song].into_boxed_slice(),
..Default::default()
},
..state
};
app = app.move_with_library(&state);
assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
}
#[rstest]
fn test_move_with_view(song: Song) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let state = AppState {
active_component: ActiveComponent::ContentView,
active_view: ActiveView::Songs,
..Default::default()
};
let mut app = App::new(&state, tx);
let state = AppState {
active_view: ActiveView::Song(song.id.id.into()),
..state
};
app = app.move_with_view(&state);
assert_eq!(app.content_view.props.active_view, state.active_view);
}
#[test]
fn test_move_with_component() {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let app = App::new(&AppState::default(), tx);
assert_eq!(app.active_component, ActiveComponent::Sidebar);
let state = AppState {
active_component: ActiveComponent::QueueBar,
..Default::default()
};
let app = app.move_with_component(&state);
assert_eq!(app.active_component, ActiveComponent::QueueBar);
}
#[rstest]
fn test_move_with_popup() {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let app = App::new(&AppState::default(), tx);
assert!(app.popup.is_none());
let popup = Box::new(Notification::new(
"Hello, World!".into(),
unbounded_channel().0,
));
let app = app.move_with_popup(Some(popup));
assert!(app.popup.is_some());
}
#[rstest]
#[case::sidebar(ActiveComponent::Sidebar)]
#[case::content_view(ActiveComponent::ContentView)]
#[case::queuebar(ActiveComponent::QueueBar)]
#[case::control_panel(ActiveComponent::ControlPanel)]
fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let state = AppState {
active_component,
..Default::default()
};
let app = App::new(&state, tx.clone());
let component = app.get_active_view_component();
match active_component {
ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
ActiveComponent::ContentView => assert_eq!(component.name(), "None"), ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
}
assert_eq!(
component.name(),
App::new(&state, tx,).get_active_view_component_mut().name()
);
}
#[test]
fn test_click_to_focus() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&AppState::default(), tx);
let (mut terminal, area) = setup_test_terminal(100, 100);
let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
let mouse = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
app.handle_mouse_event(mouse, area);
let action = rx.blocking_recv().unwrap();
assert_eq!(
action,
Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
);
let mouse = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 50,
row: 10,
modifiers: KeyModifiers::empty(),
};
app.handle_mouse_event(mouse, area);
let action = rx.blocking_recv().unwrap();
assert_eq!(
action,
Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
);
let mouse = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 90,
row: 10,
modifiers: KeyModifiers::empty(),
};
app.handle_mouse_event(mouse, area);
let action = rx.blocking_recv().unwrap();
assert_eq!(
action,
Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
);
let mouse = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 60,
row: 98,
modifiers: KeyModifiers::empty(),
};
app.handle_mouse_event(mouse, area);
let action = rx.blocking_recv().unwrap();
assert_eq!(
action,
Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
);
}
}