Skip to main content

kimun_notes/components/dialogs/
save_search_dialog.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::Style;
4use ratatui::widgets::{Block, Borders, Clear, Paragraph};
5
6use crate::components::event_state::EventState;
7use crate::components::events::{AppEvent, AppTx, InputEvent};
8use crate::components::single_line_input::{InputOutcome, SingleLineInput};
9use crate::settings::themes::Theme;
10
11pub struct SaveSearchDialog {
12    /// The query being saved (read-only context).
13    pub query: String,
14    /// User-supplied name for the saved search.
15    name: SingleLineInput,
16}
17
18impl SaveSearchDialog {
19    pub fn new(query: String) -> Self {
20        Self {
21            query,
22            name: SingleLineInput::new(),
23        }
24    }
25
26    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
27        let InputEvent::Key(key) = event else {
28            return EventState::NotConsumed;
29        };
30        match self.name.handle_key(key) {
31            InputOutcome::Submit => {
32                let name = if self.name.value().trim().is_empty() {
33                    self.query.clone()
34                } else {
35                    self.name.value().to_string()
36                };
37                tx.send(AppEvent::SaveSearchConfirmed {
38                    name,
39                    query: self.query.clone(),
40                })
41                .ok();
42                tx.send(AppEvent::CloseOverlay).ok();
43                EventState::Consumed
44            }
45            InputOutcome::Cancel => {
46                tx.send(AppEvent::CloseOverlay).ok();
47                EventState::Consumed
48            }
49            InputOutcome::Changed | InputOutcome::Consumed => EventState::Consumed,
50            InputOutcome::NotConsumed => EventState::NotConsumed,
51        }
52    }
53
54    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
55        let popup_area = super::fixed_centered_rect(62, 9, rect);
56
57        f.render_widget(Clear, popup_area);
58
59        let fg = theme.fg.to_ratatui();
60        let fg_muted = theme.fg_muted.to_ratatui();
61        let bg = theme.bg_panel.to_ratatui();
62
63        let outer_block = Block::default()
64            .title(" Save search ")
65            .borders(Borders::ALL)
66            .border_style(Style::default().fg(fg_muted))
67            .style(theme.panel_style());
68        let inner = outer_block.inner(popup_area);
69        f.render_widget(outer_block, popup_area);
70
71        let rows = Layout::default()
72            .direction(Direction::Vertical)
73            .constraints([
74                Constraint::Length(1), // 0: spacer
75                Constraint::Length(1), // 1: query (read-only context)
76                Constraint::Length(1), // 2: separator
77                Constraint::Length(1), // 3: name input
78                Constraint::Length(1), // 4: spacer
79                Constraint::Length(1), // 5: hint
80                Constraint::Min(0),    // 6: remainder
81            ])
82            .split(inner);
83
84        // Row 1: read-only query context in muted style.
85        f.render_widget(
86            Paragraph::new(format!("  Query: {}", self.query))
87                .style(Style::default().fg(fg_muted).bg(bg)),
88            rows[1],
89        );
90
91        super::render_separator(f, rows[2], fg_muted, bg);
92
93        // Row 3: name input with a "Name: " prefix.
94        let prefix = "  Name: ";
95        let prefix_len = prefix.len() as u16;
96        f.render_widget(
97            Paragraph::new(prefix).style(Style::default().fg(fg_muted).bg(bg)),
98            rows[3],
99        );
100        self.name
101            .render(f, rows[3], Style::default().fg(fg).bg(bg), prefix_len, true);
102
103        // Row 5: hints.
104        f.render_widget(
105            Paragraph::new("  [Enter] Save   [Esc] Cancel")
106                .style(Style::default().fg(fg_muted).bg(bg)),
107            rows[5],
108        );
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::components::events::{AppEvent, InputEvent};
116    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
117    use tokio::sync::mpsc::unbounded_channel;
118
119    fn key(code: KeyCode) -> InputEvent {
120        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
121    }
122
123    #[test]
124    fn submit_emits_save_event_with_typed_name() {
125        let mut d = SaveSearchDialog::new("<{note}".to_string());
126        let (tx, mut rx) = unbounded_channel();
127        for ch in ['l', 'i', 'n', 'k', 's'] {
128            d.handle_input(&key(KeyCode::Char(ch)), &tx);
129        }
130        d.handle_input(&key(KeyCode::Enter), &tx);
131        // Drain events; find the SaveSearchConfirmed.
132        let mut found = None;
133        while let Ok(e) = rx.try_recv() {
134            if let AppEvent::SaveSearchConfirmed { name, query } = e {
135                found = Some((name, query));
136            }
137        }
138        let (name, query) = found.expect("SaveSearchConfirmed emitted");
139        assert_eq!(name, "links");
140        assert_eq!(query, "<{note}");
141    }
142
143    #[test]
144    fn submit_empty_name_falls_back_to_query() {
145        let mut d = SaveSearchDialog::new("#todo".to_string());
146        let (tx, mut rx) = unbounded_channel();
147        d.handle_input(&key(KeyCode::Enter), &tx);
148        let mut found = None;
149        while let Ok(e) = rx.try_recv() {
150            if let AppEvent::SaveSearchConfirmed { name, query } = e {
151                found = Some((name, query));
152            }
153        }
154        let (name, query) = found.expect("emitted");
155        assert_eq!(name, "#todo"); // empty → query used as name
156        assert_eq!(query, "#todo");
157    }
158}