Skip to main content

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, 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
31/// The popup used to edit Dynamic Playlists.
32pub 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    /// Create a new `DynamicPlaylistEditor`.
42    #[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        // update cursor position
234        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    // will give the popup at most 1/3 of the horizontal area,
275    #[case::large((100,100), Rect::new(33, 18, 34,8))]
276    // or at least 30 if it can
277    #[case::small((40,8), Rect::new(5, 0, 30, 8))]
278    #[case::small((30,8), Rect::new(0, 0, 30, 8))]
279    // or whatever is left if the terminal is too small
280    #[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        // Test tab changes focus
300        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        // Test enter sends action
305        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        // other keys go to the focused input box
319        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        // the backspace and delete keys work as intended
330        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        // Test invalid query does not send action
338        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        // Test clicking name area changes focus
351        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}