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_color, 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 = Style::default().fg(border_color(props.is_focused).into());
175
176 let area = if let Some(state) = &self.props {
177 let border = Block::bordered()
178 .title_top(Line::from(vec![
179 Span::styled("Radio", Style::default().bold()),
180 Span::raw(" "),
181 Span::styled(format!("top {}", state.count), Style::default().italic()),
182 ]))
183 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
184 .border_style(border_style);
185 frame.render_widget(&border, props.area);
186 let content_area = border.inner(props.area);
187
188 let border = Block::default()
190 .borders(Borders::TOP)
191 .title_top("q: add to queue | p: add to playlist")
192 .border_style(border_style);
193 frame.render_widget(&border, content_area);
194 let content_area = border.inner(content_area);
195
196 let border = Block::default()
198 .borders(Borders::TOP)
199 .title_top(Line::from(vec![
200 Span::raw("Performing operations on "),
201 Span::raw(
202 if self
203 .tree_state
204 .lock()
205 .unwrap()
206 .get_checked_things()
207 .is_empty()
208 {
209 "entire radio"
210 } else {
211 "checked items"
212 },
213 )
214 .fg(TEXT_HIGHLIGHT),
215 ]))
216 .italic()
217 .border_style(border_style);
218 frame.render_widget(&border, content_area);
219 border.inner(content_area)
220 } else {
221 let border = Block::bordered()
222 .title_top("Radio")
223 .border_style(border_style);
224 frame.render_widget(&border, props.area);
225 border.inner(props.area)
226 };
227
228 RenderProps { area, ..props }
229 }
230
231 fn render_content(&self, frame: &mut Frame, props: RenderProps) {
232 if let Some(state) = &self.props {
233 let items = state
235 .songs
236 .iter()
237 .map(create_song_tree_leaf)
238 .collect::<Vec<_>>();
239
240 frame.render_stateful_widget(
242 CheckTree::new(&items)
243 .unwrap()
244 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
245 .experimental_scrollbar(Some(Scrollbar::new(
246 ScrollbarOrientation::VerticalRight,
247 ))),
248 props.area,
249 &mut self.tree_state.lock().unwrap(),
250 );
251 } else {
252 let text = "Empty Radio";
253
254 frame.render_widget(
255 Line::from(text)
256 .style(Style::default().fg(TEXT_NORMAL.into()))
257 .alignment(Alignment::Center),
258 props.area,
259 );
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::{
268 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
269 ui::components::content_view::ActiveView,
270 };
271 use anyhow::Result;
272 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
273 use pretty_assertions::assert_eq;
274 use ratatui::buffer::Buffer;
275
276 #[test]
277 fn test_new() {
278 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
279 let state = state_with_everything();
280 let view = RadioView::new(&state, tx);
281
282 assert_eq!(view.name(), "Radio");
283 assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
284 }
285
286 #[test]
287 fn test_move_with_state() {
288 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
289 let state = AppState::default();
290 let new_state = state_with_everything();
291 let view = RadioView::new(&state, tx).move_with_state(&new_state);
292
293 assert_eq!(
294 view.props,
295 Some(new_state.additional_view_data.radio.unwrap())
296 );
297 }
298
299 #[test]
300 fn test_render_empty() -> Result<()> {
301 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
302 let view = RadioView::new(&AppState::default(), tx);
303
304 let (mut terminal, area) = setup_test_terminal(16, 3);
305 let props = RenderProps {
306 area,
307 is_focused: true,
308 };
309 let buffer = terminal
310 .draw(|frame| view.render(frame, props))
311 .unwrap()
312 .buffer
313 .clone();
314 #[rustfmt::skip]
315 let expected = Buffer::with_lines([
316 "┌Radio─────────┐",
317 "│ Empty Radio │",
318 "└──────────────┘",
319 ]);
320
321 assert_buffer_eq(&buffer, &expected);
322
323 Ok(())
324 }
325
326 #[test]
327 fn test_render() -> Result<()> {
328 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
329 let view = RadioView::new(&state_with_everything(), tx);
330
331 let (mut terminal, area) = setup_test_terminal(50, 6);
332 let props = RenderProps {
333 area,
334 is_focused: true,
335 };
336 let buffer = terminal
337 .draw(|frame| view.render(frame, props))
338 .unwrap()
339 .buffer
340 .clone();
341 let expected = Buffer::with_lines([
342 "┌Radio top 1─────────────────────────────────────┐",
343 "│q: add to queue | p: add to playlist────────────│",
344 "│Performing operations on entire radio───────────│",
345 "│☐ Test Song Test Artist │",
346 "│ │",
347 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
348 ]);
349
350 assert_buffer_eq(&buffer, &expected);
351
352 Ok(())
353 }
354
355 #[test]
356 fn test_render_with_checked() -> Result<()> {
357 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
358 let mut view = RadioView::new(&state_with_everything(), tx);
359 let (mut terminal, area) = setup_test_terminal(50, 6);
360 let props = RenderProps {
361 area,
362 is_focused: true,
363 };
364 let buffer = terminal
365 .draw(|frame| view.render(frame, props))
366 .unwrap()
367 .buffer
368 .clone();
369 let expected = Buffer::with_lines([
370 "┌Radio top 1─────────────────────────────────────┐",
371 "│q: add to queue | p: add to playlist────────────│",
372 "│Performing operations on entire radio───────────│",
373 "│☐ Test Song Test Artist │",
374 "│ │",
375 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
376 ]);
377 assert_buffer_eq(&buffer, &expected);
378
379 view.handle_key_event(KeyEvent::from(KeyCode::Down));
380 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
381
382 let buffer = terminal
383 .draw(|frame| view.render(frame, props))
384 .unwrap()
385 .buffer
386 .clone();
387 let expected = Buffer::with_lines([
388 "┌Radio top 1─────────────────────────────────────┐",
389 "│q: add to queue | p: add to playlist────────────│",
390 "│Performing operations on checked items──────────│",
391 "│☑ Test Song Test Artist │",
392 "│ │",
393 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
394 ]);
395
396 assert_buffer_eq(&buffer, &expected);
397
398 Ok(())
399 }
400
401 #[test]
402 fn smoke_navigation() {
403 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
404 let mut view = RadioView::new(&state_with_everything(), tx);
405
406 view.handle_key_event(KeyEvent::from(KeyCode::Up));
407 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
408 view.handle_key_event(KeyEvent::from(KeyCode::Down));
409 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
410 view.handle_key_event(KeyEvent::from(KeyCode::Left));
411 view.handle_key_event(KeyEvent::from(KeyCode::Right));
412 }
413
414 #[test]
415 fn test_actions() {
416 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
417 let mut view = RadioView::new(&state_with_everything(), tx);
418
419 let (mut terminal, area) = setup_test_terminal(50, 6);
421 let props = RenderProps {
422 area,
423 is_focused: true,
424 };
425 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
426
427 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
430 assert_eq!(
431 rx.blocking_recv().unwrap(),
432 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
433 "song",
434 item_id()
435 )
436 .into()])))
437 );
438 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
439 assert_eq!(
440 rx.blocking_recv().unwrap(),
441 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
442 "song",
443 item_id()
444 )
445 .into()])))
446 );
447
448 view.handle_key_event(KeyEvent::from(KeyCode::Down));
451 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
452
453 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
455 assert_eq!(
456 rx.blocking_recv().unwrap(),
457 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
458 );
459
460 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
462
463 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
465 assert_eq!(
466 rx.blocking_recv().unwrap(),
467 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
468 "song",
469 item_id()
470 )
471 .into()])))
472 );
473
474 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
476 assert_eq!(
477 rx.blocking_recv().unwrap(),
478 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
479 "song",
480 item_id()
481 )
482 .into()])))
483 );
484 }
485
486 #[test]
487 fn test_mouse() {
488 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
489 let mut view = RadioView::new(&state_with_everything(), tx);
490
491 let (mut terminal, area) = setup_test_terminal(50, 6);
493 let props = RenderProps {
494 area,
495 is_focused: true,
496 };
497 let buffer = terminal
498 .draw(|frame| view.render(frame, props))
499 .unwrap()
500 .buffer
501 .clone();
502 let expected = Buffer::with_lines([
503 "┌Radio top 1─────────────────────────────────────┐",
504 "│q: add to queue | p: add to playlist────────────│",
505 "│Performing operations on entire radio───────────│",
506 "│☐ Test Song Test Artist │",
507 "│ │",
508 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
509 ]);
510 assert_buffer_eq(&buffer, &expected);
511
512 view.handle_mouse_event(
514 MouseEvent {
515 kind: MouseEventKind::ScrollDown,
516 column: 2,
517 row: 3,
518 modifiers: KeyModifiers::empty(),
519 },
520 area,
521 );
522
523 view.handle_mouse_event(
525 MouseEvent {
526 kind: MouseEventKind::Down(MouseButton::Left),
527 column: 2,
528 row: 3,
529 modifiers: KeyModifiers::empty(),
530 },
531 area,
532 );
533 assert_eq!(
534 rx.blocking_recv().unwrap(),
535 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
536 );
537 let buffer = terminal
538 .draw(|frame| view.render(frame, props))
539 .unwrap()
540 .buffer
541 .clone();
542 let expected = Buffer::with_lines([
543 "┌Radio top 1─────────────────────────────────────┐",
544 "│q: add to queue | p: add to playlist────────────│",
545 "│Performing operations on checked items──────────│",
546 "│☑ Test Song Test Artist │",
547 "│ │",
548 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
549 ]);
550 assert_buffer_eq(&buffer, &expected);
551
552 view.handle_mouse_event(
554 MouseEvent {
555 kind: MouseEventKind::ScrollUp,
556 column: 2,
557 row: 3,
558 modifiers: KeyModifiers::empty(),
559 },
560 area,
561 );
562
563 let mouse = MouseEvent {
565 kind: MouseEventKind::Down(MouseButton::Left),
566 column: 2,
567 row: 4,
568 modifiers: KeyModifiers::empty(),
569 };
570 view.handle_mouse_event(mouse, area);
571 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
572 view.handle_mouse_event(mouse, area);
573 assert_eq!(
574 rx.try_recv(),
575 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
576 );
577 }
578}