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