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