mecomp_tui/ui/components/
queuebar.rs1use 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 pub action_tx: UnboundedSender<Action>,
28 pub(crate) props: Props,
30 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 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 #[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 KeyCode::Char('c') => {
124 self.action_tx
125 .send(Action::Audio(AudioAction::Queue(QueueAction::Clear)))
126 .unwrap();
127 }
128 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 KeyCode::Char('s') => {
141 self.action_tx
142 .send(Action::Audio(AudioAction::Queue(QueueAction::Shuffle)))
143 .unwrap();
144 }
145 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 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 MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
168 self.action_tx
170 .send(Action::ActiveComponent(ComponentAction::Set(
171 ActiveComponent::QueueBar,
172 )))
173 .unwrap();
174
175 }
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 let [info_area, content_area, instructions_area] = split_area(area);
206
207 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 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 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 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}