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 let area = area.inner(Margin::new(1, 1));
192
193 match kind {
194 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
196 self.action_tx
198 .send(Action::ActiveComponent(ComponentAction::Set(
199 ActiveComponent::Sidebar,
200 )))
201 .unwrap();
202
203 let adjusted_mouse_y = mouse_position.y - area.y;
205
206 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}