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