Skip to main content

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        if kind == MouseEventKind::Down(MouseButton::Left) && area.contains(mouse_position) {
191            self.action_tx
192                .send(Action::ActiveComponent(ComponentAction::Set(
193                    ActiveComponent::Sidebar,
194                )))
195                .unwrap();
196        }
197
198        // adjust area to exclude the border
199        let area = area.inner(Margin::new(1, 1));
200
201        match kind {
202            // TODO: refactor Sidebar to use a CheckTree for better mouse handling
203            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
204                // adjust the mouse position so that it is relative to the area of the list
205                let adjusted_mouse_y = mouse_position.y - area.y;
206
207                // select the item at the mouse position
208                let new_selection = adjusted_mouse_y as usize;
209                if self.list_state.selected() == Some(new_selection) {
210                    self.handle_key_event(KeyEvent::from(KeyCode::Enter));
211                } else if new_selection < SIDEBAR_ITEMS.len() {
212                    self.list_state.select(Some(new_selection));
213                }
214            }
215            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
216            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
217            _ => {}
218        }
219    }
220}
221
222impl ComponentRender<RenderProps> for Sidebar {
223    fn render_border(&self, frame: &mut Frame<'_>, props: RenderProps) -> RenderProps {
224        let border_style = Style::default().fg(border_color(props.is_focused).into());
225
226        let border = Block::bordered()
227            .title_top("Sidebar")
228            .title_bottom(Line::from("Enter: Select").alignment(Alignment::Center))
229            .border_style(border_style);
230        frame.render_widget(&border, props.area);
231        let area = border.inner(props.area);
232        let border = Block::default()
233            .borders(Borders::BOTTOM)
234            .title_bottom(Line::from("↑/↓: Move").alignment(Alignment::Center))
235            .border_style(border_style);
236        frame.render_widget(&border, area);
237        let area = border.inner(area);
238        RenderProps {
239            area,
240            is_focused: props.is_focused,
241        }
242    }
243
244    fn render_content(&self, frame: &mut Frame<'_>, props: RenderProps) {
245        let items = SIDEBAR_ITEMS
246            .iter()
247            .map(|item| {
248                ListItem::new(Span::styled(
249                    item.to_string(),
250                    Style::default().fg((*TEXT_NORMAL).into()),
251                ))
252            })
253            .collect::<Vec<_>>();
254
255        frame.render_stateful_widget(
256            List::new(items)
257                .highlight_style(
258                    Style::default()
259                        .fg((*TEXT_HIGHLIGHT).into())
260                        .add_modifier(Modifier::BOLD),
261                )
262                .direction(ratatui::widgets::ListDirection::TopToBottom),
263            props.area,
264            &mut self.list_state.clone(),
265        );
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use anyhow::Result;
272    use ratatui::buffer::Buffer;
273
274    use super::*;
275    use crate::{
276        state::component::ActiveComponent,
277        test_utils::{assert_buffer_eq, setup_test_terminal, state_with_everything},
278    };
279
280    #[test]
281    fn test_sidebar_item_display() {
282        assert_eq!(SidebarItem::Search.to_string(), "Search");
283        assert_eq!(SidebarItem::LibraryRescan.to_string(), "Library Rescan");
284        assert_eq!(SidebarItem::LibraryAnalyze.to_string(), "Library Analyze");
285        assert_eq!(SidebarItem::Songs.to_string(), "Songs");
286        assert_eq!(SidebarItem::Artists.to_string(), "Artists");
287        assert_eq!(SidebarItem::Albums.to_string(), "Albums");
288        assert_eq!(SidebarItem::Playlists.to_string(), "Playlists");
289        assert_eq!(SidebarItem::Collections.to_string(), "Collections");
290        assert_eq!(SidebarItem::Random.to_string(), "Random");
291        assert_eq!(SidebarItem::Space.to_string(), "");
292        assert_eq!(
293            SidebarItem::LibraryRecluster.to_string(),
294            "Library Recluster"
295        );
296    }
297
298    #[test]
299    fn test_sidebar_render() -> Result<()> {
300        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
301        let sidebar = Sidebar::new(&AppState::default(), tx).move_with_state(&AppState {
302            active_component: ActiveComponent::Sidebar,
303            ..state_with_everything()
304        });
305
306        let (mut terminal, area) = setup_test_terminal(19, 16);
307        let props = RenderProps {
308            area,
309            is_focused: true,
310        };
311        let buffer = terminal.draw(|frame| sidebar.render(frame, props))?.buffer;
312        let expected = Buffer::with_lines([
313            "┌Sidebar──────────┐",
314            "│Search           │",
315            "│                 │",
316            "│Songs            │",
317            "│Artists          │",
318            "│Albums           │",
319            "│Playlists        │",
320            "│Dynamic          │",
321            "│Collections      │",
322            "│Random           │",
323            "│                 │",
324            "│Library Rescan   │",
325            "│Library Analyze  │",
326            "│Library Recluster│",
327            "│────↑/↓: Move────│",
328            "└──Enter: Select──┘",
329        ]);
330
331        assert_buffer_eq(buffer, &expected);
332
333        Ok(())
334    }
335
336    #[test]
337    fn test_navigation_wraps() {
338        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
339        let mut sidebar = Sidebar::new(&AppState::default(), tx).move_with_state(&AppState {
340            active_component: ActiveComponent::Sidebar,
341            ..state_with_everything()
342        });
343
344        sidebar.handle_key_event(KeyEvent::from(KeyCode::Up));
345        assert_eq!(sidebar.list_state.selected(), Some(SIDEBAR_ITEMS.len() - 1));
346
347        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
348        assert_eq!(sidebar.list_state.selected(), Some(0));
349    }
350
351    #[test]
352    fn test_actions() {
353        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
354        let mut sidebar = Sidebar::new(&AppState::default(), tx).move_with_state(&AppState {
355            active_component: ActiveComponent::Sidebar,
356            ..state_with_everything()
357        });
358
359        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
360        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
361        assert_eq!(
362            rx.blocking_recv().unwrap(),
363            Action::ActiveView(ViewAction::Set(ActiveView::Search))
364        );
365
366        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
367        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
368        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
369        assert_eq!(
370            rx.blocking_recv().unwrap(),
371            Action::ActiveView(ViewAction::Set(ActiveView::Songs))
372        );
373
374        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
375        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
376        assert_eq!(
377            rx.blocking_recv().unwrap(),
378            Action::ActiveView(ViewAction::Set(ActiveView::Artists))
379        );
380
381        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
382        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
383        assert_eq!(
384            rx.blocking_recv().unwrap(),
385            Action::ActiveView(ViewAction::Set(ActiveView::Albums))
386        );
387
388        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
389        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
390        assert_eq!(
391            rx.blocking_recv().unwrap(),
392            Action::ActiveView(ViewAction::Set(ActiveView::Playlists))
393        );
394
395        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
396        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
397        assert_eq!(
398            rx.blocking_recv().unwrap(),
399            Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylists))
400        );
401
402        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
403        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
404        assert_eq!(
405            rx.blocking_recv().unwrap(),
406            Action::ActiveView(ViewAction::Set(ActiveView::Collections))
407        );
408
409        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
410        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
411        assert_eq!(
412            rx.blocking_recv().unwrap(),
413            Action::ActiveView(ViewAction::Set(ActiveView::Random))
414        );
415
416        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
417        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
418        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
419        assert_eq!(
420            rx.blocking_recv().unwrap(),
421            Action::Popup(PopupAction::Open(PopupType::Notification(
422                " Library Rescan Started ".into()
423            )))
424        );
425        assert_eq!(
426            rx.blocking_recv().unwrap(),
427            Action::Library(LibraryAction::Rescan)
428        );
429
430        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
431        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
432        assert_eq!(
433            rx.blocking_recv().unwrap(),
434            Action::Popup(PopupAction::Open(PopupType::Notification(
435                " Library Analyze Started ".into()
436            )))
437        );
438        assert_eq!(
439            rx.blocking_recv().unwrap(),
440            Action::Library(LibraryAction::Analyze)
441        );
442
443        sidebar.handle_key_event(KeyEvent::from(KeyCode::Down));
444        sidebar.handle_key_event(KeyEvent::from(KeyCode::Enter));
445        assert_eq!(
446            rx.blocking_recv().unwrap(),
447            Action::Popup(PopupAction::Open(PopupType::Notification(
448                " Library Recluster Started ".into()
449            )))
450        );
451        assert_eq!(
452            rx.blocking_recv().unwrap(),
453            Action::Library(LibraryAction::Recluster)
454        );
455    }
456}