diff --git a/Cargo.lock b/Cargo.lock index c19786f2..b825467a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.89" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" [[package]] name = "arrayvec" @@ -271,6 +271,9 @@ name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] [[package]] name = "bitvec" @@ -835,6 +838,7 @@ dependencies = [ "mio", "parking_lot 0.12.3", "rustix", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -1912,7 +1916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -4687,6 +4691,7 @@ dependencies = [ name = "youtui" version = "0.0.17" dependencies = [ + "anyhow", "async-callback-manager", "async_cell", "bytes", @@ -4697,6 +4702,7 @@ dependencies = [ "gag", "itertools 0.13.0", "log", + "pretty_assertions", "rat-text", "ratatui", "rodio", diff --git a/async-callback-manager/examples/ratatui_example.rs b/async-callback-manager/examples/ratatui_example.rs index 0e1219b2..6932d48e 100644 --- a/async-callback-manager/examples/ratatui_example.rs +++ b/async-callback-manager/examples/ratatui_example.rs @@ -2,7 +2,7 @@ #![allow(clippy::unwrap_used)] use async_callback_manager::{ - AsyncCallbackManager, AsyncCallbackSender, BackendStreamingTask, BackendTask, + AsyncCallbackManager, AsyncTask, BackendStreamingTask, BackendTask, TaskOutcome, }; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind}; use futures::{stream, FutureExt}; @@ -47,7 +47,6 @@ struct State { word: String, number: String, mode: Mode, - callback_handle: AsyncCallbackSender, } impl State { fn draw(&self, f: &mut Frame) { @@ -72,25 +71,21 @@ impl State { fn handle_toggle_mode(&mut self) { self.mode = self.mode.toggle() } - async fn handle_get_word(&mut self) { + async fn handle_get_word(&mut self) -> AsyncTask { self.word = "Loading".to_string(); - self.callback_handle - .add_callback( - GetWordRequest, - |state, word| state.word = word, - (&self.mode).into(), - ) - .unwrap() + AsyncTask::new_future( + GetWordRequest, + |state: &mut Self, word| state.word = word, + (&self.mode).into(), + ) } - async fn handle_start_counter(&mut self) { + async fn handle_start_counter(&mut self) -> AsyncTask { self.number = "Loading".to_string(); - self.callback_handle - .add_stream_callback( - CounterStream, - |state, num| state.number = num, - (&self.mode).into(), - ) - .unwrap() + AsyncTask::new_stream( + CounterStream, + |state: &mut Self, num| state.number = num, + (&self.mode).into(), + ) } } @@ -103,7 +98,6 @@ async fn main() { let mut state = State { word: String::new(), number: String::new(), - callback_handle: manager.new_sender(50), mode: Default::default(), }; loop { @@ -111,19 +105,28 @@ async fn main() { tokio::select! { Some(action) = events.next() => match action { Action::Quit => break, - Action::GetWord => state.handle_get_word().await, - Action::StartCounter => state.handle_start_counter().await, + Action::GetWord => { + manager.spawn_task(&backend, + state.handle_get_word().await) + }, + Action::StartCounter => { + manager.spawn_task(&backend, + state.handle_start_counter().await) + }, Action::ToggleMode => state.handle_toggle_mode(), }, - Some(manager_event) = manager.manage_next_event(&backend) => if manager_event.is_spawned_task() { - continue + Some(outcome) = manager.get_next_response() => match outcome { + TaskOutcome::StreamClosed => continue, + TaskOutcome::TaskPanicked {error,..} => std::panic::resume_unwind(error.into_panic()), + TaskOutcome::MutationReceived { mutation, ..} => + manager.spawn_task(&backend, mutation(&mut state)), }, - mutations = state.callback_handle.get_next_mutations(10) => mutations.apply(&mut state), }; } ratatui::restore(); } +#[derive(Debug)] struct GetWordRequest; impl BackendTask for GetWordRequest { type MetadataType = (); @@ -146,6 +149,7 @@ impl BackendTask for GetWordRequest { } } +#[derive(Debug)] struct CounterStream; impl BackendStreamingTask for CounterStream { type Output = String; diff --git a/async-callback-manager/src/adaptors.rs b/async-callback-manager/src/adaptors.rs index 539de0b7..225bb49c 100644 --- a/async-callback-manager/src/adaptors.rs +++ b/async-callback-manager/src/adaptors.rs @@ -1,6 +1,6 @@ -use crate::{BackendStreamingTask, BackendTask}; +use crate::{BackendStreamingTask, BackendTask, DEFAULT_STREAM_CHANNEL_SIZE}; use futures::{Stream, StreamExt}; -use std::future::Future; +use std::{fmt::Debug, future::Future}; use tokio_stream::wrappers::ReceiverStream; impl> BackendTaskExt for T {} @@ -54,6 +54,19 @@ pub struct Map { create_next: F, } +impl Debug for Map +where + T: Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Map") + .field("first", &self.first) + // TODO: we could deduce the type name returned by the closure + .field("create_next", &"..closure..") + .finish() + } +} + impl BackendStreamingTask for Map where Bkend: Clone + Sync + Send + 'static, @@ -73,8 +86,7 @@ where ) -> impl Stream + Send + Unpin + 'static { let Map { first, create_next } = self; let backend = backend.clone(); - // TODO: Channel size - let (tx, rx) = tokio::sync::mpsc::channel(30); + let (tx, rx) = tokio::sync::mpsc::channel(DEFAULT_STREAM_CHANNEL_SIZE); tokio::task::spawn(async move { let seed = first.into_future(&backend).await; match seed { @@ -149,8 +161,7 @@ where ) -> impl Stream + Send + Unpin + 'static { let Then { first, create_next } = self; let backend = backend.clone(); - // TODO: Channel size - let (tx, rx) = tokio::sync::mpsc::channel(30); + let (tx, rx) = tokio::sync::mpsc::channel(DEFAULT_STREAM_CHANNEL_SIZE); tokio::task::spawn(async move { let seed = first.into_future(&backend).await; let mut stream = create_next(seed).into_stream(&backend); diff --git a/async-callback-manager/src/lib.rs b/async-callback-manager/src/lib.rs index 5b132fbb..91327dfe 100644 --- a/async-callback-manager/src/lib.rs +++ b/async-callback-manager/src/lib.rs @@ -1,20 +1,20 @@ use futures::Future; -use futures::FutureExt; use futures::Stream; use std::any::Any; -use tokio::sync::oneshot; mod adaptors; mod error; mod manager; -mod sender; mod task; pub use adaptors::*; pub use error::*; pub use manager::*; -pub use sender::*; -pub use task::Constraint; +pub use task::{AsyncTask, Constraint, TaskOutcome}; + +// Size of the channel used for each stream task. +// In future, this could be settable. +pub(crate) const DEFAULT_STREAM_CHANNEL_SIZE: usize = 20; pub trait BkendMap { fn map(backend: &Bkend) -> &Self; @@ -50,32 +50,3 @@ pub trait BackendStreamingTask: Send + Any { vec![] } } - -struct KillHandle(Option>); -struct KillSignal(oneshot::Receiver<()>); - -impl KillHandle { - fn kill(&mut self) -> Result<()> { - if let Some(tx) = self.0.take() { - return tx.send(()).map_err(|_| Error::ReceiverDropped); - } - Ok(()) - } -} -impl Future for KillSignal { - type Output = Result<()>; - fn poll( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - self.0.poll_unpin(cx).map_err(|_| Error::ReceiverDropped) - } -} -fn kill_channel() -> (KillHandle, KillSignal) { - let (tx, rx) = oneshot::channel(); - (KillHandle(Some(tx)), KillSignal(rx)) -} - -type DynFallibleFuture = Box> + Unpin + Send>; -type DynCallbackFn = Box; -type DynBackendTask = Box DynFallibleFuture>; diff --git a/async-callback-manager/src/manager.rs b/async-callback-manager/src/manager.rs index 6250c13c..f6e9adb0 100644 --- a/async-callback-manager/src/manager.rs +++ b/async-callback-manager/src/manager.rs @@ -1,160 +1,206 @@ use crate::{ - task::{ResponseInformation, Task, TaskFromFrontend, TaskInformation, TaskList}, - AsyncCallbackSender, + task::{ + AsyncTask, AsyncTaskKind, FutureTask, SpawnedTask, StreamTask, TaskInformation, TaskList, + TaskOutcome, TaskWaiter, + }, + Constraint, DEFAULT_STREAM_CHANNEL_SIZE, }; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use futures::{Stream, StreamExt}; +use std::{any::TypeId, future::Future, sync::Arc}; #[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub struct SenderId(usize); -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub struct TaskId(usize); +pub struct TaskId(pub(crate) u64); -type DynTaskReceivedCallback = dyn FnMut(TaskInformation); -type DynResponseReceivedCallback = dyn FnMut(ResponseInformation); +pub(crate) type DynStateMutation = + Box AsyncTask + Send>; +pub(crate) type DynMutationFuture = + Box> + Unpin + Send>; +pub(crate) type DynMutationStream = + Box> + Unpin + Send>; +pub(crate) type DynFutureTask = + Box DynMutationFuture>; +pub(crate) type DynStreamTask = + Box DynMutationStream>; -pub struct AsyncCallbackManager { - next_sender_id: usize, - next_task_id: usize, - this_sender: UnboundedSender>, - this_receiver: UnboundedReceiver>, - tasks_list: TaskList, - // TODO: Make generic instead of dynamic. - on_task_received: Box>, - on_response_received: Box, -} +pub(crate) type DynTaskSpawnCallback = dyn Fn(TaskInformation); -#[derive(Eq, PartialEq, Clone, Copy, Debug)] -pub enum ManagedEventType { - SpawnedTask, - ReceivedResponse, +pub struct AsyncCallbackManager { + next_task_id: u64, + tasks_list: TaskList, + // It could be possible to make this generic instead of dynamic, however this type would then + // require more type parameters. + on_task_spawn: Box>, } -impl ManagedEventType { - pub fn is_spawned_task(&self) -> bool { - self == &ManagedEventType::SpawnedTask - } - pub fn is_received_response(&self) -> bool { - self == &ManagedEventType::ReceivedResponse - } + +/// Temporary struct to store task details before it is added to the task list. +pub(crate) struct TempSpawnedTask { + waiter: TaskWaiter, + type_id: TypeId, + type_name: &'static str, + type_debug: Arc, } -impl Default for AsyncCallbackManager { +impl Default for AsyncCallbackManager { fn default() -> Self { Self::new() } } -impl AsyncCallbackManager { + +impl AsyncCallbackManager { /// Get a new AsyncCallbackManager. - // TODO: Consider if this should be bounded. Unbounded has been chose for now as - // it allows senders to send without blocking. pub fn new() -> Self { - let (tx, rx) = mpsc::unbounded_channel(); - AsyncCallbackManager { - next_sender_id: 0, - next_task_id: 0, - this_receiver: rx, - this_sender: tx, + Self { + next_task_id: Default::default(), tasks_list: TaskList::new(), - on_task_received: Box::new(|_| {}), - on_response_received: Box::new(|_| {}), + on_task_spawn: Box::new(|_| {}), } } - pub fn with_on_task_received_callback( + pub fn with_on_task_spawn_callback( mut self, - cb: impl FnMut(TaskInformation) + 'static, + cb: impl Fn(TaskInformation) + 'static, ) -> Self { - self.on_task_received = Box::new(cb); + self.on_task_spawn = Box::new(cb); self } - pub fn with_on_response_received_callback( - mut self, - cb: impl FnMut(ResponseInformation) + 'static, - ) -> Self { - self.on_response_received = Box::new(cb); - self - } - /// Creates a new AsyncCallbackSender that sends to this Manager. - /// Channel size refers to number of number of state mutations that can be - /// buffered from tasks. - pub fn new_sender( - &mut self, - channel_size: usize, - ) -> AsyncCallbackSender { - let (tx, rx) = mpsc::channel(channel_size); - let task_function_sender = self.this_sender.clone(); - let id = SenderId(self.next_sender_id); - let (new_id, overflowed) = self.next_sender_id.overflowing_add(1); - if overflowed { - eprintln!("WARN: SenderID has overflowed"); - } - self.next_sender_id = new_id; - AsyncCallbackSender { - id, - this_sender: tx, - this_receiver: rx, - runner_sender: task_function_sender, - } + /// Await for the next response from one of the spawned tasks, or returns + /// None if no tasks were in the list. + pub async fn get_next_response(&mut self) -> Option> { + self.tasks_list.get_next_response().await } - /// Manage the next event in the queue. - /// Combination of spawn_next_task and process_next_response. - /// Returns Some(ManagedEventType), if something was processed. - /// Returns None, if no senders or tasks exist. - pub async fn manage_next_event(&mut self, backend: &Bkend) -> Option { - tokio::select! { - Some(task) = self.this_receiver.recv() => { - self.spawn_task(backend, task); - Some(ManagedEventType::SpawnedTask) - }, - Some((response, forwarder)) = self.tasks_list.process_next_response() => { - if let Some(forwarder) = forwarder { - let _ = forwarder.await; + pub fn spawn_task(&mut self, backend: &Bkend, task: AsyncTask) + where + Frntend: 'static, + Bkend: 'static, + Md: 'static, + { + let AsyncTask { + task, + constraint, + metadata, + } = task; + match task { + AsyncTaskKind::Future(future_task) => { + let outcome = self.spawn_future_task(backend, future_task, &constraint); + self.add_task_to_list(outcome, metadata, constraint); + } + AsyncTaskKind::Stream(stream_task) => { + let outcome = self.spawn_stream_task(backend, stream_task, &constraint); + self.add_task_to_list(outcome, metadata, constraint); + } + // Don't call (self.on_task_spawn)() for NoOp. + AsyncTaskKind::Multi(tasks) => { + for task in tasks { + self.spawn_task(backend, task) } - (self.on_response_received)(response); - Some(ManagedEventType::ReceivedResponse) } - else => None + AsyncTaskKind::NoOp => (), } } - /// Spawns the next incoming task from a sender. - /// Returns Some(()), if a task was spawned. - /// Returns None, if no senders. - pub async fn spawn_next_task(&mut self, backend: &Bkend) -> Option<()> { - let task = self.this_receiver.recv().await?; - self.spawn_task(backend, task); - Some(()) - } - /// Spawns the next incoming task from a sender. - /// Returns Some(ResponseInformation), if a task was spawned. - /// Returns None, if no senders. - /// Note that the 'on_next_response' callback is not called, you're given - /// the ResponseInformation directly. - pub async fn process_next_response(&mut self) -> Option { - let (response, forwarder) = self.tasks_list.process_next_response().await?; - if let Some(forwarder) = forwarder { - let _ = forwarder.await; + fn add_task_to_list( + &mut self, + details: TempSpawnedTask, + metadata: Vec, + constraint: Option>, + ) { + let TempSpawnedTask { + waiter, + type_id, + type_name, + type_debug, + } = details; + let sp = SpawnedTask { + type_id, + task_id: TaskId(self.next_task_id), + type_name, + type_debug, + receiver: waiter, + metadata, + }; + // At one task per nanosecond, it would take 584.6 years for a library user to + // trigger overflow. + // + // https://www.wolframalpha.com/input?i=2%5E64+nanoseconds + let new_id = self + .next_task_id + .checked_add(1) + .expect("u64 shouldn't overflow!"); + self.next_task_id = new_id; + if let Some(constraint) = constraint { + self.tasks_list.handle_constraint(constraint, type_id); } - Some(response) + self.tasks_list.push(sp); } - fn spawn_task(&mut self, backend: &Bkend, task: TaskFromFrontend) { - (self.on_task_received)(task.get_information()); - if let Some(constraint) = task.constraint { - self.tasks_list - .handle_constraint(constraint, task.type_id, task.sender_id); + fn spawn_future_task( + &self, + backend: &Bkend, + future_task: FutureTask, + constraint: &Option>, + ) -> TempSpawnedTask + where + Frntend: 'static, + Bkend: 'static, + Md: 'static, + { + (self.on_task_spawn)(TaskInformation { + type_id: future_task.type_id, + type_name: future_task.type_name, + type_debug: &future_task.type_debug, + constraint, + }); + let future = (future_task.task)(backend); + let handle = tokio::spawn(future); + TempSpawnedTask { + waiter: TaskWaiter::Future(handle), + type_id: future_task.type_id, + type_name: future_task.type_name, + type_debug: Arc::new(future_task.type_debug), } - self.tasks_list.push(Task::new( - task.type_id, - task.type_name, - task.metadata, - task.receiver, - task.sender_id, - TaskId(self.next_task_id), - task.kill_handle, - )); - let (new_id, overflowed) = self.next_task_id.overflowing_add(1); - if overflowed { - eprintln!("WARN: TaskID has overflowed"); + } + fn spawn_stream_task( + &self, + backend: &Bkend, + stream_task: StreamTask, + constraint: &Option>, + ) -> TempSpawnedTask + where + Frntend: 'static, + Bkend: 'static, + Md: 'static, + { + let StreamTask { + task, + type_id, + type_name, + type_debug, + } = stream_task; + (self.on_task_spawn)(TaskInformation { + type_id, + type_name, + type_debug: &type_debug, + constraint, + }); + let mut stream = task(backend); + let (tx, rx) = tokio::sync::mpsc::channel(DEFAULT_STREAM_CHANNEL_SIZE); + let abort_handle = tokio::spawn(async move { + loop { + if let Some(mutation) = stream.next().await { + // Error could occur here if receiver is dropped. + // Doesn't seem to be a big deal to ignore this error. + let _ = tx.send(mutation).await; + continue; + } + return; + } + }) + .abort_handle(); + TempSpawnedTask { + waiter: TaskWaiter::Stream { + receiver: rx, + abort_handle, + }, + type_id, + type_name, + type_debug: Arc::new(type_debug), } - self.next_task_id = new_id; - let fut = (task.task)(backend); - tokio::spawn(fut); } } diff --git a/async-callback-manager/src/sender.rs b/async-callback-manager/src/sender.rs deleted file mode 100644 index dc87777a..00000000 --- a/async-callback-manager/src/sender.rs +++ /dev/null @@ -1,243 +0,0 @@ -use crate::{ - kill_channel, - task::{Constraint, TaskFromFrontend, TaskReceiver}, - BackendStreamingTask, BackendTask, DynCallbackFn, DynFallibleFuture, Error, KillHandle, - KillSignal, Result, SenderId, -}; -use futures::{FutureExt, StreamExt}; -use std::{ - any::{Any, TypeId}, - future::Future, -}; -use tokio::sync::{ - mpsc::{self, Receiver, Sender, UnboundedSender}, - oneshot, -}; - -pub struct AsyncCallbackSender { - pub(crate) id: SenderId, - pub(crate) this_sender: Sender>, - pub(crate) this_receiver: Receiver>, - pub(crate) runner_sender: UnboundedSender>, -} - -/// A set of state mutations, that can be applied to a Frntend. -pub struct StateMutationBundle { - mutation_list: Vec>, -} -impl StateMutationBundle { - pub fn map( - self, - mut nf: impl FnMut(&mut NewFrntend) -> &mut Frntend + Send + Copy + 'static, - ) -> StateMutationBundle { - let Self { mutation_list } = self; - let mutation_list: Vec> = mutation_list - .into_iter() - .map(|m| { - let closure = move |x: &mut NewFrntend| m(nf(x)); - Box::new(closure) as DynCallbackFn - }) - .collect(); - StateMutationBundle { mutation_list } - } -} -impl StateMutationBundle { - pub fn apply(self, frontend: &mut Frntend) { - self.mutation_list - .into_iter() - .for_each(|mutation| mutation(frontend)); - } -} - -impl AsyncCallbackSender { - pub async fn get_next_mutations( - &mut self, - max_mutations: usize, - ) -> StateMutationBundle { - let mut mutation_list = Vec::new(); - self.this_receiver - .recv_many(&mut mutation_list, max_mutations) - .await; - StateMutationBundle { mutation_list } - } - /// # Errors - /// This will return an error if the manager has been dropped. - pub fn add_stream_callback( - &self, - request: R, - // TODO: Relax Clone bounds if possible. - handler: impl FnOnce(&mut Frntend, R::Output) + Send + Clone + 'static, - constraint: Option>, - ) -> Result<()> - where - R: BackendStreamingTask + 'static, - Bkend: Send + 'static, - Frntend: 'static, - { - // TODO: channel size - let (tx, rx) = mpsc::channel(50); - let (kill_tx, kill_rx) = kill_channel(); - let completed_task_sender = self.this_sender.clone(); - let func = move |backend: &Bkend| { - Box::new( - stream_request_func( - request, - backend, - handler, - completed_task_sender, - tx, - kill_rx, - ) - .boxed(), - ) as DynFallibleFuture - }; - self.send_task::(func, R::metadata(), rx, constraint, kill_tx) - } - /// # Errors - /// This will return an error if the manager has been dropped. - pub fn add_callback( - &self, - request: R, - handler: impl FnOnce(&mut Frntend, R::Output) + Send + 'static, - constraint: Option>, - ) -> Result<()> - where - R: BackendTask + 'static, - Bkend: Send + 'static, - Frntend: 'static, - { - let (tx, rx) = oneshot::channel(); - let (kill_tx, kill_rx) = kill_channel(); - let completed_task_sender = self.this_sender.clone(); - let func = move |backend: &Bkend| { - Box::new( - request_func( - request, - backend, - handler, - completed_task_sender, - tx, - kill_rx, - ) - .boxed(), - ) as DynFallibleFuture - }; - self.send_task::(func, R::metadata(), rx, constraint, kill_tx) - } - /// # Errors - /// This will return an error if the manager has been dropped. - fn send_task( - &self, - func: impl FnOnce(&Bkend) -> DynFallibleFuture + 'static, - metadata: Vec, - rx: impl Into, - constraint: Option>, - kill_handle: KillHandle, - ) -> Result<()> { - self.runner_sender - .send(TaskFromFrontend::new( - TypeId::of::(), - std::any::type_name::(), - metadata, - func, - rx, - self.id, - constraint, - kill_handle, - )) - .map_err(|_| Error::ReceiverDropped) - } -} - -fn stream_request_func( - request: R, - backend: &Bkend, - handler: H, - sender: mpsc::Sender>, - forwarder: mpsc::Sender, - kill_signal: KillSignal, -) -> impl Future> -where - H: FnOnce(&mut Frntend, R::Output) + Send + Clone + 'static, - R: BackendStreamingTask + 'static, - Bkend: Send + 'static, - Frntend: 'static, -{ - let future_stream_tasks = request - .into_stream(backend) - .then(move |output| { - process_stream_item(output, handler.clone(), sender.clone(), forwarder.clone()) - }) - .collect::>(); - async move { - tokio::select! { - _ = future_stream_tasks => Ok(()), - Ok(()) = kill_signal => Ok(()), - } - } - .boxed() -} - -async fn process_stream_item( - output: O, - handler: H, - sender: mpsc::Sender>, - forwarder: mpsc::Sender, -) -> Result<()> -where - O: Send + 'static, - H: FnOnce(&mut Frntend, O) + Send + Clone + 'static, - Frntend: 'static, -{ - let handler = handler.clone(); - let sender = sender.clone(); - let callback = move |frontend: &mut Frntend| handler(frontend, output); - let forward_message_task = forward_message_task(callback, sender).boxed(); - if forwarder - .send(Box::new(forward_message_task)) - .await - .is_err() - { - return Err(Error::ReceiverDropped); - } - Ok(()) -} - -fn request_func( - request: R, - backend: &Bkend, - handler: H, - sender: mpsc::Sender>, - forwarder: oneshot::Sender, - kill_signal: KillSignal, -) -> impl Future> + Send + 'static -where - H: FnOnce(&mut Frntend, R::Output) + Send + 'static, - R: BackendTask + 'static, - Bkend: Send + 'static, - Frntend: 'static, -{ - let fut = request.into_future(backend); - async move { - let output = tokio::select! { - output = fut => output, - Ok(()) = kill_signal => return Ok(()), - }; - let callback = |frontend: &mut Frntend| handler(frontend, output); - let forward_message_task = forward_message_task(callback, sender).boxed(); - forwarder - .send(Box::new(forward_message_task)) - .map_err(|_| Error::ReceiverDropped) - } - .boxed() -} - -async fn forward_message_task( - callback: impl FnOnce(&mut Frntend) + Send + 'static, - sender: mpsc::Sender>, -) -> Result<()> { - sender - .send(Box::new(callback)) - .await - .map_err(|_| Error::ReceiverDropped) -} diff --git a/async-callback-manager/src/task.rs b/async-callback-manager/src/task.rs index c5710294..21f7e08e 100644 --- a/async-callback-manager/src/task.rs +++ b/async-callback-manager/src/task.rs @@ -1,50 +1,360 @@ -use crate::{DynBackendTask, DynFallibleFuture, KillHandle, SenderId, TaskId}; -use futures::{stream::FuturesUnordered, StreamExt}; -use std::any::TypeId; -use tokio::sync::{mpsc, oneshot}; +use crate::{ + BackendStreamingTask, BackendTask, DynFutureTask, DynMutationFuture, DynMutationStream, + DynStateMutation, DynStreamTask, TaskId, +}; +use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; +use std::{ + any::{type_name, TypeId}, + fmt::Debug, + sync::Arc, +}; +use tokio::{ + sync::mpsc, + task::{AbortHandle, JoinError, JoinHandle}, +}; -pub(crate) struct TaskList { - pub inner: Vec>, +/// An asynchrnonous task that can generate state mutations and/or more tasks to +/// be spawned by an AsyncCallbackManager. +#[must_use = "AsyncTasks do nothing unless you run them"] +pub struct AsyncTask { + pub(crate) task: AsyncTaskKind, + pub(crate) constraint: Option>, + pub(crate) metadata: Vec, } -// User visible struct for introspection. -#[derive(Debug, Clone)] -pub struct ResponseInformation { - pub type_id: TypeId, - pub type_name: &'static str, - pub sender_id: SenderId, - pub task_id: TaskId, - pub task_is_now_finished: bool, +pub(crate) enum AsyncTaskKind { + Future(FutureTask), + Stream(StreamTask), + Multi(Vec>), + NoOp, } -// User visible struct for introspection. -#[derive(Debug, Clone)] -pub struct TaskInformation<'a, Cstrnt> { - pub type_id: TypeId, - pub type_name: &'static str, - pub sender_id: SenderId, - pub constraint: &'a Option>, +pub(crate) struct StreamTask { + pub(crate) task: DynStreamTask, + pub(crate) type_id: TypeId, + pub(crate) type_name: &'static str, + pub(crate) type_debug: String, } -pub(crate) struct TaskFromFrontend { +pub(crate) struct FutureTask { + pub(crate) task: DynFutureTask, pub(crate) type_id: TypeId, pub(crate) type_name: &'static str, - pub(crate) metadata: Vec, - pub(crate) task: DynBackendTask, - pub(crate) receiver: TaskReceiver, - pub(crate) sender_id: SenderId, - pub(crate) constraint: Option>, - pub(crate) kill_handle: KillHandle, + pub(crate) type_debug: String, +} + +impl FromIterator> + for AsyncTask +{ + fn from_iter>>(iter: T) -> Self { + let v = iter.into_iter().collect(); + // TODO: Better handle constraints / metadata. + AsyncTask { + task: AsyncTaskKind::Multi(v), + constraint: None, + metadata: vec![], + } + } +} + +impl AsyncTask { + pub fn push(self, next: AsyncTask) -> AsyncTask { + match self.task { + AsyncTaskKind::Future(_) | AsyncTaskKind::Stream(_) => { + let v = vec![self, next]; + AsyncTask { + task: AsyncTaskKind::Multi(v), + constraint: None, + metadata: vec![], + } + } + AsyncTaskKind::Multi(mut m) => { + m.push(next); + AsyncTask { + task: AsyncTaskKind::Multi(m), + constraint: self.constraint, + metadata: self.metadata, + } + } + AsyncTaskKind::NoOp => next, + } + } + pub fn new_no_op() -> AsyncTask { + Self { + task: AsyncTaskKind::NoOp, + constraint: None, + metadata: vec![], + } + } + pub fn new_future( + request: R, + handler: impl FnOnce(&mut Frntend, R::Output) + Send + 'static, + constraint: Option>, + ) -> AsyncTask + where + R: BackendTask + Debug + 'static, + Bkend: 'static, + Frntend: 'static, + { + let metadata = R::metadata(); + let type_id = request.type_id(); + let type_name = type_name::(); + let type_debug = format!("{:?}", request); + let task = Box::new(move |b: &Bkend| { + Box::new({ + let future = request.into_future(b); + Box::pin(async move { + let output = future.await; + Box::new(move |frontend: &mut Frntend| { + handler(frontend, output); + AsyncTask::new_no_op() + }) as DynStateMutation + }) + }) as DynMutationFuture + }) as DynFutureTask; + let task = FutureTask { + task, + type_id, + type_name, + type_debug, + }; + AsyncTask { + task: AsyncTaskKind::Future(task), + constraint, + metadata, + } + } + pub fn new_future_chained( + request: R, + handler: impl FnOnce(&mut Frntend, R::Output) -> AsyncTask + Send + 'static, + constraint: Option>, + ) -> AsyncTask + where + R: BackendTask + Debug + 'static, + Bkend: 'static, + Frntend: 'static, + { + let metadata = R::metadata(); + let type_id = request.type_id(); + let type_name = type_name::(); + let type_debug = format!("{:?}", request); + let task = Box::new(move |b: &Bkend| { + Box::new({ + let future = request.into_future(b); + Box::pin(async move { + let output = future.await; + Box::new(move |frontend: &mut Frntend| handler(frontend, output)) + as DynStateMutation + }) + }) as DynMutationFuture + }) as DynFutureTask; + let task = FutureTask { + task, + type_id, + type_name, + type_debug, + }; + AsyncTask { + task: AsyncTaskKind::Future(task), + constraint, + metadata, + } + } + pub fn new_stream( + request: R, + // TODO: Review Clone bounds. + handler: impl FnOnce(&mut Frntend, R::Output) + Send + Clone + 'static, + constraint: Option>, + ) -> AsyncTask + where + R: BackendStreamingTask + Debug + 'static, + Bkend: 'static, + Frntend: 'static, + { + let metadata = R::metadata(); + let type_id = request.type_id(); + let type_name = type_name::(); + let type_debug = format!("{:?}", request); + let task = Box::new(move |b: &Bkend| { + let stream = request.into_stream(b); + Box::new({ + stream.map(move |output| { + Box::new({ + let handler = handler.clone(); + move |frontend: &mut Frntend| { + handler.clone()(frontend, output); + AsyncTask::new_no_op() + } + }) as DynStateMutation + }) + }) as DynMutationStream + }) as DynStreamTask; + let task = StreamTask { + task, + type_id, + type_name, + type_debug, + }; + AsyncTask { + task: AsyncTaskKind::Stream(task), + constraint, + metadata, + } + } + pub fn new_stream_chained( + request: R, + // TODO: Review Clone bounds. + handler: impl FnOnce(&mut Frntend, R::Output) -> AsyncTask + + Send + + Clone + + 'static, + constraint: Option>, + ) -> AsyncTask + where + R: BackendStreamingTask + Debug + 'static, + Bkend: 'static, + Frntend: 'static, + { + let metadata = R::metadata(); + let type_id = request.type_id(); + let type_name = type_name::(); + let type_debug = format!("{:?}", request); + let task = Box::new(move |b: &Bkend| { + let stream = request.into_stream(b); + Box::new({ + stream.map(move |output| { + Box::new({ + let handler = handler.clone(); + move |frontend: &mut Frntend| handler.clone()(frontend, output) + }) as DynStateMutation + }) + }) as DynMutationStream + }) as DynStreamTask; + let task = StreamTask { + task, + type_id, + type_name, + type_debug, + }; + AsyncTask { + task: AsyncTaskKind::Stream(task), + constraint, + metadata, + } + } + /// # Warning + /// This is recursive, if you have set up a cycle of AsyncTasks, map may + /// overflow. + pub fn map( + self, + f: impl Fn(&mut NewFrntend) -> &mut Frntend + Clone + Send + 'static, + ) -> AsyncTask + where + Bkend: 'static, + Frntend: 'static, + Md: 'static, + { + let Self { + task, + constraint, + metadata, + } = self; + match task { + AsyncTaskKind::Future(FutureTask { + task, + type_id, + type_name, + type_debug, + }) => { + let task = Box::new(|b: &Bkend| { + Box::new(task(b).map(|task| { + Box::new(|nf: &mut NewFrntend| { + let task = task(f(nf)); + task.map(f) + }) as DynStateMutation + })) as DynMutationFuture + }) as DynFutureTask; + let task = FutureTask { + task, + type_id, + type_name, + type_debug, + }; + AsyncTask { + task: AsyncTaskKind::Future(task), + constraint, + metadata, + } + } + AsyncTaskKind::Stream(StreamTask { + task, + type_id, + type_name, + type_debug, + }) => { + let task = Box::new(|b: &Bkend| { + Box::new({ + task(b).map(move |task| { + Box::new({ + let f = f.clone(); + move |nf: &mut NewFrntend| { + let task = task(f(nf)); + task.map(f.clone()) + } + }) + as DynStateMutation + }) + }) as DynMutationStream + }) as DynStreamTask; + let stream_task = StreamTask { + task, + type_id, + type_name, + type_debug, + }; + AsyncTask { + task: AsyncTaskKind::Stream(stream_task), + constraint, + metadata, + } + } + AsyncTaskKind::NoOp => AsyncTask { + task: AsyncTaskKind::NoOp, + constraint, + metadata, + }, + AsyncTaskKind::Multi(v) => { + let mapped = v.into_iter().map(|task| task.map(f.clone())).collect(); + AsyncTask { + task: AsyncTaskKind::Multi(mapped), + constraint, + metadata, + } + } + } + } } -pub(crate) struct Task { +pub(crate) struct TaskList { + pub inner: Vec>, +} + +pub(crate) struct SpawnedTask { pub(crate) type_id: TypeId, pub(crate) type_name: &'static str, - pub(crate) receiver: TaskReceiver, - pub(crate) sender_id: SenderId, + pub(crate) type_debug: Arc, + pub(crate) receiver: TaskWaiter, pub(crate) task_id: TaskId, - pub(crate) kill_handle: KillHandle, - pub(crate) metadata: Vec, + pub(crate) metadata: Vec, +} + +/// User visible struct for introspection. +#[derive(Debug, Clone)] +pub struct TaskInformation<'a, Cstrnt> { + pub type_id: TypeId, + pub type_name: &'static str, + pub type_debug: &'a str, + pub constraint: &'a Option>, } #[derive(Eq, PartialEq, Debug)] @@ -59,115 +369,126 @@ pub enum ConstraitType { BlockMatchingMetatdata(Cstrnt), } -pub(crate) enum TaskReceiver { - Future(oneshot::Receiver), - Stream(mpsc::Receiver), +pub(crate) enum TaskWaiter { + Future(JoinHandle>), + Stream { + receiver: mpsc::Receiver>, + abort_handle: AbortHandle, + }, } -impl From> for TaskReceiver { - fn from(value: oneshot::Receiver) -> Self { - Self::Future(value) + +impl TaskWaiter { + fn kill(&mut self) { + match self { + TaskWaiter::Future(handle) => handle.abort(), + TaskWaiter::Stream { + abort_handle: abort, + .. + } => abort.abort(), + } } } -impl From> for TaskReceiver { - fn from(value: mpsc::Receiver) -> Self { - Self::Stream(value) - } + +pub enum TaskOutcome { + /// No task was recieved because a stream closed, but there are still more + /// tasks. + StreamClosed, + /// No task was recieved because the next task panicked. + /// Currently only applicable to Future type tasks. + // TODO: Implement for Stream type tasks. + TaskPanicked { + error: JoinError, + type_id: TypeId, + type_name: &'static str, + type_debug: Arc, + task_id: TaskId, + }, + /// Mutation was received from a task. + MutationReceived { + mutation: DynStateMutation, + type_id: TypeId, + type_name: &'static str, + type_debug: Arc, + task_id: TaskId, + }, } -impl TaskList { +impl TaskList { pub(crate) fn new() -> Self { Self { inner: vec![] } } - /// Returns Some(ResponseInformation, Option) if a task - /// existed in the list, and it was processed. Returns None, if no tasks - /// were in the list. The DynFallibleFuture represents a future that - /// forwards messages from the manager back to the sender. - pub(crate) async fn process_next_response( - &mut self, - ) -> Option<(ResponseInformation, Option)> { + /// Await for the next response from one of the spawned tasks. + pub(crate) async fn get_next_response(&mut self) -> Option> { let task_completed = self .inner .iter_mut() .enumerate() .map(|(idx, task)| async move { match task.receiver { - TaskReceiver::Future(ref mut receiver) => { - if let Ok(forwarder) = receiver.await { - return ( - Some(idx), - Some(forwarder), - task.type_id, - task.type_name, - task.sender_id, - task.task_id, - ); - } - ( + TaskWaiter::Future(ref mut receiver) => match receiver.await { + Ok(mutation) => ( Some(idx), - None, - task.type_id, - task.type_name, - task.sender_id, - task.task_id, - ) - } - TaskReceiver::Stream(ref mut receiver) => { - if let Some(forwarder) = receiver.recv().await { + TaskOutcome::MutationReceived { + mutation, + type_id: task.type_id, + type_debug: task.type_debug.clone(), + task_id: task.task_id, + type_name: task.type_name, + }, + ), + Err(error) => ( + Some(idx), + TaskOutcome::TaskPanicked { + type_id: task.type_id, + type_name: task.type_name, + type_debug: task.type_debug.clone(), + task_id: task.task_id, + error, + }, + ), + }, + TaskWaiter::Stream { + ref mut receiver, .. + } => { + if let Some(mutation) = receiver.recv().await { return ( None, - Some(forwarder), - task.type_id, - task.type_name, - task.sender_id, - task.task_id, + TaskOutcome::MutationReceived { + mutation, + type_id: task.type_id, + type_name: task.type_name, + task_id: task.task_id, + type_debug: task.type_debug.clone(), + }, ); } - ( - Some(idx), - None, - task.type_id, - task.type_name, - task.sender_id, - task.task_id, - ) + (Some(idx), TaskOutcome::StreamClosed) } } }) .collect::>() .next() .await; - let (maybe_completed_id, maybe_forwarder, type_id, type_name, sender_id, task_id) = - task_completed?; - if let Some(task_completed) = maybe_completed_id { - // Safe - this value is in range as produced from enumerate on original list. - self.inner.swap_remove(task_completed); - } - Some(( - ResponseInformation { - type_id, - type_name, - sender_id, - task_id, - task_is_now_finished: maybe_completed_id.is_some(), - }, - maybe_forwarder, - )) + let (maybe_completed_id, outcome) = task_completed?; + if let Some(completed_id) = maybe_completed_id { + // Safe - this value is in range as produced from enumerate on + // original list. + self.inner.swap_remove(completed_id); + }; + Some(outcome) } - pub(crate) fn push(&mut self, task: Task) { + pub(crate) fn push(&mut self, task: SpawnedTask) { self.inner.push(task) } // TODO: Tests - pub(crate) fn handle_constraint( - &mut self, - constraint: Constraint, - type_id: TypeId, - sender_id: SenderId, - ) { + pub(crate) fn handle_constraint(&mut self, constraint: Constraint, type_id: TypeId) { + // TODO: Consider the situation where one component kills tasks belonging to + // another component. + // // Assuming here that kill implies block also. - let task_doesnt_match_constraint = - |task: &Task<_>| (task.type_id != type_id) || (task.sender_id != sender_id); + let task_doesnt_match_constraint = |task: &SpawnedTask<_, _, _>| (task.type_id != type_id); let task_doesnt_match_metadata = - |task: &Task<_>, constraint| !task.metadata.contains(constraint); + |task: &SpawnedTask<_, _, _>, constraint| !task.metadata.contains(constraint); match constraint.constraint_type { ConstraitType::BlockMatchingMetatdata(metadata) => self .inner @@ -177,7 +498,7 @@ impl TaskList { } ConstraitType::KillSameType => self.inner.retain_mut(|task| { if !task_doesnt_match_constraint(task) { - task.kill_handle.kill().expect("Task should still be alive"); + task.receiver.kill(); return false; } true @@ -186,61 +507,6 @@ impl TaskList { } } -impl TaskFromFrontend { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - type_id: TypeId, - type_name: &'static str, - metadata: Vec, - task: impl FnOnce(&Bkend) -> DynFallibleFuture + 'static, - receiver: impl Into, - sender_id: SenderId, - constraint: Option>, - kill_handle: KillHandle, - ) -> Self { - Self { - type_id, - type_name, - metadata, - task: Box::new(task), - receiver: receiver.into(), - sender_id, - constraint, - kill_handle, - } - } - pub(crate) fn get_information(&self) -> TaskInformation<'_, Cstrnt> { - TaskInformation { - type_id: self.type_id, - type_name: self.type_name, - sender_id: self.sender_id, - constraint: &self.constraint, - } - } -} - -impl Task { - pub(crate) fn new( - type_id: TypeId, - type_name: &'static str, - metadata: Vec, - receiver: TaskReceiver, - sender_id: SenderId, - task_id: TaskId, - kill_handle: KillHandle, - ) -> Self { - Self { - type_id, - type_name, - receiver, - sender_id, - kill_handle, - task_id, - metadata, - } - } -} - impl Constraint { pub fn new_block_same_type() -> Self { Self { @@ -258,3 +524,66 @@ impl Constraint { } } } + +#[cfg(test)] +mod tests { + use futures::StreamExt; + + use crate::{AsyncTask, BackendStreamingTask, BackendTask}; + #[derive(Debug)] + struct Task1; + #[derive(Debug)] + struct Task2; + #[derive(Debug)] + struct StreamingTask; + impl BackendTask<()> for Task1 { + type Output = (); + type MetadataType = (); + #[allow(clippy::manual_async_fn)] + fn into_future( + self, + _: &(), + ) -> impl std::future::Future + Send + 'static { + async {} + } + } + impl BackendTask<()> for Task2 { + type Output = (); + type MetadataType = (); + #[allow(clippy::manual_async_fn)] + fn into_future( + self, + _: &(), + ) -> impl std::future::Future + Send + 'static { + async {} + } + } + impl BackendStreamingTask<()> for StreamingTask { + type Output = (); + type MetadataType = (); + fn into_stream( + self, + _: &(), + ) -> impl futures::Stream + Send + Unpin + 'static { + futures::stream::once(async move {}).boxed() + } + } + #[tokio::test] + async fn test_recursive_map() { + let recursive_task = AsyncTask::new_stream_chained( + StreamingTask, + |_: &mut (), _| { + AsyncTask::new_future_chained( + Task1, + |_: &mut (), _| AsyncTask::new_future(Task2, |_: &mut (), _| {}, None), + None, + ) + }, + None, + ); + // Here, it's expected that this is succesful. + // TODO: Run the task for an expected outcome. + #[allow(unused_must_use)] + let _ = recursive_task.map(|tmp: &mut ()| tmp); + } +} diff --git a/async-callback-manager/tests/integration_tests.rs b/async-callback-manager/tests/integration_tests.rs index 66c89eaa..90eadb40 100644 --- a/async-callback-manager/tests/integration_tests.rs +++ b/async-callback-manager/tests/integration_tests.rs @@ -1,17 +1,19 @@ //! Integration tests for async-callback-manager. use async_callback_manager::{ - AsyncCallbackManager, AsyncCallbackSender, BackendStreamingTask, BackendTask, Constraint, + AsyncCallbackManager, AsyncTask, BackendStreamingTask, BackendTask, Constraint, }; use futures::{FutureExt, StreamExt}; use std::{future::Future, sync::Arc, time::Duration}; use tokio::sync::Mutex; -const DEFAULT_CHANNEL_SIZE: usize = 10; - +#[derive(Debug)] struct TextTask(String); +#[derive(Debug)] struct DelayedBackendMutatingRequest(String); +#[derive(Debug)] struct StreamingCounterTask(usize); +#[derive(Debug)] struct DelayedBackendMutatingStreamingCounterTask(usize); #[derive(Default)] struct MockMutatingBackend { @@ -79,22 +81,25 @@ impl BackendStreamingTask>> } } -fn init() -> ( - AsyncCallbackManager, - AsyncCallbackSender, -) { - let mut manager = async_callback_manager::AsyncCallbackManager::new(); - let sender = manager.new_sender(DEFAULT_CHANNEL_SIZE); - (manager, sender) -} - -async fn drain_manager( - mut manager: AsyncCallbackManager, - _: Bkend, -) { +async fn drain_manager( + mut manager: AsyncCallbackManager, + s: &mut Frntend, + b: &Bkend, +) where + Md: PartialEq + 'static, + Frntend: 'static, + Bkend: Clone + 'static, +{ loop { - if manager.process_next_response().await.is_none() { + let Some(resp) = manager.get_next_response().await else { return; + }; + match resp { + async_callback_manager::TaskOutcome::StreamClosed => continue, + async_callback_manager::TaskOutcome::MutationReceived { mutation, .. } => { + manager.spawn_task(b, mutation(s)) + } + async_callback_manager::TaskOutcome::TaskPanicked { .. } => panic!(), } } } @@ -102,48 +107,34 @@ async fn drain_manager( #[tokio::test] async fn test_mutate_once() { let mut state = String::new(); - let (mut manager, mut state_receiver) = init(); - state_receiver - .add_callback( - TextTask("Hello from the future".to_string()), - |state, new| *state = new, - None, - ) - .unwrap(); - manager.spawn_next_task(&()).await; - drain_manager(manager, ()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_future( + TextTask("Hello from the future".to_string()), + |state, new| *state = new, + None, + ); + manager.spawn_task(&(), task); + drain_manager(manager, &mut state, &()).await; assert_eq!(state, "Hello from the future".to_string()); } #[tokio::test] async fn test_mutate_twice() { let mut state = Vec::new(); - let (mut manager, mut state_receiver) = init(); - state_receiver - .add_callback( - TextTask("Message 1".to_string()), - |state: &mut Vec<_>, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&()).await; - state_receiver - .add_callback( - TextTask("Message 2".to_string()), - |state, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&()).await; - drain_manager(manager, ()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_future( + TextTask("Message 1".to_string()), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&(), task); + let task = AsyncTask::new_future( + TextTask("Message 2".to_string()), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&(), task); + drain_manager(manager, &mut state, &()).await; assert_eq!( state, vec!["Message 1".to_string(), "Message 2".to_string()] @@ -153,20 +144,14 @@ async fn test_mutate_twice() { #[tokio::test] async fn test_mutate_stream() { let mut state = Vec::new(); - let (mut manager, mut state_receiver) = init(); - state_receiver - .add_stream_callback( - StreamingCounterTask(10), - |state: &mut Vec<_>, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&()).await; - drain_manager(manager, ()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_stream( + StreamingCounterTask(10), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&(), task); + drain_manager(manager, &mut state, &()).await; assert_eq!(state, (0..10).collect::>()); } @@ -174,28 +159,20 @@ async fn test_mutate_stream() { async fn test_mutate_stream_twice() { let backend = Arc::new(Mutex::new(MockMutatingBackend::default())); let mut state = Vec::new(); - let (mut manager, mut state_receiver) = init(); - state_receiver - .add_stream_callback( - DelayedBackendMutatingStreamingCounterTask(5), - |state: &mut Vec<_>, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - state_receiver - .add_stream_callback( - DelayedBackendMutatingStreamingCounterTask(5), - |state: &mut Vec<_>, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - drain_manager(manager, backend).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_stream( + DelayedBackendMutatingStreamingCounterTask(5), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&backend, task); + let task = AsyncTask::new_stream( + DelayedBackendMutatingStreamingCounterTask(5), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&backend, task); + drain_manager(manager, &mut state, &backend).await; // Streams should be interleaved assert_ne!(state, vec![0, 1, 2, 3, 4, 0, 1, 2, 3, 4]); // And should contain all values @@ -207,28 +184,20 @@ async fn test_mutate_stream_twice() { async fn test_block_constraint() { let backend = Arc::new(Mutex::new(MockMutatingBackend::default())); let mut state = vec![]; - let (mut manager, mut state_receiver) = init::<_, Vec<_>, _>(); - state_receiver - .add_callback( - DelayedBackendMutatingRequest("This message should get blocked!".to_string()), - |state, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - state_receiver - .add_callback( - DelayedBackendMutatingRequest("Message 2".to_string()), - |state, new| state.push(new), - Some(Constraint::new_block_same_type()), - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - drain_manager(manager, backend.clone()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_future( + DelayedBackendMutatingRequest("This message should get blocked!".to_string()), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&backend, task); + let task = AsyncTask::new_future( + DelayedBackendMutatingRequest("Message 2".to_string()), + |state: &mut Vec<_>, new| state.push(new), + Some(Constraint::new_block_same_type()), + ); + manager.spawn_task(&backend, task); + drain_manager(manager, &mut state, &backend).await; let backend_counter = backend.lock().await.msgs_recvd; assert_eq!(state, vec!["Message 2".to_string()]); assert_eq!(backend_counter, 2) @@ -238,28 +207,20 @@ async fn test_block_constraint() { async fn test_kill_constraint() { let mut state = vec![]; let backend = Arc::new(Mutex::new(MockMutatingBackend::default())); - let (mut manager, mut state_receiver) = init::<_, Vec<_>, _>(); - state_receiver - .add_callback( - DelayedBackendMutatingRequest("This message should get killed!".to_string()), - |state, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - state_receiver - .add_callback( - DelayedBackendMutatingRequest("Message 2".to_string()), - |state, new| state.push(new), - Some(Constraint::new_kill_same_type()), - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - drain_manager(manager, backend.clone()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_future( + DelayedBackendMutatingRequest("This message should get killed!".to_string()), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&backend, task); + let task = AsyncTask::new_future( + DelayedBackendMutatingRequest("Message 2".to_string()), + |state: &mut Vec<_>, new| state.push(new), + Some(Constraint::new_kill_same_type()), + ); + manager.spawn_task(&backend, task); + drain_manager(manager, &mut state, &backend).await; let backend_counter = backend.lock().await.msgs_recvd; assert_eq!(state, vec!["Message 2".to_string()]); assert_eq!(backend_counter, 1) @@ -269,28 +230,20 @@ async fn test_kill_constraint() { async fn test_block_constraint_stream() { let backend = Arc::new(Mutex::new(MockMutatingBackend::default())); let mut state = vec![]; - let (mut manager, mut state_receiver) = init::<_, Vec<_>, _>(); - state_receiver - .add_stream_callback( - DelayedBackendMutatingStreamingCounterTask(5), - |state, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - state_receiver - .add_stream_callback( - DelayedBackendMutatingStreamingCounterTask(5), - |state, new| state.push(new), - Some(Constraint::new_block_same_type()), - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - drain_manager(manager, backend.clone()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_stream( + DelayedBackendMutatingStreamingCounterTask(5), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&backend, task); + let task = AsyncTask::new_stream( + DelayedBackendMutatingStreamingCounterTask(5), + |state: &mut Vec<_>, new| state.push(new), + Some(Constraint::new_block_same_type()), + ); + manager.spawn_task(&backend, task); + drain_manager(manager, &mut state, &backend).await; let backend_counter = backend.lock().await.msgs_recvd; assert_eq!(state, vec![0, 1, 2, 3, 4]); assert_eq!(backend_counter, 10) @@ -300,74 +253,58 @@ async fn test_block_constraint_stream() { async fn test_kill_constraint_stream() { let backend = Arc::new(Mutex::new(MockMutatingBackend::default())); let mut state = vec![]; - let (mut manager, mut state_receiver) = init::<_, Vec<_>, _>(); - state_receiver - .add_stream_callback( - DelayedBackendMutatingStreamingCounterTask(5), - |state, new| state.push(new), - None, - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - state_receiver - .add_stream_callback( - DelayedBackendMutatingStreamingCounterTask(5), - |state, new| state.push(new), - Some(Constraint::new_kill_same_type()), - ) - .unwrap(); - manager.spawn_next_task(&backend).await; - drain_manager(manager, backend.clone()).await; - state_receiver - .get_next_mutations(50) - .await - .apply(&mut state); + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_stream( + DelayedBackendMutatingStreamingCounterTask(5), + |state: &mut Vec<_>, new| state.push(new), + None, + ); + manager.spawn_task(&backend, task); + let task = AsyncTask::new_stream( + DelayedBackendMutatingStreamingCounterTask(5), + |state: &mut Vec<_>, new| state.push(new), + Some(Constraint::new_kill_same_type()), + ); + manager.spawn_task(&backend, task); + drain_manager(manager, &mut state, &backend).await; let backend_counter = backend.lock().await.msgs_recvd; assert_eq!(state, vec![0, 1, 2, 3, 4]); assert_eq!(backend_counter, 5) } #[tokio::test] -async fn test_task_received_callback() { - let (manager, state_receiver) = init::<(), (), _>(); +async fn test_task_spawn_callback() { let task_received = Arc::new(std::sync::Mutex::new(false)); let task_received_clone = task_received.clone(); - let mut manager = manager.with_on_task_received_callback(move |resp| { - eprintln!("Response {:?} received", resp); + let mut manager = AsyncCallbackManager::new().with_on_task_spawn_callback(move |_| { *task_received_clone.lock().unwrap() = true; }); - state_receiver - .add_callback( - TextTask("Hello from the future".to_string()), - |_, _| {}, - None, - ) - .unwrap(); - manager.manage_next_event(&()).await.unwrap(); + let task = AsyncTask::new_future( + TextTask("Hello from the future".to_string()), + |_: &mut (), _| {}, + None, + ); + manager.spawn_task(&(), task); assert!(*task_received.lock().unwrap()); } #[tokio::test] -async fn test_response_received_callback() { - let (manager, state_receiver) = init::<(), (), _>(); - let response_received = Arc::new(std::sync::Mutex::new(false)); - let response_received_clone = response_received.clone(); - let task_is_now_finished = Arc::new(std::sync::Mutex::new(false)); - let task_is_now_finished_clone = task_is_now_finished.clone(); - let mut manager = manager.with_on_response_received_callback(move |resp| { - eprintln!("Response {:?} received", resp); - *response_received_clone.lock().unwrap() = true; - *task_is_now_finished_clone.lock().unwrap() = resp.task_is_now_finished; - }); - state_receiver - .add_callback( - TextTask("Hello from the future".to_string()), - |_, _| {}, - None, - ) - .unwrap(); - manager.manage_next_event(&()).await.unwrap(); - manager.manage_next_event(&()).await.unwrap(); - assert!(*response_received.lock().unwrap()); - assert!(*task_is_now_finished.lock().unwrap()); +async fn test_task_spawns_task() { + let mut state: Vec = vec![]; + let mut manager = AsyncCallbackManager::new(); + let task = AsyncTask::new_future_chained( + TextTask("Hello".to_string()), + |state: &mut Vec<_>, output| { + state.push(output); + AsyncTask::new_future( + TextTask("World".to_string()), + |state: &mut Vec, output| state.push(output), + None, + ) + }, + None, + ); + manager.spawn_task(&(), task); + drain_manager(manager, &mut state, &()).await; + assert_eq!(vec!["Hello".to_string(), "World".to_string()], state); } diff --git a/json-crawler/src/iter.rs b/json-crawler/src/iter.rs index df4a26e4..6a40fa67 100644 --- a/json-crawler/src/iter.rs +++ b/json-crawler/src/iter.rs @@ -57,9 +57,9 @@ impl<'a> Iterator for JsonCrawlerArrayIterMut<'a> { } // Default implementation is correct, due to implementation of size_hint. -impl<'a> ExactSizeIterator for JsonCrawlerArrayIterMut<'a> {} +impl ExactSizeIterator for JsonCrawlerArrayIterMut<'_> {} -impl<'a> DoubleEndedIterator for JsonCrawlerArrayIterMut<'a> { +impl DoubleEndedIterator for JsonCrawlerArrayIterMut<'_> { fn next_back(&mut self) -> Option { let crawler = self.array.next_back()?; let out = Some(JsonCrawlerBorrowed { @@ -75,7 +75,7 @@ impl<'a> DoubleEndedIterator for JsonCrawlerArrayIterMut<'a> { } } -impl<'a> JsonCrawlerIterator for JsonCrawlerArrayIterMut<'a> { +impl JsonCrawlerIterator for JsonCrawlerArrayIterMut<'_> { fn find_path(mut self, path: impl AsRef) -> CrawlerResult { self.find_map(|crawler| crawler.navigate_pointer(path.as_ref()).ok()) .ok_or_else(|| { diff --git a/youtui/Cargo.toml b/youtui/Cargo.toml index cc691ce2..91cc5a41 100644 --- a/youtui/Cargo.toml +++ b/youtui/Cargo.toml @@ -15,7 +15,7 @@ rust-version = "1.79" [dependencies] clap = { version = "4.5.21", features = ["derive"] } -crossterm = { version = "0.28.1", features = ["event-stream"] } +crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } futures = "0.3.31" rat-text = "0.29.0" ratatui = { version = "0.29.0", features = ["all-widgets"] } @@ -39,7 +39,10 @@ itertools = "0.13.0" tokio-stream = "0.1.16" async_cell = "0.2.2" bytes = "1.8.0" +# This can be removed when tui-logger re-exports LevelFilter. +# https://github.com/gin66/tui-logger/pull/74 log = "0.4.22" +anyhow = "1.0.93" [dependencies.rusty_ytdl] # version = "0.7.4" @@ -56,5 +59,8 @@ version = "0.20.1" # package = "youtui-vendored-rodio" features = ["symphonia-all"] +[dev-dependencies] +pretty_assertions= "1.4.1" + [lints] workspace = true diff --git a/youtui/config/config.toml b/youtui/config/config.toml new file mode 100644 index 00000000..d7565b41 --- /dev/null +++ b/youtui/config/config.toml @@ -0,0 +1,98 @@ +# Example config.toml file for youtui containing the application defaults. +auth_type = "Browser" + +# Global keybinds +[keybinds.global] +"+" = "vol_up" +"-" = "vol_down" +">" = "next_song" +"<" = "prev_song" +"]" = "seek_forward" +"[" = "seek_back" +F1 = {action = "toggle_help", visibility = "global"} +F10 = {action = "quit", visibility = "global"} +F12 = {action = "view_logs", visibility = "global"} +space = {action = "pause", visibility = "global"} +C-c = "quit" + +# Global keybind mode names +[mode_names.global] + +[keybinds.playlist] +F5 = {action = "playlist.view_browser", visibility = "global"} +enter.enter = "playlist.play_selected" +enter.d = "playlist.delete_selected" +enter.D = "playlist.delete_all" + +[mode_names.playlist] +enter = "Playlist Action" + +[keybinds.browser] +F5 = {action = "browser.view_playlist", visibility = "global"} +F2 = {action = "browser.search", visibility = "global"} +left = "browser.left" +right = "browser.right" + +[keybinds.browser_artists] +enter = "browser_artists.display_selected_artist_albums" + +[keybinds.browser_search] +down = "browser_search.next_search_suggestion" +up = "browser_search.prev_search_suggestion" + +[keybinds.browser_songs] +F3 = {action = "browser_songs.filter", visibility = "global"} +F4 = {action = "browser_songs.sort", visibility = "global"} +enter.enter = "browser_songs.play_song" +enter.p = "browser_songs.play_songs" +enter.a = "browser_songs.play_album" +enter.space = "browser_songs.add_song_to_playlist" +enter.P = "browser_songs.add_songs_to_playlist" +enter.A = "browser_songs.add_album_to_playlist" + +[mode_names.browser_songs] +enter = "Play" + +[keybinds.help] +F1 = {action = "help.close", visibility = "global"} +esc = {action = "help.close", visibility = "hidden"} + +[keybinds.filter] +esc = {action = "filter.close", visibility = "hidden"} +F3 = {action = "filter.close", visibility = "global"} +Enter = {action = "filter.apply", visibility = "global"} +F6 = {action = "filter.clear_filter", visibility = "global"} + +[keybinds.sort] +enter = {action = "sort.sort_selected_asc", visibility = "global"} +A-enter = {action = "sort.sort_selected_desc", visibility = "global"} +C = {action = "sort.clear_sort", visibility = "global"} +esc = {action = "sort.close", visibility = "hidden"} +F4 = {action = "sort.close", visibility = "global"} + +[keybinds.log] +F5 = {action = "log.view_browser", visibility = "global"} +S-left = "log.reduce_captured" +S-right = "log.increase_captured" +left = "log.reduce_shown" +right = "log.increase_shown" +up = "log.up" +down = "log.down" +pageup = "log.page_up" +pagedown = "log.page_down" +t = "log.toggle_hide_filtered" +esc = "log.exit_page_mode" +f = "log.toggle_target_focus" +h = "log.toggle_target_selector" + +[keybinds.text_entry] +enter = {action = "text_entry.submit", visibility = "hidden"} +left = {action = "text_entry.left", visibility = "hidden"} +right = {action = "text_entry.right", visibility = "hidden"} +backspace = {action = "text_entry.backspace", visibility = "hidden"} + +[keybinds.list] +up = {action = "list.up", visibility = "hidden"} +down = {action = "list.down", visibility = "hidden"} +pageup = "list.page_up" +pagedown = "list.page_down" diff --git a/youtui/config/config.toml.vim-example b/youtui/config/config.toml.vim-example new file mode 100644 index 00000000..5b71dac7 --- /dev/null +++ b/youtui/config/config.toml.vim-example @@ -0,0 +1,17 @@ +# Example config.toml file for youtui with basic vim navigation keybinds. + +[keybinds.browser] +left = "no_op" +right = "no_op" +h = "browser.left" +l = "browser.right" + +[keybinds.list] +up = "no_op" +down = "no_op" +pageup = "no_op" +pagedown = "no_op" +k = {action = "list.up", visibility = "hidden"} +j = {action = "list.down", visibility = "hidden"} +C-b = "list.page_up" +C-f = "list.page_down" diff --git a/youtui/src/app.rs b/youtui/src/app.rs index ad64fdd0..511986b3 100644 --- a/youtui/src/app.rs +++ b/youtui/src/app.rs @@ -1,7 +1,7 @@ use super::appevent::{AppEvent, EventHandler}; use super::Result; use crate::{get_data_dir, RuntimeInfo}; -use async_callback_manager::AsyncCallbackManager; +use async_callback_manager::{AsyncCallbackManager, TaskOutcome}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, @@ -9,23 +9,22 @@ use crossterm::{ }; use log::LevelFilter; use ratatui::{backend::CrosstermBackend, Terminal}; -use server::{Server, TaskMetadata}; +use server::{ArcServer, Server, TaskMetadata}; use std::borrow::Cow; use std::{io, sync::Arc}; use structures::ListSong; use tokio::sync::mpsc; -use tracing::info; +use tracing::{error, info}; use tracing_subscriber::prelude::*; use ui::WindowContext; use ui::YoutuiWindow; -mod component; -mod keycommand; -mod musiccache; +#[macro_use] +pub mod component; mod server; mod structures; -mod ui; -mod view; +pub mod ui; +pub mod view; // We need this thread_local to ensure we know which is the main thread. Panic // hook that destructs terminal should only run on the main thread. @@ -34,7 +33,6 @@ thread_local! { } const CALLBACK_CHANNEL_SIZE: usize = 64; -const ASYNC_CALLBACK_SENDER_CHANNEL_SIZE: usize = 64; const EVENT_CHANNEL_SIZE: usize = 256; const LOG_FILE_NAME: &str = "debug.log"; @@ -42,13 +40,13 @@ pub struct Youtui { status: AppStatus, event_handler: EventHandler, window_state: YoutuiWindow, - task_manager: AsyncCallbackManager, TaskMetadata>, + task_manager: AsyncCallbackManager, server: Arc, callback_rx: mpsc::Receiver, terminal: Terminal>, - /// If Youtui will redraw on the next rendering loop. - redraw: bool, } +//TODO: Remove me! +impl_youtui_component!(Youtui); #[derive(PartialEq)] pub enum AppStatus { @@ -72,7 +70,7 @@ impl Youtui { api_key, debug, po_token, - .. + config, } = rt; // Setup tracing and link to tui_logger. init_tracing(debug)?; @@ -93,23 +91,20 @@ impl Youtui { // Setup components let (callback_tx, callback_rx) = mpsc::channel(CALLBACK_CHANNEL_SIZE); let mut task_manager = async_callback_manager::AsyncCallbackManager::new() - .with_on_task_received_callback(|task| { + .with_on_task_spawn_callback(|task| { info!( - "Received task {:?}: type_id: {:?}, sender_id: {:?}, constraint: {:?}", - task.type_name, task.type_id, task.sender_id, task.constraint - ) - }) - .with_on_response_received_callback(|response| { - info!( - "Received response to {:?}: type_id: {:?}, sender_id: {:?}, task_id: {:?}", - response.type_name, response.type_id, response.sender_id, response.task_id + "Received task {:?}: type_id: {:?}, constraint: {:?}", + task.type_debug, task.type_id, task.constraint ) }); let server = Arc::new(server::Server::new(api_key, po_token)); let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; let event_handler = EventHandler::new(EVENT_CHANNEL_SIZE)?; - let window_state = YoutuiWindow::new(callback_tx, &mut task_manager); + let (window_state, effect) = YoutuiWindow::new(callback_tx, &config); + // Even the creation of a YoutuiWindow causes an effect. We'll spawn it straight + // away. + task_manager.spawn_task(&server, effect); Ok(Youtui { status: AppStatus::Running, event_handler, @@ -118,7 +113,6 @@ impl Youtui { server, callback_rx, terminal, - redraw: true, }) } pub async fn run(&mut self) -> Result<()> { @@ -129,27 +123,23 @@ impl Youtui { // We draw after handling the event, as the event could be a keypress we want to // instantly react to. // Draw occurs before the first event, to ensure up loads immediately. - if self.redraw { - self.terminal.draw(|f| { - ui::draw::draw_app(f, &mut self.window_state); - })?; - }; - self.redraw = true; + self.terminal.draw(|f| { + ui::draw::draw_app(f, &mut self.window_state); + })?; // When running, the app is event based, and will block until one of the // following 4 message types is received. tokio::select! { // Get the next event from the event_handler and process it. // TODO: Consider checking here if redraw is required. - Some(event) = self.event_handler.next() => self.handle_event(event).await, + Some(event) = self.event_handler.next() => + self.handle_event(event).await, // Process any top-level callbacks in the queue. - Some(callback) = self.callback_rx.recv() => self.handle_callback(callback), + Some(callback) = self.callback_rx.recv() => + self.handle_callback(callback), // Process the next manager event. // If all the manager has done is spawn tasks, there's no need to draw. - Some(manager_event) = self.task_manager.manage_next_event(&self.server) => if manager_event.is_spawned_task() { - self.redraw = false; - }, - // If any state mutations have been received by the components, apply them. - _ = self.window_state.async_update() => (), + Some(outcome) = self.task_manager.get_next_response() => + self.handle_effect(outcome), } } AppStatus::Exiting(s) => { @@ -162,23 +152,55 @@ impl Youtui { } Ok(()) } + fn handle_effect(&mut self, effect: TaskOutcome) { + match effect { + async_callback_manager::TaskOutcome::StreamClosed => { + info!("Received a stream closed message from task manager") + } + async_callback_manager::TaskOutcome::TaskPanicked { + type_debug, error, .. + } => { + error!("Task {type_debug} panicked!"); + std::panic::resume_unwind(error.into_panic()) + } + async_callback_manager::TaskOutcome::MutationReceived { + mutation, + type_id, + type_debug, + task_id, + .. + } => { + info!( + "Received response to {:?}: type_id: {:?}, task_id: {:?}", + type_debug, type_id, task_id + ); + let next_task = mutation(&mut self.window_state); + self.task_manager.spawn_task(&self.server, next_task); + } + } + } async fn handle_event(&mut self, event: AppEvent) { match event { AppEvent::Tick => self.window_state.handle_tick().await, - AppEvent::Crossterm(e) => self.window_state.handle_initial_event(e).await, + AppEvent::Crossterm(e) => { + let task = self.window_state.handle_event(e).await; + self.task_manager.spawn_task(&self.server, task); + } AppEvent::QuitSignal => self.status = AppStatus::Exiting("Quit signal received".into()), } } - pub fn handle_callback(&mut self, callback: AppCallback) { + fn handle_callback(&mut self, callback: AppCallback) { match callback { AppCallback::Quit => self.status = AppStatus::Exiting("Quitting".into()), AppCallback::ChangeContext(context) => self.window_state.handle_change_context(context), AppCallback::AddSongsToPlaylist(song_list) => { - self.window_state.handle_add_songs_to_playlist(song_list); + self.window_state.handle_add_songs_to_playlist(song_list) } - AppCallback::AddSongsToPlaylistAndPlay(song_list) => self - .window_state - .handle_add_songs_to_playlist_and_play(song_list), + AppCallback::AddSongsToPlaylistAndPlay(song_list) => self.task_manager.spawn_task( + &self.server, + self.window_state + .handle_add_songs_to_playlist_and_play(song_list), + ), } } } @@ -195,16 +217,11 @@ fn destruct_terminal() -> Result<()> { /// # Panics /// If tracing fails to initialise, function will panic fn init_tracing(debug: bool) -> Result<()> { - // NOTE: It seems that tui-logger only displays events at info or higher, - // possibly a limitation with the implementation. - // https://github.com/gin66/tui-logger/issues/66 - // TODO: PR upstream let tui_logger_layer = tui_logger::tracing_subscriber_layer(); if debug { let log_file_name = get_data_dir()?.join(LOG_FILE_NAME); let log_file = std::fs::File::create(&log_file_name)?; let log_file_layer = tracing_subscriber::fmt::layer().with_writer(Arc::new(log_file)); - // TODO: Confirm if this filter is correct. let context_layer = tracing_subscriber::filter::Targets::new().with_target("youtui", tracing::Level::DEBUG); tracing_subscriber::registry() @@ -215,7 +232,6 @@ fn init_tracing(debug: bool) -> Result<()> { .expect("Expected logger to initialise succesfully"); info!("Started in debug mode, logging to {:?}.", log_file_name); } else { - // TODO: Confirm if this filter is correct. let context_layer = tracing_subscriber::filter::Targets::new().with_target("youtui", tracing::Level::INFO); tracing_subscriber::registry() diff --git a/youtui/src/app/component.rs b/youtui/src/app/component.rs index e05ee50e..e6cd3025 100644 --- a/youtui/src/app/component.rs +++ b/youtui/src/app/component.rs @@ -1,2 +1,3 @@ /// Traits related to application components +#[macro_use] pub mod actionhandler; diff --git a/youtui/src/app/component/actionhandler.rs b/youtui/src/app/component/actionhandler.rs index addebd7f..544d663a 100644 --- a/youtui/src/app/component/actionhandler.rs +++ b/youtui/src/app/component/actionhandler.rs @@ -1,13 +1,66 @@ -use crate::app::keycommand::{CommandVisibility, DisplayableCommand, KeyCommand, Keymap}; -use crossterm::event::{Event, KeyEvent, MouseEvent}; +use crate::{ + config::keymap::{KeyActionTree, Keymap}, + keyaction::{DisplayableKeyAction, KeyAction, KeyActionVisibility}, + keybind::Keybind, +}; +use async_callback_manager::AsyncTask; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}; use std::borrow::Cow; use ytmapi_rs::common::SearchSuggestion; -// An action that can be sent to a component. +/// Convenience type alias +pub type ComponentEffect = AsyncTask::Bkend, ::Md>; +/// A frontend component - has an associated backend and task metadata type. +pub trait Component { + type Bkend; + type Md; +} +/// Macro to generate the boilerplate implementation of Component used in this +/// app. +macro_rules! impl_youtui_component { + ($t:ty) => { + impl crate::app::component::actionhandler::Component for $t { + type Bkend = ArcServer; + type Md = TaskMetadata; + } + }; +} + +/// An action that can be applied to state. pub trait Action { + type State: Component; fn context(&self) -> Cow; fn describe(&self) -> Cow; } + +/// A component that can handle actions. +pub trait ActionHandler: Component + Sized { + async fn apply_action(&mut self, action: A) -> ComponentEffect; + /// Apply an action that can be mapped to Self. + async fn apply_action_mapped(&mut self, action: B, f: F) -> ComponentEffect + where + B: Action, + C: Component + ActionHandler + 'static, + F: Fn(&mut Self) -> &mut C + Send + Clone + 'static, + Self::Bkend: 'static, + Self::Md: 'static, + { + f(self) + .apply_action(action) + .await + .map(move |this: &mut Self| f(this)) + } +} + +/// A struct that is able to be "scrolled". +pub trait Scrollable { + /// Increment the list by the specified amount. + fn increment_list(&mut self, amount: isize); + /// Check if the Scrollable actually is scrollable right now, some other + /// part of it may be selected. + fn is_scrollable(&self) -> bool; +} + /// A component of the application that has different keybinds depending on what /// is focussed. For example, keybinds for browser may differ depending on /// selected pane. A keyrouter does not necessarily need to be a keyhandler and @@ -17,64 +70,46 @@ pub trait Action { /// NOTE: To implment this, the component can only have a single Action type. // XXX: Could possibly be a part of EventHandler instead. // XXX: Does this actually need to be a keyhandler? -pub trait KeyRouter { +pub trait KeyRouter { /// Get the list of active keybinds that the component and its route /// contain. - fn get_routed_keybinds<'a>(&'a self) -> Box> + 'a>; + fn get_active_keybinds(&self) -> impl Iterator>; /// Get the list of keybinds that the component and any child items can /// contain, regardless of current route. - fn get_all_keybinds<'a>(&'a self) -> Box> + 'a>; - // e.g - for use in help menu. - fn get_all_visible_keybinds<'a>(&'a self) -> Box> + 'a> { - Box::new( - self.get_all_keybinds() - .filter(|kb| kb.visibility != CommandVisibility::Hidden), - ) - } - // e.g - for use in header. - fn get_routed_global_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new( - self.get_routed_keybinds() - .filter(|kb| kb.visibility == CommandVisibility::Global), - ) - } + fn get_all_keybinds(&self) -> impl Iterator>; } + /// A component of the application that can block parent keybinds. /// For example, a component that can display a modal dialog that will prevent /// other inputs. -pub trait DominantKeyRouter { +pub trait DominantKeyRouter { /// Return true if dominant keybinds are active. fn dominant_keybinds_active(&self) -> bool; + fn get_dominant_keybinds(&self) -> impl Iterator>; } -/// A component of the application that can display all it's keybinds. -/// Not every KeyHandler/KeyRouter is a DisplayableKeyRouter - as -/// DisplayAbleKeyRouter unables conversion of typed Actions to generic. -// TODO: Type safety -// Could possibly be a part of EventHandler instead. -pub trait KeyDisplayer { - // XXX: Can these all just be derived from KeyRouter? - /// Get the list of all keybinds that the KeyHandler and any child items can - /// contain, regardless of context. - fn get_all_visible_keybinds_as_readable_iter<'a>( - &'a self, - ) -> Box> + 'a>; - /// Get the list of all non-hidden keybinds that the KeyHandler and any - /// child items can contain, regardless of context. - fn get_all_keybinds_as_readable_iter<'a>( - &'a self, - ) -> Box> + 'a>; - /// Get a context-specific list of all keybinds marked global. - // TODO: Put under DisplayableKeyHandler - fn get_context_global_keybinds_as_readable_iter<'a>( - &'a self, - ) -> Box> + 'a>; +/// Get the list of all keybinds that the KeyHandler and any child items can +/// contain, regardless of context. +pub fn get_visible_keybinds_as_readable_iter<'a, A: Action + 'static>( + keybinds: impl Iterator> + 'a, +) -> impl Iterator> + 'a { + keybinds + .flat_map(|keymap| keymap.iter()) + .filter(|(_, kt)| (*kt).get_visibility() != KeyActionVisibility::Hidden) + .map(|(kb, kt)| DisplayableKeyAction::from_keybind_and_action_tree(kb, kt)) +} +/// Get a context-specific list of all keybinds marked global. +pub fn get_global_keybinds_as_readable_iter<'a, A: Action + 'static>( + keybinds: impl Iterator> + 'a, +) -> impl Iterator> + 'a { + keybinds + .flat_map(|keymap| keymap.iter()) + .filter(|(_, kt)| (*kt).get_visibility() == KeyActionVisibility::Global) + .map(|(kb, kt)| DisplayableKeyAction::from_keybind_and_action_tree(kb, kt)) } /// A component of the application that handles text entry, currently designed /// to wrap rat_text::TextInputState. -pub trait TextHandler { +pub trait TextHandler: Component { /// Get a reference to the text. fn get_text(&self) -> &str; /// Clear text, returning false if it was already clear. @@ -84,15 +119,23 @@ pub trait TextHandler { /// Text handling could be a subset of the component. Return true if the /// text handling subset is active. fn is_text_handling(&self) -> bool; - /// Handle a crossterm event, returning true if an event was handled. - fn handle_event_repr(&mut self, event: &Event) -> bool; + /// Handle a crossterm event, returning a task if an event was handled. + fn handle_text_event_impl( + &mut self, + event: &Event, + ) -> Option> + where + Self: Sized; /// Default behaviour is to only handle an event if is_text_handling() == /// true. - fn handle_event(&mut self, event: &Event) -> bool { + fn try_handle_text(&mut self, event: &Event) -> Option> + where + Self: Sized, + { if !self.is_text_handling() { - return false; + return None; } - self.handle_event_repr(event) + self.handle_text_event_impl(event) } } // A text handler that can receive suggestions @@ -101,12 +144,6 @@ pub trait Suggestable: TextHandler { fn get_search_suggestions(&self) -> &[SearchSuggestion]; fn has_search_suggestions(&self) -> bool; } -/// A component of the application that handles actions. -/// Where an action is a message specifically sent to the component. -/// Consider if this should be inside ActionProcessor -pub trait ActionHandler { - async fn handle_action(&mut self, action: &A); -} pub trait MouseHandler { /// Not implemented yet! @@ -116,245 +153,214 @@ pub trait MouseHandler { } /// The action to do after handling a key event -pub enum KeyHandleAction { +#[derive(Debug)] +pub enum KeyHandleAction<'a, A: Action> { Action(A), - Mode, + Mode { name: String, keys: &'a Keymap }, NoMap, } -/// The action from handling a key event (no Action type required) -pub enum KeyHandleOutcome { - Action, - Mode, - NoMap, -} -/// Return a list of the current keymap for the provided stack of key_codes. -/// Note, if multiple options are available returns the first one. -pub fn get_key_subset<'a, A: Action>( - binds: Box> + 'a>, - key_stack: &[KeyEvent], -) -> Option<&'a Keymap> { - let first = index_keybinds(binds, key_stack.first()?)?; - index_keymap(first, key_stack.get(1..)?) -} -/// Check if key stack will result in an action for binds. -// Requires returning an action type so can be awkward. -pub fn handle_key_stack<'a, A>( - binds: Box> + 'a>, - key_stack: Vec, -) -> KeyHandleAction + +/// Check the current stack of keys, to see if an action is produced, a mode is +/// produced, or nothing produced. +pub fn handle_key_stack<'a, A, I>(keys: I, key_stack: &[KeyEvent]) -> KeyHandleAction<'a, A> where - A: Action + Clone, + A: Action + Copy + 'static, + I: IntoIterator>, { - if let Some(subset) = get_key_subset(binds, &key_stack) { - match &subset { - Keymap::Action(a) => { - // As Action is simply a message that is being passed around - // I am comfortable to clone it. Receiver should own the message. - // We may be able to improve on this using GATs or reference counting. - return KeyHandleAction::Action(a.clone()); - } - Keymap::Mode(_) => return KeyHandleAction::Mode, + let convert = |k: KeyEvent| { + // NOTE: kind and state fields currently unused. + let KeyEvent { + code, + mut modifiers, + .. + } = k; + // If the keycode is a character, then the shift modifier should be removed. It + // will be encoded in the character already. This same stripping occurs when + // parsing the keycode in Keybind::from_str(..). + if let KeyCode::Char(_) = code { + modifiers = modifiers.difference(KeyModifiers::SHIFT); } - } - KeyHandleAction::NoMap -} -/// Try to handle the passed key_stack if it processes an action. -/// Returns if it was handled or why it was not. -// Doesn't require returning an Action type. -pub async fn handle_key_stack_and_action<'a, A, B>( - handler: &mut B, - key_stack: Vec, -) -> KeyHandleOutcome -where - A: Action + Clone, - B: KeyRouter + ActionHandler, -{ - if let Some(subset) = get_key_subset(handler.get_routed_keybinds(), &key_stack) { - match &subset { - Keymap::Action(a) => { - // As Action is simply a message that is being passed around - // I am comfortable to clone it. Receiver should own the message. - // We may be able to improve on this using GATs or reference counting. - handler.handle_action(&a.clone()).await; - return KeyHandleOutcome::Action; - } - Keymap::Mode(_) => return KeyHandleOutcome::Mode, + Keybind { code, modifiers } + }; + let mut key_stack_iter = key_stack.iter(); + // First iteration - iterator of hashmaps. + let Some(first_key) = key_stack_iter.next() else { + return KeyHandleAction::NoMap; + }; + let first_found = keys.into_iter().find_map(|km| km.get(&convert(*first_key))); + let mut next_mode = match first_found { + Some(KeyActionTree::Key(KeyAction { action, .. })) => { + return KeyHandleAction::Action(*action); } + Some(KeyActionTree::Mode { name, keys }) => (name, keys), + None => return KeyHandleAction::NoMap, + }; + for key in key_stack_iter { + let next_found = next_mode.1.get(&convert(*key)); + match next_found { + Some(KeyActionTree::Key(KeyAction { action, .. })) => { + return KeyHandleAction::Action(*action); + } + Some(KeyActionTree::Mode { name, keys }) => next_mode = (name, keys), + None => return KeyHandleAction::NoMap, + }; + } + KeyHandleAction::Mode { + name: next_mode.0.as_deref().unwrap_or("UNNAMED MODE").to_string(), + keys: next_mode.1, } - KeyHandleOutcome::NoMap -} -/// If a list of Keybinds contains a binding for the index KeyEvent, return that -/// KeyEvent. -pub fn index_keybinds<'a, A: Action>( - binds: Box> + 'a>, - index: &KeyEvent, -) -> Option<&'a Keymap> { - let mut binds = binds; - binds - .find(|kb| kb.contains_keyevent(index)) - .map(|kb| &kb.key_map) -} -/// Recursively indexes into a Keymap using a list of KeyEvents. Yields the -/// presented Keymap, -// or none if one of the indexes fails to return a value. -pub fn index_keymap<'a, A: Action>( - map: &'a Keymap, - indexes: &[KeyEvent], -) -> Option<&'a Keymap> { - indexes - .iter() - .try_fold(map, move |target, i| match &target { - Keymap::Action(_) => None, - Keymap::Mode(m) => index_keybinds(Box::new(m.commands.iter()), i), - }) } + #[cfg(test)] mod tests { #![allow(clippy::todo)] - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - use crate::app::{ - component::actionhandler::{index_keybinds, Keymap}, - keycommand::Mode, + use super::{Action, Component}; + use crate::{ + app::component::actionhandler::{handle_key_stack, KeyHandleAction, Keymap}, + config::keymap::KeyActionTree, + keybind::Keybind, }; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use pretty_assertions::assert_eq; - use super::{index_keymap, Action, KeyCommand}; - - #[derive(PartialEq, Debug)] + #[derive(PartialEq, Debug, Copy, Clone)] enum TestAction { Test1, Test2, Test3, TestStack, } + impl Component for () { + type Bkend = (); + type Md = (); + } impl Action for TestAction { fn context(&self) -> std::borrow::Cow { todo!() } - fn describe(&self) -> std::borrow::Cow { todo!() } + type State = (); + } + fn test_keymap() -> Keymap { + [ + ( + Keybind::new_unmodified(KeyCode::F(10)), + KeyActionTree::new_key(TestAction::Test1), + ), + ( + Keybind::new_unmodified(KeyCode::F(12)), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Left), + KeyActionTree::new_key(TestAction::Test3), + ), + ( + Keybind::new_unmodified(KeyCode::Right), + KeyActionTree::new_key(TestAction::Test3), + ), + ( + Keybind::new_unmodified(KeyCode::Enter), + KeyActionTree::new_mode( + [ + ( + Keybind::new_unmodified(KeyCode::Enter), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Char('a')), + KeyActionTree::new_key(TestAction::Test3), + ), + ( + Keybind::new_unmodified(KeyCode::Char('p')), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Char(' ')), + KeyActionTree::new_key(TestAction::Test3), + ), + ( + Keybind::new_unmodified(KeyCode::Char('P')), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Char('A')), + KeyActionTree::new_key(TestAction::TestStack), + ), + ], + "Play".into(), + ), + ), + ] + .into_iter() + .collect::>() } #[test] fn test_key_stack_shift_modifier() { - let kb = vec![ - KeyCommand::new_from_code(KeyCode::F(10), TestAction::Test1), - KeyCommand::new_from_code(KeyCode::F(12), TestAction::Test2), - KeyCommand::new_from_code(KeyCode::Left, TestAction::Test3), - KeyCommand::new_from_code(KeyCode::Right, TestAction::Test3), - KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Enter, TestAction::Test2), - (KeyCode::Char('a'), TestAction::Test3), - (KeyCode::Char('p'), TestAction::Test2), - (KeyCode::Char(' '), TestAction::Test3), - (KeyCode::Char('P'), TestAction::Test2), - (KeyCode::Char('A'), TestAction::TestStack), - ], - KeyCode::Enter, - "Play", - ), - ]; + let kb = test_keymap(); let ks1 = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); let ks2 = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT); let key_stack = [ks1, ks2]; - let first = index_keybinds(Box::new(kb.iter()), key_stack.first().unwrap()).unwrap(); - let act = index_keymap(first, key_stack.get(1..).unwrap()); - let Some(Keymap::Action(a)) = act else { - panic!(); + let expected = TestAction::TestStack; + let output = handle_key_stack(std::iter::once(&kb), &key_stack); + let KeyHandleAction::Action(output) = output else { + panic!("Expected keyhandleoutcome::action"); }; - assert_eq!(*a, TestAction::TestStack); + assert_eq!(expected, output); } #[test] fn test_key_stack() { - let kb = vec![ - KeyCommand::new_from_code(KeyCode::F(10), TestAction::Test1), - KeyCommand::new_from_code(KeyCode::F(12), TestAction::Test2), - KeyCommand::new_from_code(KeyCode::Left, TestAction::Test3), - KeyCommand::new_from_code(KeyCode::Right, TestAction::Test3), - KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Enter, TestAction::Test2), - (KeyCode::Char('a'), TestAction::Test3), - (KeyCode::Char('p'), TestAction::Test2), - (KeyCode::Char(' '), TestAction::Test3), - (KeyCode::Char('P'), TestAction::Test2), - (KeyCode::Char('A'), TestAction::TestStack), - ], - KeyCode::Enter, - "Play", - ), - ]; + let kb = test_keymap(); let ks1 = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); let ks2 = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::empty()); let key_stack = [ks1, ks2]; - let first = index_keybinds(Box::new(kb.iter()), key_stack.first().unwrap()).unwrap(); - let act = index_keymap(first, key_stack.get(1..).unwrap()); - let Some(Keymap::Action(a)) = act else { - panic!(); + let expected = TestAction::TestStack; + let KeyHandleAction::Action(output) = handle_key_stack(std::iter::once(&kb), &key_stack) + else { + panic!("Expected keyhandleoutcome::action"); }; - assert_eq!(*a, TestAction::TestStack); + assert_eq!(expected, output); } #[test] fn test_index_keybinds() { - let kb = vec![ - KeyCommand::new_from_code(KeyCode::F(10), TestAction::Test1), - KeyCommand::new_from_code(KeyCode::F(12), TestAction::Test2), - KeyCommand::new_from_code(KeyCode::Left, TestAction::Test3), - KeyCommand::new_from_code(KeyCode::Right, TestAction::Test3), - KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Char('A'), TestAction::Test2), - (KeyCode::Char('a'), TestAction::Test3), - ], - KeyCode::Enter, - "Play", - ), - ]; + let kb = test_keymap(); let ks = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); - let idx = index_keybinds(Box::new(kb.iter()), &ks); - let eq = KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Char('A'), TestAction::Test2), - (KeyCode::Char('a'), TestAction::Test3), - ], - KeyCode::Enter, - "Play", - ) - .key_map; - assert_eq!(idx, Some(&eq)); - } - #[test] - fn test_index_keymap() { - let kb = Keymap::Mode(Mode { - commands: vec![ - KeyCommand::new_from_code(KeyCode::F(10), TestAction::Test1), - KeyCommand::new_from_code(KeyCode::F(12), TestAction::Test2), - KeyCommand::new_from_code(KeyCode::Left, TestAction::Test3), - KeyCommand::new_from_code(KeyCode::Right, TestAction::Test3), - KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Char('A'), TestAction::Test2), - (KeyCode::Char('a'), TestAction::Test3), - ], - KeyCode::Enter, - "Play", - ), - ], - name: "test", - }); - let ks = [KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())]; - let idx = index_keymap(&kb, &ks); - let eq = KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Char('A'), TestAction::Test2), - (KeyCode::Char('a'), TestAction::Test3), - ], - KeyCode::Enter, - "Play", - ) - .key_map; - assert_eq!(idx, Some(&eq)); + let expected_keys = [ + ( + Keybind::new_unmodified(KeyCode::Enter), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Char('a')), + KeyActionTree::new_key(TestAction::Test3), + ), + ( + Keybind::new_unmodified(KeyCode::Char('p')), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Char(' ')), + KeyActionTree::new_key(TestAction::Test3), + ), + ( + Keybind::new_unmodified(KeyCode::Char('P')), + KeyActionTree::new_key(TestAction::Test2), + ), + ( + Keybind::new_unmodified(KeyCode::Char('A')), + KeyActionTree::new_key(TestAction::TestStack), + ), + ] + .into_iter() + .collect::>(); + let expected_name = "Play".to_string(); + let KeyHandleAction::Mode { keys, name } = handle_key_stack(std::iter::once(&kb), &[ks]) + else { + panic!("Expected keyhandleoutcome::mode"); + }; + assert_eq!(name, expected_name); + assert_eq!(keys, &expected_keys); } } diff --git a/youtui/src/app/keycommand.rs b/youtui/src/app/keycommand.rs deleted file mode 100644 index f852cfce..00000000 --- a/youtui/src/app/keycommand.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! KeyCommand and Keybind model. -//! A KeyCommand is a pairing of Keybinds to an Action or a Mode. -//! A Mode is a modified set of KeyCommands accessible after pressing Keybinds. -use super::component::actionhandler::Action; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use std::{borrow::Cow, fmt::Display}; - -// Should another type be GlobalHidden? -#[derive(PartialEq, Debug, Clone)] -pub enum CommandVisibility { - Standard, - // Displayed on Header - Global, - // Not displayed in Help menu - Hidden, -} - -#[derive(PartialEq, Debug, Clone)] -pub struct KeyCommand { - pub keybinds: Vec, - pub key_map: Keymap, - pub visibility: CommandVisibility, -} -#[derive(PartialEq, Debug, Clone)] -pub struct Keybind { - code: KeyCode, - modifiers: KeyModifiers, -} -#[derive(PartialEq, Debug, Clone)] -pub enum Keymap { - Action(A), - Mode(Mode), -} -#[derive(PartialEq, Debug, Clone)] -pub struct Mode { - pub name: &'static str, - pub commands: Vec>, -} -#[derive(PartialEq, Debug, Clone)] -pub struct DisplayableCommand<'a> { - // XXX: Do we also want to display sub-keys in Modes? - pub keybinds: Cow<'a, str>, - pub context: Cow<'a, str>, - pub description: Cow<'a, str>, -} -pub struct DisplayableMode<'a> { - pub displayable_commands: Box> + 'a>, - pub description: Cow<'a, str>, -} - -impl<'a, A: Action + 'a> From<&'a KeyCommand> for DisplayableCommand<'a> { - fn from(value: &'a KeyCommand) -> Self { - // XXX: Do we also want to display sub-keys in Modes? - Self { - keybinds: value.to_string().into(), - context: value.context(), - description: value.describe(), - } - } -} - -impl Keybind { - fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { - Self { code, modifiers } - } - fn contains_keyevent(&self, keyevent: &KeyEvent) -> bool { - match self.code { - // If key code is a character it may have shift pressed, if that's the case ignore the - // shift As may have been used to capitalise the letter, which will already - // be counted in the key code. - KeyCode::Char(_) => { - self.code == keyevent.code - && self.modifiers.union(KeyModifiers::SHIFT) - == keyevent.modifiers.union(KeyModifiers::SHIFT) - } - _ => self.code == keyevent.code && self.modifiers == keyevent.modifiers, - } - } -} - -impl Display for KeyCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let w: String = - // NOTE: Replace with standard library method once stabilised. - itertools::intersperse( - self - .keybinds - .iter() - .map(|kb| Cow::from(kb.to_string())) - ," / ".into() - ) - .collect(); - write!(f, "{w}") - } -} - -impl Display for Keybind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let code: Cow = match self.code { - KeyCode::Enter => "Enter".into(), - KeyCode::Left => "Left".into(), - KeyCode::Right => "Right".into(), - KeyCode::Up => "Up".into(), - KeyCode::Down => "Down".into(), - KeyCode::PageUp => "PageUp".into(), - KeyCode::PageDown => "PageDown".into(), - KeyCode::Esc => "Esc".into(), - KeyCode::Char(c) => match c { - ' ' => "Space".into(), - c => c.to_string().into(), - }, - KeyCode::F(x) => format!("F{x}").into(), - _ => "".into(), - }; - match self.modifiers { - KeyModifiers::CONTROL => write!(f, "C-{code}"), - KeyModifiers::ALT => write!(f, "A-{code}"), - KeyModifiers::SHIFT => write!(f, "S-{code}"), - _ => write!(f, "{code}"), - } - } -} - -// Is this an implementation of Action? -impl Mode { - pub fn context(&self) -> Cow { - self.commands - .first() - .map(|kb| kb.context()) - .unwrap_or_default() - } - pub fn describe(&self) -> Cow { - self.name.into() - } - pub fn as_displayable_iter<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(self.commands.iter().map(|bind| bind.as_displayable())) - } -} - -impl KeyCommand { - // Is this an implementation of Action? - pub fn context(&self) -> Cow { - match &self.key_map { - Keymap::Action(a) => a.context(), - Keymap::Mode(m) => m.context(), - } - } - pub fn describe(&self) -> Cow { - match &self.key_map { - Keymap::Action(a) => a.describe(), - Keymap::Mode(m) => m.describe(), - } - } - pub fn as_displayable(&self) -> DisplayableCommand<'_> { - self.into() - } - pub fn contains_keyevent(&self, keyevent: &KeyEvent) -> bool { - for kb in self.keybinds.iter() { - if kb.contains_keyevent(keyevent) { - return true; - } - } - false - } - pub fn new_from_codes(code: Vec, action: A) -> KeyCommand { - let keybinds = code - .into_iter() - .map(|kc| Keybind::new(kc, KeyModifiers::empty())) - .collect(); - KeyCommand { - keybinds, - key_map: Keymap::Action(action), - visibility: CommandVisibility::Standard, - } - } - pub fn new_from_code(code: KeyCode, action: A) -> KeyCommand { - KeyCommand { - keybinds: vec![Keybind::new(code, KeyModifiers::empty())], - key_map: Keymap::Action(action), - visibility: CommandVisibility::Standard, - } - } - pub fn new_modified_from_code( - code: KeyCode, - modifiers: KeyModifiers, - action: A, - ) -> KeyCommand { - KeyCommand { - keybinds: vec![Keybind::new(code, modifiers)], - key_map: Keymap::Action(action), - visibility: CommandVisibility::Standard, - } - } - pub fn new_global_modified_from_code( - code: KeyCode, - modifiers: KeyModifiers, - action: A, - ) -> KeyCommand { - KeyCommand { - keybinds: vec![Keybind { code, modifiers }], - key_map: Keymap::Action(action), - visibility: CommandVisibility::Global, - } - } - pub fn new_global_from_code(code: KeyCode, action: A) -> KeyCommand { - KeyCommand { - keybinds: vec![Keybind { - code, - modifiers: KeyModifiers::empty(), - }], - key_map: Keymap::Action(action), - visibility: CommandVisibility::Global, - } - } - pub fn new_hidden_from_code(code: KeyCode, action: A) -> KeyCommand { - KeyCommand { - keybinds: vec![Keybind { - code, - modifiers: KeyModifiers::empty(), - }], - key_map: Keymap::Action(action), - visibility: CommandVisibility::Hidden, - } - } - pub fn new_action_only_mode( - actions: Vec<(KeyCode, A)>, - code: KeyCode, - name: &'static str, - ) -> KeyCommand { - let commands = actions - .into_iter() - .map(|(code, action)| KeyCommand { - keybinds: vec![Keybind { - code, - modifiers: KeyModifiers::empty(), - }], - key_map: Keymap::Action(action), - visibility: CommandVisibility::Standard, - }) - .collect(); - KeyCommand { - keybinds: vec![Keybind { - code, - modifiers: KeyModifiers::empty(), - }], - key_map: Keymap::Mode(Mode { commands, name }), - visibility: CommandVisibility::Standard, - } - } -} diff --git a/youtui/src/app/musiccache.rs b/youtui/src/app/musiccache.rs deleted file mode 100644 index 328c5fb1..00000000 --- a/youtui/src/app/musiccache.rs +++ /dev/null @@ -1,31 +0,0 @@ -use crate::Result; -use std::{path::PathBuf, sync::Arc}; - -const _MUSIC_DIR: &str = "music/"; - -pub struct _MusicCache { - songs: Vec, -} - -impl _MusicCache { - fn _cache_song(&mut self, song: Arc>, path: PathBuf) -> Result<()> { - let mut p = PathBuf::new(); - p.push(_MUSIC_DIR); - p.push(&path); - self.songs.push(path); - std::fs::write(p, &*song)?; - Ok(()) - } - fn _retrieve_song( - &self, - path: PathBuf, - ) -> std::result::Result>, std::io::Error> { - if self.songs.contains(&path) { - let mut p = PathBuf::new(); - p.push(_MUSIC_DIR); - p.push(&path); - return std::fs::read(p).map(Some); - } - Ok(None) - } -} diff --git a/youtui/src/app/server.rs b/youtui/src/app/server.rs index 253e0e8f..e962307c 100644 --- a/youtui/src/app/server.rs +++ b/youtui/src/app/server.rs @@ -54,10 +54,14 @@ pub enum TaskMetadata { PlayPause, } +#[derive(Debug)] pub struct GetSearchSuggestions(pub String); +#[derive(Debug)] pub struct SearchArtists(pub String); +#[derive(Debug)] pub struct GetArtistSongs(pub ArtistChannelID<'static>); +#[derive(Debug)] pub struct DownloadSong(pub VideoID<'static>, pub ListSongID); // Player Requests documentation: @@ -71,26 +75,34 @@ pub struct DownloadSong(pub VideoID<'static>, pub ListSongID); // Send IncreaseVolume(5) // Send IncreaseVolume(5), killing previous task // Volume will now be 10 - should be 15, should not allow caller to cause this. +#[derive(Debug)] pub struct IncreaseVolume(pub i8); +#[derive(Debug)] pub struct Seek { pub duration: Duration, pub direction: SeekDirection, } +#[derive(Debug)] pub struct Stop(pub ListSongID); +#[derive(Debug)] pub struct PausePlay(pub ListSongID); /// Decode a song into a format that can be played. +#[derive(Debug)] pub struct DecodeSong(pub Arc); -// Play a song, starting from the start, regardless what's queued. +/// Play a song, starting from the start, regardless what's queued. +#[derive(Debug)] pub struct PlaySong { pub song: DecodedInMemSong, pub id: ListSongID, } -// Play a song, unless it's already queued. +/// Play a song, unless it's already queued. +#[derive(Debug)] pub struct AutoplaySong { pub song: DecodedInMemSong, pub id: ListSongID, } -// Queue a song to play next. +/// Queue a song to play next. +#[derive(Debug)] pub struct QueueSong { pub song: DecodedInMemSong, pub id: ListSongID, diff --git a/youtui/src/app/server/player.rs b/youtui/src/app/server/player.rs index 1066732e..76f70ec2 100644 --- a/youtui/src/app/server/player.rs +++ b/youtui/src/app/server/player.rs @@ -10,6 +10,13 @@ use std::time::Duration; pub struct DecodedInMemSong(Decoder>); struct ArcInMemSong(Arc); +// Derive to assist with debub printing tasks +impl std::fmt::Debug for DecodedInMemSong { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("DecodedInMemSong").field(&"..").finish() + } +} + impl AsRef<[u8]> for ArcInMemSong { fn as_ref(&self) -> &[u8] { self.0.as_ref().0.as_ref() diff --git a/youtui/src/app/ui.rs b/youtui/src/app/ui.rs index 51e9f209..65619da5 100644 --- a/youtui/src/app/ui.rs +++ b/youtui/src/app/ui.rs @@ -1,33 +1,31 @@ -use std::time::Duration; - use self::{browser::Browser, logger::Logger, playlist::Playlist}; use super::component::actionhandler::{ - get_key_subset, handle_key_stack, handle_key_stack_and_action, Action, ActionHandler, - DominantKeyRouter, KeyDisplayer, KeyHandleAction, KeyHandleOutcome, KeyRouter, TextHandler, -}; -use super::keycommand::{ - CommandVisibility, DisplayableCommand, DisplayableMode, KeyCommand, Keymap, + get_visible_keybinds_as_readable_iter, handle_key_stack, ActionHandler, ComponentEffect, + DominantKeyRouter, KeyHandleAction, KeyRouter, Scrollable, TextHandler, }; use super::server::{ArcServer, IncreaseVolume, TaskMetadata}; use super::structures::*; -use super::view::Scrollable; -use super::{AppCallback, ASYNC_CALLBACK_SENDER_CHANNEL_SIZE}; +use super::AppCallback; use crate::async_rodio_sink::{SeekDirection, VolumeUpdate}; -use crate::core::{add_cb_or_error, send_or_error}; -use async_callback_manager::{AsyncCallbackSender, Constraint}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use crate::config::keymap::Keymap; +use crate::config::Config; +use crate::core::send_or_error; +use crate::keyaction::{DisplayableKeyAction, DisplayableMode}; +use action::{AppAction, ListAction, TextEntryAction, PAGE_KEY_LINES, SEEK_AMOUNT}; +use async_callback_manager::{AsyncTask, Constraint}; +use crossterm::event::{Event, KeyEvent}; +use itertools::Either; use ratatui::widgets::TableState; +use std::time::Duration; use tokio::sync::mpsc; -mod browser; +pub mod action; +pub mod browser; pub mod draw; mod footer; mod header; -mod logger; -mod playlist; - -const VOL_TICK: i8 = 5; -const SEEK_AMOUNT: Duration = Duration::from_secs(5); +pub mod logger; +pub mod playlist; // Which app level keyboard shortcuts function. // What is displayed in header @@ -40,55 +38,41 @@ pub enum WindowContext { Logs, } -// An Action that can be triggered from a keybind. -#[derive(Clone, Debug, PartialEq)] -pub enum UIAction { - Quit, - Next, - Prev, - Pause, - StepVolUp, - StepVolDown, - StepSeekForward, - StepSeekBack, - ToggleHelp, - HelpUp, - HelpDown, - ViewLogs, -} - pub struct YoutuiWindow { context: WindowContext, prev_context: WindowContext, - playlist: Playlist, - browser: Browser, - logger: Logger, - callback_tx: mpsc::Sender, - keybinds: Vec>, + pub playlist: Playlist, + pub browser: Browser, + pub logger: Logger, + pub callback_tx: mpsc::Sender, + keybinds: Keymap, + list_keybinds: Keymap, + text_entry_keybinds: Keymap, key_stack: Vec, - help: HelpMenu, - async_tx: AsyncCallbackSender, + pub help: HelpMenu, } +impl_youtui_component!(YoutuiWindow); pub struct HelpMenu { - shown: bool, + pub shown: bool, cur: usize, len: usize, - keybinds: Vec>, + keybinds: Keymap, pub widget_state: TableState, } -impl Default for HelpMenu { - fn default() -> Self { +impl HelpMenu { + fn new(config: &Config) -> Self { HelpMenu { shown: Default::default(), cur: Default::default(), len: Default::default(), - keybinds: help_keybinds(), + keybinds: help_keybinds(config), widget_state: Default::default(), } } } +impl_youtui_component!(HelpMenu); impl Scrollable for HelpMenu { fn increment_list(&mut self, amount: isize) { @@ -97,13 +81,12 @@ impl Scrollable for HelpMenu { .saturating_add_signed(amount) .min(self.len.saturating_sub(1)); } - - fn get_selected_item(&self) -> usize { - self.cur + fn is_scrollable(&self) -> bool { + true } } -impl DominantKeyRouter for YoutuiWindow { +impl DominantKeyRouter for YoutuiWindow { fn dominant_keybinds_active(&self) -> bool { self.help.shown || match self.context { @@ -112,156 +95,90 @@ impl DominantKeyRouter for YoutuiWindow { WindowContext::Logs => false, } } -} -// We can't implement KeyRouter, as it would require us to have a single Action -// type for the whole application. -impl KeyDisplayer for YoutuiWindow { - // XXX: Can turn these boxed iterators into types. - fn get_all_keybinds_as_readable_iter<'a>( - &'a self, - ) -> Box<(dyn Iterator> + 'a)> { - let kb = self.keybinds.iter().map(|kb| kb.as_displayable()); - let cx = match self.context { - // Consider if double boxing can be removed. - WindowContext::Browser => Box::new( - self.browser - .get_all_keybinds() - .map(|kb| kb.as_displayable()), - ) as Box>, - WindowContext::Playlist => Box::new( - self.playlist - .get_all_keybinds() - .map(|kb| kb.as_displayable()), - ) - as Box>, - WindowContext::Logs => { - Box::new(self.logger.get_all_keybinds().map(|kb| kb.as_displayable())) - as Box> + fn get_dominant_keybinds(&self) -> impl Iterator> { + if self.help.shown { + return Either::Right(Either::Right( + [&self.help.keybinds, &self.list_keybinds].into_iter(), + )); + } + match self.context { + WindowContext::Browser => { + Either::Left(Either::Left(self.browser.get_dominant_keybinds())) } - }; - Box::new(kb.chain(cx)) - } - - fn get_context_global_keybinds_as_readable_iter<'a>( - &'a self, - ) -> Box + 'a> { - let kb = self - .get_this_keybinds() - .filter(|kc| kc.visibility == CommandVisibility::Global) - .map(|kb| kb.as_displayable()); - if self.is_dominant_keybinds() { - return Box::new(kb); + WindowContext::Playlist => { + Either::Left(Either::Right(self.playlist.get_active_keybinds())) + } + WindowContext::Logs => Either::Right(Either::Left(self.logger.get_active_keybinds())), } - let cx = match self.context { - // Consider if double boxing can be removed. - WindowContext::Browser => Box::new( - self.browser - .get_routed_global_keybinds() - .map(|kb| kb.as_displayable()), - ) as Box>, - WindowContext::Playlist => Box::new( - self.playlist - .get_routed_global_keybinds() - .map(|kb| kb.as_displayable()), - ) - as Box>, - WindowContext::Logs => Box::new( - self.logger - .get_routed_global_keybinds() - .map(|kb| kb.as_displayable()), - ) as Box>, - }; - Box::new(kb.chain(cx)) - } - - fn get_all_visible_keybinds_as_readable_iter<'a>( - &'a self, - ) -> Box + 'a> { - // Self.keybinds is incorrect - let kb = self - .keybinds - .iter() - .filter(|kb| kb.visibility != CommandVisibility::Hidden) - .map(|kb| kb.as_displayable()); - let cx = match self.context { - // Consider if double boxing can be removed. - WindowContext::Browser => Box::new( - self.browser - .get_all_visible_keybinds() - .map(|kb| kb.as_displayable()), - ) as Box>, - WindowContext::Playlist => Box::new( - self.playlist - .get_all_visible_keybinds() - .map(|kb| kb.as_displayable()), - ) - as Box>, - WindowContext::Logs => Box::new( - self.logger - .get_all_visible_keybinds() - .map(|kb| kb.as_displayable()), - ) as Box>, - }; - Box::new(kb.chain(cx)) } } -impl ActionHandler for YoutuiWindow { - async fn handle_action(&mut self, action: &UIAction) { - match action { - UIAction::Next => self.playlist.handle_next().await, - UIAction::Prev => self.playlist.handle_previous().await, - UIAction::Pause => self.playlist.pauseplay().await, - UIAction::StepVolUp => self.handle_increase_volume(VOL_TICK).await, - UIAction::StepVolDown => self.handle_increase_volume(-VOL_TICK).await, - UIAction::StepSeekForward => self.handle_seek(SEEK_AMOUNT, SeekDirection::Forward), - UIAction::StepSeekBack => self.handle_seek(SEEK_AMOUNT, SeekDirection::Back), - UIAction::Quit => send_or_error(&self.callback_tx, AppCallback::Quit).await, - UIAction::ToggleHelp => self.toggle_help(), - UIAction::ViewLogs => self.handle_change_context(WindowContext::Logs), - UIAction::HelpUp => self.help.increment_list(-1), - UIAction::HelpDown => self.help.increment_list(1), +impl Scrollable for YoutuiWindow { + fn increment_list(&mut self, amount: isize) { + if self.help.shown { + return self.help.increment_list(amount); + } + match self.context { + WindowContext::Browser => self.browser.increment_list(amount), + WindowContext::Playlist => self.playlist.increment_list(amount), + WindowContext::Logs => (), } } + fn is_scrollable(&self) -> bool { + self.help.shown + || match self.context { + WindowContext::Browser => { + !self.browser.artist_list.search_popped + || self.browser.input_routing == browser::InputRouting::Song + } + WindowContext::Playlist => true, + WindowContext::Logs => false, + } + } } -impl Action for UIAction { - fn context(&self) -> std::borrow::Cow { - match self { - UIAction::Next | UIAction::Prev | UIAction::StepVolUp | UIAction::StepVolDown => { - "Global".into() +impl KeyRouter for YoutuiWindow { + fn get_active_keybinds(&self) -> impl Iterator> { + if self.dominant_keybinds_active() { + return Either::Right(Either::Right(self.get_dominant_keybinds())); + } + let kb = std::iter::once(&self.keybinds); + let kb = if self.is_scrollable() { + Either::Left(kb.chain(std::iter::once(&self.list_keybinds))) + } else { + Either::Right(kb) + }; + let kb = if self.is_text_handling() { + Either::Left(kb.chain(std::iter::once(&self.text_entry_keybinds))) + } else { + Either::Right(kb) + }; + match self.context { + WindowContext::Browser => { + Either::Left(Either::Left(kb.chain(self.browser.get_active_keybinds()))) + } + WindowContext::Playlist => { + Either::Left(Either::Right(kb.chain(self.playlist.get_active_keybinds()))) + } + WindowContext::Logs => { + Either::Right(Either::Left(kb.chain(self.logger.get_active_keybinds()))) } - UIAction::Quit => "Global".into(), - UIAction::ToggleHelp => "Global".into(), - UIAction::ViewLogs => "Global".into(), - UIAction::Pause => "Global".into(), - UIAction::HelpUp => "Help".into(), - UIAction::HelpDown => "Help".into(), - UIAction::StepSeekForward => "Global".into(), - UIAction::StepSeekBack => "Global".into(), } } - fn describe(&self) -> std::borrow::Cow { - match self { - UIAction::Quit => "Quit".into(), - UIAction::Prev => "Prev Song".into(), - UIAction::Next => "Next Song".into(), - UIAction::Pause => "Pause".into(), - UIAction::StepVolUp => "Vol Up".into(), - UIAction::StepVolDown => "Vol Down".into(), - UIAction::ToggleHelp => "Toggle Help".into(), - UIAction::ViewLogs => "View Logs".into(), - UIAction::HelpUp => "Help".into(), - UIAction::HelpDown => "Help".into(), - UIAction::StepSeekForward => format!("Seek Forward {}s", SEEK_AMOUNT.as_secs()).into(), - UIAction::StepSeekBack => format!("Seek Back {}s", SEEK_AMOUNT.as_secs()).into(), - } + fn get_all_keybinds(&self) -> impl Iterator> { + std::iter::once(&self.keybinds) + .chain(self.browser.get_all_keybinds()) + .chain(self.playlist.get_all_keybinds()) + .chain(self.logger.get_all_keybinds()) } } impl TextHandler for YoutuiWindow { fn is_text_handling(&self) -> bool { + if self.help.shown { + return false; + } match self.context { WindowContext::Browser => self.browser.is_text_handling(), WindowContext::Playlist => self.playlist.is_text_handling(), @@ -289,77 +206,233 @@ impl TextHandler for YoutuiWindow { WindowContext::Logs => self.logger.clear_text(), } } - fn handle_event_repr(&mut self, event: &Event) -> bool { + fn handle_text_event_impl(&mut self, event: &Event) -> Option> { match self.context { - WindowContext::Browser => self.browser.handle_event_repr(event), - WindowContext::Playlist => self.playlist.handle_event_repr(event), - WindowContext::Logs => self.logger.handle_event_repr(event), + WindowContext::Browser => self + .browser + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut YoutuiWindow| &mut this.browser)), + WindowContext::Playlist => self + .playlist + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut YoutuiWindow| &mut this.playlist)), + WindowContext::Logs => self + .logger + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut YoutuiWindow| &mut this.logger)), } } } +impl ActionHandler for YoutuiWindow { + async fn apply_action( + &mut self, + action: AppAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + // NOTE: This is the place to check if we _should_ be handling an action. + // For example if a user has set custom 'playlist' keybinds that trigger + // 'browser' actions, this could be filtered out here. + match action { + AppAction::VolUp => return self.handle_increase_volume(5).await, + AppAction::VolDown => return self.handle_increase_volume(-5).await, + AppAction::NextSong => return self.handle_next(), + AppAction::PrevSong => return self.handle_prev(), + AppAction::SeekForward => return self.handle_seek(SEEK_AMOUNT, SeekDirection::Forward), + AppAction::SeekBack => return self.handle_seek(SEEK_AMOUNT, SeekDirection::Back), + AppAction::ToggleHelp => self.toggle_help(), + AppAction::Quit => send_or_error(&self.callback_tx, AppCallback::Quit).await, + AppAction::ViewLogs => self.handle_change_context(WindowContext::Logs), + AppAction::Pause => return self.pauseplay(), + AppAction::Log(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.logger) + .await + } + AppAction::Playlist(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.playlist) + .await + } + AppAction::Browser(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.browser) + .await + } + AppAction::Filter(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.browser) + .await + } + AppAction::Sort(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.browser) + .await + } + AppAction::Help(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.help) + .await + } + AppAction::BrowserArtists(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.browser) + .await + } + AppAction::BrowserSearch(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.browser) + .await + } + AppAction::BrowserSongs(a) => { + return self + .apply_action_mapped(a, |this: &mut Self| &mut this.browser) + .await + } + AppAction::TextEntry(a) => return self.handle_text_entry_action(a), + AppAction::List(a) => return self.handle_list_action(a), + AppAction::NoOp => (), + }; + AsyncTask::new_no_op() + } +} + impl YoutuiWindow { pub fn new( callback_tx: mpsc::Sender, - callback_manager: &mut async_callback_manager::AsyncCallbackManager< - ArcServer, - TaskMetadata, - >, - ) -> YoutuiWindow { - YoutuiWindow { + config: &Config, + ) -> (YoutuiWindow, ComponentEffect) { + let (playlist, task) = Playlist::new(callback_tx.clone(), config); + let this = YoutuiWindow { context: WindowContext::Browser, prev_context: WindowContext::Browser, - playlist: Playlist::new(callback_manager, callback_tx.clone()), - browser: Browser::new(callback_manager, callback_tx.clone()), - logger: Logger::new(callback_tx.clone()), - keybinds: global_keybinds(), + playlist, + browser: Browser::new(callback_tx.clone(), config), + logger: Logger::new(callback_tx.clone(), config), key_stack: Vec::new(), - help: Default::default(), + help: HelpMenu::new(config), callback_tx, - async_tx: callback_manager.new_sender(ASYNC_CALLBACK_SENDER_CHANNEL_SIZE), - } + keybinds: global_keybinds(config), + list_keybinds: list_keybinds(config), + text_entry_keybinds: text_entry_keybinds(config), + }; + (this, task.map(|this: &mut Self| &mut this.playlist)) } - // TODO: Move to future AsyncComponent trait. - pub async fn async_update(&mut self) { - tokio::select! { - b = self.browser.async_update() => b.map(|this: &mut Self| &mut this.browser), - p = self.playlist.async_update() => p.map(|this: &mut Self| &mut this.playlist), + pub fn get_help_list_items(&self) -> impl Iterator> { + match self.context { + WindowContext::Browser => Either::Left(Either::Right( + get_visible_keybinds_as_readable_iter(self.browser.get_all_keybinds()), + )), + WindowContext::Playlist => Either::Right(get_visible_keybinds_as_readable_iter( + self.playlist.get_all_keybinds(), + )), + WindowContext::Logs => Either::Left(Either::Left( + get_visible_keybinds_as_readable_iter(self.logger.get_all_keybinds()), + )), } - .apply(self) + .chain(get_visible_keybinds_as_readable_iter( + std::iter::once(&self.keybinds) + .chain(std::iter::once(&self.list_keybinds)) + .chain(std::iter::once(&self.text_entry_keybinds)), + )) } // Splitting out event types removes one layer of indentation. - pub async fn handle_initial_event(&mut self, event: crossterm::event::Event) { - if self.handle_event(&event) { - return; - } + pub async fn handle_event(&mut self, event: crossterm::event::Event) -> ComponentEffect { + // TODO: This should be intercepted and keycodes mapped by us instead of going + // direct to rat-text. + if let Some(effect) = self.try_handle_text(&event) { + return effect; + }; match event { - Event::Key(k) => self.handle_key_event(k).await, - Event::Mouse(m) => self.handle_mouse_event(m), + Event::Key(k) => return self.handle_key_event(k).await, + Event::Mouse(m) => return self.handle_mouse_event(m), other => tracing::warn!("Received unimplemented {:?} event", other), } + AsyncTask::new_no_op() } pub async fn handle_tick(&mut self) { self.playlist.handle_tick().await; } - async fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { + async fn handle_key_event( + &mut self, + key_event: crossterm::event::KeyEvent, + ) -> ComponentEffect { self.key_stack.push(key_event); - self.global_handle_key_stack().await; + self.global_handle_key_stack().await } - fn handle_mouse_event(&mut self, mouse_event: crossterm::event::MouseEvent) { + fn handle_mouse_event( + &mut self, + mouse_event: crossterm::event::MouseEvent, + ) -> ComponentEffect { tracing::warn!("Received unimplemented {:?} mouse event", mouse_event); + AsyncTask::new_no_op() } - pub async fn handle_increase_volume(&mut self, inc: i8) { + pub fn handle_list_action(&mut self, action: ListAction) -> ComponentEffect { + if self.help.shown { + match action { + ListAction::Up => self.help.increment_list(-1), + ListAction::Down => self.help.increment_list(1), + ListAction::PageUp => self.increment_list(-PAGE_KEY_LINES), + ListAction::PageDown => self.increment_list(PAGE_KEY_LINES), + } + return AsyncTask::new_no_op(); + } + match self.context { + WindowContext::Browser => self + .browser + .handle_list_action(action) + .map(|this: &mut Self| &mut this.browser), + WindowContext::Playlist => self + .playlist + .handle_list_action(action) + .map(|this: &mut Self| &mut this.playlist), + WindowContext::Logs => AsyncTask::new_no_op(), + } + } + pub fn handle_text_entry_action(&mut self, action: TextEntryAction) -> ComponentEffect { + if !self.is_text_handling() { + return AsyncTask::new_no_op(); + } + match self.context { + WindowContext::Browser => self + .browser + .handle_text_entry_action(action) + .map(|this: &mut Self| &mut this.browser), + WindowContext::Playlist => AsyncTask::new_no_op(), + WindowContext::Logs => AsyncTask::new_no_op(), + } + } + pub fn pauseplay(&mut self) -> ComponentEffect { + self.playlist + .pauseplay() + .map(|this: &mut Self| &mut this.playlist) + } + pub fn handle_next(&mut self) -> ComponentEffect { + self.playlist + .handle_next() + .map(|this: &mut Self| &mut this.playlist) + } + pub fn handle_prev(&mut self) -> ComponentEffect { + self.playlist + .handle_previous() + .map(|this: &mut Self| &mut this.playlist) + } + pub async fn handle_increase_volume(&mut self, inc: i8) -> ComponentEffect { // Visually update the state first for instant feedback. self.increase_volume(inc); - add_cb_or_error( - &self.async_tx, + AsyncTask::new_future( IncreaseVolume(inc), Self::handle_volume_update, Some(Constraint::new_block_same_type()), - ); + ) } - pub fn handle_seek(&mut self, duration: Duration, direction: SeekDirection) { - self.playlist.handle_seek(duration, direction); + pub fn handle_seek( + &mut self, + duration: Duration, + direction: SeekDirection, + ) -> ComponentEffect { + self.playlist + .handle_seek(duration, direction) + .map(|this: &mut Self| &mut this.playlist) } pub fn handle_volume_update(&mut self, update: Option) { self.playlist.handle_volume_update(update) @@ -367,63 +440,34 @@ impl YoutuiWindow { pub fn handle_add_songs_to_playlist(&mut self, song_list: Vec) { let _ = self.playlist.push_song_list(song_list); } - pub fn handle_add_songs_to_playlist_and_play(&mut self, song_list: Vec) { - self.playlist.reset(); + pub fn handle_add_songs_to_playlist_and_play( + &mut self, + song_list: Vec, + ) -> ComponentEffect { + let effect = self.playlist.reset(); let id = self.playlist.push_song_list(song_list); - self.playlist.play_song_id(id); - } - fn is_dominant_keybinds(&self) -> bool { - self.help.shown - } - fn get_this_keybinds(&self) -> Box> + '_> { - Box::new(if self.help.shown { - Box::new(self.help.keybinds.iter()) as Box>> - } else if self.dominant_keybinds_active() { - Box::new(std::iter::empty()) as Box>> - } else { - Box::new(self.keybinds.iter()) as Box>> - }) + effect + .push(self.playlist.play_song_id(id)) + .map(|this: &mut Self| &mut this.playlist) } - - async fn global_handle_key_stack(&mut self) { - // First handle my own keybinds, otherwise forward if our keybinds are not - // dominant. TODO: Remove allocation - match handle_key_stack(self.get_this_keybinds(), self.key_stack.clone()) { + async fn global_handle_key_stack(&mut self) -> ComponentEffect { + match handle_key_stack(self.get_active_keybinds(), &self.key_stack) { KeyHandleAction::Action(a) => { - self.handle_action(&a).await; + let effect = self.apply_action(a).await; self.key_stack.clear(); - return; - } - KeyHandleAction::Mode => { - return; + effect } + KeyHandleAction::Mode { .. } => AsyncTask::new_no_op(), KeyHandleAction::NoMap => { - if self.is_dominant_keybinds() { - self.key_stack.clear(); - return; - } - } - }; - if let KeyHandleOutcome::Mode = match self.context { - // TODO: Remove allocation - WindowContext::Browser => { - handle_key_stack_and_action(&mut self.browser, self.key_stack.clone()).await - } - WindowContext::Playlist => { - handle_key_stack_and_action(&mut self.playlist, self.key_stack.clone()).await - } - WindowContext::Logs => { - handle_key_stack_and_action(&mut self.logger, self.key_stack.clone()).await + self.key_stack.clear(); + AsyncTask::new_no_op() } - } { - return; } - self.key_stack.clear() } fn key_pending(&self) -> bool { !self.key_stack.is_empty() } - fn toggle_help(&mut self) { + pub fn toggle_help(&mut self) { if self.help.shown { self.help.shown = false; } else { @@ -432,7 +476,7 @@ impl YoutuiWindow { self.help.cur = 0; // We have to get the keybind length this way as the help menu iterator is not // ExactSized - self.help.len = self.get_all_visible_keybinds_as_readable_iter().count(); + self.help.len = self.get_help_list_items().count(); } } /// Visually increment the volume, note, does not actually change the @@ -450,74 +494,33 @@ impl YoutuiWindow { // The downside of this approach is that if draw_popup is calling this function, // it is gettign called every tick. // Consider a way to set this in the in state memory. - fn get_cur_displayable_mode(&self) -> Option> { - if let Some(Keymap::Mode(mode)) = get_key_subset(self.get_this_keybinds(), &self.key_stack) - { - return Some(DisplayableMode { - displayable_commands: mode.as_displayable_iter(), - description: mode.describe(), - }); - } - match self.context { - WindowContext::Browser => { - if let Some(Keymap::Mode(mode)) = - get_key_subset(self.browser.get_routed_keybinds(), &self.key_stack) - { - return Some(DisplayableMode { - displayable_commands: mode.as_displayable_iter(), - description: mode.describe(), - }); - } - } - WindowContext::Playlist => { - if let Some(Keymap::Mode(mode)) = - get_key_subset(self.playlist.get_routed_keybinds(), &self.key_stack) - { - return Some(DisplayableMode { - displayable_commands: mode.as_displayable_iter(), - description: mode.describe(), - }); - } - } - WindowContext::Logs => { - if let Some(Keymap::Mode(mode)) = - get_key_subset(self.logger.get_routed_keybinds(), &self.key_stack) - { - return Some(DisplayableMode { - displayable_commands: mode.as_displayable_iter(), - description: mode.describe(), - }); - } - } - } - None + fn get_cur_displayable_mode( + &self, + ) -> Option>>> { + let KeyHandleAction::Mode { name, keys } = + handle_key_stack(self.get_active_keybinds(), &self.key_stack) + else { + return None; + }; + let displayable_commands = keys + .iter() + .map(|(kb, kt)| DisplayableKeyAction::from_keybind_and_action_tree(kb, kt)); + Some(DisplayableMode { + displayable_commands, + description: name.into(), + }) } } -fn global_keybinds() -> Vec> { - vec![ - KeyCommand::new_from_code(KeyCode::Char('+'), UIAction::StepVolUp), - KeyCommand::new_from_code(KeyCode::Char('-'), UIAction::StepVolDown), - KeyCommand::new_from_code(KeyCode::Char('<'), UIAction::Prev), - KeyCommand::new_from_code(KeyCode::Char('>'), UIAction::Next), - KeyCommand::new_from_code(KeyCode::Char('['), UIAction::StepSeekBack), - KeyCommand::new_from_code(KeyCode::Char(']'), UIAction::StepSeekForward), - KeyCommand::new_global_from_code(KeyCode::F(1), UIAction::ToggleHelp), - KeyCommand::new_global_from_code(KeyCode::F(10), UIAction::Quit), - KeyCommand::new_global_from_code(KeyCode::F(12), UIAction::ViewLogs), - KeyCommand::new_global_from_code(KeyCode::Char(' '), UIAction::Pause), - KeyCommand::new_modified_from_code( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - UIAction::Quit, - ), - ] +fn global_keybinds(config: &Config) -> Keymap { + config.keybinds.global.clone() +} +fn help_keybinds(config: &Config) -> Keymap { + config.keybinds.help.clone() +} +fn list_keybinds(config: &Config) -> Keymap { + config.keybinds.list.clone() } -fn help_keybinds() -> Vec> { - vec![ - KeyCommand::new_hidden_from_code(KeyCode::Down, UIAction::HelpDown), - KeyCommand::new_hidden_from_code(KeyCode::Up, UIAction::HelpUp), - KeyCommand::new_hidden_from_code(KeyCode::Esc, UIAction::ToggleHelp), - KeyCommand::new_global_from_code(KeyCode::F(1), UIAction::ToggleHelp), - ] +fn text_entry_keybinds(config: &Config) -> Keymap { + config.keybinds.text_entry.clone() } diff --git a/youtui/src/app/ui/action.rs b/youtui/src/app/ui/action.rs new file mode 100644 index 00000000..16a83365 --- /dev/null +++ b/youtui/src/app/ui/action.rs @@ -0,0 +1,217 @@ +use super::{ + browser::{ + artistalbums::{ + albumsongs::{BrowserSongsAction, FilterAction, SortAction}, + artistsearch::{BrowserArtistsAction, BrowserSearchAction}, + }, + BrowserAction, + }, + logger::LoggerAction, + playlist::PlaylistAction, + HelpMenu, YoutuiWindow, +}; +use crate::app::component::actionhandler::{Action, ActionHandler}; +use async_callback_manager::AsyncTask; +use serde::{ + de::{self}, + Deserialize, Serialize, +}; +use std::time::Duration; + +pub const VOL_TICK: i8 = 5; +pub const SEEK_AMOUNT: Duration = Duration::from_secs(5); +pub const PAGE_KEY_LINES: isize = 10; + +#[derive(Clone, Copy, PartialEq, Default, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppAction { + #[default] + Quit, + VolUp, + VolDown, + NextSong, + PrevSong, + SeekForward, + SeekBack, + ToggleHelp, + ViewLogs, + Pause, + NoOp, + Browser(BrowserAction), + Filter(FilterAction), + Sort(SortAction), + Help(HelpAction), + BrowserArtists(BrowserArtistsAction), + BrowserSearch(BrowserSearchAction), + BrowserSongs(BrowserSongsAction), + Log(LoggerAction), + Playlist(PlaylistAction), + TextEntry(TextEntryAction), + List(ListAction), +} + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HelpAction { + Close, +} + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ListAction { + Up, + Down, + PageUp, + PageDown, +} + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TextEntryAction { + Submit, + Left, + Right, + Backspace, +} + +impl Action for TextEntryAction { + type State = YoutuiWindow; + fn context(&self) -> std::borrow::Cow { + "Global".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + TextEntryAction::Submit => "Submit".into(), + TextEntryAction::Left => "Left".into(), + TextEntryAction::Right => "Right".into(), + TextEntryAction::Backspace => "Backspace".into(), + } + } +} +impl Action for ListAction { + type State = YoutuiWindow; + fn context(&self) -> std::borrow::Cow { + "Global".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + ListAction::Up => "List Up".into(), + ListAction::Down => "List Down".into(), + ListAction::PageUp => "List PageUp".into(), + ListAction::PageDown => "List PageDown".into(), + } + } +} + +impl Action for AppAction { + type State = YoutuiWindow; + fn context(&self) -> std::borrow::Cow { + match self { + AppAction::VolUp + | AppAction::VolDown + | AppAction::NextSong + | AppAction::PrevSong + | AppAction::SeekForward + | AppAction::SeekBack + | AppAction::ToggleHelp + | AppAction::Quit + | AppAction::ViewLogs + | AppAction::NoOp + | AppAction::Pause => "Global".into(), + AppAction::Log(a) => a.context(), + AppAction::Playlist(a) => a.context(), + AppAction::Browser(a) => a.context(), + AppAction::Filter(a) => a.context(), + AppAction::Sort(a) => a.context(), + AppAction::Help(a) => a.context(), + AppAction::BrowserArtists(a) => a.context(), + AppAction::BrowserSearch(a) => a.context(), + AppAction::BrowserSongs(a) => a.context(), + AppAction::TextEntry(a) => a.context(), + AppAction::List(a) => a.context(), + } + } + fn describe(&self) -> std::borrow::Cow { + match self { + AppAction::Quit => "Quit".into(), + AppAction::PrevSong => "Prev Song".into(), + AppAction::NextSong => "Next Song".into(), + AppAction::Pause => "Pause".into(), + AppAction::VolUp => format!("Vol Up {VOL_TICK}").into(), + AppAction::VolDown => format!("Vol Down {VOL_TICK}").into(), + AppAction::ToggleHelp => "Toggle Help".into(), + AppAction::ViewLogs => "View Logs".into(), + AppAction::SeekForward => format!("Seek Forward {}s", SEEK_AMOUNT.as_secs()).into(), + AppAction::SeekBack => format!("Seek Back {}s", SEEK_AMOUNT.as_secs()).into(), + AppAction::NoOp => "No Operation".into(), + AppAction::Log(a) => a.describe(), + AppAction::Playlist(a) => a.describe(), + AppAction::Browser(a) => a.describe(), + AppAction::Filter(a) => a.describe(), + AppAction::Sort(a) => a.describe(), + AppAction::Help(a) => a.describe(), + AppAction::BrowserArtists(a) => a.describe(), + AppAction::BrowserSearch(a) => a.describe(), + AppAction::BrowserSongs(a) => a.describe(), + AppAction::TextEntry(a) => a.describe(), + AppAction::List(a) => a.describe(), + } + } +} + +impl TryFrom for AppAction { + type Error = String; + fn try_from(value: String) -> std::result::Result { + let mut vec = value + .split('.') + .take(3) + .map(ToString::to_string) + .collect::>(); + if vec.len() >= 3 { + return Err(format!( + "Action {value} had too many subscripts, expected 1 max" + )); + }; + if vec.is_empty() { + return Err("Action was empty!".to_string()); + }; + let back = vec.pop().expect("Length checked above"); + let front = vec.pop(); + if let Some(tag) = front { + // Neat hack to turn tag.back into any of the nested enum variants. + let json = serde_json::json!({tag : back}); + serde_json::from_value(json).map_err(|e| e.to_string()) + } else { + // Neat hack to turn back into any of the non-nested enum variants. + Deserialize::deserialize(de::value::StringDeserializer::::new( + back, + )) + .map_err(|e| e.to_string()) + } + } +} + +impl Action for HelpAction { + type State = HelpMenu; + fn context(&self) -> std::borrow::Cow { + match self { + HelpAction::Close => "Help".into(), + } + } + fn describe(&self) -> std::borrow::Cow { + match self { + HelpAction::Close => "Close Help".into(), + } + } +} +impl ActionHandler for HelpMenu { + async fn apply_action( + &mut self, + action: HelpAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + HelpAction::Close => self.shown = false, + } + AsyncTask::new_no_op() + } +} diff --git a/youtui/src/app/ui/browser.rs b/youtui/src/app/ui/browser.rs index 241f631b..79ac9a30 100644 --- a/youtui/src/app/ui/browser.rs +++ b/youtui/src/app/ui/browser.rs @@ -1,29 +1,31 @@ use self::{ - artistalbums::{ - albumsongs::{AlbumSongsPanel, ArtistSongsAction}, - artistsearch::{ArtistAction, ArtistSearchPanel}, - }, + artistalbums::{albumsongs::AlbumSongsPanel, artistsearch::ArtistSearchPanel}, draw::draw_browser, }; -use super::{AppCallback, WindowContext}; -use crate::app::{ - component::actionhandler::{ - Action, ActionHandler, DominantKeyRouter, KeyRouter, Suggestable, TextHandler, - }, - server::{ - api::GetArtistSongsProgressUpdate, ArcServer, GetArtistSongs, - SearchArtists, Server, TaskMetadata, - }, - structures::{ListStatus, SongListComponent}, - view::{DrawableMut, Scrollable}, - CALLBACK_CHANNEL_SIZE, +use super::{ + action::{AppAction, ListAction, TextEntryAction}, + AppCallback, WindowContext, }; -use crate::{app::keycommand::KeyCommand, core::send_or_error}; -use async_callback_manager::{ - AsyncCallbackManager, AsyncCallbackSender, Constraint, StateMutationBundle, +use crate::{ + app::{ + component::actionhandler::{ + Action, ActionHandler, Component, ComponentEffect, DominantKeyRouter, KeyRouter, + Scrollable, Suggestable, TextHandler, + }, + server::{ + api::GetArtistSongsProgressUpdate, ArcServer, GetArtistSongs, SearchArtists, + TaskMetadata, + }, + structures::{ListStatus, SongListComponent}, + view::{DrawableMut, ListView, TableView}, + }, + config::keymap::Keymap, }; -use crossterm::event::KeyCode; -use std::{borrow::Cow, mem, sync::Arc}; +use crate::{config::Config, core::send_or_error}; +use async_callback_manager::{AsyncTask, Constraint}; +use itertools::Either; +use serde::{Deserialize, Serialize}; +use std::{iter::Iterator, mem}; use tokio::sync::mpsc; use tracing::error; use ytmapi_rs::{ @@ -31,21 +33,9 @@ use ytmapi_rs::{ parse::{AlbumSong, SearchResultArtist}, }; -const PAGE_KEY_LINES: isize = 10; - -mod artistalbums; +pub mod artistalbums; mod draw; -#[derive(Clone, Debug, PartialEq)] -pub enum BrowserAction { - ViewPlaylist, - ToggleSearch, - Left, - Right, - Artist(ArtistAction), - ArtistSongs(ArtistSongsAction), -} - #[derive(PartialEq)] pub enum InputRouting { Artist, @@ -53,13 +43,37 @@ pub enum InputRouting { } pub struct Browser { - callback_tx: mpsc::Sender, + pub callback_tx: mpsc::Sender, pub input_routing: InputRouting, pub prev_input_routing: InputRouting, pub artist_list: ArtistSearchPanel, pub album_songs_list: AlbumSongsPanel, - keybinds: Vec>, - async_tx: AsyncCallbackSender, Self, TaskMetadata>, + keybinds: Keymap, +} + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserAction { + ViewPlaylist, + Search, + Left, + Right, +} + +impl Action for BrowserAction { + type State = Browser; + fn context(&self) -> std::borrow::Cow { + "Browser".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + BrowserAction::ViewPlaylist => "View Playlist", + BrowserAction::Search => "Toggle Search", + BrowserAction::Left => "Left", + BrowserAction::Right => "Right", + } + .into() + } } impl InputRouting { @@ -76,26 +90,43 @@ impl InputRouting { } } } -impl Action for BrowserAction { - fn context(&self) -> Cow { - let context = "Browser"; - match self { - Self::Artist(a) => format!("{context}->{}", a.context()).into(), - Self::ArtistSongs(a) => format!("{context}->{}", a.context()).into(), - _ => context.into(), +impl Scrollable for Browser { + fn increment_list(&mut self, amount: isize) { + match self.input_routing { + InputRouting::Artist => self.artist_list.increment_list(amount), + InputRouting::Song => self.artist_list.increment_list(amount), } } - fn describe(&self) -> Cow { - match self { - Self::Left => "Left".into(), - Self::Right => "Right".into(), - Self::ViewPlaylist => "View Playlist".into(), - Self::ToggleSearch => "Toggle Search".into(), - Self::Artist(x) => x.describe(), - Self::ArtistSongs(x) => x.describe(), + fn is_scrollable(&self) -> bool { + match self.input_routing { + InputRouting::Artist => self.artist_list.is_scrollable(), + InputRouting::Song => self.artist_list.is_scrollable(), } } } +impl ActionHandler for Browser { + async fn apply_action( + &mut self, + action: BrowserAction, + ) -> crate::app::component::actionhandler::ComponentEffect + where + Self: Sized, + { + match action { + BrowserAction::Left => self.left(), + BrowserAction::Right => self.right(), + BrowserAction::ViewPlaylist => { + send_or_error( + &self.callback_tx, + AppCallback::ChangeContext(WindowContext::Playlist), + ) + .await + } + BrowserAction::Search => self.handle_toggle_search(), + } + AsyncTask::new_no_op() + } +} // Should this really be implemented on the Browser... impl Suggestable for Browser { fn get_search_suggestions(&self) -> &[SearchSuggestion] { @@ -136,10 +167,19 @@ impl TextHandler for Browser { InputRouting::Song => self.album_songs_list.clear_text(), } } - fn handle_event_repr(&mut self, event: &crossterm::event::Event) -> bool { + fn handle_text_event_impl( + &mut self, + event: &crossterm::event::Event, + ) -> Option> { match self.input_routing { - InputRouting::Artist => self.artist_list.handle_event_repr(event), - InputRouting::Song => self.album_songs_list.handle_event_repr(event), + InputRouting::Artist => self + .artist_list + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut Self| &mut this.artist_list)), + InputRouting::Song => self + .album_songs_list + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut Self| &mut this.album_songs_list)), } } } @@ -154,133 +194,92 @@ impl DrawableMut for Browser { draw_browser(f, self, chunk, selected); } } -impl KeyRouter for Browser { - fn get_all_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new( - self.keybinds - .iter() - .chain(self.artist_list.get_all_keybinds()) - .chain(self.album_songs_list.get_all_keybinds()), - ) - } - fn get_routed_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - let additional_binds = match self.input_routing { - InputRouting::Song => self.album_songs_list.get_routed_keybinds(), - InputRouting::Artist => self.artist_list.get_routed_keybinds(), - }; - // TODO: Better implementation - if self.album_songs_list.dominant_keybinds_active() - || self.album_songs_list.dominant_keybinds_active() - { - additional_binds - } else { - Box::new(self.keybinds.iter().chain(additional_binds)) - } - } -} -impl ActionHandler for Browser { - async fn handle_action(&mut self, action: &ArtistAction) { - match action { - ArtistAction::DisplayAlbums => self.get_songs().await, - ArtistAction::Search => self.search().await, - ArtistAction::Up => self.artist_list.increment_list(-1), - ArtistAction::Down => self.artist_list.increment_list(1), - ArtistAction::PageUp => self.artist_list.increment_list(-10), - ArtistAction::PageDown => self.artist_list.increment_list(10), - ArtistAction::PrevSearchSuggestion => self.artist_list.search.increment_list(-1), - ArtistAction::NextSearchSuggestion => self.artist_list.search.increment_list(1), - } - } -} -impl ActionHandler for Browser { - async fn handle_action(&mut self, action: &ArtistSongsAction) { - match action { - ArtistSongsAction::PlayAlbum => self.play_album().await, - ArtistSongsAction::PlaySong => self.play_song().await, - ArtistSongsAction::PlaySongs => self.play_songs().await, - ArtistSongsAction::AddAlbumToPlaylist => self.add_album_to_playlist().await, - ArtistSongsAction::AddSongToPlaylist => self.add_song_to_playlist().await, - ArtistSongsAction::AddSongsToPlaylist => self.add_songs_to_playlist().await, - ArtistSongsAction::Up => self.album_songs_list.increment_list(-1), - ArtistSongsAction::Down => self.album_songs_list.increment_list(1), - ArtistSongsAction::PageUp => self.album_songs_list.increment_list(-PAGE_KEY_LINES), - ArtistSongsAction::PageDown => self.album_songs_list.increment_list(PAGE_KEY_LINES), - ArtistSongsAction::PopSort => self.album_songs_list.handle_pop_sort(), - ArtistSongsAction::CloseSort => self.album_songs_list.close_sort(), - ArtistSongsAction::ClearSort => self.album_songs_list.handle_clear_sort(), - ArtistSongsAction::SortUp => self.album_songs_list.handle_sort_up(), - ArtistSongsAction::SortDown => self.album_songs_list.handle_sort_down(), - ArtistSongsAction::SortSelectedAsc => self.album_songs_list.handle_sort_cur_asc(), - ArtistSongsAction::SortSelectedDesc => self.album_songs_list.handle_sort_cur_desc(), - ArtistSongsAction::ToggleFilter => self.album_songs_list.toggle_filter(), - ArtistSongsAction::ApplyFilter => self.album_songs_list.apply_filter(), - ArtistSongsAction::ClearFilter => self.album_songs_list.clear_filter(), +impl KeyRouter for Browser { + fn get_all_keybinds(&self) -> impl Iterator> { + std::iter::once(&self.keybinds) + .chain(self.artist_list.get_all_keybinds()) + .chain(self.album_songs_list.get_all_keybinds()) + } + fn get_active_keybinds(&self) -> impl Iterator> { + if self.dominant_keybinds_active() { + return Either::Left(self.get_dominant_keybinds()); } - } -} -impl ActionHandler for Browser { - async fn handle_action(&mut self, action: &BrowserAction) { - match action { - BrowserAction::ArtistSongs(a) => self.handle_action(a).await, - BrowserAction::Artist(a) => self.handle_action(a).await, - BrowserAction::Left => self.left(), - BrowserAction::Right => self.right(), - BrowserAction::ViewPlaylist => { - send_or_error( - &self.callback_tx, - AppCallback::ChangeContext(WindowContext::Playlist), - ) - .await + Either::Right( + match self.input_routing { + InputRouting::Song => Either::Left(self.album_songs_list.get_active_keybinds()), + InputRouting::Artist => Either::Right(self.artist_list.get_active_keybinds()), } - BrowserAction::ToggleSearch => self.handle_toggle_search(), - } + .chain(std::iter::once(&self.keybinds)), + ) } } - -impl DominantKeyRouter for Browser { +impl DominantKeyRouter for Browser { fn dominant_keybinds_active(&self) -> bool { match self.input_routing { InputRouting::Artist => false, InputRouting::Song => self.album_songs_list.dominant_keybinds_active(), } } + fn get_dominant_keybinds(&self) -> impl Iterator> { + match self.input_routing { + InputRouting::Artist => Either::Left(self.artist_list.get_active_keybinds()), + InputRouting::Song => Either::Right(self.album_songs_list.get_dominant_keybinds()), + } + } } impl Browser { - pub fn new( - callback_manager: &mut AsyncCallbackManager, - ui_tx: mpsc::Sender, - ) -> Self { + pub fn new(ui_tx: mpsc::Sender, config: &Config) -> Self { Self { callback_tx: ui_tx, - artist_list: ArtistSearchPanel::new(callback_manager), - album_songs_list: AlbumSongsPanel::new(), + artist_list: ArtistSearchPanel::new(config), + album_songs_list: AlbumSongsPanel::new(config), input_routing: InputRouting::Artist, prev_input_routing: InputRouting::Artist, - keybinds: browser_keybinds(), - async_tx: callback_manager.new_sender(CALLBACK_CHANNEL_SIZE), - } - } - pub async fn async_update(&mut self) -> StateMutationBundle { - // TODO: Size - tokio::select! { - browser = self.async_tx.get_next_mutations(10) => browser, - search = self.artist_list.search.async_tx.get_next_mutations(10) => search.map(|b: &mut Self| &mut b.artist_list.search), + keybinds: config.keybinds.browser.clone(), } } - fn left(&mut self) { + pub fn left(&mut self) { // Doesn't consider previous routing. self.input_routing = self.input_routing.left(); } - fn right(&mut self) { + pub fn right(&mut self) { // Doesn't consider previous routing. self.input_routing = self.input_routing.right(); } - fn handle_toggle_search(&mut self) { + pub fn handle_list_action(&mut self, action: ListAction) -> ComponentEffect { + match self.input_routing { + InputRouting::Artist => self + .artist_list + .handle_list_action(action) + .map(|this: &mut Self| &mut this.artist_list), + InputRouting::Song => self + .album_songs_list + .handle_list_action(action) + .map(|this: &mut Self| &mut this.album_songs_list), + } + } + pub fn handle_text_entry_action(&mut self, action: TextEntryAction) -> ComponentEffect { + if self.is_text_handling() + && self.artist_list.search_popped + && self.input_routing == InputRouting::Artist + { + match action { + TextEntryAction::Submit => { + return self.search(); + } + // Handled by old handle_text_event_impl. + // + // TODO: remove the duplication of responsibilities between this function and + // handle_text_event_impl. + TextEntryAction::Left => (), + TextEntryAction::Right => (), + TextEntryAction::Backspace => (), + } + } + AsyncTask::new_no_op() + } + pub fn handle_toggle_search(&mut self) { if self.artist_list.search_popped { self.artist_list.close_search(); self.revert_routing(); @@ -289,7 +288,7 @@ impl Browser { self.change_routing(InputRouting::Artist); } } - async fn play_song(&mut self) { + pub async fn play_song(&mut self) { // Consider how resource intensive this is as it runs in the main thread. let cur_song_idx = self.album_songs_list.get_selected_item(); if let Some(cur_song) = self.album_songs_list.get_song_from_idx(cur_song_idx) { @@ -301,7 +300,7 @@ impl Browser { } // XXX: Do we want to indicate that song has been added to playlist? } - async fn play_songs(&mut self) { + pub async fn play_songs(&mut self) { // Consider how resource intensive this is as it runs in the main thread. let cur_idx = self.album_songs_list.get_selected_item(); let song_list = self @@ -317,7 +316,7 @@ impl Browser { .await; // XXX: Do we want to indicate that song has been added to playlist? } - async fn add_songs_to_playlist(&mut self) { + pub async fn add_songs_to_playlist(&mut self) { // Consider how resource intensive this is as it runs in the main thread. let cur_idx = self.album_songs_list.get_selected_item(); let song_list = self @@ -333,7 +332,7 @@ impl Browser { .await; // XXX: Do we want to indicate that song has been added to playlist? } - async fn add_song_to_playlist(&mut self) { + pub async fn add_song_to_playlist(&mut self) { // Consider how resource intensive this is as it runs in the main thread. let cur_idx = self.album_songs_list.get_selected_item(); if let Some(cur_song) = self.album_songs_list.get_song_from_idx(cur_idx) { @@ -345,7 +344,7 @@ impl Browser { } // XXX: Do we want to indicate that song has been added to playlist? } - async fn add_album_to_playlist(&mut self) { + pub async fn add_album_to_playlist(&mut self) { // Consider how resource intensive this is as it runs in the main thread. let cur_idx = self.album_songs_list.get_selected_item(); let Some(cur_song) = self.album_songs_list.get_song_from_idx(cur_idx) else { @@ -366,7 +365,7 @@ impl Browser { .await; // XXX: Do we want to indicate that song has been added to playlist? } - async fn play_album(&mut self) { + pub async fn play_album(&mut self) { // Consider how resource intensive this is as it runs in the main thread. let cur_idx = self.album_songs_list.get_selected_item(); let Some(cur_song) = self.album_songs_list.get_song_from_idx(cur_idx) else { @@ -388,7 +387,7 @@ impl Browser { .await; // XXX: Do we want to indicate that song has been added to playlist? } - async fn get_songs(&mut self) { + pub fn get_songs(&mut self) -> AsyncTask { let selected = self.artist_list.get_selected_item(); self.change_routing(InputRouting::Song); self.album_songs_list.list.clear(); @@ -401,7 +400,7 @@ impl Browser { .map(|a| a.browse_id) else { tracing::warn!("Tried to get item from list with index out of range"); - return; + return AsyncTask::new_no_op(); }; let handler = |this: &mut Self, item| match item { @@ -419,15 +418,13 @@ impl Browser { GetArtistSongsProgressUpdate::AllSongsSent => this.handle_song_list_loaded(), }; - if let Err(e) = self.async_tx.add_stream_callback( + AsyncTask::new_stream( GetArtistSongs(cur_artist_id), handler, Some(Constraint::new_kill_same_type()), - ) { - error!("Error <{e}> recieved sending message") - }; + ) } - async fn search(&mut self) { + pub fn search(&mut self) -> ComponentEffect { self.artist_list.close_search(); let search_query = self.artist_list.search.get_text().to_string(); self.artist_list.clear_text(); @@ -440,13 +437,11 @@ impl Browser { error!("Error <{e}> recieved getting artists."); } }; - if let Err(e) = self.async_tx.add_callback( + AsyncTask::new_future( SearchArtists(search_query), handler, Some(Constraint::new_kill_same_type()), - ) { - error!("Error <{e}> recieved sending message") - }; + ) } pub fn handle_search_artist_error(&mut self) { self.album_songs_list.list.state = ListStatus::Error; @@ -505,12 +500,7 @@ impl Browser { self.prev_input_routing = mem::replace(&mut self.input_routing, input_routing); } } - -fn browser_keybinds() -> Vec> { - vec![ - KeyCommand::new_global_from_code(KeyCode::F(5), BrowserAction::ViewPlaylist), - KeyCommand::new_global_from_code(KeyCode::F(2), BrowserAction::ToggleSearch), - KeyCommand::new_from_code(KeyCode::Left, BrowserAction::Left), - KeyCommand::new_from_code(KeyCode::Right, BrowserAction::Right), - ] +impl Component for Browser { + type Bkend = ArcServer; + type Md = TaskMetadata; } diff --git a/youtui/src/app/ui/browser/artistalbums/albumsongs.rs b/youtui/src/app/ui/browser/artistalbums/albumsongs.rs index 382ac37f..a5847a10 100644 --- a/youtui/src/app/ui/browser/artistalbums/albumsongs.rs +++ b/youtui/src/app/ui/browser/artistalbums/albumsongs.rs @@ -1,22 +1,30 @@ use super::get_adjusted_list_column; -use crate::app::component::actionhandler::{DominantKeyRouter, TextHandler}; +use crate::app::component::actionhandler::{ + ActionHandler, ComponentEffect, DominantKeyRouter, Scrollable, TextHandler, +}; +use crate::app::server::{ArcServer, TaskMetadata}; use crate::app::structures::{ListSong, SongListComponent}; -use crate::app::ui::browser::BrowserAction; +use crate::app::ui::action::{AppAction, ListAction, PAGE_KEY_LINES}; +use crate::app::ui::browser::Browser; use crate::app::view::{ Filter, FilterString, SortDirection, SortableTableView, TableFilterCommand, TableSortCommand, }; use crate::app::{ component::actionhandler::{Action, KeyRouter}, - keycommand::KeyCommand, structures::{AlbumSongsList, ListStatus, Percentage}, - view::{BasicConstraint, Loadable, Scrollable, TableView}, + view::{BasicConstraint, Loadable, TableView}, }; +use crate::config::keymap::Keymap; +use crate::config::Config; use crate::error::Error; use crate::Result; -use crossterm::event::{KeyCode, KeyModifiers}; +use async_callback_manager::AsyncTask; +use itertools::Either; use rat_text::text_input::{handle_events, TextInputState}; use ratatui::widgets::TableState; +use serde::{Deserialize, Serialize}; use std::borrow::Cow; +use std::iter::Iterator; use tracing::warn; #[derive(Clone, Debug, Default, PartialEq)] @@ -30,13 +38,14 @@ pub enum AlbumSongsInputRouting { #[derive(Clone)] pub struct AlbumSongsPanel { pub list: AlbumSongsList, - keybinds: Vec>, + keybinds: Keymap, pub route: AlbumSongsInputRouting, pub sort: SortManager, pub filter: FilterManager, cur_selected: usize, pub widget_state: TableState, } +impl_youtui_component!(AlbumSongsPanel); // TODO: refactor #[derive(Clone)] @@ -44,8 +53,9 @@ pub struct FilterManager { filter_commands: Vec, pub filter_text: TextInputState, pub shown: bool, - keybinds: Vec>, + keybinds: Keymap, } +impl_youtui_component!(FilterManager); // TODO: refactor #[derive(Clone)] @@ -53,30 +63,155 @@ pub struct SortManager { sort_commands: Vec, pub shown: bool, pub cur: usize, - keybinds: Vec>, + keybinds: Keymap, +} +impl_youtui_component!(SortManager); + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserSongsAction { + Filter, + Sort, + PlaySong, + PlaySongs, + PlayAlbum, + AddSongToPlaylist, + AddSongsToPlaylist, + AddAlbumToPlaylist, } -impl Default for SortManager { - fn default() -> Self { +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilterAction { + Close, + ClearFilter, + Apply, +} + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SortAction { + Close, + ClearSort, + SortSelectedAsc, + SortSelectedDesc, +} + +impl Action for FilterAction { + type State = Browser; + fn context(&self) -> std::borrow::Cow { + "Filter".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + FilterAction::Close => "Close Filter", + FilterAction::Apply => "Apply filter", + FilterAction::ClearFilter => "Clear filter", + } + .into() + } +} + +impl Action for SortAction { + type State = Browser; + fn context(&self) -> std::borrow::Cow { + "Filter".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + SortAction::Close => "Close sort", + SortAction::ClearSort => "Clear sort", + SortAction::SortSelectedAsc => "Sort ascending", + SortAction::SortSelectedDesc => "Sort descending", + } + .into() + } +} + +impl Action for BrowserSongsAction { + type State = Browser; + fn context(&self) -> std::borrow::Cow { + "Artist Songs Panel".into() + } + fn describe(&self) -> std::borrow::Cow { + match &self { + BrowserSongsAction::PlaySong => "Play song", + BrowserSongsAction::PlaySongs => "Play songs", + BrowserSongsAction::PlayAlbum => "Play album", + BrowserSongsAction::AddSongToPlaylist => "Add song to playlist", + BrowserSongsAction::AddSongsToPlaylist => "Add songs to playlist", + BrowserSongsAction::AddAlbumToPlaylist => "Add album to playlist", + BrowserSongsAction::Sort => "Sort", + BrowserSongsAction::Filter => "Filter", + } + .into() + } +} +impl ActionHandler for Browser { + async fn apply_action( + &mut self, + action: FilterAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + FilterAction::Close => self.album_songs_list.toggle_filter(), + FilterAction::Apply => self.album_songs_list.apply_filter(), + FilterAction::ClearFilter => self.album_songs_list.clear_filter(), + }; + AsyncTask::new_no_op() + } +} +impl ActionHandler for Browser { + async fn apply_action( + &mut self, + action: SortAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + SortAction::SortSelectedAsc => self.album_songs_list.handle_sort_cur_asc(), + SortAction::SortSelectedDesc => self.album_songs_list.handle_sort_cur_desc(), + SortAction::Close => self.album_songs_list.close_sort(), + SortAction::ClearSort => self.album_songs_list.handle_clear_sort(), + } + AsyncTask::new_no_op() + } +} +impl ActionHandler for Browser { + async fn apply_action( + &mut self, + action: BrowserSongsAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + BrowserSongsAction::PlayAlbum => self.play_album().await, + BrowserSongsAction::PlaySong => self.play_song().await, + BrowserSongsAction::PlaySongs => self.play_songs().await, + BrowserSongsAction::AddAlbumToPlaylist => self.add_album_to_playlist().await, + BrowserSongsAction::AddSongToPlaylist => self.add_song_to_playlist().await, + BrowserSongsAction::AddSongsToPlaylist => self.add_songs_to_playlist().await, + BrowserSongsAction::Sort => self.album_songs_list.handle_pop_sort(), + BrowserSongsAction::Filter => self.album_songs_list.toggle_filter(), + } + AsyncTask::new_no_op() + } +} +impl SortManager { + fn new(config: &Config) -> Self { Self { sort_commands: Default::default(), shown: Default::default(), cur: Default::default(), - keybinds: sort_keybinds(), + keybinds: sort_keybinds(config), } } } -impl Default for FilterManager { - fn default() -> Self { +impl FilterManager { + fn new(config: &Config) -> Self { Self { filter_text: Default::default(), filter_commands: Default::default(), shown: Default::default(), - keybinds: filter_keybinds(), + keybinds: filter_keybinds(config), } } } - impl TextHandler for FilterManager { fn is_text_handling(&self) -> bool { true @@ -90,50 +225,28 @@ impl TextHandler for FilterManager { fn clear_text(&mut self) -> bool { self.filter_text.clear() } - fn handle_event_repr(&mut self, event: &crossterm::event::Event) -> bool { + fn handle_text_event_impl( + &mut self, + event: &crossterm::event::Event, + ) -> Option> { match handle_events(&mut self.filter_text, true, event) { - rat_text::event::TextOutcome::Continue => false, - rat_text::event::TextOutcome::Unchanged => true, - rat_text::event::TextOutcome::Changed => true, - rat_text::event::TextOutcome::TextChanged => true, + rat_text::event::TextOutcome::Continue => None, + rat_text::event::TextOutcome::Unchanged => Some(AsyncTask::new_no_op()), + rat_text::event::TextOutcome::Changed => Some(AsyncTask::new_no_op()), + rat_text::event::TextOutcome::TextChanged => Some(AsyncTask::new_no_op()), } } } -#[derive(Clone, Debug, PartialEq)] -pub enum ArtistSongsAction { - PlaySong, - PlaySongs, - PlayAlbum, - AddSongToPlaylist, - AddSongsToPlaylist, - AddAlbumToPlaylist, - Up, - Down, - PageUp, - PageDown, - SortUp, - SortDown, - // Could just be two commands. - PopSort, - CloseSort, - ClearSort, - SortSelectedAsc, - SortSelectedDesc, - ToggleFilter, - ApplyFilter, - ClearFilter, -} - impl AlbumSongsPanel { - pub fn new() -> AlbumSongsPanel { + pub fn new(config: &Config) -> AlbumSongsPanel { AlbumSongsPanel { - keybinds: songs_keybinds(), + keybinds: songs_keybinds(config), cur_selected: Default::default(), list: Default::default(), route: Default::default(), - sort: Default::default(), - filter: Default::default(), + sort: SortManager::new(config), + filter: FilterManager::new(config), widget_state: Default::default(), } } @@ -224,6 +337,28 @@ impl AlbumSongsPanel { self.sort.shown = false; self.route = AlbumSongsInputRouting::List; } + pub fn handle_list_action(&mut self, action: ListAction) -> ComponentEffect { + if self.sort.shown { + match action { + ListAction::Up => self.handle_sort_up(), + ListAction::Down => self.handle_sort_down(), + // TODO: Handle PgUp / PgDown specially. + ListAction::PageUp => self.handle_sort_up(), + ListAction::PageDown => self.handle_sort_up(), + } + return AsyncTask::new_no_op(); + } + if self.filter.shown { + return AsyncTask::new_no_op(); + } + match action { + ListAction::Up => self.increment_list(-1), + ListAction::Down => self.increment_list(1), + ListAction::PageUp => self.increment_list(-PAGE_KEY_LINES), + ListAction::PageDown => self.increment_list(PAGE_KEY_LINES), + } + AsyncTask::new_no_op() + } pub fn handle_pop_sort(&mut self) { // If no sortable columns, should we not handle this command? self.sort.cur = 0; @@ -301,60 +436,40 @@ impl TextHandler for AlbumSongsPanel { fn clear_text(&mut self) -> bool { self.filter.clear_text() } - fn handle_event_repr(&mut self, event: &crossterm::event::Event) -> bool { - self.filter.handle_event_repr(event) - } -} - -impl Action for ArtistSongsAction { - fn context(&self) -> Cow { - "Artist Songs Panel".into() - } - fn describe(&self) -> Cow { - match &self { - ArtistSongsAction::PlaySong => "Play song", - ArtistSongsAction::PlaySongs => "Play songs", - ArtistSongsAction::PlayAlbum => "Play album", - ArtistSongsAction::AddSongToPlaylist => "Add song to playlist", - ArtistSongsAction::AddSongsToPlaylist => "Add songs to playlist", - ArtistSongsAction::AddAlbumToPlaylist => "Add album to playlist", - ArtistSongsAction::Up | Self::SortUp => "Up", - ArtistSongsAction::Down | Self::SortDown => "Down", - ArtistSongsAction::PageUp => "Page Up", - ArtistSongsAction::PageDown => "Page Down", - ArtistSongsAction::PopSort => "Sort", - ArtistSongsAction::ToggleFilter => "Filter", - ArtistSongsAction::ApplyFilter => "Apply filter", - ArtistSongsAction::ClearFilter => "Clear filter", - ArtistSongsAction::CloseSort => "Close sort", - ArtistSongsAction::ClearSort => "Clear sort", - ArtistSongsAction::SortSelectedAsc => "Sort ascending", - ArtistSongsAction::SortSelectedDesc => "Sort descending", - } - .into() + fn handle_text_event_impl( + &mut self, + event: &crossterm::event::Event, + ) -> Option> { + self.filter + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut AlbumSongsPanel| &mut this.filter)) } } -impl DominantKeyRouter for AlbumSongsPanel { +impl DominantKeyRouter for AlbumSongsPanel { fn dominant_keybinds_active(&self) -> bool { self.sort.shown || self.filter.shown } + + fn get_dominant_keybinds(&self) -> impl Iterator> { + self.get_active_keybinds() + } } -impl KeyRouter for AlbumSongsPanel { - fn get_all_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(self.keybinds.iter().chain(self.sort.keybinds.iter())) - } - fn get_routed_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(match self.route { - AlbumSongsInputRouting::List => self.keybinds.iter(), - AlbumSongsInputRouting::Sort => self.sort.keybinds.iter(), - AlbumSongsInputRouting::Filter => self.filter.keybinds.iter(), - }) +impl KeyRouter for AlbumSongsPanel { + fn get_all_keybinds(&self) -> impl Iterator> { + [&self.keybinds, &self.sort.keybinds].into_iter() + } + fn get_active_keybinds(&self) -> impl Iterator> { + match self.route { + AlbumSongsInputRouting::List => { + Either::Left(Either::Left(std::iter::once(&self.keybinds))) + } + AlbumSongsInputRouting::Sort => { + Either::Left(Either::Right(std::iter::once(&self.sort.keybinds))) + } + AlbumSongsInputRouting::Filter => Either::Right(std::iter::once(&self.filter.keybinds)), + } } } @@ -372,12 +487,15 @@ impl Scrollable for AlbumSongsPanel { .saturating_add_signed(amount) .min(self.get_filtered_items().count().saturating_sub(1)) } - fn get_selected_item(&self) -> usize { - self.cur_selected + fn is_scrollable(&self) -> bool { + true } } impl TableView for AlbumSongsPanel { + fn get_selected_item(&self) -> usize { + self.cur_selected + } fn get_state(&self) -> ratatui::widgets::TableState { self.widget_state.clone() } @@ -488,122 +606,14 @@ impl SortableTableView for AlbumSongsPanel { } } -fn sort_keybinds() -> Vec> { - // Consider a blocking type of keybind for this that stops all other commands - // being received. - vec![ - KeyCommand::new_global_from_code( - KeyCode::F(4), - BrowserAction::ArtistSongs(ArtistSongsAction::CloseSort), - ), - KeyCommand::new_global_from_code( - KeyCode::Enter, - BrowserAction::ArtistSongs(ArtistSongsAction::SortSelectedAsc), - ), - // Seems to not work on Windows. - KeyCommand::new_global_modified_from_code( - KeyCode::Enter, - KeyModifiers::ALT, - BrowserAction::ArtistSongs(ArtistSongsAction::SortSelectedDesc), - ), - KeyCommand::new_global_from_code( - KeyCode::Char('C'), - BrowserAction::ArtistSongs(ArtistSongsAction::ClearSort), - ), - KeyCommand::new_hidden_from_code( - KeyCode::Esc, - BrowserAction::ArtistSongs(ArtistSongsAction::CloseSort), - ), - // XXX: Consider if these type of actions can be for all lists. - KeyCommand::new_hidden_from_code( - KeyCode::Down, - BrowserAction::ArtistSongs(ArtistSongsAction::SortDown), - ), - KeyCommand::new_hidden_from_code( - KeyCode::Up, - BrowserAction::ArtistSongs(ArtistSongsAction::SortUp), - ), - ] +fn sort_keybinds(config: &Config) -> Keymap { + config.keybinds.sort.clone() } -fn filter_keybinds() -> Vec> { - // Consider a blocking type of keybind for this that stops all other commands - // being received. - vec![ - KeyCommand::new_global_from_code( - KeyCode::F(3), - BrowserAction::ArtistSongs(ArtistSongsAction::ToggleFilter), - ), - KeyCommand::new_global_from_code( - KeyCode::F(6), - BrowserAction::ArtistSongs(ArtistSongsAction::ClearFilter), - ), - KeyCommand::new_global_from_code( - KeyCode::Enter, - BrowserAction::ArtistSongs(ArtistSongsAction::ApplyFilter), - ), - KeyCommand::new_hidden_from_code( - KeyCode::Esc, - BrowserAction::ArtistSongs(ArtistSongsAction::ToggleFilter), - ), - ] +fn filter_keybinds(config: &Config) -> Keymap { + config.keybinds.filter.clone() } -pub fn songs_keybinds() -> Vec> { - vec![ - KeyCommand::new_global_from_code( - KeyCode::F(3), - BrowserAction::ArtistSongs(ArtistSongsAction::ToggleFilter), - ), - KeyCommand::new_global_from_code( - KeyCode::F(4), - BrowserAction::ArtistSongs(ArtistSongsAction::PopSort), - ), - KeyCommand::new_from_code( - KeyCode::PageUp, - BrowserAction::ArtistSongs(ArtistSongsAction::PageUp), - ), - KeyCommand::new_from_code( - KeyCode::PageDown, - BrowserAction::ArtistSongs(ArtistSongsAction::PageDown), - ), - KeyCommand::new_hidden_from_code( - KeyCode::Down, - BrowserAction::ArtistSongs(ArtistSongsAction::Down), - ), - KeyCommand::new_hidden_from_code( - KeyCode::Up, - BrowserAction::ArtistSongs(ArtistSongsAction::Up), - ), - KeyCommand::new_action_only_mode( - vec![ - ( - KeyCode::Enter, - BrowserAction::ArtistSongs(ArtistSongsAction::PlaySong), - ), - ( - KeyCode::Char('p'), - BrowserAction::ArtistSongs(ArtistSongsAction::PlaySongs), - ), - ( - KeyCode::Char('a'), - BrowserAction::ArtistSongs(ArtistSongsAction::PlayAlbum), - ), - ( - KeyCode::Char(' '), - BrowserAction::ArtistSongs(ArtistSongsAction::AddSongToPlaylist), - ), - ( - KeyCode::Char('P'), - BrowserAction::ArtistSongs(ArtistSongsAction::AddSongsToPlaylist), - ), - ( - KeyCode::Char('A'), - BrowserAction::ArtistSongs(ArtistSongsAction::AddAlbumToPlaylist), - ), - ], - KeyCode::Enter, - "Play", - ), - ] +pub fn songs_keybinds(config: &Config) -> Keymap { + config.keybinds.browser_songs.clone() } diff --git a/youtui/src/app/ui/browser/artistalbums/artistsearch.rs b/youtui/src/app/ui/browser/artistalbums/artistsearch.rs index 43ed8b88..5c92b4e7 100644 --- a/youtui/src/app/ui/browser/artistalbums/artistsearch.rs +++ b/youtui/src/app/ui/browser/artistalbums/artistsearch.rs @@ -1,21 +1,26 @@ -use std::borrow::Cow; - -use async_callback_manager::{AsyncCallbackManager, AsyncCallbackSender, Constraint}; -use crossterm::event::KeyCode; +use crate::{ + app::{ + component::actionhandler::{ + Action, ActionHandler, Component, ComponentEffect, KeyRouter, Scrollable, Suggestable, + TextHandler, + }, + server::{ArcServer, GetSearchSuggestions, TaskMetadata}, + ui::{ + action::{AppAction, ListAction, PAGE_KEY_LINES}, + browser::Browser, + }, + view::{ListView, Loadable, SortableList}, + }, + config::{keymap::Keymap, Config}, +}; +use async_callback_manager::{AsyncTask, Constraint}; use rat_text::text_input::{handle_events, TextInputState}; use ratatui::widgets::ListState; +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, iter::Iterator}; use tracing::error; use ytmapi_rs::{common::SearchSuggestion, parse::SearchResultArtist}; -use crate::app::{ - component::actionhandler::{Action, KeyRouter, Suggestable, TextHandler}, - keycommand::KeyCommand, - server::{ArcServer, GetSearchSuggestions, TaskMetadata}, - ui::browser::BrowserAction, - view::{ListView, Loadable, Scrollable, SortableList}, - CALLBACK_CHANNEL_SIZE, -}; - #[derive(Clone, Debug, Default, PartialEq)] pub enum ArtistInputRouting { Search, @@ -30,8 +35,8 @@ pub struct ArtistSearchPanel { pub route: ArtistInputRouting, selected: usize, sort_commands_list: Vec, - keybinds: Vec>, - search_keybinds: Vec>, + keybinds: Keymap, + search_keybinds: Keymap, pub search_popped: bool, pub search: SearchBlock, pub widget_state: ListState, @@ -41,34 +46,83 @@ pub struct SearchBlock { pub search_contents: TextInputState, pub search_suggestions: Vec, pub suggestions_cur: Option, - pub async_tx: AsyncCallbackSender, } +impl_youtui_component!(SearchBlock); -#[derive(Clone, Debug, PartialEq)] -pub enum ArtistAction { - DisplayAlbums, - // XXX: This could be a subset - eg ListAction - Up, - Down, - PageUp, - PageDown, - // XXX: Could be a subset just for search - Search, +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserArtistsAction { + DisplaySelectedArtistAlbums, +} + +impl Action for BrowserArtistsAction { + type State = Browser; + fn context(&self) -> std::borrow::Cow { + "Artist Search Panel".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + Self::DisplaySelectedArtistAlbums => "Display albums for selected artist", + } + .into() + } +} + +#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserSearchAction { + SearchArtist, PrevSearchSuggestion, NextSearchSuggestion, } - +impl Action for BrowserSearchAction { + type State = Browser; + fn context(&self) -> std::borrow::Cow { + "Artist Search Panel".into() + } + fn describe(&self) -> std::borrow::Cow { + match self { + BrowserSearchAction::SearchArtist => "Search", + BrowserSearchAction::PrevSearchSuggestion => "Prev Search Suggestion", + BrowserSearchAction::NextSearchSuggestion => "Next Search Suggestion", + } + .into() + } +} +impl ActionHandler for Browser { + async fn apply_action( + &mut self, + action: BrowserArtistsAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + BrowserArtistsAction::DisplaySelectedArtistAlbums => self.get_songs(), + } + } +} +impl ActionHandler for Browser { + async fn apply_action( + &mut self, + action: BrowserSearchAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + BrowserSearchAction::SearchArtist => return self.search(), + BrowserSearchAction::PrevSearchSuggestion => self.artist_list.search.increment_list(-1), + BrowserSearchAction::NextSearchSuggestion => self.artist_list.search.increment_list(1), + } + AsyncTask::new_no_op() + } +} impl ArtistSearchPanel { - pub fn new(callback_manager: &mut AsyncCallbackManager) -> Self { + pub fn new(config: &Config) -> Self { Self { - keybinds: browser_artist_search_keybinds(), - search_keybinds: search_keybinds(), + keybinds: browser_artist_search_keybinds(config), + search_keybinds: search_keybinds(config), list: Default::default(), route: Default::default(), selected: Default::default(), sort_commands_list: Default::default(), search_popped: Default::default(), - search: SearchBlock::new(callback_manager), + search: SearchBlock::new(), widget_state: Default::default(), } } @@ -80,25 +134,23 @@ impl ArtistSearchPanel { self.search_popped = false; self.route = ArtistInputRouting::List; } -} -impl Action for ArtistAction { - fn context(&self) -> Cow { - "Artist Search Panel".into() - } - fn describe(&self) -> Cow { - match &self { - Self::Search => "Search", - Self::DisplayAlbums => "Display albums for selected artist", - Self::Up => "Up", - Self::Down => "Down", - Self::PageUp => "Page Up", - Self::PageDown => "Page Down", - ArtistAction::PrevSearchSuggestion => "Next Search Suggestion", - ArtistAction::NextSearchSuggestion => "Prev Search Suggestion", + pub fn handle_list_action(&mut self, action: ListAction) -> ComponentEffect { + if self.route != ArtistInputRouting::List { + return AsyncTask::new_no_op(); } - .into() + match action { + ListAction::Up => self.increment_list(-1), + ListAction::Down => self.increment_list(1), + ListAction::PageUp => self.increment_list(-PAGE_KEY_LINES), + ListAction::PageDown => self.increment_list(PAGE_KEY_LINES), + } + AsyncTask::new_no_op() } } +impl Component for ArtistSearchPanel { + type Bkend = ArcServer; + type Md = TaskMetadata; +} impl TextHandler for SearchBlock { fn is_text_handling(&self) -> bool { @@ -115,34 +167,33 @@ impl TextHandler for SearchBlock { self.search_suggestions.clear(); self.search_contents.clear() } - fn handle_event_repr(&mut self, event: &crossterm::event::Event) -> bool { + fn handle_text_event_impl( + &mut self, + event: &crossterm::event::Event, + ) -> Option> { match handle_events(&mut self.search_contents, true, event) { - rat_text::event::TextOutcome::Continue => false, - rat_text::event::TextOutcome::Unchanged => true, - rat_text::event::TextOutcome::Changed => true, - rat_text::event::TextOutcome::TextChanged => { - self.fetch_search_suggestions(); - true - } + rat_text::event::TextOutcome::Continue => None, + rat_text::event::TextOutcome::Unchanged => Some(AsyncTask::new_no_op()), + rat_text::event::TextOutcome::Changed => Some(AsyncTask::new_no_op()), + rat_text::event::TextOutcome::TextChanged => Some(self.fetch_search_suggestions()), } } } impl SearchBlock { - pub fn new(callback_manager: &mut AsyncCallbackManager) -> Self { + pub fn new() -> Self { Self { search_contents: Default::default(), search_suggestions: Default::default(), suggestions_cur: Default::default(), - async_tx: callback_manager.new_sender(CALLBACK_CHANNEL_SIZE), } } // Ask the UI for search suggestions for the current query - fn fetch_search_suggestions(&mut self) { + fn fetch_search_suggestions(&mut self) -> AsyncTask { // No need to fetch search suggestions if contents is empty. if self.search_contents.is_empty() { self.search_suggestions.clear(); - return; + return AsyncTask::new_no_op(); } let handler = |this: &mut Self, results| match results { Ok((suggestions, text)) => { @@ -152,13 +203,11 @@ impl SearchBlock { error!("Error <{e}> recieved getting search suggestions"); } }; - if let Err(e) = self.async_tx.add_callback( + AsyncTask::new_future( GetSearchSuggestions(self.get_text().to_string()), handler, Some(Constraint::new_kill_same_type()), - ) { - error!("Error <{e}> recieved sending message") - }; + ) } fn replace_search_suggestions( &mut self, @@ -203,8 +252,13 @@ impl TextHandler for ArtistSearchPanel { fn clear_text(&mut self) -> bool { self.search.clear_text() } - fn handle_event_repr(&mut self, event: &crossterm::event::Event) -> bool { - self.search.handle_event_repr(event) + fn handle_text_event_impl( + &mut self, + event: &crossterm::event::Event, + ) -> Option> { + self.search + .handle_text_event_impl(event) + .map(|effect| effect.map(|this: &mut Self| &mut this.search)) } } @@ -217,19 +271,15 @@ impl Suggestable for ArtistSearchPanel { } } -impl KeyRouter for ArtistSearchPanel { - fn get_all_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(self.keybinds.iter().chain(self.search_keybinds.iter())) - } - fn get_routed_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(match self.route { - ArtistInputRouting::List => self.keybinds.iter(), - ArtistInputRouting::Search => self.search_keybinds.iter(), - }) +impl KeyRouter for ArtistSearchPanel { + fn get_all_keybinds(&self) -> impl Iterator> { + [&self.keybinds, &self.search_keybinds].into_iter() + } + fn get_active_keybinds(&self) -> impl Iterator> { + match self.route { + ArtistInputRouting::List => std::iter::once(&self.keybinds), + ArtistInputRouting::Search => std::iter::once(&self.search_keybinds), + } } } @@ -241,8 +291,8 @@ impl Scrollable for ArtistSearchPanel { .unwrap_or(0) .min(self.len().checked_add_signed(-1).unwrap_or(0)); } - fn get_selected_item(&self) -> usize { - self.selected + fn is_scrollable(&self) -> bool { + todo!() } } @@ -262,6 +312,9 @@ impl Loadable for ArtistSearchPanel { } } impl ListView for ArtistSearchPanel { + fn get_selected_item(&self) -> usize { + self.selected + } type DisplayItem = String; fn get_state(&self) -> ratatui::widgets::ListState { self.widget_state.clone() @@ -276,32 +329,9 @@ impl ListView for ArtistSearchPanel { "Artists".into() } } -fn search_keybinds() -> Vec> { - vec![ - KeyCommand::new_from_code(KeyCode::Enter, BrowserAction::Artist(ArtistAction::Search)), - KeyCommand::new_from_code( - KeyCode::Down, - BrowserAction::Artist(ArtistAction::NextSearchSuggestion), - ), - KeyCommand::new_from_code( - KeyCode::Up, - BrowserAction::Artist(ArtistAction::PrevSearchSuggestion), - ), - ] +fn search_keybinds(config: &Config) -> Keymap { + config.keybinds.browser_search.clone() } -fn browser_artist_search_keybinds() -> Vec> { - vec![ - KeyCommand::new_from_code( - KeyCode::Enter, - BrowserAction::Artist(ArtistAction::DisplayAlbums), - ), - // XXX: Consider if these type of actions can be for all lists. - KeyCommand::new_hidden_from_code(KeyCode::Down, BrowserAction::Artist(ArtistAction::Down)), - KeyCommand::new_hidden_from_code(KeyCode::Up, BrowserAction::Artist(ArtistAction::Up)), - KeyCommand::new_from_code(KeyCode::PageUp, BrowserAction::Artist(ArtistAction::PageUp)), - KeyCommand::new_from_code( - KeyCode::PageDown, - BrowserAction::Artist(ArtistAction::PageDown), - ), - ] +fn browser_artist_search_keybinds(config: &Config) -> Keymap { + config.keybinds.browser_artists.clone() } diff --git a/youtui/src/app/ui/draw.rs b/youtui/src/app/ui/draw.rs index 9fe97607..df7b5ca2 100644 --- a/youtui/src/app/ui/draw.rs +++ b/youtui/src/app/ui/draw.rs @@ -1,12 +1,11 @@ use super::{footer, header, WindowContext, YoutuiWindow}; -use crate::app::component::actionhandler::KeyDisplayer; -use crate::app::keycommand::{DisplayableCommand, DisplayableMode}; use crate::app::view::draw::draw_panel; use crate::app::view::{Drawable, DrawableMut}; use crate::drawutils::{ highlight_style, left_bottom_corner_rect, SELECTED_BORDER_COLOUR, TABLE_HEADINGS_COLOUR, TEXT_COLOUR, }; +use crate::keyaction::{DisplayableKeyAction, DisplayableMode}; use ratatui::prelude::{Margin, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::symbols::{block, line}; @@ -69,7 +68,7 @@ fn draw_popup(f: &mut Frame, w: &YoutuiWindow, chunk: Rect) { let (shortcut_len, description_len, commands_vec) = shortcuts_descriptions.iter().fold( (0, 0, Vec::new()), |(acc1, acc2, mut commands_vec), - DisplayableCommand { + DisplayableKeyAction { keybinds, context: _, description, @@ -106,17 +105,18 @@ fn draw_popup(f: &mut Frame, w: &YoutuiWindow, chunk: Rect) { f.render_widget(block, area); } +/// Draw the help page. The help page should show all visible commands for the +/// current page. fn draw_help(f: &mut Frame, w: &mut YoutuiWindow, chunk: Rect) { - // NOTE: if there are more commands than we can fit on the screen, some will be - // cut off. - let commands = w.get_all_visible_keybinds_as_readable_iter(); - // Get the maximum length of each element in the tuple vector created above, as - // well as the number of items. XXX: Probably don't need to map then fold, - // just fold. XXX: Fold closure could be written as a function, then becomes + // XXX: Probably don't need to map then fold, + // just fold. + // + // XXX: Fold closure could be written as a function, then becomes // testable. - let (mut s_len, mut c_len, mut d_len, items) = commands + let (mut s_len, mut c_len, mut d_len, items) = w + .get_help_list_items() .map( - |DisplayableCommand { + |DisplayableKeyAction { keybinds, context, description, @@ -132,10 +132,10 @@ fn draw_help(f: &mut Frame, w: &mut YoutuiWindow, chunk: Rect) { // Total block height required, including header and borders. let height = items + 3; // Naive implementation - // XXX: We're running get_all_visible_keybinds a second time here. + // XXX: We're running get_help_list_items a second time here. // Better to move to the fold above. - let commands_table = w.get_all_visible_keybinds_as_readable_iter().map( - |DisplayableCommand { + let commands_table = w.get_help_list_items().map( + |DisplayableKeyAction { keybinds, context, description, diff --git a/youtui/src/app/ui/header.rs b/youtui/src/app/ui/header.rs index a1286775..7a2a7b8b 100644 --- a/youtui/src/app/ui/header.rs +++ b/youtui/src/app/ui/header.rs @@ -1,6 +1,7 @@ use crate::{ - app::{component::actionhandler::KeyDisplayer, keycommand::DisplayableCommand}, + app::component::actionhandler::{get_global_keybinds_as_readable_iter, KeyRouter}, drawutils::{BUTTON_BG_COLOUR, BUTTON_FG_COLOUR}, + keyaction::DisplayableKeyAction, }; use ratatui::{ layout::Rect, @@ -11,12 +12,12 @@ use ratatui::{ }; pub fn draw_header(f: &mut Frame, w: &super::YoutuiWindow, chunk: Rect) { - let keybinds = w.get_context_global_keybinds_as_readable_iter(); + let keybinds = get_global_keybinds_as_readable_iter(w.get_active_keybinds()); let help_string = Line::from( keybinds .flat_map( - |DisplayableCommand { + |DisplayableKeyAction { keybinds, description, .. diff --git a/youtui/src/app/ui/logger.rs b/youtui/src/app/ui/logger.rs index 035a0b7d..b36e175f 100644 --- a/youtui/src/app/ui/logger.rs +++ b/youtui/src/app/ui/logger.rs @@ -1,18 +1,27 @@ -use crate::app::{ - component::actionhandler::{Action, ActionHandler, KeyRouter, TextHandler}, - keycommand::KeyCommand, - ui::AppCallback, - view::Drawable, -}; +use crate::app::component::actionhandler::ActionHandler; +use crate::config::keymap::Keymap; use crate::core::send_or_error; -use crossterm::event::KeyCode; +use crate::{ + app::{ + component::actionhandler::{Action, ComponentEffect, KeyRouter, TextHandler}, + server::{ArcServer, TaskMetadata}, + ui::AppCallback, + view::Drawable, + }, + config::Config, +}; +use async_callback_manager::AsyncTask; use draw::draw_logger; use ratatui::{prelude::Rect, Frame}; +use serde::{Deserialize, Serialize}; use std::borrow::Cow; use tokio::sync::mpsc::Sender; use tui_logger::TuiWidgetEvent; -#[derive(Clone, Debug, PartialEq)] +use super::action::AppAction; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum LoggerAction { ToggleTargetSelector, ToggleTargetFocus, @@ -29,6 +38,7 @@ pub enum LoggerAction { ViewBrowser, } impl Action for LoggerAction { + type State = Logger; fn context(&self) -> Cow { "Logger".into() } @@ -53,26 +63,42 @@ impl Action for LoggerAction { pub struct Logger { logger_state: tui_logger::TuiWidgetState, ui_tx: Sender, - keybinds: Vec>, + keybinds: Keymap, } +impl_youtui_component!(Logger); +impl ActionHandler for Logger { + async fn apply_action(&mut self, action: LoggerAction) -> ComponentEffect { + match action { + LoggerAction::ToggleTargetSelector => self.handle_toggle_target_selector(), + LoggerAction::ToggleTargetFocus => self.handle_toggle_target_focus(), + LoggerAction::ToggleHideFiltered => self.handle_toggle_hide_filtered(), + LoggerAction::Up => self.handle_up(), + LoggerAction::Down => self.handle_down(), + LoggerAction::PageUp => self.handle_pgup(), + LoggerAction::PageDown => self.handle_pgdown(), + LoggerAction::ReduceShown => self.handle_reduce_shown(), + LoggerAction::IncreaseShown => self.handle_increase_shown(), + LoggerAction::ReduceCaptured => self.handle_reduce_captured(), + LoggerAction::IncreaseCaptured => self.handle_increase_captured(), + LoggerAction::ExitPageMode => self.handle_exit_page_mode(), + LoggerAction::ViewBrowser => self.handle_view_browser().await, + } + AsyncTask::new_no_op() + } +} impl Drawable for Logger { fn draw_chunk(&self, f: &mut Frame, chunk: Rect, selected: bool) { draw_logger(f, self, chunk, selected) } } -impl KeyRouter for Logger { - // XXX: Duplication of effort here due to trait structure - not the worst. - fn get_routed_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(self.keybinds.iter()) - } - fn get_all_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - self.get_routed_keybinds() +impl KeyRouter for Logger { + fn get_active_keybinds(&self) -> impl Iterator> { + std::iter::once(&self.keybinds) + } + fn get_all_keybinds(&self) -> impl Iterator> { + self.get_active_keybinds() } } @@ -87,37 +113,20 @@ impl TextHandler for Logger { fn clear_text(&mut self) -> bool { false } - fn handle_event_repr(&mut self, _event: &crossterm::event::Event) -> bool { - false - } -} - -impl ActionHandler for Logger { - async fn handle_action(&mut self, action: &LoggerAction) { - match action { - LoggerAction::ToggleTargetSelector => self.handle_toggle_target_selector(), - LoggerAction::ToggleTargetFocus => self.handle_toggle_target_focus(), - LoggerAction::ToggleHideFiltered => self.handle_toggle_hide_filtered(), - LoggerAction::Up => self.handle_up(), - LoggerAction::Down => self.handle_down(), - LoggerAction::PageUp => self.handle_pgup(), - LoggerAction::PageDown => self.handle_pgdown(), - LoggerAction::ReduceShown => self.handle_reduce_shown(), - LoggerAction::IncreaseShown => self.handle_increase_shown(), - LoggerAction::ReduceCaptured => self.handle_reduce_captured(), - LoggerAction::IncreaseCaptured => self.handle_increase_captured(), - LoggerAction::ExitPageMode => self.handle_exit_page_mode(), - LoggerAction::ViewBrowser => self.handle_view_browser().await, - } + fn handle_text_event_impl( + &mut self, + _event: &crossterm::event::Event, + ) -> Option> { + None } } impl Logger { - pub fn new(ui_tx: Sender) -> Self { + pub fn new(ui_tx: Sender, config: &Config) -> Self { Self { ui_tx, logger_state: tui_logger::TuiWidgetState::default(), - keybinds: logger_keybinds(), + keybinds: logger_keybinds(config), } } async fn handle_view_browser(&mut self) { @@ -165,22 +174,8 @@ impl Logger { } } -fn logger_keybinds() -> Vec> { - vec![ - KeyCommand::new_global_from_code(KeyCode::F(5), LoggerAction::ViewBrowser), - KeyCommand::new_from_code(KeyCode::Char('['), LoggerAction::ReduceCaptured), - KeyCommand::new_from_code(KeyCode::Char(']'), LoggerAction::IncreaseCaptured), - KeyCommand::new_from_code(KeyCode::Left, LoggerAction::ReduceShown), - KeyCommand::new_from_code(KeyCode::Right, LoggerAction::IncreaseShown), - KeyCommand::new_from_code(KeyCode::Up, LoggerAction::Up), - KeyCommand::new_from_code(KeyCode::Down, LoggerAction::Down), - KeyCommand::new_from_code(KeyCode::PageUp, LoggerAction::PageUp), - KeyCommand::new_from_code(KeyCode::PageDown, LoggerAction::PageDown), - KeyCommand::new_from_code(KeyCode::Char(' '), LoggerAction::ToggleHideFiltered), - KeyCommand::new_from_code(KeyCode::Esc, LoggerAction::ExitPageMode), - KeyCommand::new_from_code(KeyCode::Char('f'), LoggerAction::ToggleTargetFocus), - KeyCommand::new_from_code(KeyCode::Char('h'), LoggerAction::ToggleTargetSelector), - ] +fn logger_keybinds(config: &Config) -> Keymap { + config.keybinds.log.clone() } pub mod draw { diff --git a/youtui/src/app/ui/playlist.rs b/youtui/src/app/ui/playlist.rs index b7fdf3cb..112e0da1 100644 --- a/youtui/src/app/ui/playlist.rs +++ b/youtui/src/app/ui/playlist.rs @@ -1,3 +1,4 @@ +use crate::app::component::actionhandler::{ActionHandler, ComponentEffect, Scrollable}; use crate::app::server::downloader::{DownloadProgressUpdate, DownloadProgressUpdateType}; use crate::app::server::{ ArcServer, AutoplaySong, DecodeSong, DownloadSong, IncreaseVolume, PausePlay, PlaySong, @@ -6,34 +7,33 @@ use crate::app::server::{ use crate::app::structures::{Percentage, SongListComponent}; use crate::app::view::draw::draw_table; use crate::app::view::{BasicConstraint, DrawableMut, TableItem}; -use crate::app::view::{Loadable, Scrollable, TableView}; +use crate::app::view::{Loadable, TableView}; use crate::app::{ - component::actionhandler::{Action, ActionHandler, KeyRouter, TextHandler}, - keycommand::KeyCommand, + component::actionhandler::{Action, KeyRouter, TextHandler}, structures::{AlbumSongsList, ListSong, ListSongID, PlayState}, ui::{AppCallback, WindowContext}, }; - -use crate::app::CALLBACK_CHANNEL_SIZE; use crate::async_rodio_sink::{ AutoplayUpdate, PausePlayResponse, PlayUpdate, QueueUpdate, SeekDirection, Stopped, VolumeUpdate, }; -use crate::core::{add_cb_or_error, add_stream_cb_or_error}; +use crate::config::keymap::Keymap; +use crate::config::Config; use crate::{app::structures::DownloadStatus, core::send_or_error}; -use async_callback_manager::{ - AsyncCallbackManager, AsyncCallbackSender, Constraint, StateMutationBundle, TryBackendTaskExt, -}; -use crossterm::event::KeyCode; +use async_callback_manager::{AsyncTask, Constraint, TryBackendTaskExt}; use ratatui::widgets::TableState; use ratatui::{layout::Rect, Frame}; +use serde::{Deserialize, Serialize}; use std::iter; +use std::option::Option; use std::sync::Arc; use std::time::Duration; use std::{borrow::Cow, fmt::Debug}; use tokio::sync::mpsc; use tracing::{error, info, warn}; +use super::action::{AppAction, ListAction, PAGE_KEY_LINES}; + const SONGS_AHEAD_TO_BUFFER: usize = 3; const SONGS_BEHIND_TO_SAVE: usize = 1; // How soon to trigger gapless playback @@ -46,41 +46,29 @@ pub struct Playlist { pub queue_status: QueueState, pub volume: Percentage, ui_tx: mpsc::Sender, - async_tx: AsyncCallbackSender, - keybinds: Vec>, + keybinds: Keymap, cur_selected: usize, pub widget_state: TableState, } +impl_youtui_component!(Playlist); -#[derive(Clone, Debug, PartialEq)] -pub enum QueueState { - NotQueued, - Queued(ListSongID), -} - -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum PlaylistAction { ViewBrowser, - Down, - Up, - PageDown, - PageUp, PlaySelected, DeleteSelected, DeleteAll, } impl Action for PlaylistAction { - fn context(&self) -> Cow { + type State = Playlist; + fn context(&self) -> std::borrow::Cow { "Playlist".into() } - fn describe(&self) -> Cow { + fn describe(&self) -> std::borrow::Cow { match self { PlaylistAction::ViewBrowser => "View Browser", - PlaylistAction::Down => "Down", - PlaylistAction::Up => "Up", - PlaylistAction::PageDown => "Page Down", - PlaylistAction::PageUp => "Page Up", PlaylistAction::PlaySelected => "Play Selected", PlaylistAction::DeleteSelected => "Delete Selected", PlaylistAction::DeleteAll => "Delete All", @@ -89,16 +77,33 @@ impl Action for PlaylistAction { } } -impl KeyRouter for Playlist { - fn get_all_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - self.get_routed_keybinds() +#[derive(Clone, Debug, PartialEq)] +pub enum QueueState { + NotQueued, + Queued(ListSongID), +} + +impl ActionHandler for Playlist { + async fn apply_action( + &mut self, + action: PlaylistAction, + ) -> crate::app::component::actionhandler::ComponentEffect { + match action { + PlaylistAction::ViewBrowser => self.view_browser().await, + PlaylistAction::PlaySelected => return self.play_selected(), + PlaylistAction::DeleteSelected => return self.delete_selected(), + PlaylistAction::DeleteAll => return self.delete_all(), + } + AsyncTask::new_no_op() } - fn get_routed_keybinds<'a>( - &'a self, - ) -> Box> + 'a> { - Box::new(self.keybinds.iter()) +} + +impl KeyRouter for Playlist { + fn get_all_keybinds(&self) -> impl Iterator> { + self.get_active_keybinds() + } + fn get_active_keybinds(&self) -> impl Iterator> { + std::iter::once(&self.keybinds) } } @@ -113,8 +118,11 @@ impl TextHandler for Playlist { fn clear_text(&mut self) -> bool { false } - fn handle_event_repr(&mut self, _event: &crossterm::event::Event) -> bool { - false + fn handle_text_event_impl( + &mut self, + _event: &crossterm::event::Event, + ) -> Option> { + None } } @@ -137,12 +145,15 @@ impl Scrollable for Playlist { .saturating_add_signed(amount) .min(self.list.get_list_iter().len().saturating_sub(1)) } - fn get_selected_item(&self) -> usize { - self.cur_selected + fn is_scrollable(&self) -> bool { + true } } impl TableView for Playlist { + fn get_selected_item(&self) -> usize { + self.cur_selected + } fn get_state(&self) -> TableState { self.widget_state.clone() } @@ -194,21 +205,6 @@ impl TableView for Playlist { } } -impl ActionHandler for Playlist { - async fn handle_action(&mut self, action: &PlaylistAction) { - match action { - PlaylistAction::ViewBrowser => self.view_browser().await, - PlaylistAction::Down => self.increment_list(1), - PlaylistAction::Up => self.increment_list(-1), - PlaylistAction::PageDown => self.increment_list(10), - PlaylistAction::PageUp => self.increment_list(-10), - PlaylistAction::PlaySelected => self.play_selected(), - PlaylistAction::DeleteSelected => self.delete_selected(), - PlaylistAction::DeleteAll => self.delete_all(), - } - } -} - impl SongListComponent for Playlist { fn get_song_from_idx(&self, idx: usize) -> Option<&ListSong> { self.list.get_list_iter().nth(idx) @@ -217,40 +213,34 @@ impl SongListComponent for Playlist { // Primatives impl Playlist { - pub fn new( - callback_manager: &mut AsyncCallbackManager, - ui_tx: mpsc::Sender, - ) -> Self { - let async_tx = callback_manager.new_sender(CALLBACK_CHANNEL_SIZE); + /// When creating a Playlist, an effect is also created. + pub fn new(ui_tx: mpsc::Sender, config: &Config) -> (Self, ComponentEffect) { // Ensure volume is synced with player. - async_tx - .add_callback( - // Since IncreaseVolume responds back with player volume after change, this is a - // neat hack. - IncreaseVolume(0), - Self::handle_volume_update, - Some(Constraint::new_block_same_type()), - ) - .expect("Since we just created the sender, this shouldn't fail"); - Playlist { + let task = AsyncTask::new_future( + // Since IncreaseVolume responds back with player volume after change, this is a + // neat hack. + IncreaseVolume(0), + Self::handle_volume_update, + Some(Constraint::new_block_same_type()), + ); + let playlist = Playlist { ui_tx, volume: Percentage(50), play_status: PlayState::NotPlaying, list: Default::default(), cur_played_dur: None, - keybinds: playlist_keybinds(), + keybinds: playlist_keybinds(config), cur_selected: 0, queue_status: QueueState::NotQueued, - async_tx, widget_state: Default::default(), - } + }; + (playlist, task) } /// Add a task to: /// - Stop playback of the song 'song_id', if it is still playing. /// - If stop was succesful, update state. - pub fn stop_song_id(&self, song_id: ListSongID) { - add_cb_or_error( - &self.async_tx, + pub fn stop_song_id(&self, song_id: ListSongID) -> ComponentEffect { + AsyncTask::new_future( Stop(song_id), Self::handle_stopped, Some(Constraint::new_block_matching_metadata( @@ -261,11 +251,11 @@ impl Playlist { /// Drop downloads no longer relevant for ID, download new /// relevant downloads, start playing song at ID, set PlayState. If the /// selected song is buffering, stop playback until it's complete. - pub fn play_song_id(&mut self, id: ListSongID) { + pub fn play_song_id(&mut self, id: ListSongID) -> ComponentEffect { // Drop previous songs self.drop_unscoped_from_id(id); // Queue next downloads - self.download_upcoming_from_id(id); + let mut effect = self.download_upcoming_from_id(id); // Reset duration self.cur_played_dur = None; if let Some(song_index) = self.get_index_from_id(id) { @@ -281,35 +271,43 @@ impl Playlist { let constraint = Some(Constraint::new_block_matching_metadata( TaskMetadata::PlayingSong, )); - let handle_update = move |this: &mut Self, update| { - match update { - Ok(u) => this.handle_play_update(u), - Err(e) => { - error!("Error {e} received when trying to decode {:?}", id); - this.handle_set_to_error(id); - } - }; + let handle_update = move |this: &mut Self, update| match update { + Ok(u) => this.handle_play_update(u), + Err(e) => { + error!("Error {e} received when trying to decode {:?}", id); + this.handle_set_to_error(id); + AsyncTask::new_no_op() + } }; - add_stream_cb_or_error(&self.async_tx, task, handle_update, constraint); + let effect = effect.push(AsyncTask::new_stream_chained( + task, + handle_update, + constraint, + )); self.play_status = PlayState::Playing(id); self.queue_status = QueueState::NotQueued; + return effect; } else { // Stop current song, but only if next song is buffering. - if let Some(cur_id) = self.get_cur_playing_id() { - self.stop_song_id(cur_id); - } + let maybe_effect = self + .get_cur_playing_id() + .map(|cur_id| self.stop_song_id(cur_id)); self.play_status = PlayState::Buffering(id); self.queue_status = QueueState::NotQueued; + if let Some(stop_effect) = maybe_effect { + effect = effect.push(stop_effect); + } } } + effect } /// Drop downloads no longer relevant for ID, download new /// relevant downloads, start playing song at ID, set PlayState. - pub fn autoplay_song_id(&mut self, id: ListSongID) { + pub fn autoplay_song_id(&mut self, id: ListSongID) -> ComponentEffect { // Drop previous songs self.drop_unscoped_from_id(id); // Queue next downloads - self.download_upcoming_from_id(id); + let mut effect = self.download_upcoming_from_id(id); // Reset duration self.cur_played_dur = None; if let Some(song_index) = self.get_index_from_id(id) { @@ -322,37 +320,43 @@ impl Playlist { // Result. let task = DecodeSong(pointer.clone()).map_stream(move |song| AutoplaySong { song, id }); - let handle_update = move |this: &mut Self, update| { - match update { - Ok(u) => this.handle_autoplay_update(u), - Err(e) => { - error!("Error {e} received when trying to decode {:?}", id); - this.handle_set_to_error(id); - } - }; + let handle_update = move |this: &mut Self, update| match update { + Ok(u) => this.handle_autoplay_update(u), + Err(e) => { + error!("Error {e} received when trying to decode {:?}", id); + this.handle_set_to_error(id); + AsyncTask::new_no_op() + } }; - add_stream_cb_or_error(&self.async_tx, task, handle_update, None); + let effect = effect.push(AsyncTask::new_stream_chained(task, handle_update, None)); self.play_status = PlayState::Playing(id); self.queue_status = QueueState::NotQueued; + return effect; } else { // Stop current song, but only if next song is buffering. - if let Some(cur_id) = self.get_cur_playing_id() { + let maybe_effect = self + .get_cur_playing_id() // TODO: Consider how race condition is supposed to be handled with this. - self.stop_song_id(cur_id); - } + .map(|cur_id| self.stop_song_id(cur_id)); self.play_status = PlayState::Buffering(id); self.queue_status = QueueState::NotQueued; + if let Some(stop_effect) = maybe_effect { + effect = effect.push(stop_effect); + } } }; + effect } /// Stop playing and clear playlist. - pub fn reset(&mut self) { + pub fn reset(&mut self) -> ComponentEffect { + let mut effect = AsyncTask::new_no_op(); // Stop playback, if playing. if let Some(cur_id) = self.get_cur_playing_id() { // TODO: Consider how race condition is supposed to be handled with this. - self.stop_song_id(cur_id); + effect = self.stop_song_id(cur_id); } self.clear(); + effect // XXX: Also need to kill pending download tasks // Alternatively, songs could kill their own download tasks on drop // (RAII). @@ -364,7 +368,7 @@ impl Playlist { self.list.clear(); } /// If currently playing, play previous song. - pub fn play_prev(&mut self) { + pub fn play_prev(&mut self) -> ComponentEffect { let cur = &self.play_status; match cur { PlayState::NotPlaying | PlayState::Stopped => { @@ -382,7 +386,7 @@ impl Playlist { info!("Next song id {:?}", prev_song_id); match prev_song_id { Some(id) => { - self.play_song_id(id); + return self.play_song_id(id); } None => { // TODO: Reset song to start if got here. @@ -391,20 +395,22 @@ impl Playlist { } } } + AsyncTask::new_no_op() } /// Play song at ID, if it was buffering. - pub fn handle_song_downloaded(&mut self, id: ListSongID) { + pub fn handle_song_downloaded(&mut self, id: ListSongID) -> ComponentEffect { if let PlayState::Buffering(target_id) = self.play_status { if target_id == id { info!("Playing"); - self.play_song_id(id); + return self.play_song_id(id); } } + AsyncTask::new_no_op() } /// Download song at ID, if it is still in the list. - pub fn download_song_if_exists(&mut self, id: ListSongID) { + pub fn download_song_if_exists(&mut self, id: ListSongID) -> ComponentEffect { let Some(song_index) = self.get_index_from_id(id) else { - return; + return AsyncTask::new_no_op(); }; let song = self .list @@ -415,20 +421,20 @@ impl Playlist { match song.download_status { DownloadStatus::Downloading(_) | DownloadStatus::Downloaded(_) - | DownloadStatus::Queued => return, + | DownloadStatus::Queued => return AsyncTask::new_no_op(), _ => (), }; // TODO: Consider how to handle race conditions. - add_stream_cb_or_error( - &self.async_tx, + let effect = AsyncTask::new_stream_chained( DownloadSong(song.raw.video_id.clone(), id), - |this, item| { + |this: &mut Playlist, item| { let DownloadProgressUpdate { kind, id } = item; - this.handle_song_download_progress_update(kind, id); + this.handle_song_download_progress_update(kind, id) }, None, ); song.download_status = DownloadStatus::Queued; + effect } /// Update the volume in the UI for immediate visual feedback - response /// will be delayed one tick. Note that this does not actually change the @@ -443,11 +449,12 @@ impl Playlist { // Consider then triggering the download function. } /// Play the next song in the list if it exists, otherwise, stop playing. - pub async fn play_next_or_stop(&mut self, prev_id: ListSongID) { + pub fn play_next_or_stop(&mut self, prev_id: ListSongID) -> ComponentEffect { let cur = &self.play_status; match cur { PlayState::NotPlaying | PlayState::Stopped => { warn!("Asked to play next, but not currently playing"); + AsyncTask::new_no_op() } PlayState::Paused(id) | PlayState::Playing(id) @@ -455,20 +462,18 @@ impl Playlist { | PlayState::Error(id) => { // Guard against duplicate message received. if id > &prev_id { - return; + return AsyncTask::new_no_op(); } let next_song_id = self .get_index_from_id(*id) .map(|i| i + 1) .and_then(|i| self.get_id_from_index(i)); match next_song_id { - Some(id) => { - self.play_song_id(id); - } + Some(id) => self.play_song_id(id), None => { info!("No next song - finishing playback"); self.queue_status = QueueState::NotQueued; - self.stop_song_id(*id); + self.stop_song_id(*id) } } } @@ -478,11 +483,12 @@ impl Playlist { /// stopped. This is triggered when a song has finished playing. The /// softer, Autoplay message, lets the Player use gapless playback if songs /// are queued correctly. - pub fn autoplay_next_or_stop(&mut self, prev_id: ListSongID) { + pub fn autoplay_next_or_stop(&mut self, prev_id: ListSongID) -> ComponentEffect { let cur = &self.play_status; match cur { PlayState::NotPlaying | PlayState::Stopped => { warn!("Asked to play next, but not currently playing"); + AsyncTask::new_no_op() } PlayState::Paused(id) | PlayState::Playing(id) @@ -490,32 +496,30 @@ impl Playlist { | PlayState::Error(id) => { // Guard against duplicate message received. if id > &prev_id { - return; + return AsyncTask::new_no_op(); } let next_song_id = self .get_index_from_id(*id) .map(|i| i + 1) .and_then(|i| self.get_id_from_index(i)); match next_song_id { - Some(id) => { - self.autoplay_song_id(id); - } + Some(id) => self.autoplay_song_id(id), None => { info!("No next song - resetting play status"); self.queue_status = QueueState::NotQueued; // As a neat hack I only need to ask the player to stop current ID - even if // it's playing the queued track, it doesn't know about it. - self.stop_song_id(*id); + self.stop_song_id(*id) } } } } } /// Download some upcoming songs, if they aren't already downloaded. - pub fn download_upcoming_from_id(&mut self, id: ListSongID) { + pub fn download_upcoming_from_id(&mut self, id: ListSongID) -> ComponentEffect { // Won't download if already downloaded. let Some(song_index) = self.get_index_from_id(id) else { - return; + return AsyncTask::new_no_op(); }; let mut song_ids_list = Vec::new(); song_ids_list.push(id); @@ -525,9 +529,12 @@ impl Playlist { song_ids_list.push(id); } } - for song_id in song_ids_list { - self.download_song_if_exists(song_id); - } + // TODO: Don't love the way metadata and constraints are handled with this task + // type that is collected, find a better way. + song_ids_list + .into_iter() + .map(|song_id| self.download_song_if_exists(song_id)) + .collect() } /// Drop strong reference from previous songs or songs above the buffer list /// size to drop them from memory. @@ -595,56 +602,70 @@ impl Playlist { // XXX: Consider downloading upcoming songs here. // self.download_upcoming_songs().await; } + pub fn handle_list_action(&mut self, action: ListAction) -> ComponentEffect { + match action { + ListAction::Up => self.increment_list(-1), + ListAction::Down => self.increment_list(1), + ListAction::PageUp => self.increment_list(-PAGE_KEY_LINES), + ListAction::PageDown => self.increment_list(PAGE_KEY_LINES), + } + AsyncTask::new_no_op() + } /// Handle seek command (from global keypress). - pub fn handle_seek(&mut self, duration: Duration, direction: SeekDirection) { + pub fn handle_seek( + &mut self, + duration: Duration, + direction: SeekDirection, + ) -> ComponentEffect { // Consider if we also want to update current duration. - add_cb_or_error( - &self.async_tx, + AsyncTask::new_future_chained( Seek { duration, direction, }, - |this, response| { - let Some(response) = response else { return }; + |this: &mut Playlist, response| { + let Some(response) = response else { + return AsyncTask::new_no_op(); + }; this.handle_set_song_play_progress(response.duration, response.identifier) }, None, ) } /// Handle next command (from global keypress), if currently playing. - pub async fn handle_next(&mut self) { + pub fn handle_next(&mut self) -> ComponentEffect { match self.play_status { PlayState::NotPlaying | PlayState::Stopped => { warn!("Asked to play next, but not currently playing"); + AsyncTask::new_no_op() } PlayState::Paused(id) | PlayState::Playing(id) | PlayState::Buffering(id) - | PlayState::Error(id) => { - self.play_next_or_stop(id).await; - } + | PlayState::Error(id) => self.play_next_or_stop(id), } } /// Handle previous command (from global keypress). - pub async fn handle_previous(&mut self) { - self.play_prev(); + pub fn handle_previous(&mut self) -> ComponentEffect { + self.play_prev() } /// Play the song under the cursor (from local keypress) - pub fn play_selected(&mut self) { + pub fn play_selected(&mut self) -> ComponentEffect { let Some(id) = self.get_id_from_index(self.cur_selected) else { - return; + return AsyncTask::new_no_op(); }; self.play_song_id(id) } /// Delete the song under the cursor (from local keypress). If it was /// playing, stop it and set PlayState to NotPlaying. - pub fn delete_selected(&mut self) { + pub fn delete_selected(&mut self) -> ComponentEffect { + let mut return_task = AsyncTask::new_no_op(); let cur_selected_idx = self.cur_selected; // If current song is playing, stop it. if let Some(cur_playing_id) = self.get_cur_playing_id() { if Some(cur_selected_idx) == self.get_cur_playing_index() { self.play_status = PlayState::NotPlaying; - self.stop_song_id(cur_playing_id); + return_task = self.stop_song_id(cur_playing_id); } } self.list.remove_song_index(cur_selected_idx); @@ -653,11 +674,12 @@ impl Playlist { if self.cur_selected >= cur_selected_idx && cur_selected_idx != 0 { // Safe, as checked above that cur_idx >= 0 self.cur_selected -= 1; - } + }; + return_task } /// Delete all songs. - pub fn delete_all(&mut self) { - self.reset(); + pub fn delete_all(&mut self) -> ComponentEffect { + self.reset() } /// Change to Browser window. pub async fn view_browser(&mut self) { @@ -669,7 +691,7 @@ impl Playlist { } /// Handle global pause/play action. Toggle state (visual), toggle playback /// (server). - pub async fn pauseplay(&mut self) { + pub fn pauseplay(&mut self) -> ComponentEffect { let id = match self.play_status { PlayState::Playing(id) => { self.play_status = PlayState::Paused(id); @@ -679,12 +701,11 @@ impl Playlist { self.play_status = PlayState::Playing(id); id } - _ => return, + _ => return AsyncTask::new_no_op(), }; - add_cb_or_error( - &self.async_tx, + AsyncTask::new_future( PausePlay(id), - |this, response| { + |this: &mut Playlist, response| { let Some(response) = response else { return }; match response { PausePlayResponse::Paused(id) => this.handle_paused(id), @@ -694,32 +715,28 @@ impl Playlist { Some(Constraint::new_block_matching_metadata( TaskMetadata::PlayPause, )), - ); + ) } } // Server handlers impl Playlist { - pub async fn async_update(&mut self) -> StateMutationBundle { - // TODO: Size - self.async_tx.get_next_mutations(10).await - } /// Handle song progress update from server. pub fn handle_song_download_progress_update( &mut self, update: DownloadProgressUpdateType, id: ListSongID, - ) { + ) -> ComponentEffect { // Not valid if song doesn't exist or hasn't initiated download (i.e - task // cancelled). if let Some(song) = self.get_song_from_id(id) { match song.download_status { DownloadStatus::None | DownloadStatus::Downloaded(_) | DownloadStatus::Failed => { - return + return AsyncTask::new_no_op() } _ => (), } } else { - return; + return AsyncTask::new_no_op(); } tracing::info!("Task valid - updating song download status"); match update { @@ -733,7 +750,7 @@ impl Playlist { s.download_status = DownloadStatus::Downloaded(Arc::new(song_buf)); s.id }) { - self.handle_song_downloaded(new_id) + return self.handle_song_downloaded(new_id); }; } DownloadProgressUpdateType::Error => { @@ -752,6 +769,7 @@ impl Playlist { } } } + AsyncTask::new_no_op() } /// Handle volume message from server pub fn handle_volume_update(&mut self, response: Option) { @@ -759,42 +777,55 @@ impl Playlist { self.volume = Percentage(v.0.into()) } } - pub fn handle_play_update(&mut self, update: PlayUpdate) { + pub fn handle_play_update(&mut self, update: PlayUpdate) -> ComponentEffect { match update { PlayUpdate::PlayProgress(duration, id) => { - self.handle_set_song_play_progress(duration, id) + return self.handle_set_song_play_progress(duration, id) } PlayUpdate::Playing(duration, id) => self.handle_playing(duration, id), - PlayUpdate::DonePlaying(id) => self.handle_done_playing(id), + PlayUpdate::DonePlaying(id) => return self.handle_done_playing(id), // This is a player invariant. PlayUpdate::Error(e) => error!("{e}"), } + AsyncTask::new_no_op() } - pub fn handle_queue_update(&mut self, update: QueueUpdate) { + pub fn handle_queue_update( + &mut self, + update: QueueUpdate, + ) -> ComponentEffect { match update { QueueUpdate::PlayProgress(duration, id) => { - self.handle_set_song_play_progress(duration, id) + return self.handle_set_song_play_progress(duration, id) } QueueUpdate::Queued(duration, id) => self.handle_queued(duration, id), - QueueUpdate::DonePlaying(id) => self.handle_done_playing(id), + QueueUpdate::DonePlaying(id) => return self.handle_done_playing(id), QueueUpdate::Error(e) => error!("{e}"), } + AsyncTask::new_no_op() } - pub fn handle_autoplay_update(&mut self, update: AutoplayUpdate) { + pub fn handle_autoplay_update( + &mut self, + update: AutoplayUpdate, + ) -> ComponentEffect { match update { AutoplayUpdate::PlayProgress(duration, id) => { - self.handle_set_song_play_progress(duration, id) + return self.handle_set_song_play_progress(duration, id) } AutoplayUpdate::Playing(duration, id) => self.handle_playing(duration, id), - AutoplayUpdate::DonePlaying(id) => self.handle_done_playing(id), + AutoplayUpdate::DonePlaying(id) => return self.handle_done_playing(id), AutoplayUpdate::AutoplayQueued(id) => self.handle_autoplay_queued(id), AutoplayUpdate::Error(e) => error!("{e}"), } + AsyncTask::new_no_op() } /// Handle song progress message from server - pub fn handle_set_song_play_progress(&mut self, d: Duration, id: ListSongID) { + pub fn handle_set_song_play_progress( + &mut self, + d: Duration, + id: ListSongID, + ) -> ComponentEffect { if !self.check_id_is_cur(id) { - return; + return AsyncTask::new_no_op(); } self.cur_played_dur = Some(d); // If less than the gapless playback threshold remaining, queue up the next @@ -820,32 +851,33 @@ impl Playlist { let task = DecodeSong(song.clone()).map_stream(move |song| QueueSong { song, id }); info!("Queuing up song!"); - let handle_update = move |this: &mut Self, update| { - match update { - Ok(u) => this.handle_queue_update(u), - Err(e) => { - error!("Error {e} received when trying to decode {:?}", id); - this.handle_set_to_error(id); - } - }; + let handle_update = move |this: &mut Self, update| match update { + Ok(u) => this.handle_queue_update(u), + Err(e) => { + error!("Error {e} received when trying to decode {:?}", id); + this.handle_set_to_error(id); + AsyncTask::new_no_op() + } }; - add_stream_cb_or_error(&self.async_tx, task, handle_update, None); - self.queue_status = QueueState::Queued(next_song.id) + let effect = AsyncTask::new_stream_chained(task, handle_update, None); + self.queue_status = QueueState::Queued(next_song.id); + return effect; } } } } + AsyncTask::new_no_op() } /// Handle done playing message from server - pub fn handle_done_playing(&mut self, id: ListSongID) { + pub fn handle_done_playing(&mut self, id: ListSongID) -> ComponentEffect { if QueueState::Queued(id) == self.queue_status { self.queue_status = QueueState::NotQueued; - return; + return AsyncTask::new_no_op(); } if !self.check_id_is_cur(id) { - return; + return AsyncTask::new_no_op(); } - self.autoplay_next_or_stop(id); + self.autoplay_next_or_stop(id) } /// Handle queued message from server pub fn handle_queued(&mut self, duration: Option, id: ListSongID) { @@ -916,21 +948,6 @@ impl Playlist { } } -fn playlist_keybinds() -> Vec> { - vec![ - KeyCommand::new_global_from_code(KeyCode::F(5), PlaylistAction::ViewBrowser), - KeyCommand::new_hidden_from_code(KeyCode::Down, PlaylistAction::Down), - KeyCommand::new_hidden_from_code(KeyCode::Up, PlaylistAction::Up), - KeyCommand::new_from_code(KeyCode::PageDown, PlaylistAction::PageDown), - KeyCommand::new_from_code(KeyCode::PageUp, PlaylistAction::PageUp), - KeyCommand::new_action_only_mode( - vec![ - (KeyCode::Enter, PlaylistAction::PlaySelected), - (KeyCode::Char('d'), PlaylistAction::DeleteSelected), - (KeyCode::Char('D'), PlaylistAction::DeleteAll), - ], - KeyCode::Enter, - "Playlist Action", - ), - ] +fn playlist_keybinds(config: &Config) -> Keymap { + config.keybinds.playlist.clone() } diff --git a/youtui/src/app/view.rs b/youtui/src/app/view.rs index 2fc6da3c..3427a7a6 100644 --- a/youtui/src/app/view.rs +++ b/youtui/src/app/view.rs @@ -122,30 +122,13 @@ pub fn basic_constraints_to_table_constraints( .collect() } -// A struct that is able to be "scrolled". An item will always be selected. -// XXX: Should a Scrollable also be a KeyHandler? This way, can potentially have -// common keybinds. -pub trait Scrollable { - // Increment the list by the specified amount. - fn increment_list(&mut self, amount: isize); - fn get_selected_item(&self) -> usize; -} -/// A struct that can either be scrolled or forward scroll commands to a -/// component. -// To allow scrolling at a top level. -pub trait MaybeScrollable { - /// Try to increment the list by the selected amount, return true if command - /// was handled. - fn increment_list(&mut self, amount: isize) -> bool; - /// Return true if a scrollable component in the application is active. - fn scrollable_component_active(&self) -> bool; -} - /// A simple row in a table. pub type TableItem<'a> = Box> + 'a>; /// A struct that we are able to draw a table from using the underlying data. -pub trait TableView: Scrollable + Loadable { +pub trait TableView: Loadable { + /// An item will always be selected. + fn get_selected_item(&self) -> usize; /// Get an owned version of the widget state, e.g scroll offset position. /// In practice this will clone, and this is acceptable due to the low cost. fn get_state(&self) -> TableState; @@ -182,8 +165,10 @@ pub trait SortableTableView: TableView { fn clear_filter_commands(&mut self); } // A struct that we are able to draw a list from using the underlying data. -pub trait ListView: Scrollable + SortableList + Loadable { +pub trait ListView: SortableList + Loadable { type DisplayItem: Display; + /// An item will always be selected. + fn get_selected_item(&self) -> usize; /// Get an owned version of the widget state, e.g scroll offset position. /// In practice this will clone, and this is acceptable due to the low cost. fn get_state(&self) -> ListState; diff --git a/youtui/src/config.rs b/youtui/src/config.rs index 28bc0044..34a06d05 100644 --- a/youtui/src/config.rs +++ b/youtui/src/config.rs @@ -1,11 +1,16 @@ use crate::get_config_dir; use crate::Result; use clap::ValueEnum; +use keymap::YoutuiKeymap; +use keymap::YoutuiKeymapIR; +use keymap::YoutuiModeNamesIR; use serde::{Deserialize, Serialize}; use ytmapi_rs::auth::OAuthToken; const CONFIG_FILE_NAME: &str = "config.toml"; +pub mod keymap; + #[derive(Serialize, Deserialize)] pub enum ApiKey { OAuthToken(OAuthToken), @@ -23,12 +28,7 @@ impl std::fmt::Debug for ApiKey { } } -#[derive(Default, Debug, Serialize, Deserialize)] -pub struct Config { - pub auth_type: AuthType, -} - -#[derive(ValueEnum, Copy, Clone, Default, Debug, Serialize, Deserialize)] +#[derive(ValueEnum, Copy, PartialEq, Clone, Default, Debug, Serialize, Deserialize)] pub enum AuthType { #[value(name = "oauth")] OAuth, @@ -36,13 +36,214 @@ pub enum AuthType { Browser, } +#[derive(Debug, Default, PartialEq)] +pub struct Config { + pub auth_type: AuthType, + pub keybinds: YoutuiKeymap, +} + +#[derive(Default, Debug, Deserialize)] +#[serde(default)] +/// Intermediate representation of Config for serde. +pub struct ConfigIR { + pub auth_type: AuthType, + pub keybinds: YoutuiKeymapIR, + pub mode_names: YoutuiModeNamesIR, +} + +impl TryFrom for Config { + type Error = String; + fn try_from(value: ConfigIR) -> std::result::Result { + let ConfigIR { + auth_type, + keybinds, + mode_names, + } = value; + Ok(Config { + auth_type, + keybinds: YoutuiKeymap::try_from_stringy(keybinds, mode_names)?, + }) + } +} + impl Config { - pub fn new() -> Result { + pub async fn new(debug: bool) -> Result { let config_dir = get_config_dir()?; - if let Ok(config_file) = std::fs::read_to_string(config_dir.join(CONFIG_FILE_NAME)) { - Ok(toml::from_str(&config_file)?) + let config_file_location = config_dir.join(CONFIG_FILE_NAME); + if let Ok(config_file) = tokio::fs::read_to_string(&config_file_location).await { + // NOTE: This happens before logging / app is initialised, so `println!` is + // used instead of `info!` + if debug { + println!( + "Loading config from {}", + config_file_location.to_string_lossy() + ); + } + let ir: ConfigIR = toml::from_str(&config_file)?; + Ok(Config::try_from(ir).map_err(crate::Error::Other)?) } else { + if debug { + println!( + "Config file not found in {}, using defaults", + config_file_location.to_string_lossy() + ); + } Ok(Self::default()) } } } + +#[cfg(test)] +mod tests { + use crate::config::{keymap::YoutuiKeymap, Config, ConfigIR}; + use pretty_assertions::{assert_eq, assert_ne}; + + async fn example_config_file() -> String { + tokio::fs::read_to_string("./config/config.toml") + .await + .unwrap() + } + + #[tokio::test] + async fn test_deserialize_default_config_to_ir() { + let config_file = example_config_file().await; + toml::from_str::(&config_file).unwrap(); + } + #[tokio::test] + async fn test_convert_ir_to_config() { + let config_file = example_config_file().await; + let ir: ConfigIR = toml::from_str(&config_file).unwrap(); + Config::try_from(ir).unwrap(); + } + #[tokio::test] + async fn test_default_config_equals_deserialized_config() { + let config_file = example_config_file().await; + let ir: ConfigIR = toml::from_str(&config_file).unwrap(); + let Config { + auth_type, + keybinds, + } = Config::try_from(ir).unwrap(); + let YoutuiKeymap { + global, + playlist, + browser, + browser_artists, + browser_search, + browser_songs, + help, + sort, + filter, + text_entry, + list, + log, + } = keybinds; + let Config { + auth_type: def_auth_type, + keybinds: def_keybinds, + } = Config::default(); + let YoutuiKeymap { + global: def_global, + playlist: def_playlist, + browser: def_browser, + browser_artists: def_browser_artists, + browser_search: def_browser_search, + browser_songs: def_browser_songs, + help: def_help, + sort: def_sort, + filter: def_filter, + text_entry: def_text_entry, + list: def_list, + log: def_log, + } = def_keybinds; + // Assertions are split up here, to better narrow down errors. + assert_eq!(auth_type, def_auth_type, "auth_type keybinds don't match"); + assert_eq!(global, def_global, "global keybinds don't match"); + assert_eq!(playlist, def_playlist, "playlist keybinds don't match"); + assert_eq!(browser, def_browser, "browser keybinds don't match"); + assert_eq!( + browser_artists, def_browser_artists, + "browser_artists keybinds don't match" + ); + assert_eq!( + browser_search, def_browser_search, + "browser_search keybinds don't match" + ); + assert_eq!( + browser_songs, def_browser_songs, + "browser_songs keybinds don't match" + ); + assert_eq!(help, def_help, "help keybinds don't match"); + assert_eq!(sort, def_sort, "sort keybinds don't match"); + assert_eq!(filter, def_filter, "filter keybinds don't match"); + assert_eq!( + text_entry, def_text_entry, + "text_entry keybinds don't match" + ); + assert_eq!(list, def_list, "list keybinds don't match"); + assert_eq!(log, def_log, "log keybinds don't match"); + } + #[tokio::test] + async fn test_default_config_equals_blank_config() { + let ir: ConfigIR = toml::from_str("").unwrap(); + let Config { + auth_type, + keybinds, + } = Config::try_from(ir).unwrap(); + let YoutuiKeymap { + global, + playlist, + browser, + browser_artists, + browser_search, + browser_songs, + help, + sort, + filter, + text_entry, + list, + log, + } = keybinds; + let Config { + auth_type: def_auth_type, + keybinds: def_keybinds, + } = Config::default(); + let YoutuiKeymap { + global: def_global, + playlist: def_playlist, + browser: def_browser, + browser_artists: def_browser_artists, + browser_search: def_browser_search, + browser_songs: def_browser_songs, + help: def_help, + sort: def_sort, + filter: def_filter, + text_entry: def_text_entry, + list: def_list, + log: def_log, + } = def_keybinds; + // Assertions are split up here, to better narrow down errors. + assert_eq!(auth_type, def_auth_type); + assert_eq!(global, def_global); + assert_eq!(playlist, def_playlist); + assert_eq!(browser, def_browser); + assert_eq!(browser_artists, def_browser_artists); + assert_eq!(browser_search, def_browser_search); + assert_eq!(browser_songs, def_browser_songs); + assert_eq!(help, def_help); + assert_eq!(sort, def_sort); + assert_eq!(filter, def_filter); + assert_eq!(text_entry, def_text_entry); + assert_eq!(list, def_list); + assert_eq!(log, def_log); + } + #[tokio::test] + async fn test_different_config_to_default() { + let config_file = tokio::fs::read_to_string("./config/config.toml.vim-example") + .await + .unwrap(); + let ir: ConfigIR = toml::from_str(&config_file).unwrap(); + let config = Config::try_from(ir).unwrap(); + let def_config = Config::default(); + assert_ne!(config, def_config) + } +} diff --git a/youtui/src/config/keymap.rs b/youtui/src/config/keymap.rs new file mode 100644 index 00000000..703ec09a --- /dev/null +++ b/youtui/src/config/keymap.rs @@ -0,0 +1,1037 @@ +use crate::app::component::actionhandler::Action; +use crate::app::ui::action::{AppAction, HelpAction, ListAction, TextEntryAction}; +use crate::app::ui::browser::artistalbums::albumsongs::{ + BrowserSongsAction, FilterAction, SortAction, +}; +use crate::app::ui::browser::artistalbums::artistsearch::{ + BrowserArtistsAction, BrowserSearchAction, +}; +use crate::app::ui::browser::BrowserAction; +use crate::app::ui::logger::LoggerAction; +use crate::app::ui::playlist::PlaylistAction::{self, ViewBrowser}; +use crate::keyaction::{KeyAction, KeyActionVisibility}; +use crate::keybind::Keybind; +use crossterm::event::KeyModifiers; +use serde::{Deserialize, Serialize}; +use std::collections::btree_map::Entry; +use std::{borrow::Cow, collections::BTreeMap, convert::Infallible, str::FromStr}; + +/// Convenience type alias +pub type Keymap = BTreeMap>; + +/// Merge `other` into `this` leaving `this` empty and returning the merged +/// keymap. This recurively handles modes, merging them also. +fn merge_keymaps(this: &mut Keymap, other: Keymap) { + for (other_key, other_tree) in other { + let entry = this.entry(other_key); + match entry { + Entry::Vacant(e) => { + e.insert(other_tree); + } + Entry::Occupied(mut e) => { + let this_tree = e.get_mut(); + this_tree.merge(other_tree); + } + } + } +} +/// If self is a key with action `action`, return None. +/// If self is a mode, remove any actions `action` from it, and if there are +/// none left, also return None. +fn remove_action_from_keymap(this: &mut Keymap, action: &A) { + this.retain(|_, v| match v { + KeyActionTree::Key(ka) => &ka.action != action, + KeyActionTree::Mode { keys, .. } => { + remove_action_from_keymap(keys, action); + !keys.is_empty() + } + }) +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum KeyStringTree { + #[serde(deserialize_with = "crate::core::string_or_struct")] + Key(KeyAction), + Mode(BTreeMap), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum KeyActionTree { + Key(KeyAction), + Mode { + name: Option, + keys: Keymap, + }, +} + +#[derive(Debug, PartialEq)] +pub struct YoutuiKeymap { + pub global: BTreeMap>, + pub playlist: BTreeMap>, + pub browser: BTreeMap>, + pub browser_artists: BTreeMap>, + pub browser_search: BTreeMap>, + pub browser_songs: BTreeMap>, + pub help: BTreeMap>, + pub sort: BTreeMap>, + pub filter: BTreeMap>, + pub text_entry: BTreeMap>, + pub list: BTreeMap>, + pub log: BTreeMap>, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct YoutuiKeymapIR { + pub global: BTreeMap, + pub playlist: BTreeMap, + pub browser: BTreeMap, + pub browser_artists: BTreeMap, + pub browser_search: BTreeMap, + pub browser_songs: BTreeMap, + pub help: BTreeMap, + pub sort: BTreeMap, + pub filter: BTreeMap, + pub text_entry: BTreeMap, + pub list: BTreeMap, + pub log: BTreeMap, +} + +#[derive(PartialEq, Debug, Serialize, Deserialize, Default)] +#[serde(default)] +// TODO: Mode visibility +pub struct YoutuiModeNamesIR { + global: BTreeMap, + playlist: BTreeMap, + browser: BTreeMap, + browser_artists: BTreeMap, + browser_search: BTreeMap, + browser_songs: BTreeMap, + help: BTreeMap, + sort: BTreeMap, + filter: BTreeMap, + text_entry: BTreeMap, + list: BTreeMap, + log: BTreeMap, +} + +impl Default for YoutuiKeymap { + fn default() -> Self { + Self { + global: default_global_keybinds(), + playlist: default_playlist_keybinds(), + browser: default_browser_keybinds(), + browser_artists: default_browser_artists_keybinds(), + browser_search: default_browser_search_keybinds(), + browser_songs: default_browser_songs_keybinds(), + help: default_help_keybinds(), + sort: default_sort_keybinds(), + filter: default_filter_keybinds(), + text_entry: default_text_entry_keybinds(), + list: default_list_keybinds(), + log: default_log_keybinds(), + } + } +} + +impl YoutuiKeymap { + pub fn try_from_stringy( + keys: YoutuiKeymapIR, + mode_names: YoutuiModeNamesIR, + ) -> std::result::Result { + let YoutuiKeymapIR { + global, + playlist, + browser, + browser_artists, + browser_search, + browser_songs, + help, + sort, + filter, + text_entry, + list, + log, + } = keys; + let YoutuiModeNamesIR { + global: mut global_mode_names, + playlist: mut playlist_mode_names, + browser: mut browser_mode_names, + browser_artists: mut browser_artists_mode_names, + browser_search: mut browser_search_mode_names, + browser_songs: mut browser_songs_mode_names, + help: mut help_mode_names, + sort: mut sort_mode_names, + filter: mut filter_mode_names, + text_entry: mut text_entry_mode_names, + list: mut list_mode_names, + log: mut log_mode_names, + } = mode_names; + + let global = global + .into_iter() + .map(move |(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut global_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let playlist = playlist + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut playlist_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let browser = browser + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut browser_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let browser_artists = browser_artists + .into_iter() + .map(|(k, v)| { + let v = + KeyActionTree::try_from_stringy(&k, v, Some(&mut browser_artists_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let browser_search = browser_search + .into_iter() + .map(|(k, v)| { + let v = + KeyActionTree::try_from_stringy(&k, v, Some(&mut browser_search_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let browser_songs = browser_songs + .into_iter() + .map(|(k, v)| { + let v = + KeyActionTree::try_from_stringy(&k, v, Some(&mut browser_songs_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let text_entry = text_entry + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut text_entry_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let help = help + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut help_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let sort = sort + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut sort_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let filter = filter + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut filter_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let list = list + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut list_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let log = log + .into_iter() + .map(|(k, v)| { + let v = KeyActionTree::try_from_stringy(&k, v, Some(&mut log_mode_names))?; + Ok((k, v)) + }) + .collect::, String>>()?; + let mut keymap = YoutuiKeymap::default(); + merge_keymaps(&mut keymap.global, global); + merge_keymaps(&mut keymap.playlist, playlist); + merge_keymaps(&mut keymap.browser, browser); + merge_keymaps(&mut keymap.browser_artists, browser_artists); + merge_keymaps(&mut keymap.browser_search, browser_search); + merge_keymaps(&mut keymap.browser_songs, browser_songs); + merge_keymaps(&mut keymap.text_entry, text_entry); + merge_keymaps(&mut keymap.help, help); + merge_keymaps(&mut keymap.sort, sort); + merge_keymaps(&mut keymap.filter, filter); + merge_keymaps(&mut keymap.list, list); + merge_keymaps(&mut keymap.log, log); + remove_action_from_keymap(&mut keymap.global, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.playlist, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.browser, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.browser_artists, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.browser_search, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.browser_songs, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.text_entry, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.help, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.sort, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.filter, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.list, &AppAction::NoOp); + remove_action_from_keymap(&mut keymap.log, &AppAction::NoOp); + Ok(keymap) + } +} + +impl KeyActionTree { + pub fn new_key(action: A) -> Self { + Self::Key(KeyAction { + action, + visibility: Default::default(), + }) + } + pub fn new_key_with_visibility(action: A, visibility: KeyActionVisibility) -> Self { + Self::Key(KeyAction { action, visibility }) + } + pub fn new_mode(binds: I, name: String) -> Self + where + I: IntoIterator)>, + { + Self::Mode { + keys: FromIterator::from_iter(binds), + name: Some(name), + } + } + /// Merge this KeyActionTree with another. + fn merge(&mut self, other: KeyActionTree) { + match self { + KeyActionTree::Key(_) => *self = other, + KeyActionTree::Mode { + name: this_name, + keys: keys_this, + } => match other { + KeyActionTree::Key(key_action) => *self = KeyActionTree::Key(key_action), + KeyActionTree::Mode { + name: other_name, + keys: keys_other, + } => { + if other_name.is_some() { + *this_name = other_name; + } + merge_keymaps(keys_this, keys_other); + } + }, + } + } + /// Try to create a KeyActionTree from a KeyStringTree. + fn try_from_stringy( + key: &Keybind, + stringy: KeyStringTree, + mode_names: Option<&mut BTreeMap>, + ) -> std::result::Result + where + A: TryFrom, + { + let new: KeyActionTree = match stringy { + KeyStringTree::Key(k) => KeyActionTree::Key(k.try_map(TryInto::try_into)?), + KeyStringTree::Mode(m) => { + let mode_name_enum = mode_names.and_then(|m| m.remove(key)); + let (mut next_modes, cur_mode_name) = match mode_name_enum { + Some(ModeNameEnum::Submode { name, keys }) => (Some(keys), name), + Some(ModeNameEnum::Name(name)) => (None, Some(name)), + None => (None, None), + }; + KeyActionTree::Mode { + keys: m + .into_iter() + .map(|(k, a)| { + let v = KeyActionTree::try_from_stringy(&k, a, next_modes.as_mut())?; + Ok::<_, String>((k, v)) + }) + .collect::>()?, + name: cur_mode_name, + } + } + }; + Ok(new) + } + /// # Note + /// Currently, visibility for a mode can't be set in config, so it is set to + /// the default. + pub fn get_visibility(&self) -> KeyActionVisibility { + match self { + KeyActionTree::Key(k) => k.visibility, + KeyActionTree::Mode { .. } => KeyActionVisibility::default(), + } + } + /// If a key, get the context of the key's action. + /// If a mode, recursively get the context of the first key's keyactiontree. + /// Returns String::default() if no keys in the mode. + pub fn get_context(&self) -> Cow { + match self { + KeyActionTree::Key(k) => k.action.context(), + KeyActionTree::Mode { keys, .. } => keys + .iter() + .next() + .map(|(_, kt)| kt.get_context()) + .unwrap_or_default(), + } + } +} + +impl KeyAction { + fn try_map( + self, + f: impl FnOnce(A) -> std::result::Result, + ) -> std::result::Result, E> { + let Self { action, visibility } = self; + Ok(KeyAction { + action: f(action)?, + visibility, + }) + } +} + +impl FromStr for KeyAction { + type Err = Infallible; + fn from_str(s: &str) -> std::result::Result { + Ok(KeyAction { + action: s.to_string(), + visibility: Default::default(), + }) + } +} + +#[derive(PartialEq, Debug, Serialize, Deserialize)] +pub enum ModeNameEnum { + Submode { + name: Option, + keys: BTreeMap, + }, + #[serde(untagged)] + Name(String), +} + +fn default_global_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('+')), + KeyActionTree::new_key(AppAction::VolUp), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('-')), + KeyActionTree::new_key(AppAction::VolDown), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('>')), + KeyActionTree::new_key(AppAction::NextSong), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('<')), + KeyActionTree::new_key(AppAction::PrevSong), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char(']')), + KeyActionTree::new_key(AppAction::SeekForward), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('[')), + KeyActionTree::new_key(AppAction::SeekBack), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(1)), + KeyActionTree::new_key_with_visibility( + AppAction::ToggleHelp, + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(10)), + KeyActionTree::new_key_with_visibility(AppAction::Quit, KeyActionVisibility::Global), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(12)), + KeyActionTree::new_key_with_visibility( + AppAction::ViewLogs, + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char(' ')), + KeyActionTree::new_key_with_visibility(AppAction::Pause, KeyActionVisibility::Global), + ), + ( + Keybind::new(crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyActionTree::new_key(AppAction::Quit), + ), + ]) +} +fn default_playlist_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(5)), + KeyActionTree::new_key_with_visibility( + AppAction::Playlist(ViewBrowser), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_mode( + [ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::Playlist(PlaylistAction::PlaySelected)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('d')), + KeyActionTree::new_key(AppAction::Playlist(PlaylistAction::DeleteSelected)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('D')), + KeyActionTree::new_key(AppAction::Playlist(PlaylistAction::DeleteAll)), + ), + ], + "Playlist Action".into(), + ), + ), + ]) +} +fn default_browser_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(5)), + KeyActionTree::new_key_with_visibility( + AppAction::Browser(BrowserAction::ViewPlaylist), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(2)), + KeyActionTree::new_key_with_visibility( + AppAction::Browser(BrowserAction::Search), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Left), + KeyActionTree::new_key(AppAction::Browser(BrowserAction::Left)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Right), + KeyActionTree::new_key(AppAction::Browser(BrowserAction::Right)), + ), + ]) +} +fn default_browser_artists_keybinds() -> BTreeMap> { + FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + )]) +} +fn default_browser_search_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Down), + KeyActionTree::new_key(AppAction::BrowserSearch( + BrowserSearchAction::NextSearchSuggestion, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::BrowserSearch( + BrowserSearchAction::PrevSearchSuggestion, + )), + ), + ]) +} +fn default_browser_songs_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(3)), + KeyActionTree::new_key_with_visibility( + AppAction::BrowserSongs(BrowserSongsAction::Filter), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(4)), + KeyActionTree::new_key_with_visibility( + AppAction::BrowserSongs(BrowserSongsAction::Sort), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_mode( + [ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char(' ')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::AddSongToPlaylist, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('p')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::PlaySongs, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('a')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::PlayAlbum, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::PlaySong, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('P')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::AddSongsToPlaylist, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('A')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::AddAlbumToPlaylist, + )), + ), + ], + "Play".into(), + ), + ), + ]) +} +fn default_help_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Esc), + KeyActionTree::new_key_with_visibility( + AppAction::Help(HelpAction::Close), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(1)), + KeyActionTree::new_key_with_visibility( + AppAction::Help(HelpAction::Close), + KeyActionVisibility::Global, + ), + ), + ]) +} +fn default_sort_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key_with_visibility( + AppAction::Sort(SortAction::SortSelectedAsc), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new(crossterm::event::KeyCode::Enter, KeyModifiers::ALT), + KeyActionTree::new_key_with_visibility( + AppAction::Sort(SortAction::SortSelectedDesc), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('C')), + KeyActionTree::new_key_with_visibility( + AppAction::Sort(SortAction::ClearSort), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Esc), + KeyActionTree::new_key_with_visibility( + AppAction::Sort(SortAction::Close), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(4)), + KeyActionTree::new_key_with_visibility( + AppAction::Sort(SortAction::Close), + KeyActionVisibility::Global, + ), + ), + ]) +} +fn default_filter_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Esc), + KeyActionTree::new_key_with_visibility( + AppAction::Filter(FilterAction::Close), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(3)), + KeyActionTree::new_key_with_visibility( + AppAction::Filter(FilterAction::Close), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key_with_visibility( + AppAction::Filter(FilterAction::Apply), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(6)), + KeyActionTree::new_key_with_visibility( + AppAction::Filter(FilterAction::ClearFilter), + KeyActionVisibility::Global, + ), + ), + ]) +} +fn default_text_entry_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key_with_visibility( + AppAction::TextEntry(TextEntryAction::Submit), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Left), + KeyActionTree::new_key_with_visibility( + AppAction::TextEntry(TextEntryAction::Left), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Right), + KeyActionTree::new_key_with_visibility( + AppAction::TextEntry(TextEntryAction::Right), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Backspace), + KeyActionTree::new_key_with_visibility( + AppAction::TextEntry(TextEntryAction::Backspace), + KeyActionVisibility::Hidden, + ), + ), + ]) +} +fn default_log_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::F(5)), + KeyActionTree::new_key_with_visibility( + AppAction::Log(LoggerAction::ViewBrowser), + KeyActionVisibility::Global, + ), + ), + ( + Keybind::new(crossterm::event::KeyCode::Left, KeyModifiers::SHIFT), + KeyActionTree::new_key(AppAction::Log(LoggerAction::ReduceCaptured)), + ), + ( + Keybind::new(crossterm::event::KeyCode::Right, KeyModifiers::SHIFT), + KeyActionTree::new_key(AppAction::Log(LoggerAction::IncreaseCaptured)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Left), + KeyActionTree::new_key(AppAction::Log(LoggerAction::ReduceShown)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Right), + KeyActionTree::new_key(AppAction::Log(LoggerAction::IncreaseShown)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Log(LoggerAction::Up)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Down), + KeyActionTree::new_key(AppAction::Log(LoggerAction::Down)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::PageUp), + KeyActionTree::new_key(AppAction::Log(LoggerAction::PageUp)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::PageDown), + KeyActionTree::new_key(AppAction::Log(LoggerAction::PageDown)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('t')), + KeyActionTree::new_key(AppAction::Log(LoggerAction::ToggleHideFiltered)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Esc), + KeyActionTree::new_key(AppAction::Log(LoggerAction::ExitPageMode)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('f')), + KeyActionTree::new_key(AppAction::Log(LoggerAction::ToggleTargetFocus)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char('h')), + KeyActionTree::new_key(AppAction::Log(LoggerAction::ToggleTargetSelector)), + ), + ]) +} +fn default_list_keybinds() -> BTreeMap> { + FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key_with_visibility( + AppAction::List(ListAction::Up), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Down), + KeyActionTree::new_key_with_visibility( + AppAction::List(ListAction::Down), + KeyActionVisibility::Hidden, + ), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::PageUp), + KeyActionTree::new_key(AppAction::List(ListAction::PageUp)), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::PageDown), + KeyActionTree::new_key(AppAction::List(ListAction::PageDown)), + ), + ]) +} + +#[cfg(test)] +mod tests { + use super::{merge_keymaps, KeyActionTree}; + use crate::{ + app::ui::{ + action::AppAction, + browser::artistalbums::{ + albumsongs::BrowserSongsAction, artistsearch::BrowserArtistsAction, + }, + }, + config::keymap::{remove_action_from_keymap, Keymap}, + keybind::Keybind, + }; + + #[test] + fn test_add_key() { + let mut keys = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + )]); + let to_add = FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + )]); + merge_keymaps(&mut keys, to_add); + let expected = FromIterator::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + ), + ]); + pretty_assertions::assert_eq!(keys, expected); + } + #[test] + fn test_add_key_overrides_old() { + let mut keys = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::Quit), + )]); + let to_add = FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::NoOp), + )]); + merge_keymaps(&mut keys, to_add); + let expected = FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::NoOp), + )]); + pretty_assertions::assert_eq!(keys, expected); + } + #[test] + fn test_add_mode() { + let mut keys = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + )]); + let to_add = FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_mode( + [( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + )], + "New Modename".into(), + ), + )]); + merge_keymaps(&mut keys, to_add); + let expected = Keymap::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_mode( + [( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + )], + "New Modename".into(), + ), + ), + ]); + pretty_assertions::assert_eq!(keys, expected); + } + #[test] + fn test_add_key_to_mode() { + let mut keys = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_mode( + [( + Keybind::new_unmodified(crossterm::event::KeyCode::Char(' ')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::AddSongToPlaylist, + )), + )], + "Play".into(), + ), + )]); + let to_add = FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_mode( + [( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + )], + "New Modename".into(), + ), + )]); + merge_keymaps(&mut keys, to_add); + let expected = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_mode( + [ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Char(' ')), + KeyActionTree::new_key(AppAction::BrowserSongs( + BrowserSongsAction::AddSongToPlaylist, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + ), + ], + "New Modename".into(), + ), + )]); + pretty_assertions::assert_eq!(keys, expected); + } + #[test] + fn test_remove_action() { + let mut keys = Keymap::from_iter([ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + ), + ]); + remove_action_from_keymap(&mut keys, &AppAction::Quit); + let expected = FromIterator::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + )]); + pretty_assertions::assert_eq!(keys, expected); + } + #[test] + fn test_remove_action_from_mode() { + let mut keys = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_mode( + [ + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + ), + ( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + ), + ], + "New Modename".into(), + ), + )]); + remove_action_from_keymap(&mut keys, &AppAction::Quit); + let expected = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_mode( + [( + Keybind::new_unmodified(crossterm::event::KeyCode::Enter), + KeyActionTree::new_key(AppAction::BrowserArtists( + BrowserArtistsAction::DisplaySelectedArtistAlbums, + )), + )], + "New Modename".into(), + ), + )]); + pretty_assertions::assert_eq!(keys, expected); + } + #[test] + fn test_remove_action_removes_mode() { + let mut keys = Keymap::from_iter([( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_mode( + [( + Keybind::new_unmodified(crossterm::event::KeyCode::Up), + KeyActionTree::new_key(AppAction::Quit), + )], + "New Modename".into(), + ), + )]); + remove_action_from_keymap(&mut keys, &AppAction::Quit); + let expected = Keymap::from_iter([]); + pretty_assertions::assert_eq!(keys, expected); + } +} diff --git a/youtui/src/core.rs b/youtui/src/core.rs index 532a5fb9..989e5dc5 100644 --- a/youtui/src/core.rs +++ b/youtui/src/core.rs @@ -1,5 +1,9 @@ -use async_callback_manager::{AsyncCallbackSender, BackendStreamingTask, BackendTask, Constraint}; -use std::borrow::Borrow; +//! Re-usable core functionality. +use serde::{ + de::{self, MapAccess, Visitor}, + Deserialize, Deserializer, +}; +use std::{borrow::Borrow, convert::Infallible, fmt, marker::PhantomData, str::FromStr}; use tokio::sync::mpsc; use tracing::error; @@ -12,38 +16,42 @@ pub async fn send_or_error>>(tx: S, msg: T) { .unwrap_or_else(|e| error!("Error {e} received when sending message")); } -/// Send a streaming callback to the specified AsyncCallbackSender, and if -/// sending fails, log an error with Tracing. -pub fn add_stream_cb_or_error( - sender: &AsyncCallbackSender, - // Bounds are from AsyncCallbackSender's own impl. - request: R, - handler: impl FnOnce(&mut Frntend, R::Output) + Send + Clone + 'static, - constraint: Option>, -) where - R: BackendStreamingTask + 'static, - Bkend: Send + 'static, - Frntend: 'static, +/// From serde documentation: [https://serde.rs/string-or-struct.html] +pub fn string_or_struct<'de, T, D>(deserializer: D) -> std::result::Result +where + T: Deserialize<'de> + FromStr, + D: Deserializer<'de>, { - sender - .add_stream_callback(request, handler, constraint) - .unwrap_or_else(|e| error!("Error {e} received when sending message")); -} - -/// Send a callback to the specified AsyncCallbackSender, and if sending fails, -/// log an error with Tracing. -pub fn add_cb_or_error( - sender: &AsyncCallbackSender, - // Bounds are from AsyncCallbackSender's own impl. - request: R, - handler: impl FnOnce(&mut Frntend, R::Output) + Send + 'static, - constraint: Option>, -) where - R: BackendTask + 'static, - Bkend: Send + 'static, - Frntend: 'static, -{ - sender - .add_callback(request, handler, constraint) - .unwrap_or_else(|e| error!("Error {e} received when sending message")); + // This is a Visitor that forwards string types to T's `FromStr` impl and + // forwards map types to T's `Deserialize` impl. The `PhantomData` is to + // keep the compiler from complaining about T being an unused generic type + // parameter. We need T in order to know the Value type for the Visitor + // impl. + struct StringOrStruct(PhantomData T>); + impl<'de, T> Visitor<'de> for StringOrStruct + where + T: Deserialize<'de> + FromStr, + { + type Value = T; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or map") + } + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + Ok(FromStr::from_str(value).unwrap()) + } + fn visit_map(self, map: M) -> std::result::Result + where + M: MapAccess<'de>, + { + // `MapAccessDeserializer` is a wrapper that turns a `MapAccess` + // into a `Deserializer`, allowing it to be used as the input to T's + // `Deserialize` implementation. T then deserializes itself using + // the entries from the map visitor. + Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)) + } + } + deserializer.deserialize_any(StringOrStruct(PhantomData)) } diff --git a/youtui/src/keyaction.rs b/youtui/src/keyaction.rs new file mode 100644 index 00000000..563382f1 --- /dev/null +++ b/youtui/src/keyaction.rs @@ -0,0 +1,70 @@ +use crate::{ + app::component::actionhandler::Action, config::keymap::KeyActionTree, keybind::Keybind, +}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// This is an Action that will be triggered when pressing a particular Keybind. +pub struct KeyAction { + // Consider - can there be multiple actions? + pub action: A, + #[serde(default)] + pub visibility: KeyActionVisibility, +} + +#[derive(PartialEq, Copy, Default, Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +/// Visibility of a KeyAction. +pub enum KeyActionVisibility { + /// Displayed on help menu + #[default] + Standard, + /// Displayed on Header and help menu + Global, + /// Not displayed + Hidden, +} + +#[derive(PartialEq, Debug, Clone)] +/// Type-erased keybinding for displaying. +pub struct DisplayableKeyAction<'a> { + // XXX: Do we also want to display sub-keys in Modes? + pub keybinds: Cow<'a, str>, + pub context: Cow<'a, str>, + pub description: Cow<'a, str>, +} +/// Type-erased mode for displaying its actions. +pub struct DisplayableMode<'a, I: Iterator>> { + pub displayable_commands: I, + pub description: Cow<'a, str>, +} + +impl<'a> DisplayableKeyAction<'a> { + pub fn from_keybind_and_action_tree( + key: &'a Keybind, + value: &'a KeyActionTree, + ) -> Self { + // NOTE: Currently, sub-keys of modes are not displayed. + match value { + KeyActionTree::Key(k) => DisplayableKeyAction { + keybinds: key.to_string().into(), + context: k.action.context(), + description: k.action.describe(), + }, + KeyActionTree::Mode { name, keys } => DisplayableKeyAction { + keybinds: key.to_string().into(), + context: keys + .iter() + .next() + .map(|(_, kt)| kt.get_context()) + .unwrap_or_default(), + description: name + .as_ref() + .map(ToOwned::to_owned) + .unwrap_or_else(|| key.to_string()) + .into(), + }, + } + } +} diff --git a/youtui/src/keybind.rs b/youtui/src/keybind.rs new file mode 100644 index 00000000..58633796 --- /dev/null +++ b/youtui/src/keybind.rs @@ -0,0 +1,216 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, char::ParseCharError, fmt::Display, str::FromStr}; + +#[derive(Hash, Eq, PartialEq, PartialOrd, Debug, Deserialize, Clone, Serialize)] +#[serde(try_from = "String")] +/// A keybind - particularly, a KeyCode that may have 0 to many KeyModifiers. +pub struct Keybind { + pub code: KeyCode, + pub modifiers: KeyModifiers, +} +impl Keybind { + pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { + Self { code, modifiers } + } + pub fn new_unmodified(code: KeyCode) -> Self { + Self { + code, + modifiers: KeyModifiers::NONE, + } + } +} +// Since KeyCode and KeyModifiers derive PartialOrd, it's safe to implement this +// as per below. +// +// Upstream PR that would allow derive(Ord): https://github.com/crossterm-rs/crossterm/pull/951 +#[allow(clippy::derive_ord_xor_partial_ord)] +impl Ord for Keybind { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.partial_cmp(other).expect("Keybind should be able to provide ordering for any values. Has crossterm made a breaking change?") + } +} +impl TryFrom for Keybind { + type Error = ::Err; + fn try_from(value: String) -> Result { + FromStr::from_str(&value) + } +} +impl FromStr for Keybind { + type Err = String; + fn from_str(s: &str) -> Result { + /// Note - currently doesn't parse keybinds that require additional + /// crossterm config to receive, e.g CapsLock. + // TODO: Consider ensuring this has 1:1 parity with crossterm Keybind. + fn parse_unmodified(s: &str) -> Result { + if let Ok(char) = char::from_str(s) { + return Ok(KeyCode::Char(char)); + } + match s.to_lowercase().as_str() { + "enter" => return Ok(KeyCode::Enter), + "delete" => return Ok(KeyCode::Delete), + "up" => return Ok(KeyCode::Up), + "pageup" => return Ok(KeyCode::PageUp), + "down" => return Ok(KeyCode::Down), + "pagedown" => return Ok(KeyCode::PageDown), + "left" => return Ok(KeyCode::Left), + "right" => return Ok(KeyCode::Right), + "backspace" => return Ok(KeyCode::Backspace), + "tab" => return Ok(KeyCode::Tab), + "backtab" => return Ok(KeyCode::BackTab), + "esc" => return Ok(KeyCode::Esc), + "home" => return Ok(KeyCode::Home), + "end" => return Ok(KeyCode::End), + "insert" => return Ok(KeyCode::Insert), + "space" => return Ok(KeyCode::Char(' ')), + _ => (), + }; + if let Some((before, Ok(num))) = s + .split_once("F") + .map(|(before, num)| (before, u8::from_str(num))) + { + if before.is_empty() { + return Ok(KeyCode::F(num)); + } + } + Err(s) + } + fn parse_modifier(c: char) -> Result { + match c { + 'A' => Ok(KeyModifiers::ALT), + 'C' => Ok(KeyModifiers::CONTROL), + 'S' => Ok(KeyModifiers::SHIFT), + c => Err(c), + } + } + // For ergonomics and to reduce edge cases, all whitespace is removed prior to + // parsing. + let s = s.split_whitespace().collect::(); + if let Ok(code) = parse_unmodified(&s) { + return Ok(Keybind::new(code, KeyModifiers::NONE)); + }; + let mut split = s.rsplit("-"); + if let Some(Ok(code)) = split.next().map(parse_unmodified) { + if let Ok(Ok(mut modifiers)) = split + .map(char::from_str) + .map(|res| res.map(parse_modifier)) + .collect::, ParseCharError>>() + { + // If the keycode is a character, then the shift modifier should be removed. It + // will be encoded in the character already. + if let KeyCode::Char(_) = code { + modifiers = modifiers.difference(KeyModifiers::SHIFT); + } + return Ok(Keybind::new(code, modifiers)); + } + } + Err(s.to_string()) + } +} +impl Display for Keybind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let code: Cow = match self.code { + KeyCode::Enter => "Enter".into(), + KeyCode::Left => "Left".into(), + KeyCode::Right => "Right".into(), + KeyCode::Up => "Up".into(), + KeyCode::Down => "Down".into(), + KeyCode::PageUp => "PageUp".into(), + KeyCode::PageDown => "PageDown".into(), + KeyCode::Esc => "Esc".into(), + KeyCode::Char(c) => match c { + ' ' => "Space".into(), + c => c.to_string().into(), + }, + KeyCode::Backspace => "Backspace".into(), + KeyCode::F(x) => format!("F{x}").into(), + KeyCode::Home => "Home".into(), + KeyCode::End => "End".into(), + KeyCode::Tab => "Tab".into(), + KeyCode::BackTab => "BackTab".into(), + KeyCode::Delete => "Delete".into(), + KeyCode::Insert => "Ins".into(), + KeyCode::Null => "Null".into(), + KeyCode::CapsLock => "CapsLock".into(), + KeyCode::ScrollLock => "ScrLock".into(), + KeyCode::NumLock => "NumLock".into(), + KeyCode::PrintScreen => "PrtScrn".into(), + KeyCode::Pause => "Pause".into(), + KeyCode::Menu => "Menu".into(), + KeyCode::KeypadBegin => "Begin".into(), + KeyCode::Media(media_key_code) => media_key_code.to_string().into(), + KeyCode::Modifier(modifier_key_code) => modifier_key_code.to_string().into(), + }; + match self.modifiers { + KeyModifiers::CONTROL => write!(f, "C-{code}"), + KeyModifiers::ALT => write!(f, "A-{code}"), + KeyModifiers::SHIFT => write!(f, "S-{code}"), + _ => write!(f, "{code}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::Keybind; + use crossterm::event::{KeyCode, KeyModifiers}; + use std::str::FromStr; + + #[test] + fn parse_char_key() { + let kb = Keybind::from_str("a").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::Char('a'), KeyModifiers::NONE)); + } + #[test] + fn parse_space() { + Keybind::from_str(" ").unwrap_err(); + let kb = Keybind::from_str("space").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::Char(' '), KeyModifiers::NONE)); + } + #[test] + fn parse_f_key() { + let kb = Keybind::from_str("F10").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::F(10), KeyModifiers::NONE)); + } + #[test] + fn parse_enter() { + let expected = Keybind::new(KeyCode::Enter, KeyModifiers::NONE); + let kb = Keybind::from_str("enter").unwrap(); + assert_eq!(kb, expected); + let kb = Keybind::from_str("EnTeR").unwrap(); + assert_eq!(kb, expected); + } + #[test] + fn parse_delete() { + let kb = Keybind::from_str("delete").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::Delete, KeyModifiers::NONE)); + } + #[test] + fn parse_unrecognised() { + let kb = Keybind::from_str("random").unwrap_err(); + assert_eq!(kb, "random".to_string()); + } + #[test] + fn parse_alt_key() { + let kb = Keybind::from_str("A-a").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::Char('a'), KeyModifiers::ALT)); + let kb = Keybind::from_str("A-enter").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::Enter, KeyModifiers::ALT)); + } + #[test] + fn parse_shift_key() { + let kb = Keybind::from_str("S-F1").unwrap(); + assert_eq!(kb, Keybind::new(KeyCode::F(1), KeyModifiers::SHIFT)); + } + #[test] + fn parse_ctrl_key() { + let kb = Keybind::from_str("C-A-x").unwrap(); + assert_eq!( + kb, + Keybind::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ) + ); + } +} diff --git a/youtui/src/main.rs b/youtui/src/main.rs index 3bd7624d..50f2374c 100644 --- a/youtui/src/main.rs +++ b/youtui/src/main.rs @@ -16,6 +16,8 @@ mod config; mod core; mod drawutils; mod error; +mod keyaction; +mod keybind; #[cfg(test)] mod tests; @@ -268,7 +270,7 @@ async fn try_main() -> Result<()> { // Config and API key files will be in OS directories. // Create them if they don't exist. initialise_directories().await?; - let mut config = config::Config::new()?; + let mut config = config::Config::new(debug).await?; // Command line flag for auth_type should override config for auth_type. if let Some(auth_type) = auth_type { config.auth_type = auth_type diff --git a/ytmapi-rs/src/parse.rs b/ytmapi-rs/src/parse.rs index 2e063ecb..fe3e1f04 100644 --- a/ytmapi-rs/src/parse.rs +++ b/ytmapi-rs/src/parse.rs @@ -143,13 +143,13 @@ impl<'a, Q> ProcessedResult<'a, Q> { } } -impl<'a, Q> ProcessedResult<'a, Q> { +impl ProcessedResult<'_, Q> { pub fn parse_into>(self) -> Result { O::parse_from(self) } } -impl<'a, Q> From> for JsonCrawlerOwned { +impl From> for JsonCrawlerOwned { fn from(value: ProcessedResult) -> Self { let (_, source, crawler) = value.destructure(); JsonCrawlerOwned::new(source, crawler) diff --git a/ytmapi-rs/src/parse/history.rs b/ytmapi-rs/src/parse/history.rs index 9e9b5b1c..6a6899c2 100644 --- a/ytmapi-rs/src/parse/history.rs +++ b/ytmapi-rs/src/parse/history.rs @@ -123,7 +123,7 @@ impl ParseFrom for Vec { .collect() } } -impl<'a> ParseFrom> for Vec { +impl ParseFrom> for Vec { fn parse_from(p: super::ProcessedResult) -> Result { let json_crawler = JsonCrawlerOwned::from(p); json_crawler @@ -145,7 +145,7 @@ impl<'a> ParseFrom> for Vec { .map_err(Into::into) } } -impl<'a> ParseFrom> for () { +impl ParseFrom> for () { fn parse_from(_: crate::parse::ProcessedResult) -> crate::Result { // Api only returns an empty string, no way of validating if correct or not. Ok(()) diff --git a/ytmapi-rs/src/parse/library.rs b/ytmapi-rs/src/parse/library.rs index 27044833..9d53f09a 100644 --- a/ytmapi-rs/src/parse/library.rs +++ b/ytmapi-rs/src/parse/library.rs @@ -223,7 +223,7 @@ impl Continuable for GetLibraryPlaylists { } } -impl<'a> ParseFrom> for Vec { +impl ParseFrom> for Vec { fn parse_from(p: super::ProcessedResult) -> Result { let json_crawler = JsonCrawlerOwned::from(p); json_crawler diff --git a/ytmapi-rs/src/parse/podcasts.rs b/ytmapi-rs/src/parse/podcasts.rs index 48e49b33..700b5f09 100644 --- a/ytmapi-rs/src/parse/podcasts.rs +++ b/ytmapi-rs/src/parse/podcasts.rs @@ -95,7 +95,7 @@ pub struct GetEpisode { // NOTE: This is technically the same page as the GetArtist page. It's possible // this could be generalised. -impl<'a> ParseFrom> for GetPodcastChannel { +impl ParseFrom> for GetPodcastChannel { fn parse_from(p: crate::ProcessedResult) -> Result { fn parse_podcast(crawler: impl JsonCrawler) -> Result { let mut podcast = crawler.navigate_pointer(MTRIR)?; @@ -164,7 +164,7 @@ impl<'a> ParseFrom> for GetPodcastChannel { }) } } -impl<'a> ParseFrom> for Vec { +impl ParseFrom> for Vec { fn parse_from(p: crate::ProcessedResult) -> Result { let json_crawler = JsonCrawlerOwned::from(p); json_crawler @@ -174,7 +174,7 @@ impl<'a> ParseFrom> for Vec { .collect() } } -impl<'a> ParseFrom> for GetPodcast { +impl ParseFrom> for GetPodcast { fn parse_from(p: crate::ProcessedResult) -> Result { let json_crawler = JsonCrawlerOwned::from(p); let mut two_column = json_crawler.navigate_pointer(TWO_COLUMN)?; @@ -217,7 +217,7 @@ impl<'a> ParseFrom> for GetPodcast { }) } } -impl<'a> ParseFrom> for GetEpisode { +impl ParseFrom> for GetEpisode { fn parse_from(p: crate::ProcessedResult) -> Result { let json_crawler = JsonCrawlerOwned::from(p); let mut two_column = json_crawler.navigate_pointer(TWO_COLUMN)?; diff --git a/ytmapi-rs/src/parse/upload.rs b/ytmapi-rs/src/parse/upload.rs index d1f75165..328da7c3 100644 --- a/ytmapi-rs/src/parse/upload.rs +++ b/ytmapi-rs/src/parse/upload.rs @@ -185,7 +185,7 @@ impl ParseFrom for Vec { .collect() } } -impl<'a> ParseFrom> for GetLibraryUploadAlbum { +impl ParseFrom> for GetLibraryUploadAlbum { fn parse_from(p: super::ProcessedResult) -> Result { fn parse_playlist_upload_song( mut json_crawler: JsonCrawlerOwned, @@ -251,7 +251,7 @@ impl<'a> ParseFrom> for GetLibraryUploadAlbum { }) } } -impl<'a> ParseFrom> for Vec { +impl ParseFrom> for Vec { fn parse_from(p: super::ProcessedResult) -> Result { let crawler: JsonCrawlerOwned = p.into(); let contents = get_uploads_tab(crawler)?.navigate_pointer(concatcp!( diff --git a/ytmapi-rs/src/query.rs b/ytmapi-rs/src/query.rs index 3ed96477..3e6383b5 100644 --- a/ytmapi-rs/src/query.rs +++ b/ytmapi-rs/src/query.rs @@ -156,11 +156,11 @@ pub mod album { pub struct GetAlbumQuery<'a> { browse_id: AlbumID<'a>, } - impl<'a, A: AuthToken> Query for GetAlbumQuery<'a> { + impl Query for GetAlbumQuery<'_> { type Output = GetAlbum; type Method = PostMethod; } - impl<'a> PostQuery for GetAlbumQuery<'a> { + impl PostQuery for GetAlbumQuery<'_> { fn header(&self) -> serde_json::Map { let serde_json::Value::Object(map) = json!({ "browseId" : self.browse_id.get_raw(), @@ -197,11 +197,11 @@ pub mod lyrics { pub struct GetLyricsQuery<'a> { id: LyricsID<'a>, } - impl<'a, A: AuthToken> Query for GetLyricsQuery<'a> { + impl Query for GetLyricsQuery<'_> { type Output = Lyrics; type Method = PostMethod; } - impl<'a> PostQuery for GetLyricsQuery<'a> { + impl PostQuery for GetLyricsQuery<'_> { fn header(&self) -> serde_json::Map { let serde_json::Value::Object(map) = json!({ "browseId": self.id.get_raw(), @@ -246,7 +246,7 @@ pub mod watch { playlist_id: PlaylistID<'a>, } - impl<'a> GetWatchPlaylistQueryID for VideoAndPlaylistID<'a> { + impl GetWatchPlaylistQueryID for VideoAndPlaylistID<'_> { fn get_video_id(&self) -> Option> { Some(self.video_id.get_raw().into()) } @@ -255,7 +255,7 @@ pub mod watch { self.playlist_id.get_raw().into() } } - impl<'a> GetWatchPlaylistQueryID for VideoID<'a> { + impl GetWatchPlaylistQueryID for VideoID<'_> { fn get_video_id(&self) -> Option> { Some(self.get_raw().into()) } @@ -264,7 +264,7 @@ pub mod watch { format!("RDAMVM{}", self.get_raw()).into() } } - impl<'a> GetWatchPlaylistQueryID for PlaylistID<'a> { + impl GetWatchPlaylistQueryID for PlaylistID<'_> { fn get_video_id(&self) -> Option> { None } @@ -308,7 +308,7 @@ pub mod watch { pub fn with_playlist_id( self, playlist_id: PlaylistID<'a>, - ) -> GetWatchPlaylistQuery { + ) -> GetWatchPlaylistQuery> { GetWatchPlaylistQuery { id: VideoAndPlaylistID { video_id: self.id, @@ -324,7 +324,7 @@ pub mod watch { pub fn with_video_id( self, video_id: VideoID<'a>, - ) -> GetWatchPlaylistQuery { + ) -> GetWatchPlaylistQuery> { GetWatchPlaylistQuery { id: VideoAndPlaylistID { video_id, @@ -368,11 +368,11 @@ pub mod rate { } // AUTH REQUIRED - impl<'a, A: AuthToken> Query for RateSongQuery<'a> { + impl Query for RateSongQuery<'_> { type Output = (); type Method = PostMethod; } - impl<'a> PostQuery for RateSongQuery<'a> { + impl PostQuery for RateSongQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([( "target".to_string(), @@ -388,12 +388,12 @@ pub mod rate { } // AUTH REQUIRED - impl<'a, A: AuthToken> Query for RatePlaylistQuery<'a> { + impl Query for RatePlaylistQuery<'_> { type Output = (); type Method = PostMethod; } - impl<'a> PostQuery for RatePlaylistQuery<'a> { + impl PostQuery for RatePlaylistQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([( "target".to_string(), @@ -431,7 +431,7 @@ pub mod song { signature_timestamp: u64, } - impl<'a> GetSongTrackingUrlQuery<'a> { + impl GetSongTrackingUrlQuery<'_> { /// # NOTE /// A GetSongTrackingUrlQuery stores a timestamp, it's not recommended /// to store these for a long period of time. The constructor can fail @@ -445,11 +445,11 @@ pub mod song { } } - impl<'a, A: AuthToken> Query for GetSongTrackingUrlQuery<'a> { + impl Query for GetSongTrackingUrlQuery<'_> { type Output = SongTrackingUrl<'static>; type Method = PostMethod; } - impl<'a> PostQuery for GetSongTrackingUrlQuery<'a> { + impl PostQuery for GetSongTrackingUrlQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([ ( diff --git a/ytmapi-rs/src/query/artist.rs b/ytmapi-rs/src/query/artist.rs index 6945e7f6..b774aaf2 100644 --- a/ytmapi-rs/src/query/artist.rs +++ b/ytmapi-rs/src/query/artist.rs @@ -37,11 +37,11 @@ impl<'a, T: Into>> From for GetArtistQuery<'a> { } } -impl<'a, A: AuthToken> Query for GetArtistQuery<'a> { +impl Query for GetArtistQuery<'_> { type Output = ArtistParams; type Method = PostMethod; } -impl<'a> PostQuery for GetArtistQuery<'a> { +impl PostQuery for GetArtistQuery<'_> { fn header(&self) -> serde_json::Map { // XXX: could do in new to avoid process every time called // or even better, could do this first time called, and store state so not @@ -62,11 +62,11 @@ impl<'a> PostQuery for GetArtistQuery<'a> { } } // TODO: Check if the MPLA strip is correct for both of these. -impl<'a, A: AuthToken> Query for GetArtistAlbumsQuery<'a> { +impl Query for GetArtistAlbumsQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for GetArtistAlbumsQuery<'a> { +impl PostQuery for GetArtistAlbumsQuery<'_> { fn header(&self) -> serde_json::Map { // XXX: should do in new // XXX: Think I could remove allocation here diff --git a/ytmapi-rs/src/query/continuations.rs b/ytmapi-rs/src/query/continuations.rs index 3e19d29d..fcbe157b 100644 --- a/ytmapi-rs/src/query/continuations.rs +++ b/ytmapi-rs/src/query/continuations.rs @@ -35,7 +35,7 @@ impl<'a, Q> GetContinuationsQuery<'a, Q> { } } -impl<'a, Q: Query, A: AuthToken> Query for GetContinuationsQuery<'a, Q> +impl, A: AuthToken> Query for GetContinuationsQuery<'_, Q> where Q: PostQuery, Q::Output: ParseFrom, @@ -44,7 +44,7 @@ where type Method = PostMethod; } -impl<'a, Q> PostQuery for GetContinuationsQuery<'a, Q> +impl PostQuery for GetContinuationsQuery<'_, Q> where Q: PostQuery, { diff --git a/ytmapi-rs/src/query/history.rs b/ytmapi-rs/src/query/history.rs index 4003ba61..5b163023 100644 --- a/ytmapi-rs/src/query/history.rs +++ b/ytmapi-rs/src/query/history.rs @@ -47,11 +47,11 @@ impl PostQuery for GetHistoryQuery { } // NOTE: Does not work on brand accounts -impl<'a, A: AuthToken> Query for RemoveHistoryItemsQuery<'a> { +impl Query for RemoveHistoryItemsQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for RemoveHistoryItemsQuery<'a> { +impl PostQuery for RemoveHistoryItemsQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([("feedbackTokens".to_string(), json!(self.feedback_tokens))]) } @@ -63,12 +63,12 @@ impl<'a> PostQuery for RemoveHistoryItemsQuery<'a> { } } -impl<'a, A: AuthToken> Query for AddHistoryItemQuery<'a> { +impl Query for AddHistoryItemQuery<'_> { type Output = (); type Method = GetMethod; } -impl<'a> GetQuery for AddHistoryItemQuery<'a> { +impl GetQuery for AddHistoryItemQuery<'_> { fn url(&self) -> &str { self.song_url.get_raw() } diff --git a/ytmapi-rs/src/query/library.rs b/ytmapi-rs/src/query/library.rs index 1c5fb51b..06ecd013 100644 --- a/ytmapi-rs/src/query/library.rs +++ b/ytmapi-rs/src/query/library.rs @@ -221,11 +221,11 @@ impl PostQuery for GetLibraryArtistSubscriptionsQuery { } // NOTE: Does not work on brand accounts // NOTE: Auth required -impl<'a, A: AuthToken> Query for EditSongLibraryStatusQuery<'a> { +impl Query for EditSongLibraryStatusQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for EditSongLibraryStatusQuery<'a> { +impl PostQuery for EditSongLibraryStatusQuery<'_> { fn header(&self) -> serde_json::Map { let add_feedback_tokens_raw = self .add_to_library_feedback_tokens diff --git a/ytmapi-rs/src/query/playlist.rs b/ytmapi-rs/src/query/playlist.rs index 81373b27..3dbc0787 100644 --- a/ytmapi-rs/src/query/playlist.rs +++ b/ytmapi-rs/src/query/playlist.rs @@ -73,11 +73,11 @@ impl<'a> RemovePlaylistItemsQuery<'a> { } } -impl<'a, A: AuthToken> Query for GetPlaylistQuery<'a> { +impl Query for GetPlaylistQuery<'_> { type Output = GetPlaylist; type Method = PostMethod; } -impl<'a> PostQuery for GetPlaylistQuery<'a> { +impl PostQuery for GetPlaylistQuery<'_> { fn header(&self) -> serde_json::Map { // TODO: Confirm if processing required to add 'VL' portion of playlistId let serde_json::Value::Object(map) = json!({ @@ -95,11 +95,11 @@ impl<'a> PostQuery for GetPlaylistQuery<'a> { } } -impl<'a, A: AuthToken> Query for DeletePlaylistQuery<'a> { +impl Query for DeletePlaylistQuery<'_> { type Output = (); type Method = PostMethod; } -impl<'a> PostQuery for DeletePlaylistQuery<'a> { +impl PostQuery for DeletePlaylistQuery<'_> { fn header(&self) -> serde_json::Map { // TODO: Confirm if processing required to remove 'VL' portion of playlistId let serde_json::Value::Object(map) = json!({ @@ -122,11 +122,11 @@ impl<'a> From> for DeletePlaylistQuery<'a> { } } -impl<'a, A: AuthToken> Query for RemovePlaylistItemsQuery<'a> { +impl Query for RemovePlaylistItemsQuery<'_> { type Output = (); type Method = PostMethod; } -impl<'a> PostQuery for RemovePlaylistItemsQuery<'a> { +impl PostQuery for RemovePlaylistItemsQuery<'_> { fn header(&self) -> serde_json::Map { let serde_json::Value::Object(mut map) = json!({ "playlistId": self.id, diff --git a/ytmapi-rs/src/query/playlist/additems.rs b/ytmapi-rs/src/query/playlist/additems.rs index d60256b4..7be95793 100644 --- a/ytmapi-rs/src/query/playlist/additems.rs +++ b/ytmapi-rs/src/query/playlist/additems.rs @@ -33,7 +33,7 @@ pub struct AddVideosToPlaylist<'a> { pub struct AddPlaylistToPlaylist<'a> { source_playlist: PlaylistID<'a>, } -impl<'a> SpecialisedQuery for AddVideosToPlaylist<'a> { +impl SpecialisedQuery for AddVideosToPlaylist<'_> { fn additional_header(&self) -> Option<(String, serde_json::Value)> { let actions = self .video_ids @@ -52,7 +52,7 @@ impl<'a> SpecialisedQuery for AddVideosToPlaylist<'a> { Some(("actions".to_string(), actions.collect())) } } -impl<'a> SpecialisedQuery for AddPlaylistToPlaylist<'a> { +impl SpecialisedQuery for AddPlaylistToPlaylist<'_> { fn additional_header(&self) -> Option<(String, serde_json::Value)> { Some(( "actions".to_string(), @@ -91,11 +91,11 @@ impl<'a> AddPlaylistItemsQuery<'a, AddVideosToPlaylist<'a>> { } } -impl<'a, A: AuthToken, T: SpecialisedQuery> Query for AddPlaylistItemsQuery<'a, T> { +impl Query for AddPlaylistItemsQuery<'_, T> { type Output = Vec; type Method = PostMethod; } -impl<'a, T: SpecialisedQuery> PostQuery for AddPlaylistItemsQuery<'a, T> { +impl PostQuery for AddPlaylistItemsQuery<'_, T> { fn header(&self) -> serde_json::Map { let serde_json::Value::Object(mut map) = json!({ "playlistId" : self.id, diff --git a/ytmapi-rs/src/query/playlist/create.rs b/ytmapi-rs/src/query/playlist/create.rs index 96c6992a..cacfdfe6 100644 --- a/ytmapi-rs/src/query/playlist/create.rs +++ b/ytmapi-rs/src/query/playlist/create.rs @@ -40,12 +40,12 @@ impl CreatePlaylistType for BasicCreatePlaylist { None } } -impl<'a> CreatePlaylistType for CreatePlaylistFromVideos<'a> { +impl CreatePlaylistType for CreatePlaylistFromVideos<'_> { fn additional_header(&self) -> Option<(String, serde_json::Value)> { Some(("videoIds".into(), json!(self.video_ids))) } } -impl<'a> CreatePlaylistType for CreatePlaylistFromPlaylist<'a> { +impl CreatePlaylistType for CreatePlaylistFromPlaylist<'_> { fn additional_header(&self) -> Option<(String, serde_json::Value)> { Some(("sourcePlaylistId".into(), json!(self.source_playlist))) } @@ -92,7 +92,7 @@ impl<'a> CreatePlaylistQuery<'a, BasicCreatePlaylist> { pub fn with_video_ids( self, video_ids: Vec>, - ) -> CreatePlaylistQuery<'a, CreatePlaylistFromVideos> { + ) -> CreatePlaylistQuery<'a, CreatePlaylistFromVideos<'a>> { let CreatePlaylistQuery { title, description, @@ -108,11 +108,11 @@ impl<'a> CreatePlaylistQuery<'a, BasicCreatePlaylist> { } } -impl<'a, A: AuthToken, C: CreatePlaylistType> Query for CreatePlaylistQuery<'a, C> { +impl Query for CreatePlaylistQuery<'_, C> { type Output = PlaylistID<'static>; type Method = PostMethod; } -impl<'a, C: CreatePlaylistType> PostQuery for CreatePlaylistQuery<'a, C> { +impl PostQuery for CreatePlaylistQuery<'_, C> { fn header(&self) -> serde_json::Map { // TODO: Confirm if processing required to remove 'VL' portion of playlistId let serde_json::Value::Object(mut map) = json!({ diff --git a/ytmapi-rs/src/query/playlist/edit.rs b/ytmapi-rs/src/query/playlist/edit.rs index 20057562..3667db64 100644 --- a/ytmapi-rs/src/query/playlist/edit.rs +++ b/ytmapi-rs/src/query/playlist/edit.rs @@ -134,11 +134,11 @@ impl<'a> EditPlaylistQuery<'a> { } } -impl<'a, A: AuthToken> Query for EditPlaylistQuery<'a> { +impl Query for EditPlaylistQuery<'_> { type Output = ApiOutcome; type Method = PostMethod; } -impl<'a> PostQuery for EditPlaylistQuery<'a> { +impl PostQuery for EditPlaylistQuery<'_> { fn header(&self) -> serde_json::Map { let mut actions = Vec::new(); if let Some(new_title) = &self.new_title { diff --git a/ytmapi-rs/src/query/podcasts.rs b/ytmapi-rs/src/query/podcasts.rs index aaf546e4..fabf787c 100644 --- a/ytmapi-rs/src/query/podcasts.rs +++ b/ytmapi-rs/src/query/podcasts.rs @@ -56,19 +56,19 @@ impl<'a> GetEpisodeQuery<'a> { } } -impl<'a, A: AuthToken> Query for GetChannelQuery<'a> { +impl Query for GetChannelQuery<'_> { type Output = GetPodcastChannel; type Method = PostMethod; } -impl<'a, A: AuthToken> Query for GetChannelEpisodesQuery<'a> { +impl Query for GetChannelEpisodesQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a, A: AuthToken> Query for GetPodcastQuery<'a> { +impl Query for GetPodcastQuery<'_> { type Output = GetPodcast; type Method = PostMethod; } -impl<'a, A: AuthToken> Query for GetEpisodeQuery<'a> { +impl Query for GetEpisodeQuery<'_> { type Output = GetEpisode; type Method = PostMethod; } @@ -77,7 +77,7 @@ impl Query for GetNewEpisodesQuery { type Method = PostMethod; } -impl<'a> PostQuery for GetChannelQuery<'a> { +impl PostQuery for GetChannelQuery<'_> { fn header(&self) -> serde_json::Map { FromIterator::from_iter([("browseId".into(), json!(self.channel_id))]) } @@ -88,7 +88,7 @@ impl<'a> PostQuery for GetChannelQuery<'a> { "browse" } } -impl<'a> PostQuery for GetChannelEpisodesQuery<'a> { +impl PostQuery for GetChannelEpisodesQuery<'_> { fn header(&self) -> serde_json::Map { FromIterator::from_iter([ ("browseId".into(), json!(self.channel_id)), @@ -103,7 +103,7 @@ impl<'a> PostQuery for GetChannelEpisodesQuery<'a> { } } // TODO: Continuations -impl<'a> PostQuery for GetPodcastQuery<'a> { +impl PostQuery for GetPodcastQuery<'_> { fn header(&self) -> serde_json::Map { // TODO: Confirm if any parsing required FromIterator::from_iter([("browseId".into(), json!(self.podcast_id))]) @@ -115,7 +115,7 @@ impl<'a> PostQuery for GetPodcastQuery<'a> { "browse" } } -impl<'a> PostQuery for GetEpisodeQuery<'a> { +impl PostQuery for GetEpisodeQuery<'_> { fn header(&self) -> serde_json::Map { // TODO: Confirm if any parsing required FromIterator::from_iter([("browseId".into(), json!(self.episode_id))]) diff --git a/ytmapi-rs/src/query/recommendations.rs b/ytmapi-rs/src/query/recommendations.rs index 701bf549..da1baf53 100644 --- a/ytmapi-rs/src/query/recommendations.rs +++ b/ytmapi-rs/src/query/recommendations.rs @@ -113,11 +113,11 @@ impl PostQuery for GetMoodCategoriesQuery { } } -impl<'a, A: AuthToken> Query for GetMoodPlaylistsQuery<'a> { +impl Query for GetMoodPlaylistsQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for GetMoodPlaylistsQuery<'a> { +impl PostQuery for GetMoodPlaylistsQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([ ( diff --git a/ytmapi-rs/src/query/search.rs b/ytmapi-rs/src/query/search.rs index 339579c1..29c12b7b 100644 --- a/ytmapi-rs/src/query/search.rs +++ b/ytmapi-rs/src/query/search.rs @@ -77,11 +77,11 @@ impl UnfilteredSearchType for BasicSearch {} impl UnfilteredSearchType for UploadSearch {} impl UnfilteredSearchType for LibrarySearch {} -impl<'a, S: UnfilteredSearchType, A: AuthToken> Query for SearchQuery<'a, S> { +impl Query for SearchQuery<'_, S> { type Output = SearchResults; type Method = PostMethod; } -impl<'a, S: UnfilteredSearchType> PostQuery for SearchQuery<'a, S> { +impl PostQuery for SearchQuery<'_, S> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -221,11 +221,11 @@ impl<'a, S: Into>> From for GetSearchSuggestionsQuery<'a> { } } -impl<'a, A: AuthToken> Query for GetSearchSuggestionsQuery<'a> { +impl Query for GetSearchSuggestionsQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for GetSearchSuggestionsQuery<'a> { +impl PostQuery for GetSearchSuggestionsQuery<'_> { fn header(&self) -> serde_json::Map { let value = self.query.as_ref().into(); serde_json::Map::from_iter([("input".into(), value)]) @@ -252,6 +252,6 @@ fn search_query_header( serde_json::Map::from_iter([("query".to_string(), value)]) } } -fn search_query_params<'a, S: SearchType>(query: &'a SearchQuery<'a, S>) -> Option> { +fn search_query_params<'a, S: SearchType>(query: &'a SearchQuery<'a, S>) -> Option> { query.search_type.specialised_params(&query.spelling_mode) } diff --git a/ytmapi-rs/src/query/search/filteredsearch.rs b/ytmapi-rs/src/query/search/filteredsearch.rs index e02a0a4b..5b8e751b 100644 --- a/ytmapi-rs/src/query/search/filteredsearch.rs +++ b/ytmapi-rs/src/query/search/filteredsearch.rs @@ -163,11 +163,11 @@ impl FilteredSearchType for ProfilesFilter { } } // Implementations of Query -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -178,11 +178,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -193,11 +193,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -208,11 +208,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -223,11 +223,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -238,11 +238,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -253,11 +253,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -268,11 +268,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -283,11 +283,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } @@ -298,11 +298,11 @@ impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { vec![] } } -impl<'a, A: AuthToken> Query for SearchQuery<'a, FilteredSearch> { +impl Query for SearchQuery<'_, FilteredSearch> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for SearchQuery<'a, FilteredSearch> { +impl PostQuery for SearchQuery<'_, FilteredSearch> { fn header(&self) -> serde_json::Map { search_query_header(self) } diff --git a/ytmapi-rs/src/query/upload.rs b/ytmapi-rs/src/query/upload.rs index a143411c..a09a0408 100644 --- a/ytmapi-rs/src/query/upload.rs +++ b/ytmapi-rs/src/query/upload.rs @@ -64,11 +64,11 @@ impl<'a> DeleteUploadEntityQuery<'a> { } } // Auth required -impl<'a, A: AuthToken> Query for GetLibraryUploadAlbumQuery<'a> { +impl Query for GetLibraryUploadAlbumQuery<'_> { type Output = GetLibraryUploadAlbum; type Method = PostMethod; } -impl<'a> PostQuery for GetLibraryUploadAlbumQuery<'a> { +impl PostQuery for GetLibraryUploadAlbumQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([("browseId".to_string(), json!(self.upload_album_id))]) } @@ -80,11 +80,11 @@ impl<'a> PostQuery for GetLibraryUploadAlbumQuery<'a> { } } // Auth required -impl<'a, A: AuthToken> Query for GetLibraryUploadArtistQuery<'a> { +impl Query for GetLibraryUploadArtistQuery<'_> { type Output = Vec; type Method = PostMethod; } -impl<'a> PostQuery for GetLibraryUploadArtistQuery<'a> { +impl PostQuery for GetLibraryUploadArtistQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([("browseId".to_string(), json!(self.upload_artist_id))]) } @@ -186,11 +186,11 @@ impl PostQuery for GetLibraryUploadArtistsQuery { } } // Auth required -impl<'a, A: AuthToken> Query for DeleteUploadEntityQuery<'a> { +impl Query for DeleteUploadEntityQuery<'_> { type Output = (); type Method = PostMethod; } -impl<'a> PostQuery for DeleteUploadEntityQuery<'a> { +impl PostQuery for DeleteUploadEntityQuery<'_> { fn header(&self) -> serde_json::Map { serde_json::Map::from_iter([("entityId".to_string(), json!(self.upload_entity_id))]) } diff --git a/ytmapi-rs/tests/live_integration_tests.rs b/ytmapi-rs/tests/live_integration_tests.rs index ea31fe7d..99593e12 100644 --- a/ytmapi-rs/tests/live_integration_tests.rs +++ b/ytmapi-rs/tests/live_integration_tests.rs @@ -31,6 +31,7 @@ async fn test_get_oauth_code() { // NOTE: Internal only - due to use of error.is_oauth_expired() #[tokio::test] +#[ignore = "Oauth is broken https://github.com/nick42d/youtui/issues/179"] async fn test_expired_oauth() { // XXX: Assuming this error only occurs for expired headers. // This assumption may be incorrect. @@ -257,17 +258,7 @@ async fn test_get_mood_playlists() { .next() .unwrap(); let query = GetMoodPlaylistsQuery::new(first_mood_playlist.params); - let oauth_fut = async { - let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); - // Don't stuff around trying the keep the local OAuth secret up to - //date, just refresh it each time. - api.refresh_token().await.unwrap(); - api.query(query.clone()).await.unwrap(); - }; - let browser_fut = async { - browser_api.query(query.clone()).await.unwrap(); - }; - tokio::join!(oauth_fut, browser_fut); + browser_api.query(query.clone()).await.unwrap(); } #[ignore = "Ignored by default due to quota"] @@ -282,17 +273,7 @@ async fn test_get_library_upload_artist() { .next() .expect("To run this test, you will need to upload songs from at least one artist"); let query = GetLibraryUploadArtistQuery::new(first_artist.artist_id); - let oauth_fut = async { - let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); - // Don't stuff around trying the keep the local OAuth secret up to date, just - // refresh it each time. - api.refresh_token().await.unwrap(); - let _ = api.query(query.clone()).await.unwrap(); - }; - let browser_fut = async { - browser_api.query(query.clone()).await.unwrap(); - }; - tokio::join!(oauth_fut, browser_fut); + browser_api.query(query.clone()).await.unwrap(); } #[ignore = "Ignored by default due to quota"] @@ -307,17 +288,7 @@ async fn test_get_library_upload_album() { .next() .expect("To run this test, you will need to upload songs from at least one album"); let query = GetLibraryUploadAlbumQuery::new(first_album.album_id); - let oauth_fut = async { - let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); - // Don't stuff around trying the keep the local OAuth secret up to date, just - // refresh it each time. - api.refresh_token().await.unwrap(); - let _ = api.query(query.clone()).await.unwrap(); - }; - let browser_fut = async { - browser_api.query(query.clone()).await.unwrap(); - }; - tokio::join!(oauth_fut, browser_fut); + browser_api.query(query.clone()).await.unwrap(); } #[tokio::test] @@ -447,7 +418,7 @@ async fn test_add_remove_history_items() { } #[tokio::test] -#[ignore = "Ignored by default due to quota"] +#[ignore = "Ignored by default due to quota, also oauth is broken"] async fn test_delete_create_playlist_oauth() { let mut api = new_standard_oauth_api().await.unwrap(); // Don't stuff around trying the keep the local OAuth secret up to date, just @@ -642,6 +613,7 @@ async fn test_edit_playlist() { // # BASIC TESTS WITH ADDITIONAL ASSERTIONS #[tokio::test] +#[ignore = "Oauth is broken https://github.com/nick42d/youtui/issues/179"] async fn test_get_library_playlists_oauth() { let mut api = new_standard_oauth_api().await.unwrap(); // Don't stuff around trying the keep the local OAuth secret up to date, just @@ -657,6 +629,7 @@ async fn test_get_library_playlists() { assert!(!res.playlists.is_empty()); } #[tokio::test] +#[ignore = "Oauth is broken https://github.com/nick42d/youtui/issues/179"] async fn test_get_library_artists_oauth() { let mut api = new_standard_oauth_api().await.unwrap(); // Don't stuff around trying the keep the local OAuth secret up to date, just diff --git a/ytmapi-rs/tests/utils/mod.rs b/ytmapi-rs/tests/utils/mod.rs index cd3916a4..b40d3dcd 100644 --- a/ytmapi-rs/tests/utils/mod.rs +++ b/ytmapi-rs/tests/utils/mod.rs @@ -56,22 +56,30 @@ macro_rules! generate_query_test { $fname:ident,$query:expr) => { #[tokio::test] async fn $fname() { - let oauth_future = async { - let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); - // Don't stuff around trying the keep the local OAuth secret up to date, just - // refresh it each time. - api.refresh_token().await.unwrap(); - api.query($query) - .await - .expect("Expected query to run succesfully under oauth"); - }; - let browser_auth_future = async { - let api = crate::utils::new_standard_api().await.unwrap(); - api.query($query) - .await - .expect("Expected query to run succesfully under browser auth"); - }; - tokio::join!(oauth_future, browser_auth_future); + // NOTE: Code to handle Oath and Browser tests commented out due to oauth + // issues. + // + // https://github.com/nick42d/youtui/issues/179 + // let oauth_future = async { + // let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); + // // Don't stuff around trying the keep the local OAuth secret up to date, + // just // refresh it each time. + // api.refresh_token().await.unwrap(); + // api.query($query) + // .await + // .expect("Expected query to run succesfully under oauth"); + // }; + // let browser_auth_future = async { + // let api = crate::utils::new_standard_api().await.unwrap(); + // api.query($query) + // .await + // .expect("Expected query to run succesfully under browser auth"); + // }; + // tokio::join!(oauth_future, browser_auth_future); + let api = crate::utils::new_standard_api().await.unwrap(); + api.query($query) + .await + .expect("Expected query to run succesfully under browser auth"); } }; } @@ -85,33 +93,47 @@ macro_rules! generate_stream_test { $fname:ident,$query:expr) => { #[tokio::test] async fn $fname() { + // NOTE: Code to handle Oath and Browser tests commented out due to oauth + // issues. + // + // https://github.com/nick42d/youtui/issues/179 use futures::stream::{StreamExt, TryStreamExt}; - let oauth_future = async { - let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); - // Don't stuff around trying the keep the local OAuth secret up to date, just - // refresh it each time. - api.refresh_token().await.unwrap(); - let query = $query; - let stream = api.stream(&query); - tokio::pin!(stream); - stream - .try_collect::>() - .await - .expect("Expected all results from oauth stream to suceed"); - }; - let browser_auth_future = async { - let api = crate::utils::new_standard_api().await.unwrap(); - let query = $query; - let stream = api.stream(&query); - tokio::pin!(stream); - stream - // limit test to 5 results to avoid overload - .take(5) - .try_collect::>() - .await - .expect("Expected all results from browser stream to suceed"); - }; - tokio::join!(oauth_future, browser_auth_future); + // let oauth_future = async { + // let mut api = crate::utils::new_standard_oauth_api().await.unwrap(); + // // Don't stuff around trying the keep the local OAuth secret up to date, + // just // refresh it each time. + // api.refresh_token().await.unwrap(); + // let query = $query; + // let stream = api.stream(&query); + // tokio::pin!(stream); + // stream + // .try_collect::>() + // .await + // .expect("Expected all results from oauth stream to suceed"); + // }; + // let browser_auth_future = async { + // let api = crate::utils::new_standard_api().await.unwrap(); + // let query = $query; + // let stream = api.stream(&query); + // tokio::pin!(stream); + // stream + // // limit test to 5 results to avoid overload + // .take(5) + // .try_collect::>() + // .await + // .expect("Expected all results from browser stream to suceed"); + // }; + // tokio::join!(oauth_future, browser_auth_future); + let api = crate::utils::new_standard_api().await.unwrap(); + let query = $query; + let stream = api.stream(&query); + tokio::pin!(stream); + stream + // limit test to 5 results to avoid overload + .take(5) + .try_collect::>() + .await + .expect("Expected all results from browser stream to suceed"); } }; }