kimun_notes/components/dialogs/
save_search_dialog.rs1use 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 pub query: String,
14 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
82 .split(inner);
83
84 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 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 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 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"); assert_eq!(query, "#todo");
157 }
158}