1use std::str::FromStr;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
6use mecomp_prost::{DynamicPlaylist, DynamicPlaylistChangeSet, RecordId};
7use mecomp_storage::db::schemas::dynamic::query::Query;
8use ratatui::{
9 Frame,
10 layout::{Constraint, Direction, Layout, Position, Rect},
11 style::Style,
12 text::Line,
13 widgets::Block,
14};
15use tokio::sync::mpsc::UnboundedSender;
16
17use crate::{
18 state::action::{Action, LibraryAction, PopupAction},
19 ui::{
20 AppState,
21 colors::{
22 BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL,
23 },
24 components::{Component, ComponentRender},
25 widgets::input_box::{InputBox, RenderProps},
26 },
27};
28
29use super::Popup;
30
31pub struct DynamicPlaylistEditor {
33 action_tx: UnboundedSender<Action>,
34 dynamic_playlist_id: RecordId,
35 name_input: InputBox,
36 query_input: InputBox,
37 focus: Focus,
38}
39
40impl DynamicPlaylistEditor {
41 #[must_use]
43 pub fn new(
44 state: &AppState,
45 action_tx: UnboundedSender<Action>,
46 dynamic_playlist: DynamicPlaylist,
47 ) -> Self {
48 let mut name_input = InputBox::new(state, action_tx.clone());
49 name_input.set_text(&dynamic_playlist.name);
50 let mut query_input = InputBox::new(state, action_tx.clone());
51 query_input.set_text(&dynamic_playlist.query.clone());
52
53 Self {
54 action_tx,
55 dynamic_playlist_id: dynamic_playlist.id,
56 name_input,
57 query_input,
58 focus: Focus::Name,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Default)]
64enum Focus {
65 #[default]
66 Name,
67 Query,
68}
69
70impl Focus {
71 const fn next(self) -> Self {
72 match self {
73 Self::Name => Self::Query,
74 Self::Query => Self::Name,
75 }
76 }
77}
78
79impl Popup for DynamicPlaylistEditor {
80 fn title(&self) -> Line<'static> {
81 Line::from("Edit Dynamic Playlist")
82 }
83
84 fn instructions(&self) -> Line<'static> {
85 Line::from(" \u{23CE} : Save | Esc : Cancel ")
86 }
87
88 fn area(&self, terminal_area: Rect) -> Rect {
89 let height = 8;
90 let width = u16::try_from(
91 self.name_input
92 .text()
93 .len()
94 .max(self.query_input.text().len())
95 .max(self.instructions().width())
96 .max(self.title().width())
97 + 5,
98 )
99 .unwrap_or(terminal_area.width)
100 .min(terminal_area.width);
101
102 let [_, vertical_area, _] = *Layout::default()
103 .direction(Direction::Vertical)
104 .constraints([
105 Constraint::Fill(1),
106 Constraint::Length(height),
107 Constraint::Fill(4),
108 ])
109 .split(terminal_area)
110 else {
111 panic!("Failed to split terminal area.");
112 };
113
114 let [_, horizontal_area, _] = *Layout::default()
115 .direction(Direction::Horizontal)
116 .constraints([
117 Constraint::Fill(1),
118 Constraint::Min(width),
119 Constraint::Fill(1),
120 ])
121 .split(vertical_area)
122 else {
123 panic!("Failed to split terminal area.");
124 };
125
126 horizontal_area
127 }
128
129 fn update_with_state(&mut self, _: &AppState) {}
130
131 fn inner_handle_key_event(&mut self, key: KeyEvent) {
132 let query = Query::from_str(self.query_input.text()).ok();
133
134 match (key.code, query) {
135 (KeyCode::Tab, _) => {
136 self.focus = self.focus.next();
137 }
138 (KeyCode::Enter, Some(query)) => {
139 let change_set = DynamicPlaylistChangeSet {
140 new_name: Some(self.name_input.text().into()),
141 new_query: Some(query.to_string()),
142 };
143
144 self.action_tx
145 .send(Action::Library(LibraryAction::UpdateDynamicPlaylist(
146 self.dynamic_playlist_id.ulid(),
147 change_set,
148 )))
149 .ok();
150 self.action_tx.send(Action::Popup(PopupAction::Close)).ok();
151 }
152 _ => match self.focus {
153 Focus::Name => self.name_input.handle_key_event(key),
154 Focus::Query => self.query_input.handle_key_event(key),
155 },
156 }
157 }
158
159 fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
160 let MouseEvent {
161 column, row, kind, ..
162 } = mouse;
163 let mouse_position = Position::new(column, row);
164 let [name_area, query_area] = split_area(area, 3, 3);
165
166 if name_area.contains(mouse_position) {
167 if kind == MouseEventKind::Down(MouseButton::Left) {
168 self.focus = Focus::Name;
169 }
170 self.name_input.handle_mouse_event(mouse, name_area);
171 } else if query_area.contains(mouse_position) {
172 if kind == MouseEventKind::Down(MouseButton::Left) {
173 self.focus = Focus::Query;
174 }
175 self.query_input.handle_mouse_event(mouse, query_area);
176 }
177 }
178}
179
180fn split_area(area: Rect, name_height: u16, query_height: u16) -> [Rect; 2] {
181 let [name_area, query_area] = *Layout::default()
182 .direction(Direction::Vertical)
183 .constraints([
184 Constraint::Length(name_height),
185 Constraint::Length(query_height),
186 ])
187 .split(area)
188 else {
189 panic!("Failed to split area.");
190 };
191
192 [name_area, query_area]
193}
194
195impl ComponentRender<Rect> for DynamicPlaylistEditor {
196 fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
197 self.render_popup_border(frame, area)
198 }
199
200 fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
201 let [name_area, query_area] = split_area(area, 3, 3);
202
203 let (name_color, query_color) = match self.focus {
204 Focus::Name => ((*TEXT_HIGHLIGHT_ALT).into(), (*TEXT_NORMAL).into()),
205 Focus::Query => ((*TEXT_NORMAL).into(), (*TEXT_HIGHLIGHT_ALT).into()),
206 };
207 let (name_border, query_border) = match self.focus {
208 Focus::Name => ((*BORDER_FOCUSED).into(), (*BORDER_UNFOCUSED).into()),
209 Focus::Query => ((*BORDER_UNFOCUSED).into(), (*BORDER_FOCUSED).into()),
210 };
211
212 self.name_input.render(
213 frame,
214 RenderProps {
215 border: Block::bordered()
216 .title("Enter Name:")
217 .border_style(Style::default().fg(name_border)),
218 area: name_area,
219 text_color: name_color,
220 show_cursor: self.focus == Focus::Name,
221 },
222 );
223
224 if Query::from_str(self.query_input.text()).is_ok() {
225 self.query_input.render(
226 frame,
227 RenderProps {
228 border: Block::bordered()
229 .title("Enter Query:")
230 .border_style(Style::default().fg(query_border)),
231 area: query_area,
232 text_color: query_color,
233 show_cursor: self.focus == Focus::Query,
234 },
235 );
236 } else {
237 self.query_input.render(
238 frame,
239 RenderProps {
240 border: Block::bordered()
241 .title("Invalid Query:")
242 .border_style(Style::default().fg(query_border)),
243 area: query_area,
244 text_color: (*TEXT_HIGHLIGHT).into(),
245 show_cursor: self.focus == Focus::Query,
246 },
247 );
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use crate::test_utils::{assert_buffer_eq, item_id, setup_test_terminal};
255
256 use super::*;
257
258 use crossterm::event::KeyModifiers;
259 use pretty_assertions::assert_eq;
260 use ratatui::buffer::Buffer;
261 use rstest::{fixture, rstest};
262
263 #[fixture]
264 fn state() -> AppState {
265 AppState::default()
266 }
267
268 #[fixture]
269 fn playlist() -> DynamicPlaylist {
270 DynamicPlaylist {
271 id: RecordId::new("dynamic", item_id()),
272 name: "Test".into(),
273 query: Query::from_str("title = \"foo \"").unwrap().to_string(),
274 }
275 }
276
277 #[test]
278 fn test_focus_next() {
279 assert_eq!(Focus::Name.next(), Focus::Query);
280 assert_eq!(Focus::Query.next(), Focus::Name);
281 }
282
283 #[rstest]
284 #[case::large((100,100), Rect::new(33, 18, 34,8))]
286 #[case::small((40,8), Rect::new(5, 0, 30, 8))]
288 #[case::small((30,8), Rect::new(0, 0, 30, 8))]
289 #[case::too_small((20,8), Rect::new(0, 0, 20, 8))]
291 fn test_area(
292 #[case] terminal_size: (u16, u16),
293 #[case] expected_area: Rect,
294 state: AppState,
295 playlist: DynamicPlaylist,
296 ) {
297 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
298 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
299 let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
300 let area = editor.area(area);
301 assert_eq!(area, expected_area);
302 }
303
304 #[rstest]
305 fn test_key_event_handling(state: AppState, playlist: DynamicPlaylist) {
306 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
307
308 let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist.clone());
309
310 assert_eq!(editor.focus, Focus::Name);
312 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Tab));
313 assert_eq!(editor.focus, Focus::Query);
314
315 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
317 assert_eq!(
318 rx.blocking_recv(),
319 Some(Action::Library(LibraryAction::UpdateDynamicPlaylist(
320 playlist.id.into(),
321 DynamicPlaylistChangeSet {
322 new_name: Some(playlist.name.clone()),
323 new_query: Some(playlist.query.to_string())
324 }
325 )))
326 );
327 assert_eq!(rx.blocking_recv(), Some(Action::Popup(PopupAction::Close)));
328
329 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
331 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('b')));
332 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('c')));
333 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('d')));
334 assert_eq!(editor.query_input.text(), "title = \"foo \"abcd");
335 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Tab));
336 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('e')));
337 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('f')));
338 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('g')));
339 assert_eq!(editor.name_input.text(), "Testefg");
340 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Left));
342 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Left));
343 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Delete));
344 assert_eq!(editor.name_input.text(), "Testeg");
345 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
346 assert_eq!(editor.name_input.text(), "Testg");
347
348 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
350 let action = rx.try_recv();
351 assert_eq!(action, Err(tokio::sync::mpsc::error::TryRecvError::Empty));
352 }
353
354 #[rstest]
355 fn test_mouse_event_handling(state: AppState, playlist: DynamicPlaylist) {
356 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
357
358 let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist);
359 let area = Rect::new(0, 0, 50, 10);
360
361 let mouse_event = MouseEvent {
363 kind: MouseEventKind::Down(MouseButton::Left),
364 column: 1,
365 row: 1,
366 modifiers: KeyModifiers::empty(),
367 };
368 editor.inner_handle_mouse_event(mouse_event, area);
369 assert_eq!(editor.focus, Focus::Name);
370 }
371
372 #[rstest]
373 fn test_render(state: AppState, playlist: DynamicPlaylist) {
374 let (mut terminal, _) = setup_test_terminal(30, 8);
375 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
376 let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
377 let buffer = terminal
378 .draw(|frame| editor.render_popup(frame))
379 .unwrap()
380 .buffer
381 .clone();
382
383 let expected = Buffer::with_lines([
384 "┌Edit Dynamic Playlist───────┐",
385 "│┌Enter Name:───────────────┐│",
386 "││Test ││",
387 "│└──────────────────────────┘│",
388 "│┌Enter Query:──────────────┐│",
389 "││title = \"foo \" ││",
390 "│└──────────────────────────┘│",
391 "└ ⏎ : Save | Esc : Cancel ───┘",
392 ]);
393
394 assert_buffer_eq(&buffer, &expected);
395 }
396}