1use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use ratatui::{
7 layout::{Alignment, Margin, Rect},
8 style::{Style, Stylize},
9 text::{Line, Span},
10 widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
11 Frame,
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::{checktree_utils::create_song_tree_leaf, RadioViewProps};
16use crate::{
17 state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
18 ui::{
19 colors::{BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT, TEXT_NORMAL},
20 components::{Component, ComponentRender, RenderProps},
21 widgets::{
22 popups::PopupType,
23 tree::{state::CheckTreeState, CheckTree},
24 },
25 AppState,
26 },
27};
28
29#[allow(clippy::module_name_repetitions)]
30pub struct RadioView {
31 pub action_tx: UnboundedSender<Action>,
33 pub props: Option<RadioViewProps>,
35 tree_state: Mutex<CheckTreeState<String>>,
37}
38
39impl Component for RadioView {
40 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
41 where
42 Self: Sized,
43 {
44 Self {
45 action_tx,
46 props: state.additional_view_data.radio.clone(),
47 tree_state: Mutex::new(CheckTreeState::default()),
48 }
49 }
50
51 fn move_with_state(self, state: &AppState) -> Self
52 where
53 Self: Sized,
54 {
55 if let Some(props) = &state.additional_view_data.radio {
56 Self {
57 props: Some(props.to_owned()),
58 tree_state: Mutex::new(CheckTreeState::default()),
59 ..self
60 }
61 } else {
62 self
63 }
64 }
65
66 fn name(&self) -> &'static str {
67 "Radio"
68 }
69
70 fn handle_key_event(&mut self, key: KeyEvent) {
71 match key.code {
72 KeyCode::PageUp => {
74 self.tree_state.lock().unwrap().select_relative(|current| {
75 current.map_or(
76 self.props
77 .as_ref()
78 .map_or(0, |p| p.songs.len().saturating_sub(1)),
79 |c| c.saturating_sub(10),
80 )
81 });
82 }
83 KeyCode::Up => {
84 self.tree_state.lock().unwrap().key_up();
85 }
86 KeyCode::PageDown => {
87 self.tree_state
88 .lock()
89 .unwrap()
90 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
91 }
92 KeyCode::Down => {
93 self.tree_state.lock().unwrap().key_down();
94 }
95 KeyCode::Left => {
96 self.tree_state.lock().unwrap().key_left();
97 }
98 KeyCode::Right => {
99 self.tree_state.lock().unwrap().key_right();
100 }
101 KeyCode::Char(' ') => {
102 self.tree_state.lock().unwrap().key_space();
103 }
104 KeyCode::Enter => {
106 if self.tree_state.lock().unwrap().toggle_selected() {
107 let things = self.tree_state.lock().unwrap().get_selected_thing();
108
109 if let Some(thing) = things {
110 self.action_tx
111 .send(Action::ActiveView(ViewAction::Set(thing.into())))
112 .unwrap();
113 }
114 }
115 }
116 KeyCode::Char('q') => {
118 let things = self.tree_state.lock().unwrap().get_checked_things();
119 if !things.is_empty() {
120 self.action_tx
121 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
122 .unwrap();
123 } else if let Some(props) = &self.props {
124 self.action_tx
125 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(
126 props.songs.iter().map(|s| s.id.clone().into()).collect(),
127 ))))
128 .expect("failed to send action");
129 }
130 }
131 KeyCode::Char('p') => {
133 let things = self.tree_state.lock().unwrap().get_checked_things();
134 if !things.is_empty() {
135 self.action_tx
136 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
137 things,
138 ))))
139 .unwrap();
140 } else if let Some(props) = &self.props {
141 self.action_tx
142 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
143 props.songs.iter().map(|s| s.id.clone().into()).collect(),
144 ))))
145 .expect("failed to send action");
146 }
147 }
148 _ => {}
149 }
150 }
151
152 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
153 let area = area.inner(Margin::new(1, 1));
155 let area = Rect {
156 y: area.y + 2,
157 height: area.height - 2,
158 ..area
159 };
160
161 let result = self
162 .tree_state
163 .lock()
164 .unwrap()
165 .handle_mouse_event(mouse, area);
166 if let Some(action) = result {
167 self.action_tx.send(action).unwrap();
168 }
169 }
170}
171
172impl ComponentRender<RenderProps> for RadioView {
173 fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
174 let border_style = if props.is_focused {
175 Style::default().fg(BORDER_FOCUSED.into())
176 } else {
177 Style::default().fg(BORDER_UNFOCUSED.into())
178 };
179
180 let area = if let Some(state) = &self.props {
181 let border = Block::bordered()
182 .title_top(Line::from(vec![
183 Span::styled("Radio", Style::default().bold()),
184 Span::raw(" "),
185 Span::styled(format!("top {}", state.count), Style::default().italic()),
186 ]))
187 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
188 .border_style(border_style);
189 frame.render_widget(&border, props.area);
190 let content_area = border.inner(props.area);
191
192 let border = Block::default()
194 .borders(Borders::TOP)
195 .title_top("q: add to queue | p: add to playlist")
196 .border_style(border_style);
197 frame.render_widget(&border, content_area);
198 let content_area = border.inner(content_area);
199
200 let border = Block::default()
202 .borders(Borders::TOP)
203 .title_top(Line::from(vec![
204 Span::raw("Performing operations on "),
205 Span::raw(
206 if self
207 .tree_state
208 .lock()
209 .unwrap()
210 .get_checked_things()
211 .is_empty()
212 {
213 "entire radio"
214 } else {
215 "checked items"
216 },
217 )
218 .fg(TEXT_HIGHLIGHT),
219 ]))
220 .italic()
221 .border_style(border_style);
222 frame.render_widget(&border, content_area);
223 border.inner(content_area)
224 } else {
225 let border = Block::bordered()
226 .title_top("Radio")
227 .border_style(border_style);
228 frame.render_widget(&border, props.area);
229 border.inner(props.area)
230 };
231
232 RenderProps { area, ..props }
233 }
234
235 fn render_content(&self, frame: &mut Frame, props: RenderProps) {
236 if let Some(state) = &self.props {
237 let items = state
239 .songs
240 .iter()
241 .map(create_song_tree_leaf)
242 .collect::<Vec<_>>();
243
244 frame.render_stateful_widget(
246 CheckTree::new(&items)
247 .unwrap()
248 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
249 .experimental_scrollbar(Some(Scrollbar::new(
250 ScrollbarOrientation::VerticalRight,
251 ))),
252 props.area,
253 &mut self.tree_state.lock().unwrap(),
254 );
255 } else {
256 let text = "Empty Radio";
257
258 frame.render_widget(
259 Line::from(text)
260 .style(Style::default().fg(TEXT_NORMAL.into()))
261 .alignment(Alignment::Center),
262 props.area,
263 );
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::{
272 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
273 ui::components::content_view::ActiveView,
274 };
275 use anyhow::Result;
276 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
277 use pretty_assertions::assert_eq;
278 use ratatui::buffer::Buffer;
279
280 #[test]
281 fn test_new() {
282 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
283 let state = state_with_everything();
284 let view = RadioView::new(&state, tx);
285
286 assert_eq!(view.name(), "Radio");
287 assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
288 }
289
290 #[test]
291 fn test_move_with_state() {
292 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
293 let state = AppState::default();
294 let new_state = state_with_everything();
295 let view = RadioView::new(&state, tx).move_with_state(&new_state);
296
297 assert_eq!(
298 view.props,
299 Some(new_state.additional_view_data.radio.unwrap())
300 );
301 }
302
303 #[test]
304 fn test_render_empty() -> Result<()> {
305 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
306 let view = RadioView::new(&AppState::default(), tx);
307
308 let (mut terminal, area) = setup_test_terminal(16, 3);
309 let props = RenderProps {
310 area,
311 is_focused: true,
312 };
313 let buffer = terminal
314 .draw(|frame| view.render(frame, props))
315 .unwrap()
316 .buffer
317 .clone();
318 #[rustfmt::skip]
319 let expected = Buffer::with_lines([
320 "┌Radio─────────┐",
321 "│ Empty Radio │",
322 "└──────────────┘",
323 ]);
324
325 assert_buffer_eq(&buffer, &expected);
326
327 Ok(())
328 }
329
330 #[test]
331 fn test_render() -> Result<()> {
332 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
333 let view = RadioView::new(&state_with_everything(), tx);
334
335 let (mut terminal, area) = setup_test_terminal(50, 6);
336 let props = RenderProps {
337 area,
338 is_focused: true,
339 };
340 let buffer = terminal
341 .draw(|frame| view.render(frame, props))
342 .unwrap()
343 .buffer
344 .clone();
345 let expected = Buffer::with_lines([
346 "┌Radio top 1─────────────────────────────────────┐",
347 "│q: add to queue | p: add to playlist────────────│",
348 "│Performing operations on entire radio───────────│",
349 "│☐ Test Song Test Artist │",
350 "│ │",
351 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
352 ]);
353
354 assert_buffer_eq(&buffer, &expected);
355
356 Ok(())
357 }
358
359 #[test]
360 fn test_render_with_checked() -> Result<()> {
361 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
362 let mut view = RadioView::new(&state_with_everything(), tx);
363 let (mut terminal, area) = setup_test_terminal(50, 6);
364 let props = RenderProps {
365 area,
366 is_focused: true,
367 };
368 let buffer = terminal
369 .draw(|frame| view.render(frame, props))
370 .unwrap()
371 .buffer
372 .clone();
373 let expected = Buffer::with_lines([
374 "┌Radio top 1─────────────────────────────────────┐",
375 "│q: add to queue | p: add to playlist────────────│",
376 "│Performing operations on entire radio───────────│",
377 "│☐ Test Song Test Artist │",
378 "│ │",
379 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
380 ]);
381 assert_buffer_eq(&buffer, &expected);
382
383 view.handle_key_event(KeyEvent::from(KeyCode::Down));
384 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
385
386 let buffer = terminal
387 .draw(|frame| view.render(frame, props))
388 .unwrap()
389 .buffer
390 .clone();
391 let expected = Buffer::with_lines([
392 "┌Radio top 1─────────────────────────────────────┐",
393 "│q: add to queue | p: add to playlist────────────│",
394 "│Performing operations on checked items──────────│",
395 "│☑ Test Song Test Artist │",
396 "│ │",
397 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
398 ]);
399
400 assert_buffer_eq(&buffer, &expected);
401
402 Ok(())
403 }
404
405 #[test]
406 fn smoke_navigation() {
407 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
408 let mut view = RadioView::new(&state_with_everything(), tx);
409
410 view.handle_key_event(KeyEvent::from(KeyCode::Up));
411 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
412 view.handle_key_event(KeyEvent::from(KeyCode::Down));
413 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
414 view.handle_key_event(KeyEvent::from(KeyCode::Left));
415 view.handle_key_event(KeyEvent::from(KeyCode::Right));
416 }
417
418 #[test]
419 fn test_actions() {
420 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
421 let mut view = RadioView::new(&state_with_everything(), tx);
422
423 let (mut terminal, area) = setup_test_terminal(50, 6);
425 let props = RenderProps {
426 area,
427 is_focused: true,
428 };
429 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
430
431 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
434 assert_eq!(
435 rx.blocking_recv().unwrap(),
436 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
437 "song",
438 item_id()
439 )
440 .into()])))
441 );
442 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
443 assert_eq!(
444 rx.blocking_recv().unwrap(),
445 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
446 "song",
447 item_id()
448 )
449 .into()])))
450 );
451
452 view.handle_key_event(KeyEvent::from(KeyCode::Down));
455 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
456
457 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
459 assert_eq!(
460 rx.blocking_recv().unwrap(),
461 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
462 );
463
464 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
466
467 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
469 assert_eq!(
470 rx.blocking_recv().unwrap(),
471 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
472 "song",
473 item_id()
474 )
475 .into()])))
476 );
477
478 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
480 assert_eq!(
481 rx.blocking_recv().unwrap(),
482 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
483 "song",
484 item_id()
485 )
486 .into()])))
487 );
488 }
489
490 #[test]
491 fn test_mouse() {
492 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
493 let mut view = RadioView::new(&state_with_everything(), tx);
494
495 let (mut terminal, area) = setup_test_terminal(50, 6);
497 let props = RenderProps {
498 area,
499 is_focused: true,
500 };
501 let buffer = terminal
502 .draw(|frame| view.render(frame, props))
503 .unwrap()
504 .buffer
505 .clone();
506 let expected = Buffer::with_lines([
507 "┌Radio top 1─────────────────────────────────────┐",
508 "│q: add to queue | p: add to playlist────────────│",
509 "│Performing operations on entire radio───────────│",
510 "│☐ Test Song Test Artist │",
511 "│ │",
512 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
513 ]);
514 assert_buffer_eq(&buffer, &expected);
515
516 view.handle_mouse_event(
518 MouseEvent {
519 kind: MouseEventKind::ScrollDown,
520 column: 2,
521 row: 3,
522 modifiers: KeyModifiers::empty(),
523 },
524 area,
525 );
526
527 view.handle_mouse_event(
529 MouseEvent {
530 kind: MouseEventKind::Down(MouseButton::Left),
531 column: 2,
532 row: 3,
533 modifiers: KeyModifiers::empty(),
534 },
535 area,
536 );
537 assert_eq!(
538 rx.blocking_recv().unwrap(),
539 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
540 );
541 let buffer = terminal
542 .draw(|frame| view.render(frame, props))
543 .unwrap()
544 .buffer
545 .clone();
546 let expected = Buffer::with_lines([
547 "┌Radio top 1─────────────────────────────────────┐",
548 "│q: add to queue | p: add to playlist────────────│",
549 "│Performing operations on checked items──────────│",
550 "│☑ Test Song Test Artist │",
551 "│ │",
552 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
553 ]);
554 assert_buffer_eq(&buffer, &expected);
555
556 view.handle_mouse_event(
558 MouseEvent {
559 kind: MouseEventKind::ScrollUp,
560 column: 2,
561 row: 3,
562 modifiers: KeyModifiers::empty(),
563 },
564 area,
565 );
566
567 let mouse = MouseEvent {
569 kind: MouseEventKind::Down(MouseButton::Left),
570 column: 2,
571 row: 4,
572 modifiers: KeyModifiers::empty(),
573 };
574 view.handle_mouse_event(mouse, area);
575 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
576 view.handle_mouse_event(mouse, area);
577 assert_eq!(
578 rx.try_recv(),
579 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
580 );
581 }
582}