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