kimun_notes/components/dialogs/
create_note_dialog.rs1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent};
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::Style;
9use ratatui::widgets::Paragraph;
10
11use crate::components::Component;
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx, AppTxExt, OverlayData};
14use crate::components::hint_row::HintRow;
15use crate::components::panel::{ModalSpec, modal_chrome};
16use crate::settings::themes::Theme;
17
18pub struct CreateNoteDialog {
19 pub path: VaultPath,
25 pub vault: Arc<NoteVault>,
26 pub path_display: String,
28 pub error: Option<String>,
29 pub content: Option<String>,
33 hints: HintRow,
34}
35
36impl CreateNoteDialog {
37 pub fn new(path: VaultPath, vault: Arc<NoteVault>, content: Option<String>) -> Self {
38 let path_display = format!(" {}", path);
39 Self {
40 path,
41 vault,
42 path_display,
43 error: None,
44 content,
45 hints: super::confirm_hints("Create"),
46 }
47 }
48
49 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
52 if let Some(key) = self.hints.hit(m) {
53 self.handle_key(key, tx);
54 }
55 EventState::Consumed
56 }
57
58 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
61 match key.code {
62 KeyCode::Enter => {
63 let path = self.path.clone();
64 let vault = Arc::clone(&self.vault);
65 let content = self.content.clone();
66 let tx_clone = tx.clone();
67 tokio::spawn(async move {
68 let result = match &content {
73 Some(text) => vault
74 .create_note_avoiding_conflicts(&path, text)
75 .await
76 .map(|actual_path| (actual_path, true)),
77 None => vault
78 .load_or_create_note(&path, content)
79 .await
80 .map(|(_, created)| (path.clone(), created)),
81 };
82 match result {
83 Ok((actual_path, created)) => {
84 tx_clone.announce_and_open(actual_path, created)
85 }
86 Err(e) => {
87 tx_clone
88 .send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
89 .ok();
90 }
91 }
92 });
93 EventState::Consumed
94 }
95 KeyCode::Esc => {
96 tx.send(AppEvent::CloseOverlay).ok();
97 EventState::Consumed
98 }
99 _ => EventState::NotConsumed,
100 }
101 }
102}
103
104impl Component for CreateNoteDialog {
105 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
106 let height = if self.error.is_some() { 10 } else { 9 };
107 let popup_area = super::fixed_centered_rect(52, height, rect);
108
109 let gray = theme.gray.to_ratatui();
110 let fg = theme.fg.to_ratatui();
111 let bg = theme.bg_panel.to_ratatui();
112
113 let inner = modal_chrome(
114 f,
115 popup_area,
116 theme,
117 ModalSpec {
118 title: Some(" Create note? "),
119 border: Some(Style::default().fg(gray)),
120 ..Default::default()
121 },
122 );
123
124 let rows = Layout::default()
125 .direction(Direction::Vertical)
126 .constraints([
127 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
136 .split(inner);
137
138 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
139 super::render_separator(f, rows[2], gray, bg);
140 f.render_widget(
141 Paragraph::new(" Note doesn't exist.").style(Style::default().fg(gray).bg(bg)),
142 rows[3],
143 );
144 self.hints
145 .render(f, rows[5], Style::default().fg(gray).bg(bg), theme);
146 if let Some(msg) = &self.error {
147 super::render_error_row(f, rows[6], msg, theme);
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use kimun_core::VaultConfig;
156 use tokio::sync::mpsc;
157
158 #[tokio::test]
168 #[ignore = "requires a real vault directory with kimun.sqlite"]
169 async fn new_with_vault_does_not_panic() {
170 let tmp = std::env::temp_dir().join("kimun_test_vault");
171 std::fs::create_dir_all(&tmp).unwrap();
172
173 let vault = Arc::new(
174 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp)))
175 .await
176 .expect("vault creation failed"),
177 );
178 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
179 let dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
180 assert!(dialog.error.is_none());
181 }
182
183 #[test]
184 fn esc_sends_close_dialog() {
185 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
186
187 let rt = tokio::runtime::Runtime::new().unwrap();
188 rt.block_on(async {
189 let tmp = std::env::temp_dir().join("kimun_create_esc_test");
190 std::fs::create_dir_all(&tmp).unwrap();
191
192 let vault_result =
193 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
194 let Ok(vault) = vault_result else { return };
195 let vault = Arc::new(vault);
196
197 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
198 let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
199
200 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
201 let state = dialog.handle_key(key, &tx);
202
203 assert_eq!(state, EventState::Consumed);
204 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
205 assert!(matches!(event, AppEvent::CloseOverlay));
206 });
207 }
208
209 #[test]
210 fn enter_returns_consumed() {
211 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
212
213 let rt = tokio::runtime::Runtime::new().unwrap();
214 rt.block_on(async {
215 let tmp = std::env::temp_dir().join("kimun_create_enter_test");
216 std::fs::create_dir_all(&tmp).unwrap();
217
218 let vault_result =
219 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
220 let Ok(vault) = vault_result else { return };
221 let vault = Arc::new(vault);
222
223 let (tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
224 let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
229
230 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
231 let state = dialog.handle_key(key, &tx);
232
233 assert_eq!(state, EventState::Consumed);
234 });
235 }
236}