1use 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 pub action_tx: UnboundedSender<Action>,
36 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, 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 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 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 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 let area = area.inner(Margin::new(1, 1));
200
201 match kind {
202 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
204 let adjusted_mouse_y = mouse_position.y - area.y;
206
207 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}