mecomp_tui/ui/components/content_view/views/
mod.rs

1pub mod dynamic;
2use mecomp_core::format_duration;
3use mecomp_storage::db::schemas::{
4    RecordId,
5    album::{Album, AlbumBrief},
6    artist::{Artist, ArtistBrief},
7    collection::{Collection, CollectionBrief},
8    dynamic::DynamicPlaylist,
9    playlist::{Playlist, PlaylistBrief},
10    song::{Song, SongBrief},
11};
12use one_or_many::OneOrMany;
13use ratatui::{
14    layout::Alignment,
15    style::{Style, Stylize},
16    text::{Line, Span},
17    widgets::{Paragraph, Widget},
18};
19use traits::ItemViewProps;
20
21use crate::ui::widgets::tree::item::CheckTreeItem;
22
23pub mod album;
24pub mod artist;
25pub mod collection;
26pub mod generic;
27pub mod none;
28pub mod playlist;
29pub mod radio;
30pub mod random;
31pub mod search;
32pub mod song;
33pub mod sort_mode;
34pub mod traits;
35
36/// Data needed by the views (that isn't directly handled by a state store)
37#[allow(clippy::module_name_repetitions)]
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct ViewData {
40    pub album: Option<AlbumViewProps>,
41    pub artist: Option<ArtistViewProps>,
42    pub collection: Option<CollectionViewProps>,
43    pub dynamic_playlist: Option<DynamicPlaylistViewProps>,
44    pub playlist: Option<PlaylistViewProps>,
45    pub song: Option<SongViewProps>,
46    pub radio: Option<RadioViewProps>,
47    pub random: Option<RandomViewProps>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct AlbumViewProps {
52    pub id: RecordId,
53    pub album: Album,
54    pub artists: OneOrMany<ArtistBrief>,
55    pub songs: Box<[SongBrief]>,
56}
57
58impl ItemViewProps for AlbumViewProps {
59    fn id(&self) -> &RecordId {
60        &self.id
61    }
62
63    fn retrieve(view_data: &ViewData) -> Option<Self> {
64        view_data.album.clone()
65    }
66
67    fn title() -> &'static str {
68        "Album View"
69    }
70
71    fn name() -> &'static str
72    where
73        Self: Sized,
74    {
75        "album"
76    }
77
78    fn none_checked_string() -> &'static str
79    where
80        Self: Sized,
81    {
82        "entire album"
83    }
84
85    fn info_widget(&self) -> impl Widget {
86        Paragraph::new(vec![
87            Line::from(vec![
88                Span::styled(self.album.title.to_string(), Style::default().bold()),
89                Span::raw(" "),
90                Span::styled(
91                    self.album
92                        .artist
93                        .iter()
94                        .map(ToString::to_string)
95                        .collect::<Vec<String>>()
96                        .join(", "),
97                    Style::default().italic(),
98                ),
99            ]),
100            Line::from(vec![
101                Span::raw("Release Year: "),
102                Span::styled(
103                    self.album
104                        .release
105                        .map_or_else(|| "unknown".to_string(), |y| y.to_string()),
106                    Style::default().italic(),
107                ),
108                Span::raw("  Songs: "),
109                Span::styled(self.album.song_count.to_string(), Style::default().italic()),
110                Span::raw("  Duration: "),
111                Span::styled(
112                    format_duration(&self.album.runtime),
113                    Style::default().italic(),
114                ),
115            ]),
116        ])
117        .alignment(Alignment::Center)
118    }
119
120    fn tree_items(&self) -> Result<Vec<CheckTreeItem<'_, String>>, std::io::Error> {
121        let artist_tree = checktree_utils::create_artist_tree_item(self.artists.as_slice())?;
122        let song_tree = checktree_utils::create_song_tree_item(self.songs.as_ref())?;
123        Ok(vec![artist_tree, song_tree])
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ArtistViewProps {
129    pub id: RecordId,
130    pub artist: Artist,
131    pub albums: Box<[AlbumBrief]>,
132    pub songs: Box<[SongBrief]>,
133}
134
135impl ItemViewProps for ArtistViewProps {
136    fn id(&self) -> &RecordId {
137        &self.id
138    }
139
140    fn retrieve(view_data: &ViewData) -> Option<Self> {
141        view_data.artist.clone()
142    }
143
144    fn title() -> &'static str {
145        "Artist View"
146    }
147
148    fn name() -> &'static str
149    where
150        Self: Sized,
151    {
152        "artist"
153    }
154
155    fn none_checked_string() -> &'static str
156    where
157        Self: Sized,
158    {
159        "entire artist"
160    }
161
162    fn info_widget(&self) -> impl Widget {
163        Paragraph::new(vec![
164            Line::from(Span::styled(
165                self.artist.name.to_string(),
166                Style::default().bold(),
167            )),
168            Line::from(vec![
169                Span::raw("Albums: "),
170                Span::styled(
171                    self.artist.album_count.to_string(),
172                    Style::default().italic(),
173                ),
174                Span::raw("  Songs: "),
175                Span::styled(
176                    self.artist.song_count.to_string(),
177                    Style::default().italic(),
178                ),
179                Span::raw("  Duration: "),
180                Span::styled(
181                    format_duration(&self.artist.runtime),
182                    Style::default().italic(),
183                ),
184            ]),
185        ])
186        .alignment(Alignment::Center)
187    }
188
189    fn tree_items(&self) -> Result<Vec<CheckTreeItem<'_, String>>, std::io::Error> {
190        let album_tree = checktree_utils::create_album_tree_item(self.albums.as_ref())?;
191        let song_tree = checktree_utils::create_song_tree_item(self.songs.as_ref())?;
192        Ok(vec![album_tree, song_tree])
193    }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct CollectionViewProps {
198    pub id: RecordId,
199    pub collection: Collection,
200    pub songs: Box<[SongBrief]>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct DynamicPlaylistViewProps {
205    pub id: RecordId,
206    pub dynamic_playlist: DynamicPlaylist,
207    pub songs: Box<[SongBrief]>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct PlaylistViewProps {
212    pub id: RecordId,
213    pub playlist: Playlist,
214    pub songs: Box<[SongBrief]>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct SongViewProps {
219    pub id: RecordId,
220    pub song: Song,
221    pub artists: OneOrMany<ArtistBrief>,
222    pub album: AlbumBrief,
223    pub playlists: Box<[PlaylistBrief]>,
224    pub collections: Box<[CollectionBrief]>,
225}
226
227impl ItemViewProps for SongViewProps {
228    fn id(&self) -> &RecordId {
229        &self.id
230    }
231
232    fn retrieve(view_data: &ViewData) -> Option<Self> {
233        view_data.song.clone()
234    }
235
236    fn title() -> &'static str {
237        "Song View"
238    }
239
240    fn name() -> &'static str
241    where
242        Self: Sized,
243    {
244        "song"
245    }
246
247    fn none_checked_string() -> &'static str
248    where
249        Self: Sized,
250    {
251        "the song"
252    }
253
254    fn info_widget(&self) -> impl Widget {
255        Paragraph::new(vec![
256            Line::from(vec![
257                Span::styled(self.song.title.to_string(), Style::default().bold()),
258                Span::raw(" "),
259                Span::styled(
260                    self.song
261                        .artist
262                        .iter()
263                        .map(ToString::to_string)
264                        .collect::<Vec<String>>()
265                        .join(", "),
266                    Style::default().italic(),
267                ),
268            ]),
269            Line::from(vec![
270                Span::raw("Track/Disc: "),
271                Span::styled(
272                    format!(
273                        "{}/{}",
274                        self.song.track.unwrap_or_default(),
275                        self.song.disc.unwrap_or_default()
276                    ),
277                    Style::default().italic(),
278                ),
279                Span::raw("  Duration: "),
280                Span::styled(
281                    format!(
282                        "{}:{:04.1}",
283                        self.song.runtime.as_secs() / 60,
284                        self.song.runtime.as_secs_f32() % 60.0,
285                    ),
286                    Style::default().italic(),
287                ),
288                Span::raw("  Genre(s): "),
289                Span::styled(
290                    self.song
291                        .genre
292                        .iter()
293                        .map(ToString::to_string)
294                        .collect::<Vec<String>>()
295                        .join(", "),
296                    Style::default().italic(),
297                ),
298            ]),
299        ])
300        .alignment(Alignment::Center)
301    }
302
303    fn tree_items(&self) -> Result<Vec<CheckTreeItem<'_, String>>, std::io::Error> {
304        let artist_tree = checktree_utils::create_artist_tree_item(self.artists.as_slice())?;
305        let album_tree =
306            checktree_utils::create_album_tree_leaf(&self.album, Some(Span::raw("Album: ")));
307        let playlist_tree = checktree_utils::create_playlist_tree_item(&self.playlists)?;
308        let collection_tree = checktree_utils::create_collection_tree_item(&self.collections)?;
309        Ok(vec![
310            artist_tree,
311            album_tree,
312            playlist_tree,
313            collection_tree,
314        ])
315    }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct RadioViewProps {
320    /// The number of similar songs to get
321    pub count: u32,
322    /// The songs that are similar to the things
323    pub songs: Box<[SongBrief]>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct RandomViewProps {
328    /// id of a random album
329    pub album: RecordId,
330    /// id of a random artist
331    pub artist: RecordId,
332    /// id of a random song
333    pub song: RecordId,
334}
335
336pub mod checktree_utils {
337    use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
338    use mecomp_storage::db::schemas::{
339        RecordId, album::AlbumBrief, artist::ArtistBrief, collection::CollectionBrief,
340        dynamic::DynamicPlaylist, playlist::PlaylistBrief, song::SongBrief,
341    };
342    use ratatui::{
343        layout::Position,
344        style::{Style, Stylize},
345        text::{Line, Span, Text},
346    };
347
348    use crate::{
349        state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
350        ui::{
351            components::content_view::ActiveView,
352            widgets::{
353                popups::PopupType,
354                tree::{item::CheckTreeItem, state::CheckTreeState},
355            },
356        },
357    };
358
359    impl CheckTreeState<String> {
360        /// Get the checked things from the tree state
361        #[must_use]
362        pub fn get_checked_things(&self) -> Vec<RecordId> {
363            self.checked()
364                .iter()
365                .filter_map(|id| id.iter().find_map(|id| id.parse::<RecordId>().ok()))
366                .collect()
367        }
368
369        /// Get the selected thing from the tree state
370        #[must_use]
371        pub fn get_selected_thing(&self) -> Option<RecordId> {
372            self.selected()
373                .iter()
374                .find_map(|id| id.parse::<RecordId>().ok())
375        }
376
377        /// Handle mouse events interacting with the tree
378        ///
379        /// Assumes that the given area only includes the `CheckTree`
380        ///
381        /// # Arguments
382        ///
383        /// `event` - the mouse event to handle
384        /// `area` - the area of the tree in the terminal
385        /// `swap_ctrl_click_behavior` - whether to swap the behavior of ctrl+click and click:
386        ///  - if `true`, ctrl+click will toggle the check state of the item, and click will open the item
387        ///  - if `false` (default), ctrl+click will open the item, and click will toggle the check state of the item
388        ///
389        /// # Returns
390        ///
391        /// an action if the mouse event requires it
392        pub fn handle_mouse_event(
393            &mut self,
394            event: MouseEvent,
395            area: ratatui::layout::Rect,
396            swap_ctrl_click_behavior: bool,
397        ) -> Option<Action> {
398            let MouseEvent {
399                kind,
400                column,
401                row,
402                modifiers,
403            } = event;
404            let mouse_position = Position::new(column, row);
405
406            if !area.contains(mouse_position) {
407                return None;
408            }
409
410            match kind {
411                MouseEventKind::Down(MouseButton::Left) => {
412                    // do a click (toggle check state or open item)
413                    let click_result = self.mouse_click(mouse_position);
414
415                    // if it was a control-click,
416                    let condition = modifiers.contains(KeyModifiers::CONTROL) && click_result;
417                    // and we aren't swapping the behavior,
418                    let condition = if swap_ctrl_click_behavior {
419                        !condition
420                    } else {
421                        condition
422                    };
423
424                    // then attempt to open the selected thing
425                    if condition {
426                        self.get_selected_thing()
427                            .map(|thing| Action::ActiveView(ViewAction::Set(thing.into())))
428                    } else {
429                        None
430                    }
431                }
432                MouseEventKind::ScrollDown => {
433                    self.key_down();
434                    None
435                }
436                MouseEventKind::ScrollUp => {
437                    self.key_up();
438                    None
439                }
440                _ => None,
441            }
442        }
443    }
444
445    impl<'items> CheckTreeItem<'items, String> {
446        /// Create a `CheckTreeState` from a list of things
447        ///
448        /// # Errors
449        ///
450        /// returns an error if the tree state cannot be created (e.g. duplicate ids)
451        pub fn new_with_items<'a, 'text, Item, LeafFn>(
452            items: &'items [Item],
453            identifier: impl AsRef<str>,
454            text: impl Into<Text<'text>>,
455            leaf_fn: LeafFn,
456        ) -> Result<Self, std::io::Error>
457        where
458            'a: 'text,
459            'a: 'items,
460            'text: 'items,
461            LeafFn: FnMut(&Item) -> CheckTreeItem<'a, String>,
462        {
463            let identifier = identifier.as_ref().to_string();
464            let mut tree =
465                CheckTreeItem::new(identifier, text, items.iter().map(leaf_fn).collect())?;
466            if tree.children().is_empty() {
467                tree.add_child(create_dummy_leaf())?;
468            }
469            Ok(tree)
470        }
471    }
472
473    /// Construct an `Action` to add the checked things to a playlist, if there are any,
474    /// otherwise add the thing being displayed by the view
475    ///
476    /// # Returns
477    ///
478    /// None - if there are no checked things and the current thing is None
479    /// Some(Action) - if there are checked things or the current thing is Some
480    #[must_use]
481    pub fn construct_add_to_playlist_action(
482        checked_things: Vec<RecordId>,
483        current_thing: Option<&RecordId>,
484    ) -> Option<Action> {
485        if checked_things.is_empty() {
486            current_thing
487                .map(|id| Action::Popup(PopupAction::Open(PopupType::Playlist(vec![id.clone()]))))
488        } else {
489            Some(Action::Popup(PopupAction::Open(PopupType::Playlist(
490                checked_things,
491            ))))
492        }
493    }
494
495    /// Construct an `Action` to add the checked things to the queue if there are any,
496    /// otherwise add the thing being displayed by the view
497    ///
498    /// # Returns
499    ///
500    /// None - if there are no checked things and the current thing is None
501    /// Some(Action) - if there are checked things or the current thing is Some
502    #[must_use]
503    pub fn construct_add_to_queue_action(
504        checked_things: Vec<RecordId>,
505        current_thing: Option<&RecordId>,
506    ) -> Option<Action> {
507        if checked_things.is_empty() {
508            current_thing
509                .map(|id| Action::Audio(AudioAction::Queue(QueueAction::Add(vec![id.clone()]))))
510        } else {
511            Some(Action::Audio(AudioAction::Queue(QueueAction::Add(
512                checked_things,
513            ))))
514        }
515    }
516
517    /// Construct an `Action` to start a radio from the checked things if there are any,
518    /// otherwise start a radio from the thing being displayed by the view
519    ///
520    /// # Returns
521    ///
522    /// None - if there are no checked things and the current thing is None
523    /// Some(Action) - if there are checked things or the current thing is Some
524    #[must_use]
525    pub fn construct_start_radio_action(
526        checked_things: Vec<RecordId>,
527        current_thing: Option<&RecordId>,
528    ) -> Option<Action> {
529        if checked_things.is_empty() {
530            current_thing
531                .map(|id| Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![id.clone()]))))
532        } else {
533            Some(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
534                checked_things,
535            ))))
536        }
537    }
538
539    fn create_dummy_leaf() -> CheckTreeItem<'static, String> {
540        CheckTreeItem::new_leaf("dummy".to_string(), "")
541    }
542
543    /// # Errors
544    ///
545    /// Returns an error if the tree item cannot be created (e.g. duplicate ids)
546    pub fn create_album_tree_item(
547        albums: &[AlbumBrief],
548    ) -> Result<CheckTreeItem<'_, String>, std::io::Error> {
549        CheckTreeItem::<String>::new_with_items(
550            albums,
551            "Albums",
552            format!("Albums ({}):", albums.len()),
553            |album| create_album_tree_leaf(album, None),
554        )
555    }
556
557    pub fn create_album_tree_leaf<'a>(
558        album: &AlbumBrief,
559        prefix: Option<Span<'a>>,
560    ) -> CheckTreeItem<'a, String> {
561        CheckTreeItem::new_leaf(
562            album.id.to_string(),
563            Line::from(vec![
564                prefix.unwrap_or_default(),
565                Span::styled(album.title.to_string(), Style::default().bold()),
566                Span::raw(" "),
567                Span::styled(
568                    album
569                        .artist
570                        .iter()
571                        .map(ToString::to_string)
572                        .collect::<Vec<String>>()
573                        .join(", "),
574                    Style::default().italic(),
575                ),
576            ]),
577        )
578    }
579
580    /// # Errors
581    ///
582    /// Returns an error if the tree item cannot be created (e.g. duplicate ids)
583    pub fn create_artist_tree_item(
584        artists: &[ArtistBrief],
585    ) -> Result<CheckTreeItem<'_, String>, std::io::Error> {
586        CheckTreeItem::<String>::new_with_items(
587            artists,
588            "Artists",
589            format!("Artists ({}):", artists.len()),
590            create_artist_tree_leaf,
591        )
592    }
593
594    #[must_use]
595    pub fn create_artist_tree_leaf<'a>(artist: &ArtistBrief) -> CheckTreeItem<'a, String> {
596        CheckTreeItem::new_leaf(
597            artist.id.to_string(),
598            Line::from(vec![Span::styled(
599                artist.name.to_string(),
600                Style::default().bold(),
601            )]),
602        )
603    }
604
605    /// # Errors
606    ///
607    /// Returns an error if the tree item cannot be created (e.g. duplicate ids)
608    pub fn create_collection_tree_item(
609        collections: &[CollectionBrief],
610    ) -> Result<CheckTreeItem<'_, String>, std::io::Error> {
611        CheckTreeItem::<String>::new_with_items(
612            collections,
613            "Collections",
614            format!("Collections ({}):", collections.len()),
615            create_collection_tree_leaf,
616        )
617    }
618
619    #[must_use]
620    pub fn create_collection_tree_leaf<'a>(
621        collection: &CollectionBrief,
622    ) -> CheckTreeItem<'a, String> {
623        CheckTreeItem::new_leaf(
624            collection.id.to_string(),
625            Line::from(vec![Span::styled(
626                collection.name.to_string(),
627                Style::default().bold(),
628            )]),
629        )
630    }
631
632    /// # Errors
633    ///
634    /// Returns an error if the tree item cannot be created (e.g. duplicate ids)
635    pub fn create_playlist_tree_item(
636        playlists: &[PlaylistBrief],
637    ) -> Result<CheckTreeItem<'_, String>, std::io::Error> {
638        CheckTreeItem::<String>::new_with_items(
639            playlists,
640            "Playlists",
641            format!("Playlists ({}):", playlists.len()),
642            create_playlist_tree_leaf,
643        )
644    }
645
646    #[must_use]
647    pub fn create_playlist_tree_leaf<'a>(playlist: &PlaylistBrief) -> CheckTreeItem<'a, String> {
648        CheckTreeItem::new_leaf(
649            playlist.id.to_string(),
650            Line::from(vec![Span::styled(
651                playlist.name.to_string(),
652                Style::default().bold(),
653            )]),
654        )
655    }
656
657    /// # Errors
658    ///
659    /// Returns an error if the tree item cannot be created (e.g. duplicate ids)
660    pub fn create_dynamic_playlist_tree_item(
661        dynamic_playlists: &[DynamicPlaylist],
662    ) -> Result<CheckTreeItem<'_, String>, std::io::Error> {
663        CheckTreeItem::<String>::new_with_items(
664            dynamic_playlists,
665            "Dynamic Playlists",
666            format!("Dynamic Playlists ({}):", dynamic_playlists.len()),
667            create_dynamic_playlist_tree_leaf,
668        )
669    }
670
671    #[must_use]
672    pub fn create_dynamic_playlist_tree_leaf<'a>(
673        dynamic_playlist: &DynamicPlaylist,
674    ) -> CheckTreeItem<'a, String> {
675        CheckTreeItem::new_leaf(
676            dynamic_playlist.id.to_string(),
677            Line::from(vec![Span::styled(
678                dynamic_playlist.name.to_string(),
679                Style::default().bold(),
680            )]),
681        )
682    }
683
684    /// # Errors
685    ///
686    /// Returns an error if the tree item cannot be created (e.g. duplicate ids)
687    pub fn create_song_tree_item(
688        songs: &[SongBrief],
689    ) -> Result<CheckTreeItem<'_, String>, std::io::Error> {
690        CheckTreeItem::<String>::new_with_items(
691            songs,
692            "Songs",
693            format!("Songs ({}):", songs.len()),
694            create_song_tree_leaf,
695        )
696    }
697
698    pub fn create_song_tree_leaf<'a>(song: &SongBrief) -> CheckTreeItem<'a, String> {
699        CheckTreeItem::new_leaf(
700            song.id.to_string(),
701            Line::from(vec![
702                Span::styled(song.title.to_string(), Style::default().bold()),
703                Span::raw(" "),
704                Span::styled(
705                    song.artist
706                        .iter()
707                        .map(ToString::to_string)
708                        .collect::<Vec<String>>()
709                        .join(", "),
710                    Style::default().italic(),
711                ),
712            ]),
713        )
714    }
715}