1pub 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 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 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 #[default]
72 None,
73 Search,
75 Songs,
77 Song(Id),
79 Albums,
81 Album(Id),
83 Artists,
85 Artist(Id),
87 Playlists,
89 Playlist(Id),
91 DynamicPlaylists,
93 DynamicPlaylist(Id),
95 Collections,
97 Collection(Id),
99 Radio(Vec<Thing>),
101 Random,
103 }
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 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 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 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 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 self.get_active_view_component_mut()
275 .handle_mouse_event(mouse, area);
276 }
277}
278
279impl ComponentRender<RenderProps> for ContentView {
280 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_eq!(
395 view.name(),
396 ContentView::new(&state, tx,)
397 .get_active_view_component_mut()
398 .name()
399 );
400 }
401}