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    config::Settings,
39    rpc::{MusicPlayerClient, SearchResult},
40    state::{library::LibraryFull, StateAudio},
41};
42use mecomp_storage::db::schemas::{album, artist, collection, dynamic, playlist, song, Thing};
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::{action::Action, component::ActiveComponent, Receivers},
51    termination::Interrupted,
52};
53
54#[derive(Debug, Clone, Default)]
55pub struct AppState {
56    pub active_component: ActiveComponent,
57    pub audio: StateAudio,
58    pub search: SearchResult,
59    pub library: LibraryFull,
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: 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 = 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 = Thing {
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 song_view_props = SongViewProps {
257                    id: song_id,
258                    song,
259                    artists,
260                    album,
261                    playlists,
262                    collections,
263                };
264                Some(ViewData {
265                    song: Some(song_view_props),
266                    ..state.additional_view_data.clone()
267                })
268            } else {
269                Some(ViewData {
270                    song: None,
271                    ..state.additional_view_data.clone()
272                })
273            }
274        }
275        ActiveView::Album(id) => {
276            let album_id = Thing {
277                tb: album::TABLE_NAME.to_string(),
278                id: id.to_owned(),
279            };
280
281            if let Ok((Some(album), artists, Some(songs))) = tokio::try_join!(
282                daemon.library_album_get(Context::current(), album_id.clone()),
283                daemon.library_album_get_artist(Context::current(), album_id.clone()),
284                daemon.library_album_get_songs(Context::current(), album_id.clone()),
285            ) {
286                let album_view_props = AlbumViewProps {
287                    id: album_id,
288                    album,
289                    artists,
290                    songs,
291                };
292                Some(ViewData {
293                    album: Some(album_view_props),
294                    ..state.additional_view_data.clone()
295                })
296            } else {
297                Some(ViewData {
298                    album: None,
299                    ..state.additional_view_data.clone()
300                })
301            }
302        }
303        ActiveView::Artist(id) => {
304            let artist_id = Thing {
305                tb: artist::TABLE_NAME.to_string(),
306                id: id.to_owned(),
307            };
308
309            if let Ok((Some(artist), Some(albums), Some(songs))) = tokio::try_join!(
310                daemon.library_artist_get(Context::current(), artist_id.clone()),
311                daemon.library_artist_get_albums(Context::current(), artist_id.clone()),
312                daemon.library_artist_get_songs(Context::current(), artist_id.clone()),
313            ) {
314                let artist_view_props = ArtistViewProps {
315                    id: artist_id,
316                    artist,
317                    albums,
318                    songs,
319                };
320                Some(ViewData {
321                    artist: Some(artist_view_props),
322                    ..state.additional_view_data.clone()
323                })
324            } else {
325                Some(ViewData {
326                    artist: None,
327                    ..state.additional_view_data.clone()
328                })
329            }
330        }
331        ActiveView::Playlist(id) => {
332            let playlist_id = Thing {
333                tb: playlist::TABLE_NAME.to_string(),
334                id: id.to_owned(),
335            };
336
337            if let Ok((Some(playlist), Some(songs))) = tokio::try_join!(
338                daemon.playlist_get(Context::current(), playlist_id.clone()),
339                daemon.playlist_get_songs(Context::current(), playlist_id.clone()),
340            ) {
341                let playlist_view_props = PlaylistViewProps {
342                    id: playlist_id,
343                    playlist,
344                    songs,
345                };
346                Some(ViewData {
347                    playlist: Some(playlist_view_props),
348                    ..state.additional_view_data.clone()
349                })
350            } else {
351                Some(ViewData {
352                    playlist: None,
353                    ..state.additional_view_data.clone()
354                })
355            }
356        }
357        ActiveView::DynamicPlaylist(id) => {
358            let dynamic_playlist_id = Thing {
359                tb: dynamic::TABLE_NAME.to_string(),
360                id: id.to_owned(),
361            };
362
363            if let Ok((Some(dynamic_playlist), Some(songs))) = tokio::try_join!(
364                daemon.dynamic_playlist_get(Context::current(), dynamic_playlist_id.clone()),
365                daemon.dynamic_playlist_get_songs(Context::current(), dynamic_playlist_id.clone()),
366            ) {
367                let dynamic_playlist_view_props = DynamicPlaylistViewProps {
368                    id: dynamic_playlist_id,
369                    songs,
370                    dynamic_playlist,
371                };
372                Some(ViewData {
373                    dynamic_playlist: Some(dynamic_playlist_view_props),
374                    ..state.additional_view_data.clone()
375                })
376            } else {
377                Some(ViewData {
378                    dynamic_playlist: None,
379                    ..state.additional_view_data.clone()
380                })
381            }
382        }
383        ActiveView::Collection(id) => {
384            let collection_id = Thing {
385                tb: collection::TABLE_NAME.to_string(),
386                id: id.to_owned(),
387            };
388
389            if let Ok((Some(collection), Some(songs))) = tokio::try_join!(
390                daemon.collection_get(Context::current(), collection_id.clone()),
391                daemon.collection_get_songs(Context::current(), collection_id.clone()),
392            ) {
393                let collection_view_props = CollectionViewProps {
394                    id: collection_id,
395                    collection,
396                    songs,
397                };
398                Some(ViewData {
399                    collection: Some(collection_view_props),
400                    ..state.additional_view_data.clone()
401                })
402            } else {
403                Some(ViewData {
404                    collection: None,
405                    ..state.additional_view_data.clone()
406                })
407            }
408        }
409        ActiveView::Radio(ids) => {
410            let count = state.settings.tui.radio_count;
411            if let Ok(Ok(songs)) = daemon
412                .radio_get_similar(Context::current(), ids.clone(), count)
413                .await
414            {
415                let radio_view_props = RadioViewProps { count, songs };
416                Some(ViewData {
417                    radio: Some(radio_view_props),
418                    ..state.additional_view_data.clone()
419                })
420            } else {
421                Some(ViewData {
422                    radio: None,
423                    ..state.additional_view_data.clone()
424                })
425            }
426        }
427        ActiveView::Random => {
428            if let Ok((Some(album), Some(artist), Some(song))) = tokio::try_join!(
429                daemon.rand_album(Context::current()),
430                daemon.rand_artist(Context::current()),
431                daemon.rand_song(Context::current()),
432            ) {
433                let random_view_props = RandomViewProps {
434                    album: album.id.into(),
435                    artist: artist.id.into(),
436                    song: song.id.into(),
437                };
438                Some(ViewData {
439                    random: Some(random_view_props),
440                    ..state.additional_view_data.clone()
441                })
442            } else {
443                Some(ViewData {
444                    random: None,
445                    ..state.additional_view_data.clone()
446                })
447            }
448        }
449
450        ActiveView::None
451        | ActiveView::Search
452        | ActiveView::Songs
453        | ActiveView::Albums
454        | ActiveView::Artists
455        | ActiveView::Playlists
456        | ActiveView::DynamicPlaylists
457        | ActiveView::Collections => None,
458    }
459}