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
152impl_dialog!(CreateNoteDialog, error);
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use kimun_core::VaultConfig;
158 use tokio::sync::mpsc;
159
160 #[tokio::test]
170 #[ignore = "requires a real vault directory with kimun.sqlite"]
171 async fn new_with_vault_does_not_panic() {
172 let tmp = std::env::temp_dir().join("kimun_test_vault");
173 std::fs::create_dir_all(&tmp).unwrap();
174
175 let vault = Arc::new(
176 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp)))
177 .await
178 .expect("vault creation failed"),
179 );
180 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
181 let dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
182 assert!(dialog.error.is_none());
183 }
184
185 #[test]
186 fn esc_sends_close_dialog() {
187 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
188
189 let rt = tokio::runtime::Runtime::new().unwrap();
190 rt.block_on(async {
191 let tmp = std::env::temp_dir().join("kimun_create_esc_test");
192 std::fs::create_dir_all(&tmp).unwrap();
193
194 let vault_result =
195 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
196 let Ok(vault) = vault_result else { return };
197 let vault = Arc::new(vault);
198
199 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
200 let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
201
202 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
203 let state = dialog.handle_key(key, &tx);
204
205 assert_eq!(state, EventState::Consumed);
206 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
207 assert!(matches!(event, AppEvent::CloseOverlay));
208 });
209 }
210
211 #[test]
212 fn enter_returns_consumed() {
213 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
214
215 let rt = tokio::runtime::Runtime::new().unwrap();
216 rt.block_on(async {
217 let tmp = std::env::temp_dir().join("kimun_create_enter_test");
218 std::fs::create_dir_all(&tmp).unwrap();
219
220 let vault_result =
221 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
222 let Ok(vault) = vault_result else { return };
223 let vault = Arc::new(vault);
224
225 let (tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
226 let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
231
232 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
233 let state = dialog.handle_key(key, &tx);
234
235 assert_eq!(state, EventState::Consumed);
236 });
237 }
238}