Skip to main content

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