From 50a540d3e3d241b7a7f3ef4ee4dba6fc64445774 Mon Sep 17 00:00:00 2001 From: Leonardo Riether Date: Sat, 10 Feb 2024 12:12:28 -0300 Subject: [PATCH] State Rewrite (phase 0) (#41) * BrowseScreen: Reorganize refresh methods * [wip] start implementing `update` * Separate Actions from CrosstermEvents * Add to default_config --- Cargo.lock | 28 + tori-player/src/output.rs | 2 +- tori/Cargo.toml | 1 + tori/src/app/app_screen/mod.rs | 242 --------- tori/src/app/browse_screen/mod.rs | 480 ----------------- tori/src/app/browse_screen/playlists.rs | 227 -------- tori/src/app/browse_screen/songs.rs | 483 ------------------ tori/src/app/component.rs | 33 -- tori/src/app/filtered_list.rs | 4 + tori/src/app/mod.rs | 164 +++--- tori/src/app/modal/confirmation_modal.rs | 17 +- tori/src/app/modal/help_modal.rs | 10 +- tori/src/app/modal/hotkey_modal.rs | 10 +- tori/src/app/modal/input_modal.rs | 31 +- tori/src/app/modal/mod.rs | 161 +++--- tori/src/app/playlist_screen/centered_list.rs | 289 ----------- tori/src/app/playlist_screen/mod.rs | 150 ------ tori/src/component/now_playing.rs | 7 +- tori/src/default_config.yaml | 4 + tori/src/events/action.rs | 14 + tori/src/events/channel.rs | 21 +- tori/src/events/mod.rs | 31 +- tori/src/m3u/playlist_management.rs | 42 +- tori/src/state/browse_screen.rs | 102 ++-- tori/src/ui/list.rs | 12 +- tori/src/ui/mod.rs | 14 +- tori/src/update/mod.rs | 139 +++-- 27 files changed, 461 insertions(+), 2257 deletions(-) delete mode 100644 tori/src/app/app_screen/mod.rs delete mode 100644 tori/src/app/browse_screen/mod.rs delete mode 100644 tori/src/app/browse_screen/playlists.rs delete mode 100644 tori/src/app/browse_screen/songs.rs delete mode 100644 tori/src/app/component.rs delete mode 100644 tori/src/app/playlist_screen/centered_list.rs delete mode 100644 tori/src/app/playlist_screen/mod.rs create mode 100644 tori/src/events/action.rs diff --git a/Cargo.lock b/Cargo.lock index 70a846c..02e2252 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,6 +503,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + [[package]] name = "getrandom" version = "0.2.10" @@ -1746,6 +1752,27 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "tokio-scoped" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4beb8ba13bc53ac53ce1d52b42f02e5d8060f0f42138862869beb769722b256" +dependencies = [ + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.11" @@ -1793,6 +1820,7 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", + "tokio-scoped", "tori-player", "unicode-width", "webbrowser", diff --git a/tori-player/src/output.rs b/tori-player/src/output.rs index 651050e..cdfb3a8 100644 --- a/tori-player/src/output.rs +++ b/tori-player/src/output.rs @@ -220,6 +220,6 @@ impl AudioOutput for CpalAudioOutputImpl { } } -pub fn try_open(spec: SignalSpec, duration: Duration) -> Result> { +pub fn _try_open(spec: SignalSpec, duration: Duration) -> Result> { CpalAudioOutput::try_open(spec, duration) } diff --git a/tori/Cargo.toml b/tori/Cargo.toml index 5d728ae..0eadd23 100644 --- a/tori/Cargo.toml +++ b/tori/Cargo.toml @@ -57,6 +57,7 @@ mpv035 = { version = "2.0.2-fork.1", package = "libmpv-sirno", optional = true } # Player: tori-player tori-player = { path = "../tori-player", version = "0.1.0", optional = true } tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread", "fs", "sync", "time"] } +tokio-scoped = "0.2.0" [build-dependencies] winres = "0.1" diff --git a/tori/src/app/app_screen/mod.rs b/tori/src/app/app_screen/mod.rs deleted file mode 100644 index 9b96c54..0000000 --- a/tori/src/app/app_screen/mod.rs +++ /dev/null @@ -1,242 +0,0 @@ -use crate::{error::Result, events, player::Player, rect_ops::RectOps}; - -use tui::layout::Rect; - -use super::{ - browse_screen::BrowseScreen, - component::{Component, MouseHandler}, - playlist_screen::PlaylistScreen, - App, Mode, -}; - -#[derive(Debug, Default)] -pub enum Selected { - #[default] - Browse, - Playlist, -} - -#[derive(Debug)] -pub struct AppScreen<'a> { - browse: BrowseScreen<'a>, - playlist: PlaylistScreen, - selected: Selected, -} - -impl<'a> AppScreen<'a> { - pub fn new() -> Result { - Ok(Self { - browse: BrowseScreen::new()?, - playlist: PlaylistScreen::default(), - selected: Selected::default(), - }) - } - - pub fn select(&mut self, selection: Selected) { - self.selected = selection; - } - - pub fn pass_event_down(&mut self, app: &mut App, event: events::Event) -> Result<()> { - match self.selected { - Selected::Browse => self.browse.handle_event(app, event), - Selected::Playlist => self.playlist.handle_event(app, event), - } - } - - fn handle_command(&mut self, app: &mut App, cmd: events::Command) -> Result<()> { - use events::Command::*; - match cmd { - Quit => { - } - SeekForward => { - app.state.player.seek(10.)?; - - } - SeekBackward => { - app.state.player.seek(-10.)?; - - } - NextSong => { - app.state.player - .playlist_next() - .unwrap_or_else(|_| app.state.notify_err("No next song")); - - } - PrevSong => { - app.state.player - .playlist_previous() - .unwrap_or_else(|_| app.state.notify_err("No previous song")); - - } - TogglePause => { - app.state.player.toggle_pause()?; - - } - ToggleLoop => { - app.state.player.toggle_loop_file()?; - - } - VolumeUp => { - app.state.player.add_volume(5)?; - - } - VolumeDown => { - app.state.player.add_volume(-5)?; - - } - Mute => { - app.state.player.toggle_mute()?; - - } - _ => self.pass_event_down(app, events::Event::Command(cmd))?, - } - Ok(()) - } - - /// Returns (app chunk, now_playing chunk) - fn subcomponent_chunks(frame: Rect) -> (Rect, Rect) { - frame.split_bottom(2) - } -} - -impl<'a> Component for AppScreen<'a> { - type RenderState = (); - - fn mode(&self) -> Mode { - match self.selected { - Selected::Browse => self.browse.mode(), - Selected::Playlist => self.playlist.mode(), - } - } - - fn render(&mut self, frame: &mut tui::Frame, chunk: Rect, (): ()) { - let vchunks = Self::subcomponent_chunks(chunk); - - match self.selected { - Selected::Browse => self.browse.render(frame, vchunks.0, ()), - Selected::Playlist => self.playlist.render(frame, vchunks.0, ()), - } - - - } - - fn handle_event(&mut self, app: &mut App, event: events::Event) -> Result<()> { - use crossterm::event::KeyCode; - use events::Event::*; - match &event { - Command(cmd) => self.handle_command(app, *cmd)?, - Terminal(crossterm::event::Event::Key(key_event)) => match key_event.code { - KeyCode::Char('1') if self.mode() == Mode::Normal => { - self.select(Selected::Browse); - } - KeyCode::Char('2') if self.mode() == Mode::Normal => { - self.playlist.update(&app.state.player)?; - self.select(Selected::Playlist); - } - _ => self.pass_event_down(app, event)?, - }, - Tick => { - - self.pass_event_down(app, event)?; - } - _ => self.pass_event_down(app, event)?, - } - Ok(()) - } -} - -impl<'a> MouseHandler for AppScreen<'a> { - fn handle_mouse( - &mut self, - app: &mut App, - chunk: Rect, - event: crossterm::event::MouseEvent, - ) -> Result<()> { - let vchunks = Self::subcomponent_chunks(chunk); - if vchunks.0.contains(event.column, event.row) { - return match self.selected { - Selected::Browse => self.browse.handle_mouse(app, vchunks.0, event), - Selected::Playlist => self.playlist.handle_mouse(app, vchunks.0, event), - }; - } - if vchunks.1.contains(event.column, event.row) { - return Ok(()) // ? - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_big_frame_size() { - let frame = Rect { - x: 0, - y: 0, - width: 128, - height: 64, - }; - let app = Rect { - x: 0, - y: 0, - width: 128, - height: 62, - }; - let now_playing = Rect { - x: 0, - y: 62, - width: 128, - height: 2, - }; - assert_eq!(AppScreen::subcomponent_chunks(frame), (app, now_playing)); - } - - #[test] - fn test_small_frame_size() { - let frame = Rect { - x: 0, - y: 0, - width: 16, - height: 10, - }; - let app = Rect { - x: 0, - y: 0, - width: 16, - height: 8, - }; - let now_playing = Rect { - x: 0, - y: 8, - width: 16, - height: 2, - }; - assert_eq!(AppScreen::subcomponent_chunks(frame), (app, now_playing)); - } - - #[test] - fn test_unusably_small_frame_size() { - let frame = Rect { - x: 0, - y: 0, - width: 16, - height: 1, - }; - let app = Rect { - x: 0, - y: 0, - width: 16, - height: 0, - }; - let now_playing = Rect { - x: 0, - y: 0, - width: 16, - height: 1, - }; - assert_eq!(AppScreen::subcomponent_chunks(frame), (app, now_playing)); - } -} diff --git a/tori/src/app/browse_screen/mod.rs b/tori/src/app/browse_screen/mod.rs deleted file mode 100644 index adb8aca..0000000 --- a/tori/src/app/browse_screen/mod.rs +++ /dev/null @@ -1,480 +0,0 @@ -use crate::{ - app::{component::Component, App}, - error::Result, - events::{self, Event}, - m3u::playlist_management, - player::Player, - rect_ops::RectOps, -}; - -use crossterm::event::{KeyCode, MouseEvent, MouseEventKind}; - -use std::borrow::Cow; -use std::rc::Rc; -use tui::layout::Rect; -use tui::style::Color; -use tui::style::Style; -use tui::{ - layout::{Constraint, Direction, Layout}, - Frame, -}; - -mod playlists; -use playlists::PlaylistsPane; - -mod songs; -use songs::SongsPane; - -use super::Mode; -use super::{component::MouseHandler, modal::HotkeyModal}; -use crate::app::modal::{self, ConfirmationModal, HelpModal, InputModal, Modal}; - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ModalType { - Help, - Hotkey, - Play, - AddSong { playlist: String }, - AddPlaylist, - RenamePlaylist { playlist: String }, - DeletePlaylist { playlist: String }, - RenameSong { playlist: String, index: usize }, - DeleteSong { playlist: String, index: usize }, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -#[repr(i8)] -enum BrowsePane { - #[default] - Playlists, - Songs, - Modal(ModalType), -} - -#[derive(Default)] -pub struct BrowseScreen<'a> { - playlists: PlaylistsPane, - songs: SongsPane<'a>, - modal: Box, - selected_pane: BrowsePane, -} - -impl<'a> std::fmt::Debug for BrowseScreen<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("BrowseScreen") - .field("playlists", &self.playlists) - .field("songs", &self.songs) - .field("selected_pane", &self.selected_pane) - .finish_non_exhaustive() - } -} - -impl<'a> BrowseScreen<'a> { - pub fn new() -> Result { - let playlists = PlaylistsPane::new()?; - let mut songs = SongsPane::default(); - songs.update_from_playlist_pane(&playlists)?; - Ok(Self { - playlists, - songs, - ..Default::default() - }) - } - - pub fn reload_songs(&mut self) -> Result<()> { - self.songs.update_from_playlist_pane(&self.playlists) - } - - /// Passes the event down to the currently selected pane. - fn pass_event_down(&mut self, app: &mut App, event: Event) -> Result<()> { - use BrowsePane::*; - match self.selected_pane { - Playlists => self.playlists.handle_event(app, event), - Songs => self.songs.handle_event(app, event), - Modal(_) => { - let msg = self.modal.handle_event(event)?; - self.handle_modal_message(app, msg) - } - } - } - - /// When a modal handles an event, it returns a message, which can be Nothing, Quit, or - /// Commit(String). This method handles that message. - fn handle_modal_message(&mut self, app: &mut App, msg: modal::Message) -> Result<()> { - if let BrowsePane::Modal(modal_type) = &self.selected_pane { - use modal::Message::*; - use ModalType::*; - match (modal_type, msg) { - (_, Nothing) => {} - - (Help, _) => { - self.selected_pane = BrowsePane::Songs; - } - (Hotkey, _) => { - self.selected_pane = BrowsePane::Songs; - } - - // AddSong - (AddSong { playlist: _ }, Quit) => { - self.selected_pane = BrowsePane::Songs; - } - (AddSong { playlist }, Commit(song)) => { - playlist_management::add_song(app, playlist, song); - self.selected_pane = BrowsePane::Songs; - } - - // AddPlaylist - (AddPlaylist, Quit) => { - self.selected_pane = BrowsePane::Playlists; - } - (AddPlaylist, Commit(playlist)) => { - use playlist_management::CreatePlaylistError; - match playlist_management::create_playlist(&playlist) { - Ok(_) => { - self.playlists.reload_from_dir()?; - self.reload_songs()?; - } - Err(CreatePlaylistError::PlaylistAlreadyExists) => { - app.state - .notify_err(format!("Playlist '{}' already exists!", playlist)); - } - Err(CreatePlaylistError::InvalidChar(c)) => { - app.state - .notify_err(format!("Playlist names cannot contain '{}'", c)); - } - Err(CreatePlaylistError::IOError(e)) => return Err(e.into()), - } - self.selected_pane = BrowsePane::Playlists; - } - - // RenamePlaylist - (RenamePlaylist { playlist: _ }, Quit) => { - self.selected_pane = BrowsePane::Playlists; - } - (RenamePlaylist { playlist }, Commit(new_name)) => { - use playlist_management::RenamePlaylistError; - match playlist_management::rename_playlist(playlist, &new_name) { - Err(RenamePlaylistError::PlaylistAlreadyExists) => { - app.state - .notify_err(format!("Playlist '{}' already exists!", new_name)); - } - Err(RenamePlaylistError::EmptyPlaylistName) => { - app.state.notify_err("Playlist name cannot be empty !"); - } - Err(RenamePlaylistError::InvalidChar(c)) => { - app.state - .notify_err(format!("Playlist name cannot contain '{}' !", c)); - } - Err(RenamePlaylistError::IOError(e)) => return Err(e.into()), - Ok(_) => self.playlists.reload_from_dir()?, - } - self.selected_pane = BrowsePane::Playlists; - } - - // DeletePlaylist - (DeletePlaylist { playlist: _ }, Quit) => { - self.selected_pane = BrowsePane::Playlists; - } - (DeletePlaylist { playlist }, Commit(_)) => { - playlist_management::delete_playlist(playlist)?; - self.playlists = PlaylistsPane::new()?; - self.reload_songs()?; - self.selected_pane = BrowsePane::Playlists; - } - - // Play - (Play, Quit) => { - self.selected_pane = BrowsePane::Songs; - } - (Play, Commit(path)) => { - app.state.player.play(&path)?; - self.selected_pane = BrowsePane::Songs; - } - - // RenameSong - ( - RenameSong { - playlist: _, - index: _, - }, - Quit, - ) => { - self.selected_pane = BrowsePane::Songs; - } - (RenameSong { playlist, index }, Commit(new_name)) => { - playlist_management::rename_song(playlist, *index, &new_name)?; - self.reload_songs()?; - self.selected_pane = BrowsePane::Songs; - } - - // DeleteSong - ( - DeleteSong { - playlist: _, - index: _, - }, - Quit, - ) => { - self.selected_pane = BrowsePane::Songs; - } - (DeleteSong { playlist, index }, Commit(_)) => { - playlist_management::delete_song(playlist, *index)?; - self.reload_songs()?; - self.selected_pane = BrowsePane::Songs; - } - } - } else { - panic!("Please don't call BrowseScreen::handle_modal_message without a selected modal"); - } - Ok(()) - } - - /// Handles an Event::Command(cmd) - fn handle_command(&mut self, app: &mut App, cmd: events::Command) -> Result<()> { - use events::Command::*; - match cmd { - PlayFromModal => { - self.open_modal(" Play ", ModalType::Play); - } - OpenHelpModal => { - self.open_help_modal(); - } - OpenHotkeyModal => { - self.open_hotkey_modal(); - } - SelectRight | SelectLeft => self.select_next_panel(), - // TODO: this should probably be in each pane's handle_event, somehow - Add => match self.selected_pane { - BrowsePane::Playlists => { - self.open_modal(" Add playlist ", ModalType::AddPlaylist); - } - BrowsePane::Songs => { - if let Some(playlist) = self.playlists.selected_item() { - self.open_modal( - " Add song ", - ModalType::AddSong { - playlist: playlist.to_owned(), - }, - ); - } else { - app.state - .notify_err("Please select a playlist before adding a song"); - } - } - BrowsePane::Modal(_) => {} - }, - Rename => match self.selected_pane { - BrowsePane::Playlists => { - if let Some(playlist) = self.playlists.selected_item() { - self.open_modal( - "This message will be overwritten by the next input modal", - ModalType::RenamePlaylist { - playlist: playlist.to_owned(), - }, - ); - - let playlist_name = self.playlists.selected_item().unwrap().to_string(); - self.modal = Box::new( - InputModal::new(" Rename playlist (esc cancels) ") - .set_input(playlist_name), - ); - } - } - BrowsePane::Songs => { - if let (Some(playlist), Some(index)) = - (self.playlists.selected_item(), self.songs.selected_index()) - { - // kind of a hack, sorry - // couldn't figure out how to downcast Box to Box - self.open_modal( - "", - ModalType::RenameSong { - playlist: playlist.to_owned(), - index, - }, - ); - - let song_title = self.songs.selected_item().unwrap().title.clone(); - self.modal = Box::new( - InputModal::new(" Rename song (esc cancels) ").set_input(song_title), - ); - } - } - _ => {} - }, - Delete => match self.selected_pane { - BrowsePane::Playlists => { - if let Some(playlist) = self.playlists.selected_item() { - let title = format!("Do you really want to delete '{}'?", playlist); - let modal_type = ModalType::DeletePlaylist { - playlist: playlist.to_owned(), - }; - self.open_confirmation(title.as_str(), modal_type) - .apply_style(Style::default().fg(Color::LightRed)); - } - } - BrowsePane::Songs => { - if let (Some(playlist), Some(index)) = - (self.playlists.selected_item(), self.songs.selected_index()) - { - let title = format!( - "Do you really want to delete '{}'?", - self.songs.selected_item().unwrap().title - ); - let modal_type = ModalType::DeleteSong { - playlist: playlist.to_owned(), - index, - }; - self.open_confirmation(title.as_str(), modal_type) - .apply_style(Style::default().fg(Color::LightRed)); - } - } - _ => {} - }, - OpenInEditor => self.playlists.open_editor_for_selected(app)?, - _ => self.pass_event_down(app, Event::Command(cmd))?, - } - Ok(()) - } - - /// Handles an Event::Terminal(event) - fn handle_terminal_event( - &mut self, - app: &mut App, - event: crossterm::event::Event, - ) -> Result<()> { - use Event::*; - use KeyCode::*; - - if let BrowsePane::Modal(_) = self.selected_pane { - return self.pass_event_down(app, Terminal(event)); - } - - match event { - crossterm::event::Event::Key(event) => match event.code { - Right | Left => self.select_next_panel(), - _ => self.pass_event_down(app, Terminal(crossterm::event::Event::Key(event)))?, - }, - _ => self.pass_event_down(app, Terminal(event))?, - } - Ok(()) - } - - // TODO: I don't know how to make this 'a instead of 'static :( - fn open_modal(&mut self, title: T, modal_type: ModalType) -> &mut Box - where - T: Into>, - { - self.selected_pane = BrowsePane::Modal(modal_type); - self.modal = Box::new(InputModal::new(title)); - &mut self.modal - } - - fn open_confirmation(&mut self, title: &str, modal_type: ModalType) -> &mut Box { - self.selected_pane = BrowsePane::Modal(modal_type); - self.modal = Box::new(ConfirmationModal::new(title)); - &mut self.modal - } - - fn open_help_modal(&mut self) -> &mut Box { - self.selected_pane = BrowsePane::Modal(ModalType::Help); - self.modal = Box::new(HelpModal::new()); - &mut self.modal - } - - fn open_hotkey_modal(&mut self) -> &mut Box { - self.selected_pane = BrowsePane::Modal(ModalType::Hotkey); - self.modal = Box::::default(); - &mut self.modal - } - - fn select_next_panel(&mut self) { - use BrowsePane::*; - match self.selected_pane { - Playlists => { - self.selected_pane = Songs; - } - Songs => { - self.selected_pane = Playlists; - } - Modal(_) => {} - } - } - - fn subcomponent_chunks(&self, chunk: Rect) -> Rc<[Rect]> { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(15), Constraint::Percentage(85)].as_ref()) - .split(chunk) - } -} - -impl<'t> Component for BrowseScreen<'t> { - type RenderState = (); - - fn render(&mut self, frame: &mut Frame, chunk: Rect, (): ()) { - let hchunks = self.subcomponent_chunks(chunk); - - self.playlists.render( - frame, - hchunks[0], - self.selected_pane == BrowsePane::Playlists, - ); - self.songs - .render(frame, hchunks[1], self.selected_pane == BrowsePane::Songs); - } - - fn handle_event(&mut self, app: &mut App, event: Event) -> Result<()> { - use events::Action; - use Event::*; - match event { - Command(cmd) => self.handle_command(app, cmd)?, - Action(Action::SongAdded { playlist, song }) => { - if self.playlists.selected_item() == Some(playlist.as_str()) { - self.reload_songs()?; - } - app.state - .notify_ok(format!("\"{}\" was added to {}", song, playlist)); - } - Tick => {} - Action(Action::ChangedPlaylist) => { - self.reload_songs()?; - } - Terminal(event) => self.handle_terminal_event(app, event)?, - Action(Action::SelectSong(_i)) => todo!(), - Action(Action::SelectPlaylist(_i)) => todo!(), - } - Ok(()) - } - - fn mode(&self) -> Mode { - use BrowsePane::*; - match self.selected_pane { - Playlists => self.playlists.mode(), - Songs => self.songs.mode(), - Modal(_) => self.modal.mode(), - } - } -} - -impl<'a> MouseHandler for BrowseScreen<'a> { - fn handle_mouse(&mut self, app: &mut App, chunk: Rect, event: MouseEvent) -> Result<()> { - if let BrowsePane::Modal(_) = self.selected_pane { - // No modal clicks for now - return Ok(()); - } - - let hchunks = self.subcomponent_chunks(chunk); - if hchunks[0].contains(event.column, event.row) { - if let MouseEventKind::Down(_) = event.kind { - self.selected_pane = BrowsePane::Playlists; - } - self.playlists.handle_mouse(app, hchunks[0], event) - } else { - if let MouseEventKind::Down(_) = event.kind { - self.selected_pane = BrowsePane::Songs; - } - self.songs.handle_mouse(app, hchunks[1], event) - } - } -} diff --git a/tori/src/app/browse_screen/playlists.rs b/tori/src/app/browse_screen/playlists.rs deleted file mode 100644 index 4ee0eab..0000000 --- a/tori/src/app/browse_screen/playlists.rs +++ /dev/null @@ -1,227 +0,0 @@ -use crate::{ - app::{ - component::{Component, MouseHandler}, - filtered_list::FilteredList, - App, Mode - }, - config::Config, - error::Result, - events::{Event, Action}, -}; -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind}; -use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; -use crossterm::ExecutableCommand; -use std::result::Result as StdResult; -use std::io; -use tui::{ - layout::{self, Rect}, - widgets::{ListState}, - Frame, -}; - -#[derive(Debug, Default)] -pub struct PlaylistsPane { - playlists: Vec, - shown: FilteredList, - filter: String, -} - -impl PlaylistsPane { - pub fn new() -> Result { - let mut me = Self::default(); - me.reload_from_dir()?; - Ok(me) - } - - pub fn reload_from_dir(&mut self) -> Result<()> { - let dir = std::fs::read_dir(&Config::global().playlists_dir) - .map_err(|e| format!("Failed to read playlists directory: {}", e))?; - - use std::fs::DirEntry; - let extract_playlist_name = |entry: StdResult| { - Ok(entry - .unwrap() - .file_name() - .into_string() - .map_err(|filename| format!("File '{:?}' has invalid UTF-8", filename))? - .trim_end_matches(".m3u8") - .to_string()) - }; - - self.playlists = dir - .into_iter() - .map(extract_playlist_name) - .collect::>()?; - - self.playlists.sort(); - self.refresh_shown(); - Ok(()) - } - - fn refresh_shown(&mut self) { - self.shown.filter( - &self.playlists, - |s| { - self.filter.is_empty() - || s.to_lowercase() - .contains(&self.filter[1..].trim_end_matches('\n').to_lowercase()) - }, - |i, j| i.cmp(&j), - ); - } - - pub fn handle_filter_key_event(&mut self, event: KeyEvent) -> Result { - match event.code { - KeyCode::Char(c) => { - self.filter.push(c); - Ok(true) - } - KeyCode::Backspace => { - self.filter.pop(); - Ok(true) - } - KeyCode::Esc => { - self.filter.clear(); - Ok(true) - } - KeyCode::Enter => { - self.filter.push('\n'); - Ok(true) - } - _ => Ok(false), - } - } - - pub fn select_next(&mut self, app: &mut App) { - self.shown.select_next(); - app.channel.tx.send(Event::Action(Action::ChangedPlaylist)).unwrap(); - } - - pub fn select_prev(&mut self, app: &mut App) { - self.shown.select_prev(); - app.channel.tx.send(Event::Action(Action::ChangedPlaylist)).unwrap(); - } - - pub fn select_index(&mut self, app: &mut App, i: Option) { - self.shown.state.select(i); - app.channel.tx.send(Event::Action(Action::ChangedPlaylist)).unwrap(); - } - - pub fn selected_item(&self) -> Option<&str> { - self.shown - .selected_item() - .and_then(|i| self.playlists.get(i)) - .map(|s| s.as_str()) - } - - pub fn open_editor_for_selected(&mut self, app: &mut App) -> Result<()> { - if let Some(selected) = self.selected_item() { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); - - let _lock = app.channel.receiving_crossterm.lock().unwrap(); - io::stdout().execute(LeaveAlternateScreen)?; - - let res = std::process::Command::new(&editor) - .arg(Config::playlist_path(selected)) - .status() - .map_err(|err| format!("Failed to execute editor '{}': {}", editor, err)); - - io::stdout().execute(EnterAlternateScreen)?; - - res?; - self.reload_from_dir()?; - app.terminal.clear()?; - } - Ok(()) - } - - fn click(&mut self, app: &mut App, frame: Rect, y: u16) { - let top = frame - .inner(&layout::Margin { - vertical: 1, - horizontal: 1, - }) - .top(); - let line = y.saturating_sub(top) as usize; - let index = line + self.shown.state.offset(); - if index < self.shown.items.len() && Some(index) != self.shown.selected_item() { - self.select_index(app, Some(index)); - } - } -} - -impl Component for PlaylistsPane { - type RenderState = bool; - - fn mode(&self) -> Mode { - if self.filter.is_empty() || self.filter.as_bytes().last() == Some(&b'\n') { - Mode::Normal - } else { - Mode::Insert - } - } - - fn render(&mut self, _frame: &mut Frame, _chunk: layout::Rect, _is_focused: bool) { } - - #[allow(clippy::collapsible_match)] - #[allow(clippy::single_match)] - fn handle_event(&mut self, app: &mut App, event: Event) -> Result<()> { - use crate::events::Command::*; - use crate::events::Action; - use Event::*; - use KeyCode::*; - - match event { - Command(cmd) => match cmd { - SelectNext => self.select_next(app), - SelectPrev => self.select_prev(app), - Search => self.filter = "/".into(), - _ => {} - }, - Terminal(event) => match event { - crossterm::event::Event::Key(event) => { - if self.mode() == Mode::Insert && self.handle_filter_key_event(event)? { - self.refresh_shown(); - app.channel.tx.send(Event::Action(Action::ChangedPlaylist)).unwrap(); - return Ok(()); - } - - match event.code { - Up => self.select_prev(app), - Down => self.select_next(app), - Char('/') => self.filter = "/".into(), - Esc => { - self.filter.clear(); - self.refresh_shown(); - app.channel.tx.send(Event::Action(Action::ChangedPlaylist)).unwrap(); - } - _ => {} - } - } - _ => {} - }, - _ => {} - } - - Ok(()) - } -} - -impl MouseHandler for PlaylistsPane { - fn handle_mouse( - &mut self, - app: &mut App, - chunk: Rect, - event: crossterm::event::MouseEvent, - ) -> Result<()> { - match event.kind { - MouseEventKind::ScrollUp => self.select_prev(app), - MouseEventKind::ScrollDown => self.select_next(app), - MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) => { - self.click(app, chunk, event.row) - } - _ => {} - } - Ok(()) - } -} diff --git a/tori/src/app/browse_screen/songs.rs b/tori/src/app/browse_screen/songs.rs deleted file mode 100644 index 061b690..0000000 --- a/tori/src/app/browse_screen/songs.rs +++ /dev/null @@ -1,483 +0,0 @@ -use std::borrow::Cow; - -use std::path::Path; - -use crate::app::component::MouseHandler; -use crate::error::Result; -use crate::events::Event; -use crate::events::{self}; -use crate::player::Player; - -use crate::util::ClickInfo; -use crate::{ - app::{component::Component, filtered_list::FilteredList, App, Mode}, - config::Config, -}; -use crate::{m3u, util}; - -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind}; -use tui::layout::Rect; - -use tui::{ - layout::{self}, - widgets::{TableState}, - Frame, -}; - -///////////////////////////////// -// SortingMethod // -///////////////////////////////// -#[derive(Debug, Default, Clone, Copy)] -enum SortingMethod { - #[default] - /// identity permutation - Index, - Title, - Duration, -} - -impl SortingMethod { - pub fn next(&self) -> Self { - use SortingMethod::*; - match self { - Index => Title, - Title => Duration, - Duration => Index, - } - } -} - -fn compare_songs( - i: usize, - j: usize, - songs: &[m3u::Song], - method: SortingMethod, -) -> std::cmp::Ordering { - match method { - SortingMethod::Index => i.cmp(&j), - SortingMethod::Title => songs[i].title.cmp(&songs[j].title), - SortingMethod::Duration => songs[i].duration.cmp(&songs[j].duration), - } -} - -////////////////////////////////////// -// MousePressLocation // -////////////////////////////////////// -#[derive(Debug, Clone, Copy, PartialEq)] -enum MousePressLocation { - List, - Scrollbar, -} - -///////////////////////////// -// SongsPane // -///////////////////////////// -/// Displays the list of songs of a given playlist -#[derive(Debug, Default)] -pub struct SongsPane<'t> { - /// Generally the name of the playlist - title: Cow<'t, str>, - songs: Vec, - shown: FilteredList, - sorting_method: SortingMethod, - filter: String, - last_click: Option, - mouse_press_location: Option, -} - -impl<'t> SongsPane<'t> { - pub fn new() -> Self { - Self { - title: " songs ".into(), - ..Default::default() - } - } - - pub fn state(&self) -> TableState { - self.shown.state.clone() - } - pub fn set_state(&mut self, state: TableState) { - self.shown.state = state; - } - - pub fn update_from_playlist_pane( - &mut self, - playlists: &super::playlists::PlaylistsPane, - ) -> Result<()> { - match playlists.selected_item() { - Some(playlist) => self.update_from_playlist_named(playlist), - None => { - *self = SongsPane::new(); - Ok(()) - } - } - } - - pub fn update_from_playlist_named(&mut self, name: &str) -> Result<()> { - self.update_from_playlist(Config::playlist_path(name)) - } - - pub fn update_from_playlist(&mut self, path: impl AsRef) -> Result<()> { - let file = std::fs::File::open(&path) - .map_err(|_| format!("Couldn't open playlist file {}", path.as_ref().display()))?; - - let title = Cow::Owned( - path.as_ref() - .file_stem() - .unwrap() - .to_string_lossy() - .to_string(), - ); - - let songs = m3u::Parser::from_reader(file).all_songs()?; - let state = self.state(); - - // Update stuff - self.title = title; - self.songs = songs; - self.filter.clear(); - self.refresh_shown(); - - // Try to reuse previous state - if matches!(state.selected(), Some(i) if i < self.songs.len()) { - self.set_state(state); - } else if self.shown.items.is_empty() { - self.select_index(None); - } else { - self.select_index(Some(0)); - } - - Ok(()) - } - - fn refresh_shown(&mut self) { - let pred = |s: &m3u::Song| { - self.filter.is_empty() - || s.title - .to_lowercase() - .contains(&self.filter[1..].trim_end_matches('\n').to_lowercase()) - || s.path - .to_lowercase() - .contains(&self.filter[1..].trim_end_matches('\n').to_lowercase()) - }; - let comparison = |i, j| compare_songs(i, j, &self.songs, self.sorting_method); - self.shown.filter(&self.songs, pred, comparison); - } - - fn next_sorting_method(&mut self) { - self.sorting_method = self.sorting_method.next(); - } - - #[allow(clippy::single_match)] - fn handle_terminal_event( - &mut self, - app: &mut App, - event: crossterm::event::Event, - ) -> Result<()> { - use KeyCode::*; - - match event { - crossterm::event::Event::Key(event) => { - if self.mode() == Mode::Insert && self.handle_filter_key_event(event)? { - self.refresh_shown(); - return Ok(()); - } - - match event.code { - Enter => self.play_selected(app)?, - Esc => { - self.filter.clear(); - self.refresh_shown(); - } - // Go to the top, kind of like in vim - Char('g') if self.mode() == Mode::Normal => { - if !self.shown.items.is_empty() { - self.shown.state.select(Some(0)); - } - } - // Go to the bottom, also like in vim - Char('G') if self.mode() == Mode::Normal => { - if !self.shown.items.is_empty() { - self.shown.state.select(Some(self.shown.items.len() - 1)); - } - } - Up => self.select_prev(), - Down => self.select_next(), - Char('/') => self.filter = "/".into(), - _ => {} - } - } - _ => {} - } - Ok(()) - } - - fn handle_command(&mut self, app: &mut App, cmd: events::Command) -> Result<()> { - use events::Command::*; - - match cmd { - SelectNext => self.select_next(), - SelectPrev => self.select_prev(), - QueueSong => { - if let Some(song) = self.selected_item() { - app.state.player.queue(&song.path)?; - } - } - QueueShown => { - for &i in self.shown.items.iter() { - let path = self.songs[i].path.as_str(); - app.state.player.queue(path)?; - } - } - Shuffle => { - app.state.player.shuffle()?; - } - OpenInBrowser => { - if let Some(song) = self.selected_item() { - // TODO: reconsider if I really need a library to write this one line - webbrowser::open(&song.path)?; - } - } - CopyUrl => { - if let Some(song) = self.selected_item() { - util::copy_to_clipboard(song.path.clone()); - #[cfg(feature = "clip")] - app.state - .notify_info(format!("Copied {} to the clipboard", song.path)); - #[cfg(not(feature = "clip"))] - app.notify_info("Clipboard support is disabled for this build. You can enable it by building with '--features clip'"); - } - } - CopyTitle => { - if let Some(song) = self.selected_item() { - util::copy_to_clipboard(song.title.clone()); - #[cfg(feature = "clip")] - app.state - .notify_info(format!("Copied {} to the clipboard", song.title)); - #[cfg(not(feature = "clip"))] - app.notify_info("Clipboard support is disabled for this build. You can enable it by building with '--features clip'"); - } - } - SwapSongUp if self.filter.is_empty() => match self.selected_index() { - Some(i) if i >= 1 => { - m3u::playlist_management::swap_song(&self.title, i - 1)?; - self.songs.swap(i - 1, i); - self.select_prev(); - } - _ => {} - }, - SwapSongDown if self.filter.is_empty() => match self.selected_index() { - Some(i) if i + 1 < self.songs.len() => { - m3u::playlist_management::swap_song(&self.title, i)?; - self.songs.swap(i, i + 1); - self.select_next(); - } - _ => {} - }, - NextSortingMode => { - self.next_sorting_method(); - self.refresh_shown(); - } - Search => self.filter = "/".into(), - _ => {} - } - Ok(()) - } - - /// Handles a key event when the filter is active. - pub fn handle_filter_key_event(&mut self, event: KeyEvent) -> Result { - match event.code { - KeyCode::Char(c) => { - self.filter.push(c); - Ok(true) - } - KeyCode::Backspace => { - self.filter.pop(); - Ok(true) - } - KeyCode::Esc => { - self.filter.clear(); - Ok(true) - } - KeyCode::Enter => { - self.filter.push('\n'); - Ok(true) - } - _ => Ok(false), - } - } - - /// Handle a click on the song component - pub fn click( - &mut self, - app: &mut App, - chunk: Rect, - (x, y): (u16, u16), - kind: MouseEventKind, - ) -> Result<()> { - match kind { - MouseEventKind::Up(MouseButton::Left) => { - self.mouse_press_location = None; - } - // If the mouse press (MouseEventKind::Down event) was done on the scrollbar, - // any drag events will still be handled by the scrollbar, even if the mouse - // is moved outside of the scrollbar. - MouseEventKind::Drag(MouseButton::Left) - if self.mouse_press_location == Some(MousePressLocation::Scrollbar) => - { - self.click_scrollbar(app, chunk, (x, y), kind)?; - } - MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) => { - if x + 1 == chunk.right() { - // Clicked on the scrollbar - self.click_scrollbar(app, chunk, (x, y), kind)?; - } else { - // Clicked on the song list - self.click_list(app, chunk, (x, y), kind)?; - } - } - _ => {} - } - Ok(()) - } - - /// Handle a click on the scrollbar - fn click_scrollbar( - &mut self, - _app: &mut App, - chunk: Rect, - (_x, y): (u16, u16), - kind: MouseEventKind, - ) -> Result<()> { - if let MouseEventKind::Down(MouseButton::Left) = kind { - self.mouse_press_location = Some(MousePressLocation::Scrollbar); - } - - let perc = (y as f64 - chunk.top() as f64 - 1.0) / (chunk.height - 2) as f64; - let len = self.shown.items.len().saturating_sub(1); - self.select_index(Some(((perc * len as f64) as usize).max(0).min(len))); - Ok(()) - } - - /// Handle a click on the song list - fn click_list( - &mut self, - app: &mut App, - chunk: Rect, - (_x, y): (u16, u16), - kind: MouseEventKind, - ) -> Result<()> { - if let MouseEventKind::Down(MouseButton::Left) = kind { - self.mouse_press_location = Some(MousePressLocation::List); - } - - // Compute clicked item - let top = chunk - .inner(&layout::Margin { - vertical: 1, - horizontal: 1, - }) - .top(); - let line = y.saturating_sub(top) as usize; - let index = line + self.shown.state.offset(); - - // Update self.last_click with current click - let click_summary = ClickInfo::update(&mut self.last_click, y); - - // User clicked outside the list - if index >= self.shown.items.len() { - return Ok(()); - } - - // Select song - self.select_index(Some(index)); - - // If it's a double click, play this selected song - if click_summary.double_click && matches!(kind, MouseEventKind::Down(MouseButton::Left)) { - self.play_selected(app)?; - } - Ok(()) - } - - pub fn play_selected(&self, app: &mut App) -> Result<()> { - if let Some(song) = self.selected_item() { - app.state.player.play(&song.path)?; - } - Ok(()) - } - - pub fn select_next(&mut self) { - self.shown.select_next(); - } - - pub fn select_prev(&mut self) { - self.shown.select_prev(); - } - - pub fn select_index(&mut self, i: Option) { - self.shown.state.select(i); - } - - pub fn selected_item(&self) -> Option<&m3u::Song> { - self.shown.selected_item().and_then(|i| self.songs.get(i)) - } - - pub fn selected_index(&self) -> Option { - self.shown.selected_item() - } -} - -impl<'t> Component for SongsPane<'t> { - type RenderState = bool; - - fn mode(&self) -> Mode { - if self.filter.is_empty() || self.filter.as_bytes().last() == Some(&b'\n') { - Mode::Normal - } else { - Mode::Insert - } - } - - fn render(&mut self, _frame: &mut Frame, _chunk: layout::Rect, _is_focused: bool) {} - - fn handle_event(&mut self, app: &mut App, event: Event) -> Result<()> { - use events::Action::*; - use Event::*; - - match event { - Command(cmd) => self.handle_command(app, cmd)?, - Terminal(event) => self.handle_terminal_event(app, event)?, - Action(SongAdded { - playlist: _, - song: _, - }) => { - // scroll to the bottom - if !self.shown.items.is_empty() { - self.shown.state.select(Some(self.shown.items.len() - 1)); - } - } - _ => {} - } - - Ok(()) - } -} - -impl<'a> MouseHandler for SongsPane<'a> { - fn handle_mouse( - &mut self, - app: &mut App, - chunk: Rect, - event: crossterm::event::MouseEvent, - ) -> Result<()> { - match event.kind { - MouseEventKind::ScrollUp => self.select_prev(), - MouseEventKind::ScrollDown => self.select_next(), - MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) => { - self.click(app, chunk, (event.column, event.row), event.kind)? - } - _ => {} - } - Ok(()) - } -} diff --git a/tori/src/app/component.rs b/tori/src/app/component.rs deleted file mode 100644 index 71a20ca..0000000 --- a/tori/src/app/component.rs +++ /dev/null @@ -1,33 +0,0 @@ -use super::App; -use crate::{error::Result, events}; -use tui::{layout::Rect, Frame}; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[repr(i8)] -pub enum Mode { - #[default] - Normal, - Insert, -} - -pub trait Component { - type RenderState; - - fn mode(&self) -> Mode; - fn render( - &mut self, - frame: &mut Frame, - chunk: Rect, - render_state: Self::RenderState, - ); - fn handle_event(&mut self, app: &mut App, event: events::Event) -> Result<()>; -} - -pub trait MouseHandler { - fn handle_mouse( - &mut self, - app: &mut App, - chunk: Rect, - event: crossterm::event::MouseEvent, - ) -> Result<()>; -} diff --git a/tori/src/app/filtered_list.rs b/tori/src/app/filtered_list.rs index 1b2078a..9cd41cf 100644 --- a/tori/src/app/filtered_list.rs +++ b/tori/src/app/filtered_list.rs @@ -76,6 +76,10 @@ impl FilteredList { }); } + pub fn select(&mut self, i: Option) { + self.state.select(i); + } + pub fn selected_item(&self) -> Option { self.state.selected().map(|i| self.items[i]) } diff --git a/tori/src/app/mod.rs b/tori/src/app/mod.rs index a7f157e..2a81da9 100644 --- a/tori/src/app/mod.rs +++ b/tori/src/app/mod.rs @@ -3,40 +3,31 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use std::{ - io, - time::{self, Duration, Instant}, +use std::{io, sync::Arc}; +use tokio::{ + select, + sync::{mpsc, Mutex}, }; -use tokio::select; use tui::{backend::CrosstermBackend, Terminal}; use crate::{ - app::component::Mode, error::Result, events::channel::Channel, state::State, ui::ui, - update::update, + error::Result, + events::channel::Channel, + state::State, + ui::ui, + update::{handle_event, update}, }; -pub mod app_screen; -pub mod browse_screen; -pub mod component; pub mod filtered_list; pub mod modal; -pub mod playlist_screen; - -use crate::events::Event; type MyBackend = CrosstermBackend; -const FRAME_DELAY_MS: u16 = 16; -const HIGH_EVENT_TIMEOUT: u16 = 1000; -const LOW_EVENT_TIMEOUT: u16 = 17; - /// Controls the application main loop pub struct App<'n> { - pub state: State<'n>, + pub state: Arc>>, pub channel: Channel, pub terminal: Terminal, - next_render: time::Instant, - next_poll_timeout: u16, } impl<'a> App<'a> { @@ -45,81 +36,99 @@ impl<'a> App<'a> { let backend = CrosstermBackend::new(stdout); Ok(App { - state: State::new()?, + state: Arc::new(Mutex::new(State::new()?)), channel: Channel::new(), terminal: Terminal::new(backend)?, - next_render: time::Instant::now(), - next_poll_timeout: LOW_EVENT_TIMEOUT, }) } - pub async fn run(&mut self) -> Result<()> { + pub async fn run(self) -> Result<()> { chain_hook(); setup_terminal()?; - while !self.state.quit { - self.render() - .map_err(|e| self.state.notify_err(format!("Rendering error: {e}"))) - .ok(); - - if let Some(ev) = self.recv_event().await { - let tx = self.channel.tx.clone(); - match update(&mut self.state, tx, ev) { - Ok(Some(ev)) => self.channel.tx.send(ev).expect("Failed to send event"), - Ok(None) => {} - Err(e) => self.state.notify_err(e.to_string()), + tokio_scoped::scope(|scope| { + let App { + state: state_, + mut channel, + mut terminal, + } = self; + + let (render_tx_, mut render_rx) = mpsc::channel::<()>(1); + + // Updating task + let state = state_.clone(); + let render_tx = render_tx_.clone(); + scope.spawn(async move { + while !state.lock().await.quit { + App::recv(&state, &mut channel).await; + render_tx + .send(()) + .await + .expect("Failed to send render event"); } - } - } + }); + + // Rendering task + let state = state_.clone(); + scope.spawn(async move { + while !state.lock().await.quit { + if render_rx.recv().await.is_some() { + let mut state = state.lock().await; + App::render(&mut terminal, &mut state) + .map_err(|e| state.notify_err(format!("Rendering error: {e}"))) + .ok(); + } + } + }); + + // Trigger first render immediately + let render_tx = render_tx_.clone(); + scope.spawn(async move { + render_tx + .send(()) + .await + .expect("Failed to send render event"); + }); + }); reset_terminal()?; Ok(()) } - #[inline] - fn render(&mut self) -> Result<()> { - if Instant::now() >= self.next_render { - self.terminal.draw(|frame| { - let area = frame.size(); - let buf = frame.buffer_mut(); - ui(&mut self.state, area, buf); - })?; - - self.next_render = time::Instant::now() - .checked_add(Duration::from_millis(FRAME_DELAY_MS as u64)) - .unwrap(); - } - Ok(()) - } - - async fn recv_event(&mut self) -> Option { - // NOTE: Big timeout if the last event was long ago, small timeout otherwise. - // This makes it so after a burst of events, like a Ctrl+V, we get a small timeout - // immediately after the last event, which triggers a fast render. - let timeout = Duration::from_millis(self.next_poll_timeout as u64); - + async fn recv(state: &Mutex>, channel: &mut Channel) { select! { - e = self.channel.rx.recv() => { - self.next_poll_timeout = FRAME_DELAY_MS; - if let Some(e) = e { - if should_handle_event(&e) { - return Some(e); + crossterm_event = channel.crossterm_rx.recv() => { + if let Some(ev) = crossterm_event { + let mut state = state.lock().await; + match handle_event(&mut state, ev) { + Ok(Some(a)) => channel.tx.send(a).expect("Failed to send action"), + Ok(None) => {} + Err(e) => state.notify_err(e.to_string()), } } } - _ = tokio::time::sleep(timeout) => { - self.next_poll_timeout = self.suitable_event_timeout(); + + action = channel.rx.recv() => { + if let Some(ev) = action { + let mut state = state.lock().await; + match update(&mut state, channel.tx.clone(), ev) { + Ok(Some(a)) => channel.tx.send(a).expect("Failed to send action"), + Ok(None) => {} + Err(e) => state.notify_err(e.to_string()), + } + } } } - None } - #[inline] - fn suitable_event_timeout(&self) -> u16 { - match self.state.visualizer.0 { - Some(_) => LOW_EVENT_TIMEOUT, - None => HIGH_EVENT_TIMEOUT, - } + fn render(terminal: &mut Terminal, state: &mut State<'_>) -> Result<()> { + terminal.draw(|frame| { + let area = frame.size(); + let buf = frame.buffer_mut(); + ui(state, area, buf); + })?; + + Ok(()) } } @@ -144,14 +153,3 @@ pub fn reset_terminal() -> Result<()> { execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; Ok(()) } - -fn should_handle_event(event: &Event) -> bool { - match event { - Event::Terminal(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Release => { - // WARN: we ignore every key release event for now because of a crossterm 0.26 - // quirk: https://github.com/crossterm-rs/crossterm/pull/745 - false - } - _ => true, - } -} diff --git a/tori/src/app/modal/confirmation_modal.rs b/tori/src/app/modal/confirmation_modal.rs index c8515d3..e180a47 100644 --- a/tori/src/app/modal/confirmation_modal.rs +++ b/tori/src/app/modal/confirmation_modal.rs @@ -1,18 +1,14 @@ use super::{get_modal_chunk, Message, Modal}; -use crossterm::event::KeyCode; +use crossterm::event::{Event, KeyCode}; use tui::{ layout::Alignment, + prelude::*, style::{Color, Style}, widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget}, - prelude::*, }; -use crate::{ - app::component::Mode, - error::Result, - events::Event, -}; +use crate::error::Result; /// A confirmation modal box that asks for user yes/no input #[derive(Debug, Default)] @@ -36,9 +32,8 @@ impl Modal for ConfirmationModal { } fn handle_event(&mut self, event: Event) -> Result { - use Event::*; use KeyCode::*; - if let Terminal(crossterm::event::Event::Key(event)) = event { + if let Event::Key(event) = event { return match event.code { Backspace | Esc | Char('q') | Char('n') | Char('N') => Ok(Message::Quit), Enter | Char('y') | Char('Y') => Ok(Message::Commit("y".into())), @@ -66,7 +61,7 @@ impl Modal for ConfirmationModal { paragraph.render(chunk, buf); } - fn mode(&self) -> Mode { - Mode::Insert + fn mode(&self) -> ! { + todo!() } } diff --git a/tori/src/app/modal/help_modal.rs b/tori/src/app/modal/help_modal.rs index 73cee01..7bc2752 100644 --- a/tori/src/app/modal/help_modal.rs +++ b/tori/src/app/modal/help_modal.rs @@ -1,5 +1,6 @@ use super::{get_modal_chunk, Message, Modal}; +use crossterm::event::Event; use tui::{ layout::{Alignment, Constraint}, style::{Color, Style}, @@ -10,10 +11,9 @@ use tui::{ use unicode_width::UnicodeWidthStr; use crate::{ - app::component::Mode, config::{shortcuts::InputStr, Config}, error::Result, - events::{Command, Event}, + events::Command, }; /// A modal box that asks for user input @@ -63,7 +63,7 @@ impl Modal for HelpModal { fn apply_style(&mut self, _style: Style) {} fn handle_event(&mut self, event: Event) -> Result { - if let Event::Terminal(crossterm::event::Event::Key(_)) = event { + if let Event::Key(_) = event { return Ok(Message::Quit); } Ok(Message::Nothing) @@ -101,7 +101,7 @@ impl Modal for HelpModal { table.render(chunk, buf); } - fn mode(&self) -> Mode { - Mode::Insert + fn mode(&self) -> ! { + todo!() } } diff --git a/tori/src/app/modal/hotkey_modal.rs b/tori/src/app/modal/hotkey_modal.rs index d3a767d..dc71e1b 100644 --- a/tori/src/app/modal/hotkey_modal.rs +++ b/tori/src/app/modal/hotkey_modal.rs @@ -1,8 +1,6 @@ use crate::{ - app::component::Mode, config::shortcuts::InputStr, error::Result, - events::Event, }; use crossterm::event::Event as CrosstermEvent; use tui::{ @@ -26,8 +24,8 @@ pub struct HotkeyModal { impl Modal for HotkeyModal { fn apply_style(&mut self, _style: Style) {} - fn handle_event(&mut self, event: Event) -> Result { - if let Event::Terminal(CrosstermEvent::Key(key)) = event { + fn handle_event(&mut self, event: CrosstermEvent) -> Result { + if let CrosstermEvent::Key(key) = event { if let crossterm::event::KeyCode::Esc = key.code { return Ok(super::Message::Quit); } @@ -56,7 +54,7 @@ impl Modal for HotkeyModal { paragraph.render(chunk, buf); } - fn mode(&self) -> Mode { - Mode::Insert + fn mode(&self) -> ! { + todo!() } } diff --git a/tori/src/app/modal/input_modal.rs b/tori/src/app/modal/input_modal.rs index 8927777..6d9143f 100644 --- a/tori/src/app/modal/input_modal.rs +++ b/tori/src/app/modal/input_modal.rs @@ -1,8 +1,9 @@ use super::{get_modal_chunk, Message, Modal}; -use std::{borrow::Cow, cell::Cell, mem}; +use std::{borrow::Cow, mem}; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, Event}; +use std::sync::Mutex; use tui::{ layout::Alignment, prelude::*, @@ -11,14 +12,14 @@ use tui::{ widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget}, }; -use crate::{app::component::Mode, error::Result, events::Event}; +use crate::error::Result; /// A modal box that asks for user input #[derive(Debug, Default)] pub struct InputModal<'t> { title: Cow<'t, str>, cursor: usize, - scroll: Cell, + scroll: Mutex, input: String, style: Style, } @@ -28,7 +29,7 @@ impl<'t> InputModal<'t> { Self { title: title.into(), cursor: 0, - scroll: Cell::new(0), + scroll: Mutex::new(0), input: String::default(), style: Style::default().fg(Color::LightBlue), } @@ -56,9 +57,8 @@ impl<'t> Modal for InputModal<'t> { } fn handle_event(&mut self, event: Event) -> Result { - use Event::*; use KeyCode::*; - if let Terminal(crossterm::event::Event::Key(event)) = event { + if let Event::Key(event) = event { match event.code { Char(c) => { self.input.insert(self.cursor, c); @@ -138,25 +138,24 @@ impl<'t> Modal for InputModal<'t> { paragraph.render(chunk, buf); } - fn mode(&self) -> Mode { - Mode::Insert + fn mode(&self) -> ! { + todo!() } } impl<'t> InputModal<'t> { /// Updates and calculates the Paragraph's scroll based on the current cursor and input fn calculate_scroll(&self, chunk_width: u16) -> u16 { - let mut scroll = self.scroll.get(); - if self.cursor as u16 > scroll + chunk_width - 1 { - scroll = self.cursor as u16 + 1 - chunk_width; + let mut scroll = self.scroll.lock().unwrap(); + if self.cursor as u16 > *scroll + chunk_width - 1 { + *scroll = self.cursor as u16 + 1 - chunk_width; } - if (self.cursor as u16) <= scroll { - scroll = (self.cursor as u16).saturating_sub(1); + if (self.cursor as u16) <= *scroll { + *scroll = (self.cursor as u16).saturating_sub(1); } - self.scroll.set(scroll); - scroll + *scroll } } diff --git a/tori/src/app/modal/mod.rs b/tori/src/app/modal/mod.rs index b705d92..0f1d944 100644 --- a/tori/src/app/modal/mod.rs +++ b/tori/src/app/modal/mod.rs @@ -4,17 +4,14 @@ pub mod hotkey_modal; pub mod input_modal; pub use confirmation_modal::ConfirmationModal; +use crossterm::event::Event; pub use help_modal::HelpModal; pub use hotkey_modal::HotkeyModal; pub use input_modal::InputModal; -use tui::{layout::Rect, style::Style, prelude::*}; +use tui::{layout::Rect, prelude::*, style::Style}; -use crate::{ - app::component::Mode, - error::Result, - events::Event, -}; +use crate::error::Result; /////////////////////////////////////////////////// // Message // @@ -36,11 +33,14 @@ pub enum Message { ///////////////////////////////////////////////// // Modal // ///////////////////////////////////////////////// -pub trait Modal { +pub trait Modal +where + Self: Sync + Send, +{ fn apply_style(&mut self, style: Style); fn handle_event(&mut self, event: Event) -> Result; fn render(&self, area: Rect, buf: &mut Buffer); - fn mode(&self) -> Mode; + fn mode(&self) -> !; } impl Default for Box { @@ -61,76 +61,75 @@ pub fn get_modal_chunk(frame: Rect) -> Rect { } } -#[cfg(test)] -mod tests { - use super::*; - use crate::events::Event; - use crossterm::event::{ - Event::Key, - KeyCode::{self, Backspace, Char, Enter, Esc}, - KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, - }; - - fn key_event(code: KeyCode) -> crossterm::event::Event { - Key(KeyEvent { - code, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) - } - - #[test] - fn test_modal_commit_lifecycle() { - let mut modal = InputModal::new("commit lifecycle"); - assert_eq!( - modal - .handle_event(Event::Terminal(key_event(Char('h')))) - .ok(), - Some(Message::Nothing) - ); - assert_eq!( - modal - .handle_event(Event::Terminal(key_event(Char('i')))) - .ok(), - Some(Message::Nothing) - ); - assert_eq!( - modal - .handle_event(Event::Terminal(key_event(Char('!')))) - .ok(), - Some(Message::Nothing) - ); - assert_eq!( - modal - .handle_event(Event::Terminal(key_event(Backspace))) - .ok(), - Some(Message::Nothing) - ); - assert_eq!( - modal.handle_event(Event::Terminal(key_event(Enter))).ok(), - Some(Message::Commit("hi".into())) - ); - } - - #[test] - fn test_modal_quit_lifecycle() { - let mut modal = InputModal::new("commit lifecycle"); - assert_eq!( - modal - .handle_event(Event::Terminal(key_event(Char('h')))) - .ok(), - Some(Message::Nothing) - ); - assert_eq!( - modal - .handle_event(Event::Terminal(key_event(Char('i')))) - .ok(), - Some(Message::Nothing) - ); - assert_eq!( - modal.handle_event(Event::Terminal(key_event(Esc))).ok(), - Some(Message::Quit) - ); - } -} +// #[cfg(test)] +// mod tests { +// use super::*; +// use crossterm::event::{ +// Event::Key, +// KeyCode::{self, Backspace, Char, Enter, Esc}, +// KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, +// }; +// +// fn key_event(code: KeyCode) -> crossterm::event::Event { +// Key(KeyEvent { +// code, +// modifiers: KeyModifiers::NONE, +// kind: KeyEventKind::Press, +// state: KeyEventState::NONE, +// }) +// } +// +// #[test] +// fn test_modal_commit_lifecycle() { +// let mut modal = InputModal::new("commit lifecycle"); +// assert_eq!( +// modal +// .handle_event(Event::Terminal(key_event(Char('h')))) +// .ok(), +// Some(Message::Nothing) +// ); +// assert_eq!( +// modal +// .handle_event(Event::Terminal(key_event(Char('i')))) +// .ok(), +// Some(Message::Nothing) +// ); +// assert_eq!( +// modal +// .handle_event(Event::Terminal(key_event(Char('!')))) +// .ok(), +// Some(Message::Nothing) +// ); +// assert_eq!( +// modal +// .handle_event(Event::Terminal(key_event(Backspace))) +// .ok(), +// Some(Message::Nothing) +// ); +// assert_eq!( +// modal.handle_event(Event::Terminal(key_event(Enter))).ok(), +// Some(Message::Commit("hi".into())) +// ); +// } +// +// #[test] +// fn test_modal_quit_lifecycle() { +// let mut modal = InputModal::new("commit lifecycle"); +// assert_eq!( +// modal +// .handle_event(Event::Terminal(key_event(Char('h')))) +// .ok(), +// Some(Message::Nothing) +// ); +// assert_eq!( +// modal +// .handle_event(Event::Terminal(key_event(Char('i')))) +// .ok(), +// Some(Message::Nothing) +// ); +// assert_eq!( +// modal.handle_event(Event::Terminal(key_event(Esc))).ok(), +// Some(Message::Quit) +// ); +// } +// } diff --git a/tori/src/app/playlist_screen/centered_list.rs b/tori/src/app/playlist_screen/centered_list.rs deleted file mode 100644 index 707b927..0000000 --- a/tori/src/app/playlist_screen/centered_list.rs +++ /dev/null @@ -1,289 +0,0 @@ -/// Mostly copied from the [tui-rs List](tui::widgets::List), but this list has -/// centered items! -/// Still waiting for ratatui to support centered list items... Dunno why it doesn't, even when -/// Line::alignment exists... -use tui::{ - buffer::Buffer, - layout::{Corner, Rect}, - style::Style, - widgets::{Block, StatefulWidget}, -}; - -use tui::{text::Text, widgets::Widget}; -use unicode_width::UnicodeWidthStr; - -#[derive(Debug, Clone, Default)] -pub struct CenteredListState { - offset: usize, - selected: Option, -} - -impl CenteredListState { - #[allow(dead_code)] - pub fn selected(&self) -> Option { - self.selected - } - - pub fn select(&mut self, index: Option) { - self.selected = index; - if index.is_none() { - self.offset = 0; - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CenteredListItem<'a> { - content: Text<'a>, - style: Style, -} - -impl<'a> CenteredListItem<'a> { - pub fn new(content: T) -> CenteredListItem<'a> - where - T: Into>, - { - CenteredListItem { - content: content.into(), - style: Style::default(), - } - } - - #[allow(dead_code)] - pub fn style(mut self, style: Style) -> CenteredListItem<'a> { - self.style = style; - self - } - - pub fn height(&self) -> usize { - self.content.height() - } -} - -#[derive(Debug, Clone)] -pub struct CenteredList<'a> { - block: Option>, - items: Vec>, - /// Style used as a base style for the widget - style: Style, - start_corner: Corner, - /// Style used to render selected item - highlight_style: Style, - /// Symbol in front of the selected item (Shift all items to the right) - highlight_symbol: Option<&'a str>, - /// Symbol to the right of the selected item - highlight_symbol_right: Option<&'a str>, - /// Whether to repeat the highlight symbol for each line of the selected item - repeat_highlight_symbol: bool, -} - -impl<'a> CenteredList<'a> { - pub fn new(items: T) -> Self - where - T: Into>>, - { - Self { - block: None, - style: Style::default(), - items: items.into(), - start_corner: Corner::TopLeft, - highlight_style: Style::default(), - highlight_symbol: None, - highlight_symbol_right: None, - repeat_highlight_symbol: false, - } - } - - pub fn block(mut self, block: Block<'a>) -> Self { - self.block = Some(block); - self - } - - #[allow(dead_code)] - pub fn style(mut self, style: Style) -> Self { - self.style = style; - self - } - - pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self { - self.highlight_symbol = Some(highlight_symbol); - self - } - - pub fn highlight_symbol_right(mut self, highlight_symbol: &'a str) -> Self { - self.highlight_symbol_right = Some(highlight_symbol); - self - } - - pub fn highlight_style(mut self, style: Style) -> Self { - self.highlight_style = style; - self - } - - #[allow(dead_code)] - pub fn repeat_highlight_symbol(mut self, repeat: bool) -> Self { - self.repeat_highlight_symbol = repeat; - self - } - - #[allow(dead_code)] - pub fn start_corner(mut self, corner: Corner) -> Self { - self.start_corner = corner; - self - } - - fn get_items_bounds( - &self, - selected: Option, - offset: usize, - max_height: usize, - ) -> (usize, usize) { - let offset = offset.min(self.items.len().saturating_sub(1)); - let mut start = offset; - let mut end = offset; - let mut height = 0; - for item in self.items.iter().skip(offset) { - if height + item.height() > max_height { - break; - } - height += item.height(); - end += 1; - } - - let selected = selected.unwrap_or(0).min(self.items.len() - 1); - while selected >= end { - height = height.saturating_add(self.items[end].height()); - end += 1; - while height > max_height { - height = height.saturating_sub(self.items[start].height()); - start += 1; - } - } - while selected < start { - start -= 1; - height = height.saturating_add(self.items[start].height()); - while height > max_height { - end -= 1; - height = height.saturating_sub(self.items[end].height()); - } - } - (start, end) - } -} - -impl<'a> StatefulWidget for CenteredList<'a> { - type State = CenteredListState; - - fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - buf.set_style(area, self.style); - let list_area = match self.block.take() { - Some(b) => { - let inner_area = b.inner(area); - b.render(area, buf); - inner_area - } - None => area, - }; - - if list_area.width < 1 || list_area.height < 1 { - return; - } - - if self.items.is_empty() { - return; - } - let list_height = list_area.height as usize; - - let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); - state.offset = start; - - let highlight_symbol = self.highlight_symbol.unwrap_or(""); - let highlight_symbol_right = self.highlight_symbol_right.unwrap_or(""); - let blank_symbol = " ".repeat(highlight_symbol.width()); - - let mut current_height = 0; - let has_selection = state.selected.is_some(); - for (i, item) in self - .items - .iter_mut() - .enumerate() - .skip(state.offset) - .take(end - start) - { - let (x, y) = match self.start_corner { - Corner::BottomLeft => { - current_height += item.height() as u16; - (list_area.left(), list_area.bottom() - current_height) - } - _ => { - let pos = (list_area.left(), list_area.top() + current_height); - current_height += item.height() as u16; - pos - } - }; - let area = Rect { - x, - y, - width: list_area.width, - height: item.height() as u16, - }; - let item_style = self.style; //.patch(item.style); - buf.set_style(area, item_style); - - let is_selected = state.selected.map(|s| s == i).unwrap_or(false); - for (j, line) in item.content.lines.iter().enumerate() { - // if the item is selected, we need to display the hightlight symbol: - // - either for the first line of the item only, - // - or for each line of the item if the appropriate option is set - let symbol = if is_selected && (j == 0 || self.repeat_highlight_symbol) { - highlight_symbol - } else { - &blank_symbol - }; - - let offset = list_area.width.saturating_sub(line.width() as u16) / 2; - let offset = offset.saturating_sub(1); // idk why either - - let (elem_x, max_element_width) = if has_selection { - let (elem_x, _) = buf.set_stringn( - x + offset, - y + j as u16, - symbol, - list_area.width as usize, - item_style, - ); - (elem_x, list_area.width - (elem_x - x)) - } else { - (x + offset, list_area.width) - }; - - let (x_after, _) = buf.set_line(elem_x, y + j as u16, line, max_element_width); - - if has_selection { - let symbol = if is_selected && (j == 0 || self.repeat_highlight_symbol) { - highlight_symbol_right - } else { - &blank_symbol - }; - buf.set_stringn( - x_after, - y + j as u16, - symbol, - list_area.width as usize, - item_style, - ); - } - } - if is_selected { - buf.set_style(area, self.highlight_style); - } - } - } -} - -impl<'a> Widget for CenteredList<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let mut state = CenteredListState::default(); - StatefulWidget::render(self, area, buf, &mut state); - } -} diff --git a/tori/src/app/playlist_screen/mod.rs b/tori/src/app/playlist_screen/mod.rs deleted file mode 100644 index 3375c13..0000000 --- a/tori/src/app/playlist_screen/mod.rs +++ /dev/null @@ -1,150 +0,0 @@ -use self::centered_list::{CenteredList, CenteredListItem, CenteredListState}; -use super::{ - component::{Component, MouseHandler}, - App, Mode, -}; -use crate::{events, error::Result, player::Player, ui::Scrollbar}; -use std::{thread, time::Duration}; -use tui::{ - layout::{Alignment, Rect}, - style::{Color, Style}, - widgets::{Block, BorderType, Borders}, -}; - -mod centered_list; - -/// Screen that shows the current mpv playlist. You can press '2' to access it. -#[derive(Debug, Default)] -pub struct PlaylistScreen { - songs: Vec, - playing: CenteredListState, -} - -impl PlaylistScreen { - /// See - pub fn update(&mut self, player: &impl Player) -> Result<&mut Self> { - let n = player.playlist_count()?; - - self.songs = (0..n) - .map(|i| player.playlist_track_title(i)) - .collect::>()?; - - self.playing.select(player.playlist_position().ok()); - - Ok(self) - } - - /// Waits a couple of milliseconds, then calls [update](PlaylistScreen::update). It's used - /// primarily by [select_next](PlaylistScreen::select_next) and - /// [select_prev](PlaylistScreen::select_prev) because mpv takes a while to update the playlist - /// properties after changing the selection. - pub fn update_after_delay(&self, app: &App) { - let sender = app.channel.tx.clone(); - thread::spawn(move || { - thread::sleep(Duration::from_millis(16)); - sender.send(events::Event::Tick).ok(); - }); - } - - fn handle_command(&mut self, app: &mut App, cmd: events::Command) -> Result<()> { - use events::Command::*; - match cmd { - SelectNext | NextSong => self.select_next(app), - SelectPrev | PrevSong => self.select_prev(app), - _ => {} - } - Ok(()) - } - - fn handle_terminal_event( - &mut self, - app: &mut App, - event: crossterm::event::Event, - ) -> Result<()> { - use crossterm::event::{Event, KeyCode}; - if let Event::Key(key_event) = event { - match key_event.code { - KeyCode::Up => self.select_prev(app), - KeyCode::Down => self.select_next(app), - _ => {} - } - } - Ok(()) - } - - pub fn select_next(&self, app: &mut App) { - app.state.player - .playlist_next() - .unwrap_or_else(|_| app.state.notify_err("No next song")); - self.update_after_delay(app); - } - - pub fn select_prev(&self, app: &mut App) { - app.state.player - .playlist_previous() - .unwrap_or_else(|_| app.state.notify_err("No previous song")); - self.update_after_delay(app); - } -} - -impl Component for PlaylistScreen { - type RenderState = (); - - fn mode(&self) -> Mode { - Mode::Normal - } - - fn render(&mut self, frame: &mut tui::Frame, chunk: Rect, (): ()) { - let block = Block::default() - .title(" Playlist ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::LightRed)); - - let items: Vec<_> = self - .songs - .iter() - .map(|x| CenteredListItem::new(x.as_str())) - .collect(); - let list = CenteredList::new(items) - .block(block) - .highlight_style(Style::default().bg(Color::Red).fg(Color::White)) - .highlight_symbol("›") - .highlight_symbol_right("‹"); - - frame.render_stateful_widget(list, chunk, &mut self.playing); - - if self.songs.len() > chunk.height as usize - 2 { - if let Some(index) = self.playing.selected() { - let scrollbar = Scrollbar::new(index as u16, self.songs.len() as u16) - .with_style(Style::default().fg(Color::Red)); - frame.render_widget(scrollbar, chunk); - } - } - } - - fn handle_event(&mut self, app: &mut App, event: events::Event) -> Result<()> { - use events::Event::*; - match event { - Command(cmd) => self.handle_command(app, cmd)?, - Terminal(event) => self.handle_terminal_event(app, event)?, - Tick => { - self.update(&app.state.player)?; - } - _ => {} - } - Ok(()) - } -} - -impl MouseHandler for PlaylistScreen { - fn handle_mouse( - &mut self, - _app: &mut App, - _chunk: Rect, - _event: crossterm::event::MouseEvent, - ) -> Result<()> { - Ok(()) - } -} diff --git a/tori/src/component/now_playing.rs b/tori/src/component/now_playing.rs index d0f9a6c..3e9cce7 100644 --- a/tori/src/component/now_playing.rs +++ b/tori/src/component/now_playing.rs @@ -1,7 +1,6 @@ use crate::{ - events::Event, player::Player, - ui::{self, eventful_widget::Listener, EventfulWidget}, + ui::{self, eventful_widget::Listener, EventfulWidget}, events::Action, }; use tui::{ prelude::*, @@ -36,8 +35,8 @@ impl NowPlaying { } } -impl EventfulWidget for NowPlaying { - fn render(&mut self, area: Rect, buf: &mut Buffer, l: &mut Vec>) { +impl EventfulWidget for NowPlaying { + fn render(&mut self, area: Rect, buf: &mut Buffer, l: &mut Vec>) { let (playback_left_str, playback_right_str) = playback_strs(self); let chunks = subcomponent_chunks(self, area); diff --git a/tori/src/default_config.yaml b/tori/src/default_config.yaml index 74180ed..d72cefc 100644 --- a/tori/src/default_config.yaml +++ b/tori/src/default_config.yaml @@ -32,6 +32,10 @@ keybindings: j: SelectNext k: SelectPrev l: SelectRight + left: SelectLeft + down: SelectNext + up: SelectPrev + right: SelectRight a: Add u: QueueSong C-q: QueueShown diff --git a/tori/src/events/action.rs b/tori/src/events/action.rs new file mode 100644 index 0000000..3538697 --- /dev/null +++ b/tori/src/events/action.rs @@ -0,0 +1,14 @@ +use super::Command; +use crate::config::Config; +use crossterm::event::KeyEvent; + +#[derive(Debug, Clone)] +pub enum Action { + Tick, + Input(KeyEvent), + Command(Command), + SongAdded { playlist: String, song: String }, + RefreshSongs, + SelectSong(usize), + SelectPlaylist(usize), +} diff --git a/tori/src/events/channel.rs b/tori/src/events/channel.rs index 6656625..850938c 100644 --- a/tori/src/events/channel.rs +++ b/tori/src/events/channel.rs @@ -1,15 +1,16 @@ -use super::Event; +use super::Action; +use crossterm::event::Event as TermEvent; use std::{ sync::{Arc, Mutex}, thread, time, }; use tokio::{ - sync::mpsc::{unbounded_channel, UnboundedSender, UnboundedReceiver}, + sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, task::JoinHandle, }; -pub type Tx = UnboundedSender; -pub type Rx = UnboundedReceiver; +pub type Tx = UnboundedSender; +pub type Rx = UnboundedReceiver; /// Channel creates a tokio mpsc channel and spawns two event emitters: one for user input events, /// another for ticks that happen every second @@ -17,6 +18,8 @@ pub struct Channel { pub tx: Tx, pub rx: Rx, + pub crossterm_rx: UnboundedReceiver, + /// Whoever owns this mutex can receive events from crossterm::event::read(). /// /// Sometimes, like when the editor is open for editing a playlist, we dont' want to call @@ -35,21 +38,23 @@ impl Default for Channel { impl Channel { pub fn new() -> Self { let (tx, rx) = unbounded_channel(); + let (crossterm_tx, crossterm_rx) = unbounded_channel(); let receiving_crossterm = Arc::new(Mutex::new(())); - spawn_terminal_event_getter(tx.clone(), receiving_crossterm.clone()); + spawn_terminal_event_getter(crossterm_tx.clone(), receiving_crossterm.clone()); spawn_ticks(tx.clone()); Self { tx, rx, + crossterm_rx, receiving_crossterm, } } } fn spawn_terminal_event_getter( - tx: Tx, + tx: UnboundedSender, receiving_crossterm: Arc>, ) -> thread::JoinHandle<()> { thread::spawn(move || loop { @@ -61,7 +66,7 @@ fn spawn_terminal_event_getter( }; if let Ok(event) = crossterm::event::read() { - tx.send(Event::Terminal(event)).unwrap(); + tx.send(event).unwrap(); } }) } @@ -70,7 +75,7 @@ fn spawn_ticks(tx: Tx) -> JoinHandle<()> { tokio::spawn(async move { loop { tokio::time::sleep(time::Duration::from_secs(1)).await; - let sent = tx.send(Event::Tick); + let sent = tx.send(Action::Tick); // Stop spawning ticks if the receiver has been dropped. This prevents a // 'called `Result::unwrap()` on an `Err` value: SendError { .. }' panic when Ctrl+C is diff --git a/tori/src/events/mod.rs b/tori/src/events/mod.rs index 74a6b04..d159247 100644 --- a/tori/src/events/mod.rs +++ b/tori/src/events/mod.rs @@ -1,33 +1,6 @@ +pub mod action; pub mod channel; pub mod command; -use crate::config::Config; +pub use action::Action; pub use command::Command; -use crossterm::event::{Event as CrosstermEvent, KeyEvent}; - -#[derive(Debug, Clone)] -pub enum Event { - Tick, - Action(Action), - Command(Command), - Terminal(CrosstermEvent), -} - -#[derive(Debug, Clone)] -pub enum Action { - SongAdded { playlist: String, song: String }, - ChangedPlaylist, - SelectSong(usize), - SelectPlaylist(usize), -} - -/// Transforms a key event into the corresponding command, if there is one. -/// Assumes state is in normal mode -pub fn transform_normal_mode_key(key_event: KeyEvent) -> Event { - use crossterm::event::Event::Key; - use Event::*; - match Config::global().keybindings.get_from_event(key_event) { - Some(cmd) if cmd != command::Command::Nop => Command(cmd), - _ => Terminal(Key(key_event)), - } -} diff --git a/tori/src/m3u/playlist_management.rs b/tori/src/m3u/playlist_management.rs index 23125d3..5c55c4c 100644 --- a/tori/src/m3u/playlist_management.rs +++ b/tori/src/m3u/playlist_management.rs @@ -3,32 +3,32 @@ use std::{ io::{self, Write}, path, result::Result as StdResult, - thread, }; -use crate::{app::App, config::Config, error::Result, events::{Event, Action}, m3u}; +use crate::{app::App, config::Config, error::Result, m3u}; /// Adds a song to an existing playlist pub fn add_song(app: &mut App, playlist: &str, song_path: String) { - app.state.notify_info(format!("Adding {}...", song_path)); - - if surely_invalid_path(&song_path) { - app.state.notify_err(format!("Failed to add song path '{}'. Doesn't look like a URL and is not a valid path in your filesystem.", song_path)); - return; - } - - let sender = app.channel.tx.clone(); - let playlist = playlist.to_string(); - thread::spawn(move || { - add_song_recursively(&song_path, &playlist); - - // Extract last part (separated by '/') of the song_path - let mut rsplit = song_path.trim_end_matches('/').rsplit('/'); - let song = rsplit.next().unwrap_or(&song_path).to_string(); - - let event = Event::Action(Action::SongAdded { playlist, song }); - sender.send(event).expect("Failed to send internal event"); - }); + todo!("gotta rewrite add_song!") + // app.state.notify_info(format!("Adding {}...", song_path)); + // + // if surely_invalid_path(&song_path) { + // app.state.notify_err(format!("Failed to add song path '{}'. Doesn't look like a URL and is not a valid path in your filesystem.", song_path)); + // return; + // } + // + // let sender = app.channel.tx.clone(); + // let playlist = playlist.to_string(); + // thread::spawn(move || { + // add_song_recursively(&song_path, &playlist); + // + // // Extract last part (separated by '/') of the song_path + // let mut rsplit = song_path.trim_end_matches('/').rsplit('/'); + // let song = rsplit.next().unwrap_or(&song_path).to_string(); + // + // let event = Event::Action(Action::SongAdded { playlist, song }); + // sender.send(event).expect("Failed to send internal event"); + // }); } /// Adds songs from some path. If the path points to a directory, it'll traverse the directory diff --git a/tori/src/state/browse_screen.rs b/tori/src/state/browse_screen.rs index 4591907..31185a3 100644 --- a/tori/src/state/browse_screen.rs +++ b/tori/src/state/browse_screen.rs @@ -45,7 +45,7 @@ impl SortingMethod { impl BrowseScreen { pub fn new() -> Result { let mut me = Self::default(); - me.reload_playlists()?; + me.refresh_playlists()?; Ok(me) } @@ -53,8 +53,18 @@ impl BrowseScreen { self.sorting_method = self.sorting_method.next(); } - // TODO: definitely rethink this - pub fn reload_playlists(&mut self) -> Result<()> { + ///////////////////////////////////// + // Refresh playlists // + ///////////////////////////////////// + pub fn refresh_playlists(&mut self) -> Result<()> { + self.load_playlists()?; + self.filter_playlists(); + self.update_selected_playlist(); + self.refresh_songs()?; + Ok(()) + } + + fn load_playlists(&mut self) -> Result<()> { let dir = std::fs::read_dir(&Config::global().playlists_dir) .map_err(|e| format!("Failed to read playlists directory: {}", e))?; @@ -74,8 +84,26 @@ impl BrowseScreen { .map(extract_playlist_name) .collect::>()?; self.playlists.sort(); - self.refresh_shown(); + Ok(()) + } + fn filter_playlists(&mut self) { + match &self.focus { + Focus::PlaylistsFilter(filter) if !filter.is_empty() => { + let filter = filter.trim_end_matches('\n').to_lowercase(); + self.shown_playlists.filter( + &self.playlists, + |s| s.to_lowercase().contains(&filter), + |i, j| i.cmp(&j), + ) + } + _ => self + .shown_playlists + .filter(&self.playlists, |_| true, |i, j| i.cmp(&j)), + } + } + + fn update_selected_playlist(&mut self) { // Try to reuse previous state let state = self.shown_playlists.state.clone(); if matches!(state.selected(), Some(i) if i < self.playlists.len()) { @@ -85,13 +113,19 @@ impl BrowseScreen { } else { self.shown_playlists.state.select(Some(0)); } + } - self.pull_songs()?; - self.refresh_shown(); + ///////////////////////////////// + // Refresh songs // + ///////////////////////////////// + pub fn refresh_songs(&mut self) -> Result<()> { + self.load_songs()?; + self.filter_songs(); + self.update_selected_song(); Ok(()) } - fn pull_songs(&mut self) -> Result<()> { + fn load_songs(&mut self) -> Result<()> { let playlist_name = match self.selected_playlist() { Some(name) => name, None => { @@ -102,46 +136,14 @@ impl BrowseScreen { }; let path = Config::playlist_path(playlist_name); - let file = std::fs::File::open(&path) .map_err(|_| format!("Couldn't open playlist file {}", path.display()))?; - let songs = m3u::Parser::from_reader(file).all_songs()?; - let state = self.shown_songs.state.clone(); - - // Update stuff - self.songs = songs; - self.refresh_shown(); - - // Try to reuse previous state - if matches!(state.selected(), Some(i) if i < self.songs.len()) { - self.shown_songs.state = state; - } else if self.shown_songs.items.is_empty() { - self.shown_songs.state.select(None); - } else { - self.shown_songs.state.select(Some(0)); - } - + self.songs = m3u::Parser::from_reader(file).all_songs()?; Ok(()) } - fn refresh_shown(&mut self) { - // Refresh playlists - match &self.focus { - Focus::PlaylistsFilter(filter) if !filter.is_empty() => { - let filter = filter.trim_end_matches('\n').to_lowercase(); - self.shown_playlists.filter( - &self.playlists, - |s| s.to_lowercase().contains(&filter), - |i, j| i.cmp(&j), - ) - } - _ => self - .shown_playlists - .filter(&self.playlists, |_| true, |i, j| i.cmp(&j)), - } - - // Refresh songs + fn filter_songs(&mut self) { match &self.focus { Focus::SongsFilter(filter) if !filter.is_empty() => { let filter = filter.trim_end_matches('\n').to_lowercase(); @@ -158,11 +160,25 @@ impl BrowseScreen { } } + fn update_selected_song(&mut self) { + let state = self.shown_songs.state.clone(); + if matches!(state.selected(), Some(i) if i < self.songs.len()) { + self.shown_songs.state = state; + } else if self.shown_songs.items.is_empty() { + self.shown_songs.state.select(None); + } else { + self.shown_songs.state.select(Some(0)); + } + } + + ///////////////////////////// + // Selectors // + ///////////////////////////// pub fn select_next(&mut self) -> Result<()> { match self.focus { Focus::Playlists => { self.shown_playlists.select_next(); - self.pull_songs()?; + self.refresh_songs()?; } Focus::Songs => self.shown_songs.select_next(), Focus::PlaylistsFilter(_) | Focus::SongsFilter(_) => {} @@ -174,7 +190,7 @@ impl BrowseScreen { match self.focus { Focus::Playlists => { self.shown_playlists.select_prev(); - self.pull_songs()?; + self.refresh_songs()?; } Focus::Songs => self.shown_songs.select_prev(), Focus::PlaylistsFilter(_) | Focus::SongsFilter(_) => {} diff --git a/tori/src/ui/list.rs b/tori/src/ui/list.rs index 800914a..6d632f5 100644 --- a/tori/src/ui/list.rs +++ b/tori/src/ui/list.rs @@ -1,4 +1,4 @@ -use crate::{events::Event, ui::Scrollbar}; +use crate::{ui::Scrollbar, events::Action}; use super::{on, EventfulWidget, Listener, UIEvent}; use std::mem; @@ -19,11 +19,11 @@ pub struct List<'a, const C: usize> { state: TableState, items: Vec<[String; C]>, help_message: String, - click_event: Option Event>>, + click_event: Option Action>>, } -impl<'a, const C: usize> EventfulWidget for List<'a, C> { - fn render(&mut self, area: Rect, buf: &mut Buffer, l: &mut Vec>) { +impl<'a, const C: usize> EventfulWidget for List<'a, C> { + fn render(&mut self, area: Rect, buf: &mut Buffer, l: &mut Vec>) { let block = Block::default() .title(mem::take(&mut self.title)) .borders(self.borders) @@ -133,8 +133,8 @@ impl<'a, const C: usize> List<'a, C> { self } - pub fn click_event(mut self, event: impl Fn(usize) -> Event + 'static) -> Self { - self.click_event = Some(Box::new(event)); + pub fn click_event(mut self, action: impl Fn(usize) -> Action + 'static) -> Self { + self.click_event = Some(Box::new(action)); self } } diff --git a/tori/src/ui/mod.rs b/tori/src/ui/mod.rs index fecf516..5b3ea1e 100644 --- a/tori/src/ui/mod.rs +++ b/tori/src/ui/mod.rs @@ -9,7 +9,7 @@ pub use list::*; use crate::{ config::Config, - events::{Action::*, Command, Event}, + events::{Action, Command}, rect_ops::RectOps, state::{ browse_screen::{BrowseScreen, Focus, SortingMethod}, @@ -18,7 +18,7 @@ use crate::{ }; use tui::{prelude::*, widgets::Borders}; -pub fn ui(state: &mut State, area: Rect, buf: &mut Buffer) -> Vec> { +pub fn ui(state: &mut State, area: Rect, buf: &mut Buffer) -> Vec> { let mut l = Vec::new(); state.visualizer.render(area, buf); @@ -44,7 +44,7 @@ fn browse_screen( screen: &mut BrowseScreen, area: Rect, buf: &mut Buffer, - l: &mut Vec>, + l: &mut Vec>, ) { let (left, right) = area.split_vertically_p(0.15); playlists_pane(screen, left, buf, l); @@ -55,7 +55,7 @@ fn playlists_pane( screen: &mut BrowseScreen, area: Rect, buf: &mut Buffer, - l: &mut Vec>, + l: &mut Vec>, ) { let border_style = if matches!(screen.focus, Focus::PlaylistsFilter(_) | Focus::Playlists) { Style::default().fg(Color::LightBlue) @@ -90,7 +90,7 @@ fn playlists_pane( .border_style(border_style) .borders(Borders::ALL & !Borders::RIGHT) .highlight_style(Style::default().bg(Color::LightBlue).fg(Color::Black)) - .click_event(|i| Event::Action(SelectPlaylist(i))); + .click_event(|i| Action::SelectPlaylist(i)); list.render(area, buf, l); screen.shown_playlists.state = list.get_state(); } @@ -99,7 +99,7 @@ fn songs_pane( screen: &mut BrowseScreen, area: Rect, buf: &mut Buffer, - l: &mut Vec>, + l: &mut Vec>, ) { let sorting = match screen.sorting_method { SortingMethod::Index => "", @@ -156,7 +156,7 @@ fn songs_pane( .borders(Borders::ALL) .highlight_style(Style::default().bg(Color::Yellow).fg(Color::Black)) .highlight_symbol(" ◇") - .click_event(|i| Event::Action(SelectSong(i))); + .click_event(|i| Action::SelectSong(i)); list.render(area, buf, l); screen.shown_songs.state = list.get_state(); } diff --git a/tori/src/update/mod.rs b/tori/src/update/mod.rs index dd49dbb..e07bdd4 100644 --- a/tori/src/update/mod.rs +++ b/tori/src/update/mod.rs @@ -1,43 +1,83 @@ use crate::{ error::Result, - events::{self, channel::Tx, Command, Event, Action}, + events::{channel::Tx, Action, Command}, player::Player, - state::State, + state::{browse_screen::Focus, Screen, State}, config::Config, }; -use crossterm::event::{Event as TermEvent, KeyCode}; +use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -pub fn update(state: &mut State<'_>, tx: Tx, ev: Event) -> Result> { - use events::Event::*; +pub fn handle_event(state: &mut State<'_>, ev: TermEvent) -> Result> { + Ok(match ev { + TermEvent::Key(key) if key.kind != KeyEventKind::Release => match &state.screen { + Screen::BrowseScreen(screen) => match &screen.focus { + Focus::Playlists | Focus::Songs => Some(transform_normal_mode_key(key)), + Focus::PlaylistsFilter(_) | Focus::SongsFilter(_) => Some(Action::Input(key)), + }, + }, + _ => None, + }) +} + +/// Transforms a key event into the corresponding action, if there is one. +/// Assumes state is in normal mode +fn transform_normal_mode_key(key_event: KeyEvent) -> Action { + match Config::global().keybindings.get_from_event(key_event) { + Some(cmd) if cmd != Command::Nop => Action::Command(cmd), + _ => Action::Input(key_event), + } +} + +pub fn update(state: &mut State<'_>, tx: Tx, act: Action) -> Result> { + use Action::*; + match act { + Input(_) => {} - match ev { Tick => { state.now_playing.update(&state.player); state.visualizer.update()?; - if state.notification.as_ref().filter(|n| n.is_expired()).is_some() { + if state + .notification + .as_ref() + .filter(|n| n.is_expired()) + .is_some() + { state.notification = None; } } - Terminal(TermEvent::Key(key)) => { - if key.code == KeyCode::Char('q') { - state.quit(); + SongAdded { + playlist, + song: _song, + } => { + let Screen::BrowseScreen(screen) = &mut state.screen; + if screen.selected_playlist() == Some(playlist.as_str()) { + screen.refresh_songs()?; + screen + .shown_songs + .select(Some(screen.shown_songs.items.len() - 1)); } - }, - Terminal(TermEvent::Mouse(_mouse)) => {} - Terminal(_) => {} + } + RefreshSongs => { + let Screen::BrowseScreen(screen) = &mut state.screen; + screen.refresh_songs()?; + } + SelectSong(i) => { + let Screen::BrowseScreen(screen) = &mut state.screen; + screen.shown_songs.select(Some(i)); + } + SelectPlaylist(i) => { + let Screen::BrowseScreen(screen) = &mut state.screen; + screen.shown_playlists.select(Some(i)); + screen.refresh_songs()?; + } - Action(act) => return handle_action(state, tx, act), Command(cmd) => return handle_command(state, tx, cmd), } Ok(None) } -fn handle_action(_state: &mut State<'_>, _tx: Tx, _act: Action) -> Result> { - todo!() -} - -fn handle_command(state: &mut State<'_>, _tx: Tx, cmd: Command) -> Result> { +fn handle_command(state: &mut State<'_>, _tx: Tx, cmd: Command) -> Result> { use Command::*; match cmd { Nop => {} @@ -46,45 +86,45 @@ fn handle_command(state: &mut State<'_>, _tx: Tx, cmd: Command) -> Result