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, 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        .areas(area);
188
189    [name_area, query_area]
190}
191
192impl ComponentRender<Rect> for DynamicPlaylistEditor {
193    fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
194        self.render_popup_border(frame, area)
195    }
196
197    fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
198        let [name_area, query_area] = split_area(area, 3, 3);
199
200        let (name_color, query_color) = match self.focus {
201            Focus::Name => ((*TEXT_HIGHLIGHT_ALT).into(), (*TEXT_NORMAL).into()),
202            Focus::Query => ((*TEXT_NORMAL).into(), (*TEXT_HIGHLIGHT_ALT).into()),
203        };
204        let (name_border, query_border) = match self.focus {
205            Focus::Name => ((*BORDER_FOCUSED).into(), (*BORDER_UNFOCUSED).into()),
206            Focus::Query => ((*BORDER_UNFOCUSED).into(), (*BORDER_FOCUSED).into()),
207        };
208
209        self.name_input.render(
210            frame,
211            RenderProps {
212                border: Block::bordered()
213                    .title("Enter Name:")
214                    .border_style(Style::default().fg(name_border)),
215                area: name_area,
216                text_color: name_color,
217                show_cursor: self.focus == Focus::Name,
218            },
219        );
220
221        if Query::from_str(self.query_input.text()).is_ok() {
222            self.query_input.render(
223                frame,
224                RenderProps {
225                    border: Block::bordered()
226                        .title("Enter Query:")
227                        .border_style(Style::default().fg(query_border)),
228                    area: query_area,
229                    text_color: query_color,
230                    show_cursor: self.focus == Focus::Query,
231                },
232            );
233        } else {
234            self.query_input.render(
235                frame,
236                RenderProps {
237                    border: Block::bordered()
238                        .title("Invalid Query:")
239                        .border_style(Style::default().fg(query_border)),
240                    area: query_area,
241                    text_color: (*TEXT_HIGHLIGHT).into(),
242                    show_cursor: self.focus == Focus::Query,
243                },
244            );
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use crate::test_utils::{assert_buffer_eq, item_id, setup_test_terminal};
252
253    use super::*;
254
255    use crossterm::event::KeyModifiers;
256    use pretty_assertions::assert_eq;
257    use ratatui::buffer::Buffer;
258    use rstest::{fixture, rstest};
259
260    #[fixture]
261    fn state() -> AppState {
262        AppState::default()
263    }
264
265    #[fixture]
266    fn playlist() -> DynamicPlaylist {
267        DynamicPlaylist {
268            id: RecordId::new("dynamic", item_id()),
269            name: "Test".into(),
270            query: Query::from_str("title = \"foo \"").unwrap().to_string(),
271        }
272    }
273
274    #[test]
275    fn test_focus_next() {
276        assert_eq!(Focus::Name.next(), Focus::Query);
277        assert_eq!(Focus::Query.next(), Focus::Name);
278    }
279
280    #[rstest]
281    // will give the popup at most 1/3 of the horizontal area,
282    #[case::large((100,100), Rect::new(33, 18, 34,8))]
283    // or at least 30 if it can
284    #[case::small((40,8), Rect::new(5, 0, 30, 8))]
285    #[case::small((30,8), Rect::new(0, 0, 30, 8))]
286    // or whatever is left if the terminal is too small
287    #[case::too_small((20,8), Rect::new(0, 0, 20, 8))]
288    fn test_area(
289        #[case] terminal_size: (u16, u16),
290        #[case] expected_area: Rect,
291        state: AppState,
292        playlist: DynamicPlaylist,
293    ) {
294        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
295        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
296        let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
297        let area = editor.area(area);
298        assert_eq!(area, expected_area);
299    }
300
301    #[rstest]
302    fn test_key_event_handling(state: AppState, playlist: DynamicPlaylist) {
303        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
304
305        let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist.clone());
306
307        // Test tab changes focus
308        assert_eq!(editor.focus, Focus::Name);
309        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Tab));
310        assert_eq!(editor.focus, Focus::Query);
311
312        // Test enter sends action
313        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
314        assert_eq!(
315            rx.blocking_recv(),
316            Some(Action::Library(LibraryAction::UpdateDynamicPlaylist(
317                playlist.id.into(),
318                DynamicPlaylistChangeSet {
319                    new_name: Some(playlist.name.clone()),
320                    new_query: Some(playlist.query.to_string())
321                }
322            )))
323        );
324        assert_eq!(rx.blocking_recv(), Some(Action::Popup(PopupAction::Close)));
325
326        // other keys go to the focused input box
327        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
328        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('b')));
329        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('c')));
330        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('d')));
331        assert_eq!(editor.query_input.text(), "title = \"foo \"abcd");
332        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Tab));
333        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('e')));
334        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('f')));
335        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('g')));
336        assert_eq!(editor.name_input.text(), "Testefg");
337        // the backspace and delete keys work as intended
338        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Left));
339        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Left));
340        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Delete));
341        assert_eq!(editor.name_input.text(), "Testeg");
342        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
343        assert_eq!(editor.name_input.text(), "Testg");
344
345        // Test invalid query does not send action
346        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
347        let action = rx.try_recv();
348        assert_eq!(action, Err(tokio::sync::mpsc::error::TryRecvError::Empty));
349    }
350
351    #[rstest]
352    fn test_mouse_event_handling(state: AppState, playlist: DynamicPlaylist) {
353        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
354
355        let mut editor = DynamicPlaylistEditor::new(&state, tx, playlist);
356        let area = Rect::new(0, 0, 50, 10);
357
358        // Test clicking name area changes focus
359        let mouse_event = MouseEvent {
360            kind: MouseEventKind::Down(MouseButton::Left),
361            column: 1,
362            row: 1,
363            modifiers: KeyModifiers::empty(),
364        };
365        editor.inner_handle_mouse_event(mouse_event, area);
366        assert_eq!(editor.focus, Focus::Name);
367    }
368
369    #[rstest]
370    fn test_render(state: AppState, playlist: DynamicPlaylist) {
371        let (mut terminal, _) = setup_test_terminal(30, 8);
372        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
373        let editor = DynamicPlaylistEditor::new(&state, tx, playlist);
374        let buffer = terminal
375            .draw(|frame| editor.render_popup(frame))
376            .unwrap()
377            .buffer
378            .clone();
379
380        let expected = Buffer::with_lines([
381            "┌Edit Dynamic Playlist───────┐",
382            "│┌Enter Name:───────────────┐│",
383            "││Test                      ││",
384            "│└──────────────────────────┘│",
385            "│┌Enter Query:──────────────┐│",
386            "││title = \"foo \"            ││",
387            "│└──────────────────────────┘│",
388            "└ ⏎ : Save | Esc : Cancel ───┘",
389        ]);
390
391        assert_buffer_eq(&buffer, &expected);
392    }
393}