mecomp_tui/ui/components/
queuebar.rs1use 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 pub action_tx: UnboundedSender<Action>,
30 pub(crate) props: Props,
32 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 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 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 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 KeyCode::Char('c') => {
125 self.action_tx
126 .send(Action::Audio(AudioAction::Queue(QueueAction::Clear)))
127 .unwrap();
128 }
129 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 KeyCode::Char('s') => {
141 self.action_tx
142 .send(Action::Audio(AudioAction::Queue(QueueAction::Shuffle)))
143 .unwrap();
144 }
145 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 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 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
183 self.action_tx
185 .send(Action::ActiveComponent(ComponentAction::Set(
186 ActiveComponent::QueueBar,
187 )))
188 .unwrap();
189
190 }
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 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 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 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 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 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}