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