mecomp_tui/ui/components/
sidebar.rs

1//! Implement the sidebar component.
2//!
3//! Responsible for allowing users to navigate between different `ContentViews`.
4
5use std::fmt::Display;
6
7use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
8use ratatui::{
9    Frame,
10    layout::{Alignment, Margin, Position, Rect},
11    style::{Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, Borders, List, ListItem, ListState},
14};
15use tokio::sync::mpsc::UnboundedSender;
16
17use crate::{
18    state::{
19        action::{Action, ComponentAction, LibraryAction, PopupAction, ViewAction},
20        component::ActiveComponent,
21    },
22    ui::{
23        AppState,
24        colors::{TEXT_HIGHLIGHT, TEXT_NORMAL, border_color},
25        components::{Component, ComponentRender, RenderProps},
26        widgets::popups::PopupType,
27    },
28};
29
30use super::content_view::ActiveView;
31
32#[allow(clippy::module_name_repetitions)]
33pub struct Sidebar {
34    /// Action Sender
35    pub action_tx: UnboundedSender<Action>,
36    /// List state
37    list_state: ListState,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[allow(clippy::module_name_repetitions)]
42pub enum SidebarItem {
43    Search,
44    Songs,
45    Artists,
46    Albums,
47    Playlists,
48    DynamicPlaylists,
49    Collections,
50    Random,
51    Space, // this is used to create space between the library actions and the other items
52    LibraryRescan,
53    LibraryAnalyze,
54    LibraryRecluster,
55}
56
57impl SidebarItem {
58    #[must_use]
59    pub const fn to_action(&self) -> Option<Action> {
60        match self {
61            Self::Search => Some(Action::ActiveView(ViewAction::Set(ActiveView::Search))),
62            Self::Songs => Some(Action::ActiveView(ViewAction::Set(ActiveView::Songs))),
63            Self::Artists => Some(Action::ActiveView(ViewAction::Set(ActiveView::Artists))),
64            Self::Albums => Some(Action::ActiveView(ViewAction::Set(ActiveView::Albums))),
65            Self::Playlists => Some(Action::ActiveView(ViewAction::Set(ActiveView::Playlists))),
66            Self::DynamicPlaylists => Some(Action::ActiveView(ViewAction::Set(
67                ActiveView::DynamicPlaylists,
68            ))),
69            Self::Collections => Some(Action::ActiveView(ViewAction::Set(ActiveView::Collections))),
70            Self::Random => Some(Action::ActiveView(ViewAction::Set(ActiveView::Random))),
71            Self::Space => None,
72            Self::LibraryRescan => Some(Action::Library(LibraryAction::Rescan)),
73            Self::LibraryAnalyze => Some(Action::Library(LibraryAction::Analyze)),
74            Self::LibraryRecluster => Some(Action::Library(LibraryAction::Recluster)),
75        }
76    }
77}
78
79impl Display for SidebarItem {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Search => write!(f, "Search"),
83            Self::Songs => write!(f, "Songs"),
84            Self::Artists => write!(f, "Artists"),
85            Self::Albums => write!(f, "Albums"),
86            Self::Playlists => write!(f, "Playlists"),
87            Self::DynamicPlaylists => write!(f, "Dynamic"),
88            Self::Collections => write!(f, "Collections"),
89            Self::Random => write!(f, "Random"),
90            Self::Space => write!(f, ""),
91            Self::LibraryRescan => write!(f, "Library Rescan"),
92            Self::LibraryAnalyze => write!(f, "Library Analyze"),
93            Self::LibraryRecluster => write!(f, "Library Recluster"),
94        }
95    }
96}
97
98const SIDEBAR_ITEMS: [SidebarItem; 13] = [
99    SidebarItem::Search,
100    SidebarItem::Space,
101    SidebarItem::Songs,
102    SidebarItem::Artists,
103    SidebarItem::Albums,
104    SidebarItem::Playlists,
105    SidebarItem::DynamicPlaylists,
106    SidebarItem::Collections,
107    SidebarItem::Random,
108    SidebarItem::Space,
109    SidebarItem::LibraryRescan,
110    SidebarItem::LibraryAnalyze,
111    SidebarItem::LibraryRecluster,
112];
113
114impl Component for Sidebar {
115    fn new(_state: &AppState, action_tx: UnboundedSender<Action>) -> Self
116    where
117        Self: Sized,
118    {
119        Self {
120            action_tx,
121            list_state: ListState::default(),
122        }
123    }
124
125    fn move_with_state(self, _state: &AppState) -> Self
126    where
127        Self: Sized,
128    {
129        self
130    }
131
132    fn name(&self) -> &'static str {
133        "Sidebar"
134    }
135
136    fn handle_key_event(&mut self, key: KeyEvent) {
137        match key.code {
138            // move the selected index up
139            KeyCode::Up => {
140                let new_selection = self
141                    .list_state
142                    .selected()
143                    .filter(|selected| *selected > 0)
144                    .map_or_else(|| SIDEBAR_ITEMS.len() - 1, |selected| selected - 1);
145
146                self.list_state.select(Some(new_selection));
147            }
148            // move the selected index down
149            KeyCode::Down => {
150                let new_selection = self
151                    .list_state
152                    .selected()
153                    .filter(|selected| *selected < SIDEBAR_ITEMS.len() - 1)
154                    .map_or(0, |selected| selected + 1);
155
156                self.list_state.select(Some(new_selection));
157            }
158            // select the current item
159            KeyCode::Enter => {
160                if let Some(selected) = self.list_state.selected() {
161                    let item = SIDEBAR_ITEMS[selected];
162                    if let Some(action) = item.to_action() {
163                        if matches!(
164                            item,
165                            SidebarItem::LibraryAnalyze
166                                | SidebarItem::LibraryRescan
167                                | SidebarItem::LibraryRecluster
168                        ) {
169                            self.action_tx
170                                .send(Action::Popup(PopupAction::Open(PopupType::Notification(
171                                    format!(" {item} Started ").into(),
172                                ))))
173                                .unwrap();
174                        }
175
176                        self.action_tx.send(action).unwrap();
177                    }
178                }
179            }
180            _ => {}
181        }
182    }
183
184    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
185        let MouseEvent {
186            kind, column, row, ..
187        } = mouse;
188        let mouse_position = Position::new(column, row);
189
190        // adjust area to exclude the border
191        let area = area.inner(Margin::new(1, 1));
192
193        match kind {
194            // TODO: refactor Sidebar to use a CheckTree for better mouse handling
195            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
196                // make this the active component
197                self.action_tx
198                    .send(Action::ActiveComponent(ComponentAction::Set(
199                        ActiveComponent::Sidebar,
200                    )))
201                    .unwrap();
202
203                // adjust the mouse position so that it is relative to the area of the list
204                let adjusted_mouse_y = mouse_position.y - area.y;
205
206                // select the item at the mouse position
207                let new_selection = adjusted_mouse_y as usize;
208                if self.list_state.selected() == Some(new_selection) {
209                    self.handle_key_event(KeyEvent::from(KeyCode::Enter));
210                } else if new_selection < SIDEBAR_ITEMS.len() {
211                    self.list_state.select(Some(new_selection));
212                }
213            }
214            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
215            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
216            _ => {}
217        }
218    }
219}
220
221impl ComponentRender<RenderProps> for Sidebar {
222    fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
223        let border_style = Style::default().fg(border_color(props.is_focused).into());
224
225        let border = Block::bordered()
226            .title_top("Sidebar")
227            .title_bottom(Line::from("Enter: Select").alignment(Alignment::Center))
228            .border_style(border_style);
229        frame.render_widget(&border, props.area);
230        let area = border.inner(props.area);
231        let border = Block::default()
232            .borders(Borders::BOTTOM)
233            .title_bottom(Line::from("↑/↓: Move").alignment(Alignment::Center))
234            .border_style(border_style);
235        frame.render_widget(&border, area);
236        let area = border.inner(area);
237        RenderProps {
238            area,
239            is_focused: props.is_focused,
240        }
241    }
242
243    fn render_content(&self, frame: &mut Frame, props: RenderProps) {
244        let items = SIDEBAR_ITEMS
245            .iter()
246            .map(|item| {
247                ListItem::new(Span::styled(
248                    item.to_string(),
249                    Style::default().fg(TEXT_NORMAL.into()),
250                ))
251            })
252            .collect::<Vec<_>>();
253
254        frame.render_stateful_widget(
255            List::new(items)
256                .highlight_style(
257                    Style::default()
258                        .fg(TEXT_HIGHLIGHT.into())
259                        .add_modifier(Modifier::BOLD),
260                )
261                .direction(ratatui::widgets::ListDirection::TopToBottom),
262            props.area,
263            &mut self.list_state.clone(),
264        );
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use anyhow::Result;
271    use ratatui::buffer::Buffer;
272
273    use super::*;
274    use crate::{
275        state::component::ActiveComponent,
276        test_utils::{assert_buffer_eq, setup_test_terminal, state_with_everything},
277    };
278
279    #[test]
280    fn test_sidebar_item_display() {
281        assert_eq!(SidebarItem::Search.to_string(), "Search");
282        assert_eq!(SidebarItem::LibraryRescan.to_string(), "Library Rescan");
283        assert_eq!(SidebarItem::LibraryAnalyze.to_string(), "Library Analyze");
284        assert_eq!(SidebarItem::Songs.to_string(), "Songs");
285        assert_eq!(SidebarItem::Artists.to_string(), "Artists");
286        assert_eq!(SidebarItem::Albums.to_string(), "Albums");
287        assert_eq!(SidebarItem::Playlists.to_string(), "Playlists");
288        assert_eq!(SidebarItem::Collections.to_string(), "Collections");
289        assert_eq!(SidebarItem::Random.to_string(), "Random");
290        assert_eq!(SidebarItem::Space.to_string(), "");
291        assert_eq!(
292            SidebarItem::LibraryRecluster.to_string(),
293            "Library Recluster"
294        );
295    }
296
297    #[test]
298    fn test_sidebar_render() -> Result<()> {
299        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
300        let sidebar = Sidebar::new(&AppState::default(), tx).move_with_state(&AppState {
301            active_component: ActiveComponent::Sidebar,
302            ..state_with_everything()
303        });
304
305        let (mut terminal, area) = setup_test_terminal(19, 16);
306        let props = RenderProps {
307            area,
308            is_focused: true,
309        };
310        let buffer = terminal.draw(|frame| sidebar.render(frame, props))?.buffer;
311        let expected = Buffer::with_lines([
312            "┌Sidebar──────────┐",
313            "│Search           │",
314            "│                 │",
315            "│Songs            │",
316            "│Artists          │",
317            "│Albums           │",
318            "│Playlists        │",
319            "│Dynamic          │",
320            "│Collections      │",
321            "│Random           │",
322            "│                 │",
323            "│Library Rescan   │",
324            "│Library Analyze  │",
325            "│Library Recluster│",
326            "│────↑/↓: Move────│",
327            "└──Enter: Select──┘",
328        ]);
329
330        assert_buffer_eq(buffer, &expected);
331
332        Ok(())
333    }
334
335    #[test]
336    fn test_navigation_wraps() {
337        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
338        let mut sidebar = Sidebar::new(&AppState::default(), tx).move_with_state(&AppState {
339            active_component: ActiveComponent::Sidebar,
340            ..state_with_everything()
341        });
342
343        sidebar.handle_key_event(KeyEvent::from(KeyCode::Up));
344        assert_eq!(sidebar.list_state.selected(), Some(SIDEBAR_ITEMS.len() - 1));
345
346        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
347        assert_eq!(sidebar.list_state.selected(), Some(0));
348    }
349
350    #[test]
351    fn test_actions() {
352        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
353        let mut sidebar = Sidebar::new(&AppState::default(), tx).move_with_state(&AppState {
354            active_component: ActiveComponent::Sidebar,
355            ..state_with_everything()
356        });
357
358        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
359        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
360        assert_eq!(
361            rx.blocking_recv().unwrap(),
362            Action::ActiveView(ViewAction::Set(ActiveView::Search))
363        );
364
365        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
366        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
367        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
368        assert_eq!(
369            rx.blocking_recv().unwrap(),
370            Action::ActiveView(ViewAction::Set(ActiveView::Songs))
371        );
372
373        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
374        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
375        assert_eq!(
376            rx.blocking_recv().unwrap(),
377            Action::ActiveView(ViewAction::Set(ActiveView::Artists))
378        );
379
380        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
381        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
382        assert_eq!(
383            rx.blocking_recv().unwrap(),
384            Action::ActiveView(ViewAction::Set(ActiveView::Albums))
385        );
386
387        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
388        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
389        assert_eq!(
390            rx.blocking_recv().unwrap(),
391            Action::ActiveView(ViewAction::Set(ActiveView::Playlists))
392        );
393
394        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
395        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
396        assert_eq!(
397            rx.blocking_recv().unwrap(),
398            Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylists))
399        );
400
401        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
402        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
403        assert_eq!(
404            rx.blocking_recv().unwrap(),
405            Action::ActiveView(ViewAction::Set(ActiveView::Collections))
406        );
407
408        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
409        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
410        assert_eq!(
411            rx.blocking_recv().unwrap(),
412            Action::ActiveView(ViewAction::Set(ActiveView::Random))
413        );
414
415        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
416        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
417        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
418        assert_eq!(
419            rx.blocking_recv().unwrap(),
420            Action::Popup(PopupAction::Open(PopupType::Notification(
421                " Library Rescan Started ".into()
422            )))
423        );
424        assert_eq!(
425            rx.blocking_recv().unwrap(),
426            Action::Library(LibraryAction::Rescan)
427        );
428
429        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
430        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
431        assert_eq!(
432            rx.blocking_recv().unwrap(),
433            Action::Popup(PopupAction::Open(PopupType::Notification(
434                " Library Analyze Started ".into()
435            )))
436        );
437        assert_eq!(
438            rx.blocking_recv().unwrap(),
439            Action::Library(LibraryAction::Analyze)
440        );
441
442        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
443        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
444        assert_eq!(
445            rx.blocking_recv().unwrap(),
446            Action::Popup(PopupAction::Open(PopupType::Notification(
447                " Library Recluster Started ".into()
448            )))
449        );
450        assert_eq!(
451            rx.blocking_recv().unwrap(),
452            Action::Library(LibraryAction::Recluster)
453        );
454    }
455}