1use 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 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 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 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 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 let area = area.inner(Margin::new(1, 1));
198
199 match kind {
200 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
202 self.action_tx
204 .send(Action::ActiveComponent(ComponentAction::Set(
205 ActiveComponent::Sidebar,
206 )))
207 .unwrap();
208
209 let adjusted_mouse_y = mouse_position.y - area.y;
211
212 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}