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