1pub 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 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 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 #[default]
73 None,
74 Search,
76 Songs,
78 Song(Ulid),
80 Albums,
82 Album(Ulid),
84 Artists,
86 Artist(Ulid),
88 Playlists,
90 Playlist(Ulid),
92 DynamicPlaylists,
94 DynamicPlaylist(Ulid),
96 Collections,
98 Collection(Ulid),
100 Radio(Vec<RecordId>),
102 Random,
104 }
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 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 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 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 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 self.get_active_view_component_mut()
276 .handle_mouse_event(mouse, area);
277 }
278}
279
280impl ComponentRender<RenderProps> for ContentView {
281 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_eq!(
396 view.name(),
397 ContentView::new(&state, tx,)
398 .get_active_view_component_mut()
399 .name()
400 );
401 }
402}