mecomp_tui/ui/widgets/popups/
dynamic.rs

1//! Module for the popup used to edit Dynamic Playlists.
2
3use 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
31/// The popup used to edit Dynamic Playlists.
32pub 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    /// Create a new `DynamicPlaylistEditor`.
42    #[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    // will give the popup at most 1/3 of the horizontal area,
285    #[case::large((100,100), Rect::new(33, 18, 34,8))]
286    // or at least 30 if it can
287    #[case::small((40,8), Rect::new(5, 0, 30, 8))]
288    #[case::small((30,8), Rect::new(0, 0, 30, 8))]
289    // or whatever is left if the terminal is too small
290    #[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        // Test tab changes focus
311        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        // Test enter sends action
316        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        // other keys go to the focused input box
330        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        // the backspace and delete keys work as intended
341        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        // Test invalid query does not send action
349        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        // Test clicking name area changes focus
362        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}