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