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_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
33/// The popup used to edit Dynamic Playlists.
34pub 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    /// Create a new `DynamicPlaylistEditor`.
44    #[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 {
83        Line::from("Edit Dynamic Playlist")
84    }
85
86    fn instructions(&self) -> Line {
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    // will give the popup at most 1/3 of the horizontal area,
287    #[case::large((100,100), Rect::new(33, 18, 34,8))]
288    // or at least 30 if it can
289    #[case::small((40,8), Rect::new(5, 0, 30, 8))]
290    #[case::small((30,8), Rect::new(0, 0, 30, 8))]
291    // or whatever is left if the terminal is too small
292    #[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        // Test tab changes focus
313        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        // Test enter sends action
318        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        // other keys go to the focused input box
332        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
343        // Test invalid query does not send action
344        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
345        let action = rx.try_recv();
346        assert_eq!(action, Err(tokio::sync::mpsc::error::TryRecvError::Empty));
347    }
348
349    #[rstest]
350    fn test_mouse_event_handling(state: AppState, playlist: DynamicPlaylist) {
351        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
352
353        let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist);
354        let area = Rect::new(0, 0, 50, 10);
355
356        // Test clicking name area changes focus
357        let mouse_event = MouseEvent {
358            kind: MouseEventKind::Down(MouseButton::Left),
359            column: 1,
360            row: 1,
361            modifiers: KeyModifiers::empty(),
362        };
363        editor.inner_handle_mouse_event(mouse_event, area);
364        assert_eq!(editor.focus, Focus::Name);
365    }
366
367    #[rstest]
368    fn test_render(state: AppState, playlist: DynamicPlaylist) {
369        let (mut terminal, _) = setup_test_terminal(30, 8);
370        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
371        let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
372        let buffer = terminal
373            .draw(|frame| editor.render_popup(frame))
374            .unwrap()
375            .buffer
376            .clone();
377
378        let expected = Buffer::with_lines([
379            "┌Edit Dynamic Playlist───────┐",
380            "│┌Enter Name:───────────────┐│",
381            "││Test                      ││",
382            "│└──────────────────────────┘│",
383            "│┌Enter Query:──────────────┐│",
384            "││title = \"foo \"            ││",
385            "│└──────────────────────────┘│",
386            "└ ⏎ : Save | Esc : Cancel ───┘",
387        ]);
388
389        assert_buffer_eq(&buffer, &expected);
390    }
391}