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 .areas(area);
188
189 [name_area, query_area]
190}
191
192impl ComponentRender<Rect> for DynamicPlaylistEditor {
193 fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
194 self.render_popup_border(frame, area)
195 }
196
197 fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
198 let [name_area, query_area] = split_area(area, 3, 3);
199
200 let (name_color, query_color) = match self.focus {
201 Focus::Name => ((*TEXT_HIGHLIGHT_ALT).into(), (*TEXT_NORMAL).into()),
202 Focus::Query => ((*TEXT_NORMAL).into(), (*TEXT_HIGHLIGHT_ALT).into()),
203 };
204 let (name_border, query_border) = match self.focus {
205 Focus::Name => ((*BORDER_FOCUSED).into(), (*BORDER_UNFOCUSED).into()),
206 Focus::Query => ((*BORDER_UNFOCUSED).into(), (*BORDER_FOCUSED).into()),
207 };
208
209 self.name_input.render(
210 frame,
211 RenderProps {
212 border: Block::bordered()
213 .title("Enter Name:")
214 .border_style(Style::default().fg(name_border)),
215 area: name_area,
216 text_color: name_color,
217 show_cursor: self.focus == Focus::Name,
218 },
219 );
220
221 if Query::from_str(self.query_input.text()).is_ok() {
222 self.query_input.render(
223 frame,
224 RenderProps {
225 border: Block::bordered()
226 .title("Enter Query:")
227 .border_style(Style::default().fg(query_border)),
228 area: query_area,
229 text_color: query_color,
230 show_cursor: self.focus == Focus::Query,
231 },
232 );
233 } else {
234 self.query_input.render(
235 frame,
236 RenderProps {
237 border: Block::bordered()
238 .title("Invalid Query:")
239 .border_style(Style::default().fg(query_border)),
240 area: query_area,
241 text_color: (*TEXT_HIGHLIGHT).into(),
242 show_cursor: self.focus == Focus::Query,
243 },
244 );
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use crate::test_utils::{assert_buffer_eq, item_id, setup_test_terminal};
252
253 use super::*;
254
255 use crossterm::event::KeyModifiers;
256 use pretty_assertions::assert_eq;
257 use ratatui::buffer::Buffer;
258 use rstest::{fixture, rstest};
259
260 #[fixture]
261 fn state() -> AppState {
262 AppState::default()
263 }
264
265 #[fixture]
266 fn playlist() -> DynamicPlaylist {
267 DynamicPlaylist {
268 id: RecordId::new("dynamic", item_id()),
269 name: "Test".into(),
270 query: Query::from_str("title = \"foo \"").unwrap().to_string(),
271 }
272 }
273
274 #[test]
275 fn test_focus_next() {
276 assert_eq!(Focus::Name.next(), Focus::Query);
277 assert_eq!(Focus::Query.next(), Focus::Name);
278 }
279
280 #[rstest]
281 #[case::large((100,100), Rect::new(33, 18, 34,8))]
283 #[case::small((40,8), Rect::new(5, 0, 30, 8))]
285 #[case::small((30,8), Rect::new(0, 0, 30, 8))]
286 #[case::too_small((20,8), Rect::new(0, 0, 20, 8))]
288 fn test_area(
289 #[case] terminal_size: (u16, u16),
290 #[case] expected_area: Rect,
291 state: AppState,
292 playlist: DynamicPlaylist,
293 ) {
294 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
295 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
296 let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
297 let area = editor.area(area);
298 assert_eq!(area, expected_area);
299 }
300
301 #[rstest]
302 fn test_key_event_handling(state: AppState, playlist: DynamicPlaylist) {
303 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
304
305 let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist.clone());
306
307 assert_eq!(editor.focus, Focus::Name);
309 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Tab));
310 assert_eq!(editor.focus, Focus::Query);
311
312 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
314 assert_eq!(
315 rx.blocking_recv(),
316 Some(Action::Library(LibraryAction::UpdateDynamicPlaylist(
317 playlist.id.into(),
318 DynamicPlaylistChangeSet {
319 new_name: Some(playlist.name.clone()),
320 new_query: Some(playlist.query.to_string())
321 }
322 )))
323 );
324 assert_eq!(rx.blocking_recv(), Some(Action::Popup(PopupAction::Close)));
325
326 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
328 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('b')));
329 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('c')));
330 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('d')));
331 assert_eq!(editor.query_input.text(), "title = \"foo \"abcd");
332 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Tab));
333 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('e')));
334 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('f')));
335 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('g')));
336 assert_eq!(editor.name_input.text(), "Testefg");
337 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Left));
339 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Left));
340 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Delete));
341 assert_eq!(editor.name_input.text(), "Testeg");
342 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
343 assert_eq!(editor.name_input.text(), "Testg");
344
345 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
347 let action = rx.try_recv();
348 assert_eq!(action, Err(tokio::sync::mpsc::error::TryRecvError::Empty));
349 }
350
351 #[rstest]
352 fn test_mouse_event_handling(state: AppState, playlist: DynamicPlaylist) {
353 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
354
355 let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist);
356 let area = Rect::new(0, 0, 50, 10);
357
358 let mouse_event = MouseEvent {
360 kind: MouseEventKind::Down(MouseButton::Left),
361 column: 1,
362 row: 1,
363 modifiers: KeyModifiers::empty(),
364 };
365 editor.inner_handle_mouse_event(mouse_event, area);
366 assert_eq!(editor.focus, Focus::Name);
367 }
368
369 #[rstest]
370 fn test_render(state: AppState, playlist: DynamicPlaylist) {
371 let (mut terminal, _) = setup_test_terminal(30, 8);
372 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
373 let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
374 let buffer = terminal
375 .draw(|frame| editor.render_popup(frame))
376 .unwrap()
377 .buffer
378 .clone();
379
380 let expected = Buffer::with_lines([
381 "┌Edit Dynamic Playlist───────┐",
382 "│┌Enter Name:───────────────┐│",
383 "││Test ││",
384 "│└──────────────────────────┘│",
385 "│┌Enter Query:──────────────┐│",
386 "││title = \"foo \" ││",
387 "│└──────────────────────────┘│",
388 "└ ⏎ : Save | Esc : Cancel ───┘",
389 ]);
390
391 assert_buffer_eq(&buffer, &expected);
392 }
393}