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::{TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL, border_color},
21};
22
23use super::{AppState, Component, ComponentRender, RenderProps};
24
25pub struct QueueBar {
26 pub action_tx: UnboundedSender<Action>,
28 pub(crate) props: Props,
30 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 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 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 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 KeyCode::Char('c') => {
123 self.action_tx
124 .send(Action::Audio(AudioAction::Queue(QueueAction::Clear)))
125 .unwrap();
126 }
127 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 KeyCode::Char('s') => {
139 self.action_tx
140 .send(Action::Audio(AudioAction::Queue(QueueAction::Shuffle)))
141 .unwrap();
142 }
143 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 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 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
181 self.action_tx
183 .send(Action::ActiveComponent(ComponentAction::Set(
184 ActiveComponent::QueueBar,
185 )))
186 .unwrap();
187
188 }
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 let [info_area, content_area, instructions_area] = split_area(area);
225
226 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 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 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 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}