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