mecomp_tui/ui/
mod.rs

1//! This module contains the implementations of the TUI.
2//!
3//! The app is responsible for rendering the state of the application to the terminal.
4//!
5//! The app is updated every tick, and they use the state stores to get the latest state.
6
7pub mod app;
8pub mod colors;
9pub mod components;
10pub mod widgets;
11
12use std::{
13    io::{self, Stdout},
14    sync::Arc,
15    time::Duration,
16};
17
18use anyhow::Context as _;
19use app::App;
20use components::{
21    content_view::{
22        views::{
23            AlbumViewProps, ArtistViewProps, CollectionViewProps, DynamicPlaylistViewProps,
24            PlaylistViewProps, RadioViewProps, RandomViewProps, SongViewProps, ViewData,
25        },
26        ActiveView,
27    },
28    Component, ComponentRender,
29};
30use crossterm::{
31    event::{
32        DisableMouseCapture, EnableMouseCapture, Event, EventStream, PopKeyboardEnhancementFlags,
33    },
34    execute,
35    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
36};
37use mecomp_core::{
38    rpc::{MusicPlayerClient, SearchResult},
39    state::{library::LibraryFull, StateAudio},
40};
41use mecomp_storage::db::schemas::{album, artist, collection, dynamic, playlist, song, Thing};
42use one_or_many::OneOrMany;
43use ratatui::prelude::*;
44use tarpc::context::Context;
45use tokio::sync::{broadcast, mpsc};
46use tokio_stream::StreamExt;
47
48use crate::{
49    state::{action::Action, component::ActiveComponent, Receivers},
50    termination::Interrupted,
51};
52
53#[derive(Debug, Clone, Default)]
54pub struct AppState {
55    pub active_component: ActiveComponent,
56    pub audio: StateAudio,
57    pub search: SearchResult,
58    pub library: LibraryFull,
59    pub active_view: ActiveView,
60    pub additional_view_data: ViewData,
61}
62
63const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
64
65#[allow(clippy::module_name_repetitions)]
66pub struct UiManager {
67    action_tx: mpsc::UnboundedSender<Action>,
68}
69
70impl UiManager {
71    #[must_use]
72    pub const fn new(action_tx: mpsc::UnboundedSender<Action>) -> Self {
73        Self { action_tx }
74    }
75
76    /// Main loop for the UI manager.
77    ///
78    /// This function will run until the user exits the application.
79    ///
80    /// # Errors
81    ///
82    /// This function will return an error if there was an issue rendering to the terminal.
83    pub async fn main_loop(
84        self,
85        daemon: Arc<MusicPlayerClient>,
86        mut state_rx: Receivers,
87        mut interrupt_rx: broadcast::Receiver<Interrupted>,
88    ) -> anyhow::Result<Interrupted> {
89        // consume the first state to initialize the ui app
90        let mut state = AppState {
91            active_component: ActiveComponent::default(),
92            audio: state_rx.audio.recv().await.unwrap_or_default(),
93            search: state_rx.search.recv().await.unwrap_or_default(),
94            library: state_rx.library.recv().await.unwrap_or_default(),
95            active_view: state_rx.view.recv().await.unwrap_or_default(),
96            additional_view_data: ViewData::default(),
97        };
98        let mut app = App::new(&state, self.action_tx.clone());
99
100        let mut terminal = setup_terminal()?;
101        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
102        let mut crossterm_events = EventStream::new();
103
104        let result: anyhow::Result<Interrupted> = loop {
105            tokio::select! {
106                // Tick to terminate the select every N milliseconds
107                _ = ticker.tick() => (),
108                // Catch and handle crossterm events
109               maybe_event = crossterm_events.next() => match maybe_event {
110                    Some(Ok(Event::Key(key)))  => {
111                        app.handle_key_event(key);
112                    },
113                    Some(Ok(Event::Mouse(mouse))) => {
114                        let terminal_size = terminal.size().context("could not get terminal size")?;
115                        let area = Rect::new(0, 0, terminal_size.width, terminal_size.height);
116                        app.handle_mouse_event(mouse, area);
117                    },
118                    None => break Ok(Interrupted::UserInt),
119                    _ => (),
120                },
121                // Handle state updates
122                Some(audio) = state_rx.audio.recv() => {
123                    state = AppState {
124                        audio,
125                        ..state
126                    };
127                    app = app.move_with_audio(&state);
128                },
129                Some(search) = state_rx.search.recv() => {
130                    state = AppState {
131                        search,
132                        ..state
133                    };
134                    app = app.move_with_search(&state);
135                },
136                Some(library) = state_rx.library.recv() => {
137                    state = AppState {
138                        library,
139                        // Fixes edge case where user has a playlist open, modifies that playlist, and tries to view it again without first viewing another playlist
140                        additional_view_data: handle_additional_view_data(daemon.clone(), &state, &state.active_view).await.unwrap_or(state.additional_view_data),
141                        ..state
142                    };
143                    app = app.move_with_library(&state);
144                },
145                Some(active_view) = state_rx.view.recv() => {
146                    // update view_data
147                    let additional_view_data = handle_additional_view_data(daemon.clone(), &state, &active_view).await.unwrap_or(state.additional_view_data);
148
149                    state = AppState {
150                        active_view,
151                        additional_view_data,
152                        ..state
153                    };
154                    app = app.move_with_view(&state);
155                },
156                Some(active_component) = state_rx.component.recv() => {
157                    state = AppState {
158                        active_component,
159                        ..state
160                    };
161                    app = app.move_with_component(&state);
162                },
163                Some(popup) = state_rx.popup.recv() => {
164                     app = app.move_with_popup( popup.map(|popup| {
165                         popup.into_popup(&state, self.action_tx.clone())
166                     }));
167                }
168                // Catch and handle interrupt signal to gracefully shutdown
169                Ok(interrupted) = interrupt_rx.recv() => {
170                    break Ok(interrupted);
171                }
172            }
173
174            if let Err(err) = terminal
175                .draw(|frame| app.render(frame, frame.area()))
176                .context("could not render to the terminal")
177            {
178                break Err(err);
179            }
180        };
181
182        restore_terminal(&mut terminal)?;
183
184        result
185    }
186}
187
188#[cfg(not(tarpaulin_include))]
189fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
190    let mut stdout = io::stdout();
191
192    enable_raw_mode()?;
193
194    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
195
196    Ok(Terminal::new(CrosstermBackend::new(stdout))?)
197}
198
199#[cfg(not(tarpaulin_include))]
200fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> anyhow::Result<()> {
201    disable_raw_mode()?;
202
203    execute!(
204        terminal.backend_mut(),
205        LeaveAlternateScreen,
206        DisableMouseCapture,
207        PopKeyboardEnhancementFlags,
208    )?;
209
210    Ok(terminal.show_cursor()?)
211}
212
213#[cfg(not(tarpaulin_include))]
214pub fn init_panic_hook() {
215    let original_hook = std::panic::take_hook();
216    std::panic::set_hook(Box::new(move |panic_info| {
217        // intentionally ignore errors here since we're already in a panic
218        let _ = disable_raw_mode();
219        let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
220
221        original_hook(panic_info);
222    }));
223}
224
225/// Returns `None` if new data is not needed
226#[allow(clippy::too_many_lines)]
227async fn handle_additional_view_data(
228    daemon: Arc<MusicPlayerClient>,
229    state: &AppState,
230    active_view: &ActiveView,
231) -> Option<ViewData> {
232    match active_view {
233        ActiveView::Song(id) => {
234            let song_id = Thing {
235                tb: song::TABLE_NAME.to_string(),
236                id: id.to_owned(),
237            };
238
239            let song_view_props = if let Ok((
240                Some(song),
241                artists @ (OneOrMany::Many(_) | OneOrMany::One(_)),
242                Some(album),
243                playlists,
244                collections,
245            )) = tokio::try_join!(
246                daemon.library_song_get(Context::current(), song_id.clone()),
247                daemon.library_song_get_artist(Context::current(), song_id.clone()),
248                daemon.library_song_get_album(Context::current(), song_id.clone()),
249                daemon.library_song_get_playlists(Context::current(), song_id.clone()),
250                daemon.library_song_get_collections(Context::current(), song_id.clone()),
251            ) {
252                Some(SongViewProps {
253                    id: song_id,
254                    song,
255                    artists,
256                    album,
257                    playlists,
258                    collections,
259                })
260            } else {
261                None
262            };
263
264            Some(ViewData {
265                song: song_view_props,
266                ..state.additional_view_data.clone()
267            })
268        }
269        ActiveView::Album(id) => {
270            let album_id = Thing {
271                tb: album::TABLE_NAME.to_string(),
272                id: id.to_owned(),
273            };
274
275            let album_view_props = if let Ok((Some(album), artists, Some(songs))) = tokio::try_join!(
276                daemon.library_album_get(Context::current(), album_id.clone()),
277                daemon.library_album_get_artist(Context::current(), album_id.clone()),
278                daemon.library_album_get_songs(Context::current(), album_id.clone()),
279            ) {
280                Some(AlbumViewProps {
281                    id: album_id,
282                    album,
283                    artists,
284                    songs,
285                })
286            } else {
287                None
288            };
289
290            Some(ViewData {
291                album: album_view_props,
292                ..state.additional_view_data.clone()
293            })
294        }
295        ActiveView::Artist(id) => {
296            let artist_id = Thing {
297                tb: artist::TABLE_NAME.to_string(),
298                id: id.to_owned(),
299            };
300
301            let artist_view_props = if let Ok((Some(artist), Some(albums), Some(songs))) = tokio::try_join!(
302                daemon.library_artist_get(Context::current(), artist_id.clone()),
303                daemon.library_artist_get_albums(Context::current(), artist_id.clone()),
304                daemon.library_artist_get_songs(Context::current(), artist_id.clone()),
305            ) {
306                Some(ArtistViewProps {
307                    id: artist_id,
308                    artist,
309                    albums,
310                    songs,
311                })
312            } else {
313                None
314            };
315
316            Some(ViewData {
317                artist: artist_view_props,
318                ..state.additional_view_data.clone()
319            })
320        }
321        ActiveView::Playlist(id) => {
322            let playlist_id = Thing {
323                tb: playlist::TABLE_NAME.to_string(),
324                id: id.to_owned(),
325            };
326
327            let playlist_view_props = if let Ok((Some(playlist), Some(songs))) = tokio::try_join!(
328                daemon.playlist_get(Context::current(), playlist_id.clone()),
329                daemon.playlist_get_songs(Context::current(), playlist_id.clone()),
330            ) {
331                Some(PlaylistViewProps {
332                    id: playlist_id,
333                    playlist,
334                    songs,
335                })
336            } else {
337                None
338            };
339
340            Some(ViewData {
341                playlist: playlist_view_props,
342                ..state.additional_view_data.clone()
343            })
344        }
345        ActiveView::DynamicPlaylist(id) => {
346            let dynamic_playlist_id = Thing {
347                tb: dynamic::TABLE_NAME.to_string(),
348                id: id.to_owned(),
349            };
350
351            let dynamic_playlist_view_props = if let Ok((Some(dynamic_playlist), Some(songs))) = tokio::try_join!(
352                daemon.dynamic_playlist_get(Context::current(), dynamic_playlist_id.clone()),
353                daemon.dynamic_playlist_get_songs(Context::current(), dynamic_playlist_id.clone()),
354            ) {
355                Some(DynamicPlaylistViewProps {
356                    id: dynamic_playlist_id,
357                    songs,
358                    dynamic_playlist,
359                })
360            } else {
361                None
362            };
363
364            Some(ViewData {
365                dynamic_playlist: dynamic_playlist_view_props,
366                ..state.additional_view_data.clone()
367            })
368        }
369        ActiveView::Collection(id) => {
370            let collection_id = Thing {
371                tb: collection::TABLE_NAME.to_string(),
372                id: id.to_owned(),
373            };
374
375            let collection_view_props = if let Ok((Some(collection), Some(songs))) = tokio::try_join!(
376                daemon.collection_get(Context::current(), collection_id.clone()),
377                daemon.collection_get_songs(Context::current(), collection_id.clone()),
378            ) {
379                Some(CollectionViewProps {
380                    id: collection_id,
381                    collection,
382                    songs,
383                })
384            } else {
385                None
386            };
387
388            Some(ViewData {
389                collection: collection_view_props,
390                ..state.additional_view_data.clone()
391            })
392        }
393        ActiveView::Radio(ids, count) => {
394            let radio_view_props = if let Ok(Ok(songs)) = daemon
395                .radio_get_similar(Context::current(), ids.clone(), *count)
396                .await
397            {
398                Some(RadioViewProps {
399                    count: *count,
400                    songs,
401                })
402            } else {
403                None
404            };
405
406            Some(ViewData {
407                radio: radio_view_props,
408                ..state.additional_view_data.clone()
409            })
410        }
411        ActiveView::Random => {
412            let random_view_props = if let Ok((Some(album), Some(artist), Some(song))) = tokio::try_join!(
413                daemon.rand_album(Context::current()),
414                daemon.rand_artist(Context::current()),
415                daemon.rand_song(Context::current()),
416            ) {
417                Some(RandomViewProps {
418                    album: album.id.into(),
419                    artist: artist.id.into(),
420                    song: song.id.into(),
421                })
422            } else {
423                None
424            };
425
426            Some(ViewData {
427                random: random_view_props,
428                ..state.additional_view_data.clone()
429            })
430        }
431
432        ActiveView::None
433        | ActiveView::Search
434        | ActiveView::Songs
435        | ActiveView::Albums
436        | ActiveView::Artists
437        | ActiveView::Playlists
438        | ActiveView::DynamicPlaylists
439        | ActiveView::Collections => None,
440    }
441}