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

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