Skip to main content

mecomp_tui/ui/components/content_view/
mod.rs

1//! The content view displays the contents of the current view (e.g. the songs in a playlist, the search results, etc.).
2
3pub mod views;
4
5use crossterm::event::{MouseButton, MouseEventKind};
6use mecomp_prost::{RecordId, Ulid};
7use mecomp_storage::db::schemas::{album, artist, collection, dynamic, playlist, song};
8use ratatui::layout::Position;
9use tokio::sync::mpsc::UnboundedSender;
10use views::{
11    album::{AlbumView, LibraryAlbumsView},
12    artist::{ArtistView, LibraryArtistsView},
13    collection::{CollectionView, LibraryCollectionsView},
14    dynamic::{DynamicView, LibraryDynamicView},
15    none::NoneView,
16    playlist::{LibraryPlaylistsView, PlaylistView},
17    radio::RadioView,
18    random::RandomView,
19    search::SearchView,
20    song::{LibrarySongsView, SongView},
21};
22
23use crate::{
24    state::{
25        action::{Action, ComponentAction, ViewAction},
26        component::ActiveComponent,
27    },
28    ui::AppState,
29};
30
31use super::{Component, ComponentRender, RenderProps};
32
33pub struct ContentView {
34    pub(crate) props: Props,
35    //
36    pub(crate) none_view: NoneView,
37    pub(crate) search_view: SearchView,
38    pub(crate) songs_view: LibrarySongsView,
39    pub(crate) song_view: SongView,
40    pub(crate) albums_view: LibraryAlbumsView,
41    pub(crate) album_view: AlbumView,
42    pub(crate) artists_view: LibraryArtistsView,
43    pub(crate) artist_view: ArtistView,
44    pub(crate) playlists_view: LibraryPlaylistsView,
45    pub(crate) playlist_view: PlaylistView,
46    pub(crate) dynamic_playlists_view: LibraryDynamicView,
47    pub(crate) dynamic_playlist_view: DynamicView,
48    pub(crate) collections_view: LibraryCollectionsView,
49    pub(crate) collection_view: CollectionView,
50    pub(crate) radio_view: RadioView,
51    pub(crate) random_view: RandomView,
52    //
53    pub(crate) action_tx: UnboundedSender<Action>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Props {
58    pub(crate) active_view: ActiveView,
59}
60
61impl From<&AppState> for Props {
62    fn from(value: &AppState) -> Self {
63        Self {
64            active_view: value.active_view.clone(),
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub enum ActiveView {
71    /// Blank view.
72    #[default]
73    None,
74    /// A view with a search bar and search results.
75    Search,
76    /// A view with all the songs in the users library.
77    Songs,
78    /// A view of a specific song.
79    Song(Ulid),
80    /// A view with all the albums in the users library.
81    Albums,
82    /// A view of a specific album.
83    Album(Ulid),
84    /// A view with all the artists in the users library.
85    Artists,
86    /// A view of a specific artist.
87    Artist(Ulid),
88    /// A view with all the playlists in the users library.
89    Playlists,
90    /// A view of a specific playlist.
91    Playlist(Ulid),
92    /// A view of all the dynamic playlists in the users library.
93    DynamicPlaylists,
94    /// A view of a specific dynamic playlist.
95    DynamicPlaylist(Ulid),
96    /// A view with all the collections in the users library.
97    Collections,
98    /// A view of a specific collection.
99    Collection(Ulid),
100    /// A view of a radio
101    Radio(Vec<RecordId>),
102    /// A view for getting a random song, album, etc.
103    Random,
104    // TODO: views for genres, settings, etc.
105}
106
107impl From<RecordId> for ActiveView {
108    fn from(value: RecordId) -> Self {
109        match value.tb.as_str() {
110            album::TABLE_NAME => Self::Album(value.ulid()),
111            artist::TABLE_NAME => Self::Artist(value.ulid()),
112            collection::TABLE_NAME => Self::Collection(value.ulid()),
113            playlist::TABLE_NAME => Self::Playlist(value.ulid()),
114            song::TABLE_NAME => Self::Song(value.ulid()),
115            dynamic::TABLE_NAME => Self::DynamicPlaylist(value.ulid()),
116            _ => Self::None,
117        }
118    }
119}
120
121impl ContentView {
122    fn get_active_view_component(&self) -> &dyn Component {
123        match &self.props.active_view {
124            ActiveView::None => &self.none_view,
125            ActiveView::Search => &self.search_view,
126            ActiveView::Songs => &self.songs_view,
127            ActiveView::Song(_) => &self.song_view,
128            ActiveView::Albums => &self.albums_view,
129            ActiveView::Album(_) => &self.album_view,
130            ActiveView::Artists => &self.artists_view,
131            ActiveView::Artist(_) => &self.artist_view,
132            ActiveView::Playlists => &self.playlists_view,
133            ActiveView::Playlist(_) => &self.playlist_view,
134            ActiveView::DynamicPlaylists => &self.dynamic_playlists_view,
135            ActiveView::DynamicPlaylist(_) => &self.dynamic_playlist_view,
136            ActiveView::Collections => &self.collections_view,
137            ActiveView::Collection(_) => &self.collection_view,
138            ActiveView::Radio(_) => &self.radio_view,
139            ActiveView::Random => &self.random_view,
140        }
141    }
142
143    fn get_active_view_component_mut(&mut self) -> &mut dyn Component {
144        match &self.props.active_view {
145            ActiveView::None => &mut self.none_view,
146            ActiveView::Search => &mut self.search_view,
147            ActiveView::Songs => &mut self.songs_view,
148            ActiveView::Song(_) => &mut self.song_view,
149            ActiveView::Albums => &mut self.albums_view,
150            ActiveView::Album(_) => &mut self.album_view,
151            ActiveView::Artists => &mut self.artists_view,
152            ActiveView::Artist(_) => &mut self.artist_view,
153            ActiveView::Playlists => &mut self.playlists_view,
154            ActiveView::Playlist(_) => &mut self.playlist_view,
155            ActiveView::DynamicPlaylists => &mut self.dynamic_playlists_view,
156            ActiveView::DynamicPlaylist(_) => &mut self.dynamic_playlist_view,
157            ActiveView::Collections => &mut self.collections_view,
158            ActiveView::Collection(_) => &mut self.collection_view,
159            ActiveView::Radio(_) => &mut self.radio_view,
160            ActiveView::Random => &mut self.random_view,
161        }
162    }
163}
164
165impl Component for ContentView {
166    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
167    where
168        Self: Sized,
169    {
170        Self {
171            props: Props::from(state),
172            none_view: NoneView::new(state, action_tx.clone()),
173            search_view: SearchView::new(state, action_tx.clone()),
174            songs_view: LibrarySongsView::new(state, action_tx.clone()),
175            song_view: SongView::new(state, action_tx.clone()),
176            albums_view: LibraryAlbumsView::new(state, action_tx.clone()),
177            album_view: AlbumView::new(state, action_tx.clone()),
178            artists_view: LibraryArtistsView::new(state, action_tx.clone()),
179            artist_view: ArtistView::new(state, action_tx.clone()),
180            playlists_view: LibraryPlaylistsView::new(state, action_tx.clone()),
181            playlist_view: PlaylistView::new(state, action_tx.clone()),
182            dynamic_playlists_view: LibraryDynamicView::new(state, action_tx.clone()),
183            dynamic_playlist_view: DynamicView::new(state, action_tx.clone()),
184            collections_view: LibraryCollectionsView::new(state, action_tx.clone()),
185            collection_view: CollectionView::new(state, action_tx.clone()),
186            radio_view: RadioView::new(state, action_tx.clone()),
187            random_view: RandomView::new(state, action_tx.clone()),
188            action_tx,
189        }
190        .move_with_state(state)
191    }
192
193    fn move_with_state(self, state: &AppState) -> Self
194    where
195        Self: Sized,
196    {
197        Self {
198            props: Props::from(state),
199            none_view: self.none_view.move_with_state(state),
200            search_view: self.search_view.move_with_state(state),
201            songs_view: self.songs_view.move_with_state(state),
202            song_view: self.song_view.move_with_state(state),
203            albums_view: self.albums_view.move_with_state(state),
204            album_view: self.album_view.move_with_state(state),
205            artists_view: self.artists_view.move_with_state(state),
206            artist_view: self.artist_view.move_with_state(state),
207            playlists_view: self.playlists_view.move_with_state(state),
208            playlist_view: self.playlist_view.move_with_state(state),
209            dynamic_playlists_view: self.dynamic_playlists_view.move_with_state(state),
210            dynamic_playlist_view: self.dynamic_playlist_view.move_with_state(state),
211            collections_view: self.collections_view.move_with_state(state),
212            collection_view: self.collection_view.move_with_state(state),
213            radio_view: self.radio_view.move_with_state(state),
214            random_view: self.random_view.move_with_state(state),
215            action_tx: self.action_tx,
216        }
217    }
218
219    fn name(&self) -> &str {
220        self.get_active_view_component().name()
221    }
222
223    fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
224        // handle undo/redo navigation first
225        match key.code {
226            crossterm::event::KeyCode::Char('z')
227                if key.modifiers == crossterm::event::KeyModifiers::CONTROL =>
228            {
229                self.action_tx
230                    .send(Action::ActiveView(ViewAction::Back))
231                    .unwrap();
232                return;
233            }
234            crossterm::event::KeyCode::Char('y')
235                if key.modifiers == crossterm::event::KeyModifiers::CONTROL =>
236            {
237                self.action_tx
238                    .send(Action::ActiveView(ViewAction::Next))
239                    .unwrap();
240                return;
241            }
242            _ => {}
243        }
244
245        // defer to active view
246        self.get_active_view_component_mut().handle_key_event(key);
247    }
248
249    fn handle_mouse_event(
250        &mut self,
251        mouse: crossterm::event::MouseEvent,
252        area: ratatui::prelude::Rect,
253    ) {
254        let mouse_position = Position::new(mouse.column, mouse.row);
255        match mouse.kind {
256            // this doesn't return because the active view may want to do something as well
257            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
258                self.action_tx
259                    .send(Action::ActiveComponent(ComponentAction::Set(
260                        ActiveComponent::ContentView,
261                    )))
262                    .unwrap();
263            }
264            // this returns because the active view should handle the event (since it changes the active view)
265            MouseEventKind::Down(MouseButton::Right) if area.contains(mouse_position) => {
266                self.action_tx
267                    .send(Action::ActiveView(ViewAction::Back))
268                    .unwrap();
269                return;
270            }
271            _ => {}
272        }
273
274        // defer to active view
275        self.get_active_view_component_mut()
276            .handle_mouse_event(mouse, area);
277    }
278}
279
280impl ComponentRender<RenderProps> for ContentView {
281    /// we defer all border rendering to the active view
282    fn render_border(&self, _: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
283        props
284    }
285
286    fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
287        match &self.props.active_view {
288            ActiveView::None => self.none_view.render(frame, props),
289            ActiveView::Search => self.search_view.render(frame, props),
290            ActiveView::Songs => self.songs_view.render(frame, props),
291            ActiveView::Song(_) => self.song_view.render(frame, props),
292            ActiveView::Albums => self.albums_view.render(frame, props),
293            ActiveView::Album(_) => self.album_view.render(frame, props),
294            ActiveView::Artists => self.artists_view.render(frame, props),
295            ActiveView::Artist(_) => self.artist_view.render(frame, props),
296            ActiveView::Playlists => self.playlists_view.render(frame, props),
297            ActiveView::Playlist(_) => self.playlist_view.render(frame, props),
298            ActiveView::DynamicPlaylists => self.dynamic_playlists_view.render(frame, props),
299            ActiveView::DynamicPlaylist(_) => self.dynamic_playlist_view.render(frame, props),
300            ActiveView::Collections => self.collections_view.render(frame, props),
301            ActiveView::Collection(_) => self.collection_view.render(frame, props),
302            ActiveView::Radio(_) => self.radio_view.render(frame, props),
303            ActiveView::Random => self.random_view.render(frame, props),
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::test_utils::{item_id, setup_test_terminal, state_with_everything};
312    use pretty_assertions::assert_eq;
313    use rstest::rstest;
314
315    #[rstest]
316    #[case(ActiveView::None)]
317    #[case(ActiveView::Search)]
318    #[case(ActiveView::Songs)]
319    #[case(ActiveView::Song(Ulid::new(item_id())))]
320    #[case(ActiveView::Albums)]
321    #[case(ActiveView::Album(Ulid::new(item_id())))]
322    #[case(ActiveView::Artists)]
323    #[case(ActiveView::Artist(Ulid::new(item_id())))]
324    #[case(ActiveView::Playlists)]
325    #[case(ActiveView::Playlist(Ulid::new(item_id())))]
326    #[case(ActiveView::DynamicPlaylists)]
327    #[case(ActiveView::DynamicPlaylist(Ulid::new(item_id())))]
328    #[case(ActiveView::Collections)]
329    #[case(ActiveView::Collection(Ulid::new(item_id())))]
330    #[case(ActiveView::Radio(vec![RecordId::new("song", item_id())]))]
331    #[case(ActiveView::Random)]
332    fn smoke_render(#[case] active_view: ActiveView, #[values(true, false)] is_focused: bool) {
333        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
334        let content_view = ContentView::new(&AppState::default(), tx).move_with_state(&AppState {
335            active_view,
336            ..state_with_everything()
337        });
338
339        let (mut terminal, area) = setup_test_terminal(100, 100);
340        let completed_frame =
341            terminal.draw(|frame| content_view.render(frame, RenderProps { area, is_focused }));
342
343        assert!(completed_frame.is_ok());
344    }
345
346    #[rstest]
347    #[case(ActiveView::None)]
348    #[case(ActiveView::Search)]
349    #[case(ActiveView::Songs)]
350    #[case(ActiveView::Song(Ulid::new(item_id())))]
351    #[case(ActiveView::Albums)]
352    #[case(ActiveView::Album(Ulid::new(item_id())))]
353    #[case(ActiveView::Artists)]
354    #[case(ActiveView::Artist(Ulid::new(item_id())))]
355    #[case(ActiveView::Playlists)]
356    #[case(ActiveView::Playlist(Ulid::new(item_id())))]
357    #[case(ActiveView::DynamicPlaylists)]
358    #[case(ActiveView::DynamicPlaylist(Ulid::new(item_id())))]
359    #[case(ActiveView::Collections)]
360    #[case(ActiveView::Collection(Ulid::new(item_id())))]
361    #[case(ActiveView::Radio(vec![RecordId::new("song", item_id())]))]
362    #[case(ActiveView::Random)]
363    fn test_get_active_view_component(#[case] active_view: ActiveView) {
364        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
365        let state = AppState {
366            active_view: active_view.clone(),
367            ..state_with_everything()
368        };
369        let content_view = ContentView::new(&state, tx.clone());
370
371        let view = content_view.get_active_view_component();
372
373        match active_view {
374            ActiveView::None => assert_eq!(view.name(), "None"),
375            ActiveView::Search => assert_eq!(view.name(), "Search"),
376            ActiveView::Songs => assert_eq!(view.name(), "Library Songs View"),
377            ActiveView::Song(_) => assert_eq!(view.name(), "Song View"),
378            ActiveView::Albums => assert_eq!(view.name(), "Library Albums View"),
379            ActiveView::Album(_) => assert_eq!(view.name(), "Album View"),
380            ActiveView::Artists => assert_eq!(view.name(), "Library Artists View"),
381            ActiveView::Artist(_) => assert_eq!(view.name(), "Artist View"),
382            ActiveView::Playlists => assert_eq!(view.name(), "Library Playlists View"),
383            ActiveView::Playlist(_) => assert_eq!(view.name(), "Playlist View"),
384            ActiveView::DynamicPlaylists => {
385                assert_eq!(view.name(), "Library Dynamic Playlists View");
386            }
387            ActiveView::DynamicPlaylist(_) => assert_eq!(view.name(), "Dynamic Playlist View"),
388            ActiveView::Collections => assert_eq!(view.name(), "Library Collections View"),
389            ActiveView::Collection(_) => assert_eq!(view.name(), "Collection View"),
390            ActiveView::Radio(_) => assert_eq!(view.name(), "Radio"),
391            ActiveView::Random => assert_eq!(view.name(), "Random"),
392        }
393
394        // assert that the two "get_active_view_component" methods return the same component
395        assert_eq!(
396            view.name(),
397            ContentView::new(&state, tx,)
398                .get_active_view_component_mut()
399                .name()
400        );
401    }
402}