Skip to main content

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::SongBrief;
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<[SongBrief]>,
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                    #[allow(clippy::cast_possible_truncation)]
115                    self.action_tx
116                        .send(Action::Audio(AudioAction::Queue(QueueAction::SetPosition(
117                            index as u64,
118                        ))))
119                        .unwrap();
120                }
121            }
122            // Clear the queue
123            KeyCode::Char('c') => {
124                self.action_tx
125                    .send(Action::Audio(AudioAction::Queue(QueueAction::Clear)))
126                    .unwrap();
127            }
128            // Remove the selected index from the queue
129            KeyCode::Char('d') => {
130                if let Some(index) = self.list_state.selected() {
131                    #[allow(clippy::cast_possible_truncation)]
132                    self.action_tx
133                        .send(Action::Audio(AudioAction::Queue(QueueAction::Remove(
134                            index as u64,
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') => {
147                let repeat_mode = self.props.repeat_mode.into();
148                self.action_tx
149                    .send(Action::Audio(AudioAction::Queue(
150                        QueueAction::SetRepeatMode(repeat_mode),
151                    )))
152                    .unwrap();
153            }
154            _ => {}
155        }
156    }
157
158    // TODO: refactor QueueBar to use a CheckTree for better mouse handling
159    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
160        let MouseEvent {
161            kind, column, row, ..
162        } = mouse;
163        let mouse_position = Position::new(column, row);
164
165        match kind {
166            // TODO: refactor Sidebar to use a CheckTree for better mouse handling
167            MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
168                // make this the active component
169                self.action_tx
170                    .send(Action::ActiveComponent(ComponentAction::Set(
171                        ActiveComponent::QueueBar,
172                    )))
173                    .unwrap();
174
175                // TODO: when we have better mouse handling, we can use this to select an item
176            }
177            MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
178            MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
179            _ => {}
180        }
181    }
182}
183
184fn split_area(area: Rect) -> [Rect; 3] {
185    let [info_area, content_area, instructions_area] = Layout::default()
186        .direction(Direction::Vertical)
187        .constraints([
188            Constraint::Length(1),
189            Constraint::Min(0),
190            Constraint::Length(3),
191        ])
192        .areas(area);
193    [info_area, content_area, instructions_area]
194}
195
196impl ComponentRender<RenderProps> for QueueBar {
197    fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
198        let border_style = Style::default().fg(border_color(props.is_focused).into());
199
200        let border = Block::bordered().title("Queue").border_style(border_style);
201        frame.render_widget(&border, props.area);
202        let area = border.inner(props.area);
203
204        // split up area
205        let [info_area, content_area, instructions_area] = split_area(area);
206
207        // border the content area
208        let border = Block::default()
209            .borders(Borders::TOP | Borders::BOTTOM)
210            .title(format!("Songs ({})", self.props.queue.len()))
211            .border_style(border_style);
212        frame.render_widget(&border, content_area);
213        let content_area = border.inner(content_area);
214
215        // render queue info chunk
216        let queue_info = format!(
217            "repeat: {}",
218            self.props.repeat_mode.to_string().to_lowercase()
219        );
220        frame.render_widget(
221            Paragraph::new(queue_info)
222                .style(Style::default().fg((*TEXT_NORMAL).into()))
223                .alignment(ratatui::layout::Alignment::Center),
224            info_area,
225        );
226
227        // render instructions
228        frame.render_widget(
229            Paragraph::new(Text::from(vec![
230                Line::from("↑/↓: Move | c: Clear"),
231                Line::from("\u{23CE} : Select | d: Delete"),
232                Line::from("s: Shuffle | r: Repeat"),
233            ]))
234            .style(Style::default().fg((*TEXT_NORMAL).into()))
235            .alignment(ratatui::layout::Alignment::Center),
236            instructions_area,
237        );
238
239        // return the new props
240        RenderProps {
241            area: content_area,
242            is_focused: props.is_focused,
243        }
244    }
245
246    fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
247        let items = self
248            .props
249            .queue
250            .iter()
251            .enumerate()
252            .map(|(index, song)| {
253                let style = if Some(index) == self.props.current_position {
254                    Style::default().fg((*TEXT_HIGHLIGHT_ALT).into())
255                } else {
256                    Style::default().fg((*TEXT_NORMAL).into())
257                };
258
259                ListItem::new(song.title.as_str()).style(style)
260            })
261            .collect::<Vec<_>>();
262
263        frame.render_stateful_widget(
264            List::new(items)
265                .highlight_style(
266                    Style::default()
267                        .fg((*TEXT_HIGHLIGHT).into())
268                        .add_modifier(Modifier::BOLD),
269                )
270                .scroll_padding(1)
271                .direction(ratatui::widgets::ListDirection::TopToBottom),
272            props.area,
273            &mut self.list_state.clone(),
274        );
275    }
276}