1use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use ratatui::{
7 Frame,
8 layout::{Alignment, Margin, Rect},
9 style::{Style, Stylize},
10 text::{Line, Span},
11 widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use super::{RadioViewProps, checktree_utils::create_song_tree_leaf};
16use crate::{
17 state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
18 ui::{
19 AppState,
20 colors::{TEXT_HIGHLIGHT, TEXT_NORMAL, border_color},
21 components::{Component, ComponentRender, RenderProps},
22 widgets::{
23 popups::PopupType,
24 tree::{CheckTree, state::CheckTreeState},
25 },
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 crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
272 use pretty_assertions::assert_eq;
273 use ratatui::buffer::Buffer;
274
275 #[test]
276 fn test_new() {
277 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
278 let state = state_with_everything();
279 let view = RadioView::new(&state, tx);
280
281 assert_eq!(view.name(), "Radio");
282 assert_eq!(view.props, Some(state.additional_view_data.radio.unwrap()));
283 }
284
285 #[test]
286 fn test_move_with_state() {
287 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
288 let state = AppState::default();
289 let new_state = state_with_everything();
290 let view = RadioView::new(&state, tx).move_with_state(&new_state);
291
292 assert_eq!(
293 view.props,
294 Some(new_state.additional_view_data.radio.unwrap())
295 );
296 }
297
298 #[test]
299 fn test_render_empty() {
300 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
301 let view = RadioView::new(&AppState::default(), tx);
302
303 let (mut terminal, area) = setup_test_terminal(16, 3);
304 let props = RenderProps {
305 area,
306 is_focused: true,
307 };
308 let buffer = terminal
309 .draw(|frame| view.render(frame, props))
310 .unwrap()
311 .buffer
312 .clone();
313 #[rustfmt::skip]
314 let expected = Buffer::with_lines([
315 "┌Radio─────────┐",
316 "│ Empty Radio │",
317 "└──────────────┘",
318 ]);
319
320 assert_buffer_eq(&buffer, &expected);
321 }
322
323 #[test]
324 fn test_render() {
325 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
326 let view = RadioView::new(&state_with_everything(), tx);
327
328 let (mut terminal, area) = setup_test_terminal(50, 6);
329 let props = RenderProps {
330 area,
331 is_focused: true,
332 };
333 let buffer = terminal
334 .draw(|frame| view.render(frame, props))
335 .unwrap()
336 .buffer
337 .clone();
338 let expected = Buffer::with_lines([
339 "┌Radio top 1─────────────────────────────────────┐",
340 "│q: add to queue | p: add to playlist────────────│",
341 "│Performing operations on entire radio───────────│",
342 "│☐ Test Song Test Artist │",
343 "│ │",
344 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
345 ]);
346
347 assert_buffer_eq(&buffer, &expected);
348 }
349
350 #[test]
351 fn test_render_with_checked() {
352 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
353 let mut view = RadioView::new(&state_with_everything(), tx);
354 let (mut terminal, area) = setup_test_terminal(50, 6);
355 let props = RenderProps {
356 area,
357 is_focused: true,
358 };
359 let buffer = terminal
360 .draw(|frame| view.render(frame, props))
361 .unwrap()
362 .buffer
363 .clone();
364 let expected = Buffer::with_lines([
365 "┌Radio top 1─────────────────────────────────────┐",
366 "│q: add to queue | p: add to playlist────────────│",
367 "│Performing operations on entire radio───────────│",
368 "│☐ Test Song Test Artist │",
369 "│ │",
370 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
371 ]);
372 assert_buffer_eq(&buffer, &expected);
373
374 view.handle_key_event(KeyEvent::from(KeyCode::Down));
375 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
376
377 let buffer = terminal
378 .draw(|frame| view.render(frame, props))
379 .unwrap()
380 .buffer
381 .clone();
382 let expected = Buffer::with_lines([
383 "┌Radio top 1─────────────────────────────────────┐",
384 "│q: add to queue | p: add to playlist────────────│",
385 "│Performing operations on checked items──────────│",
386 "│☑ Test Song Test Artist │",
387 "│ │",
388 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
389 ]);
390
391 assert_buffer_eq(&buffer, &expected);
392 }
393
394 #[test]
395 fn smoke_navigation() {
396 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
397 let mut view = RadioView::new(&state_with_everything(), tx);
398
399 view.handle_key_event(KeyEvent::from(KeyCode::Up));
400 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
401 view.handle_key_event(KeyEvent::from(KeyCode::Down));
402 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
403 view.handle_key_event(KeyEvent::from(KeyCode::Left));
404 view.handle_key_event(KeyEvent::from(KeyCode::Right));
405 }
406
407 #[test]
408 fn test_actions() {
409 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
410 let mut view = RadioView::new(&state_with_everything(), tx);
411
412 let (mut terminal, area) = setup_test_terminal(50, 6);
414 let props = RenderProps {
415 area,
416 is_focused: true,
417 };
418 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
419
420 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
423 assert_eq!(
424 rx.blocking_recv().unwrap(),
425 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
426 ("song", item_id()).into()
427 ])))
428 );
429 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
430 assert_eq!(
431 rx.blocking_recv().unwrap(),
432 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
433 ("song", item_id()).into()
434 ])))
435 );
436
437 view.handle_key_event(KeyEvent::from(KeyCode::Down));
440 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
441
442 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
444 assert_eq!(
445 rx.blocking_recv().unwrap(),
446 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
447 );
448
449 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
451
452 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
454 assert_eq!(
455 rx.blocking_recv().unwrap(),
456 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
457 ("song", item_id()).into()
458 ])))
459 );
460
461 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
463 assert_eq!(
464 rx.blocking_recv().unwrap(),
465 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
466 ("song", item_id()).into()
467 ])))
468 );
469 }
470
471 #[test]
472 fn test_mouse() {
473 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
474 let mut view = RadioView::new(&state_with_everything(), tx);
475
476 let (mut terminal, area) = setup_test_terminal(50, 6);
478 let props = RenderProps {
479 area,
480 is_focused: true,
481 };
482 let buffer = terminal
483 .draw(|frame| view.render(frame, props))
484 .unwrap()
485 .buffer
486 .clone();
487 let expected = Buffer::with_lines([
488 "┌Radio top 1─────────────────────────────────────┐",
489 "│q: add to queue | p: add to playlist────────────│",
490 "│Performing operations on entire radio───────────│",
491 "│☐ Test Song Test Artist │",
492 "│ │",
493 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
494 ]);
495 assert_buffer_eq(&buffer, &expected);
496
497 view.handle_mouse_event(
499 MouseEvent {
500 kind: MouseEventKind::ScrollDown,
501 column: 2,
502 row: 3,
503 modifiers: KeyModifiers::empty(),
504 },
505 area,
506 );
507
508 view.handle_mouse_event(
510 MouseEvent {
511 kind: MouseEventKind::Down(MouseButton::Left),
512 column: 2,
513 row: 3,
514 modifiers: KeyModifiers::empty(),
515 },
516 area,
517 );
518 assert_eq!(
519 rx.blocking_recv().unwrap(),
520 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
521 );
522 let buffer = terminal
523 .draw(|frame| view.render(frame, props))
524 .unwrap()
525 .buffer
526 .clone();
527 let expected = Buffer::with_lines([
528 "┌Radio top 1─────────────────────────────────────┐",
529 "│q: add to queue | p: add to playlist────────────│",
530 "│Performing operations on checked items──────────│",
531 "│☑ Test Song Test Artist │",
532 "│ │",
533 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check─────────┘",
534 ]);
535 assert_buffer_eq(&buffer, &expected);
536
537 view.handle_mouse_event(
539 MouseEvent {
540 kind: MouseEventKind::ScrollUp,
541 column: 2,
542 row: 3,
543 modifiers: KeyModifiers::empty(),
544 },
545 area,
546 );
547
548 let mouse = MouseEvent {
550 kind: MouseEventKind::Down(MouseButton::Left),
551 column: 2,
552 row: 4,
553 modifiers: KeyModifiers::empty(),
554 };
555 view.handle_mouse_event(mouse, area);
556 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
557 view.handle_mouse_event(mouse, area);
558 assert_eq!(
559 rx.try_recv(),
560 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
561 );
562 }
563}