mecomp_tui/ui/components/
queuebar.rs

1//! Implementation of the Queue Bar component, a scrollable list of the songs in the queue.
2
3use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4use mecomp_core::state::RepeatMode;
5use mecomp_storage::db::schemas::song::Song;
6use ratatui::{
7    layout::{Constraint, Direction, Layout, Position, Rect},
8    style::{Modifier, Style},
9    text::{Line, Text},
10    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
11};
12
13use tokio::sync::mpsc::UnboundedSender;
14
15use crate::{
16    state::{
17        action::{Action, AudioAction, ComponentAction, QueueAction},
18        component::ActiveComponent,
19    },
20    ui::colors::{
21        BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL,
22    },
23};
24
25use super::{AppState, Component, ComponentRender, RenderProps};
26
27pub struct QueueBar {
28    /// Action Sender
29    pub action_tx: UnboundedSender<Action>,
30    /// Mapped Props from state
31    pub(crate) props: Props,
32    /// list state
33    list_state: ListState,
34}
35
36pub struct Props {
37    pub(crate) queue: Box<[Song]>,
38    pub(crate) current_position: Option<usize>,
39    pub(crate) repeat_mode: RepeatMode,
40}
41
42impl From<&AppState> for Props {
43    fn from(value: &AppState) -> Self {
44        Self {
45            current_position: value.audio.queue_position,
46            repeat_mode: value.audio.repeat_mode,
47            queue: value.audio.queue.clone(),
48        }
49    }
50}
51
52impl Component for QueueBar {
53    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
54    where
55        Self: Sized,
56    {
57        let props = Props::from(state);
58        Self {
59            action_tx,
60            list_state: ListState::default().with_selected(props.current_position),
61            props,
62        }
63    }
64
65    fn move_with_state(self, state: &AppState) -> Self
66    where
67        Self: Sized,
68    {
69        let old_current_index = self.props.current_position;
70        let new_current_index = state.audio.queue_position;
71
72        let list_state = if old_current_index == new_current_index {
73            self.list_state
74        } else {
75            self.list_state.with_selected(new_current_index)
76        };
77
78        Self {
79            list_state,
80            props: Props::from(state),
81            ..self
82        }
83    }
84
85    fn name(&self) -> &'static str {
86        "Queue"
87    }
88
89    fn handle_key_event(&mut self, key: KeyEvent) {
90        match key.code {
91            // Move the selected index up
92            KeyCode::Up => {
93                if let Some(index) = self.list_state.selected() {
94                    let new_index = if index == 0 {
95                        self.props.queue.len() - 1
96                    } else {
97                        index - 1
98                    };
99                    self.list_state.select(Some(new_index));
100                }
101            }
102            // Move the selected index down
103            KeyCode::Down => {
104                if let Some(index) = self.list_state.selected() {
105                    let new_index = if index == self.props.queue.len() - 1 {
106                        0
107                    } else {
108                        index + 1
109                    };
110                    self.list_state.select(Some(new_index));
111                }
112            }
113            // Set the current song to the selected index
114            KeyCode::Enter => {
115                if let Some(index) = self.list_state.selected() {
116                    self.action_tx
117                        .send(Action::Audio(AudioAction::Queue(QueueAction::SetPosition(
118                            index,
119                        ))))
120                        .unwrap();
121                }
122            }
123            // Clear the queue
124            KeyCode::Char('c') => {
125                self.action_tx
126                    .send(Action::Audio(AudioAction::Queue(QueueAction::Clear)))
127                    .unwrap();
128            }
129            // Remove the selected index from the queue
130            KeyCode::Char('d') => {
131                if let Some(index) = self.list_state.selected() {
132                    self.action_tx
133                        .send(Action::Audio(AudioAction::Queue(QueueAction::Remove(
134                            index,
135                        ))))
136                        .unwrap();
137                }
138            }
139            // shuffle the queue
140            KeyCode::Char('s') => {
141                self.action_tx
142                    .send(Action::Audio(AudioAction::Queue(QueueAction::Shuffle)))
143                    .unwrap();
144            }
145            // set the repeat mode
146            KeyCode::Char('r') => match self.props.repeat_mode {
147                RepeatMode::None => {
148                    self.action_tx
149                        .send(Action::Audio(AudioAction::Queue(
150                            QueueAction::SetRepeatMode(RepeatMode::One),
151                        )))
152                        .unwrap();
153                }
154                RepeatMode::One => {
155                    self.action_tx
156                        .send(Action::Audio(AudioAction::Queue(
157                            QueueAction::SetRepeatMode(RepeatMode::All),
158                        )))
159                        .unwrap();
160                }
161                RepeatMode::All => {
162                    self.action_tx
163                        .send(Action::Audio(AudioAction::Queue(
164                            QueueAction::SetRepeatMode(RepeatMode::None),
165                        )))
166                        .unwrap();
167                }
168            },
169            _ => {}
170        }
171    }
172
173    // TODO: refactor QueueBar to use a CheckTree for better mouse handling
174    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
175        let MouseEvent {
176            kind, column, row, ..
177        } = mouse;
178        let mouse_position = Position::new(column, row);
179
180        match kind {
181            // TODO: refactor Sidebar to use a CheckTree for better mouse handling
182            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
183                // make this the active component
184                self.action_tx
185                    .send(Action::ActiveComponent(ComponentAction::Set(
186                        ActiveComponent::QueueBar,
187                    )))
188                    .unwrap();
189
190                // TODO: when we have better mouse handling, we can use this to select an item
191            }
192            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
193            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
194            _ => {}
195        }
196    }
197}
198
199impl ComponentRender<RenderProps> for QueueBar {
200    fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
201        let border_style = if props.is_focused {
202            Style::default().fg(BORDER_FOCUSED.into())
203        } else {
204            Style::default().fg(BORDER_UNFOCUSED.into())
205        };
206
207        let border = Block::bordered().title("Queue").border_style(border_style);
208        frame.render_widget(&border, props.area);
209        let area = border.inner(props.area);
210
211        // split up area
212        let [info_area, content_area, instructions_area] = *Layout::default()
213            .direction(Direction::Vertical)
214            .constraints(
215                [
216                    Constraint::Length(2),
217                    Constraint::Min(0),
218                    Constraint::Length(3),
219                ]
220                .as_ref(),
221            )
222            .split(area)
223        else {
224            panic!("Failed to split queue bar area");
225        };
226
227        // border the content area
228        let border = Block::default()
229            .borders(Borders::TOP | Borders::BOTTOM)
230            .title(format!("Songs ({})", self.props.queue.len()))
231            .border_style(border_style);
232        frame.render_widget(&border, content_area);
233        let content_area = border.inner(content_area);
234
235        // render queue info chunk
236        let queue_info = format!(
237            "repeat: {}",
238            match self.props.repeat_mode {
239                RepeatMode::None => "none",
240                RepeatMode::One => "one",
241                RepeatMode::All => "all",
242            }
243        );
244        frame.render_widget(
245            Paragraph::new(queue_info)
246                .style(Style::default().fg(TEXT_NORMAL.into()))
247                .alignment(ratatui::layout::Alignment::Center),
248            info_area,
249        );
250
251        // render instructions
252        frame.render_widget(
253            Paragraph::new(Text::from(vec![
254                Line::from("↑/↓: Move | c: Clear"),
255                Line::from("\u{23CE} : Select | d: Delete"),
256                Line::from("s: Shuffle | r: Repeat"),
257            ]))
258            .style(Style::default().fg(TEXT_NORMAL.into()))
259            .alignment(ratatui::layout::Alignment::Center),
260            instructions_area,
261        );
262
263        // return the new props
264        RenderProps {
265            area: content_area,
266            is_focused: props.is_focused,
267        }
268    }
269
270    fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
271        let items = self
272            .props
273            .queue
274            .iter()
275            .enumerate()
276            .map(|(index, song)| {
277                let style = if Some(index) == self.props.current_position {
278                    Style::default().fg(TEXT_HIGHLIGHT_ALT.into())
279                } else {
280                    Style::default().fg(TEXT_NORMAL.into())
281                };
282
283                ListItem::new(song.title.as_ref()).style(style)
284            })
285            .collect::<Vec<_>>();
286
287        frame.render_stateful_widget(
288            List::new(items)
289                .highlight_style(
290                    Style::default()
291                        .fg(TEXT_HIGHLIGHT.into())
292                        .add_modifier(Modifier::BOLD),
293                )
294                .scroll_padding(1)
295                .direction(ratatui::widgets::ListDirection::TopToBottom),
296            props.area,
297            &mut self.list_state.clone(),
298        );
299    }
300}