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