kimun_notes/components/dialogs/
file_ops_menu.rs1use kimun_core::nfs::VaultPath;
2use ratatui::Frame;
3use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent};
4use ratatui::layout::{Constraint, Direction, Layout, Rect};
5use ratatui::style::{Modifier, Style};
6use ratatui::widgets::Paragraph;
7
8use crate::components::Component;
9use crate::components::clickable::{is_press_outside, key_at};
10use crate::components::event_state::EventState;
11use crate::components::events::{AppEvent, AppTx, FileOp};
12use crate::components::hint_row::HintRow;
13use crate::components::panel::{ModalSpec, modal_chrome};
14use crate::settings::themes::Theme;
15
16pub struct FileOpsMenuDialog {
33 pub path: VaultPath,
35 pub path_display: String,
37 popup_rect: Rect,
40 action_rects: Vec<(Rect, KeyCode)>,
42 hints: HintRow,
43}
44
45impl FileOpsMenuDialog {
46 pub fn new(path: VaultPath) -> Self {
47 let path_display = format!(" {}", path);
48 Self {
49 path,
50 path_display,
51 popup_rect: Rect::default(),
52 action_rects: Vec::new(),
53 hints: HintRow::new(&[(KeyCode::Esc, "Esc", "Cancel")]),
54 }
55 }
56
57 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
60 if is_press_outside(m, self.popup_rect) {
61 return self.handle_key(KeyEvent::from(KeyCode::Esc), tx);
62 }
63 if let Some(key) = self.hints.hit(m) {
64 return self.handle_key(key, tx);
65 }
66 if let Some(key) = key_at(&self.action_rects, m) {
67 return self.handle_key(key, tx);
68 }
69 EventState::Consumed
70 }
71
72 pub fn handle_key(
75 &mut self,
76 key: ratatui::crossterm::event::KeyEvent,
77 tx: &AppTx,
78 ) -> EventState {
79 match key.code {
80 KeyCode::Char('d') | KeyCode::Char('D') => {
81 tx.send(AppEvent::FileOp(FileOp::ShowDelete(self.path.clone())))
82 .ok();
83 EventState::Consumed
84 }
85 KeyCode::Char('r') | KeyCode::Char('R') => {
86 tx.send(AppEvent::FileOp(FileOp::ShowRename(self.path.clone())))
87 .ok();
88 EventState::Consumed
89 }
90 KeyCode::Char('m') | KeyCode::Char('M') => {
91 tx.send(AppEvent::FileOp(FileOp::ShowMove(self.path.clone())))
92 .ok();
93 EventState::Consumed
94 }
95 KeyCode::Esc => {
96 tx.send(AppEvent::CloseOverlay).ok();
97 EventState::Consumed
98 }
99 _ => EventState::Consumed, }
101 }
102}
103
104impl Component for FileOpsMenuDialog {
109 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
110 let popup_area = super::fixed_centered_rect(46, 9, rect);
114 self.popup_rect = popup_area;
115
116 let inner = modal_chrome(
117 f,
118 popup_area,
119 theme,
120 ModalSpec {
121 title: Some(" File Operations "),
122 border: Some(Style::default().fg(theme.fg.to_ratatui())),
123 ..Default::default()
124 },
125 );
126
127 let rows = Layout::default()
137 .direction(Direction::Vertical)
138 .constraints([
139 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
147 .split(inner);
148
149 let bg = theme.bg_panel.to_ratatui();
150 let fg = theme.fg.to_ratatui();
151 let gray = theme.gray.to_ratatui();
152
153 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
155
156 super::render_separator(f, rows[2], gray, bg);
158
159 let action_cols = Layout::default()
163 .direction(Direction::Horizontal)
164 .constraints([
165 Constraint::Ratio(1, 3),
166 Constraint::Ratio(1, 3),
167 Constraint::Ratio(1, 3),
168 ])
169 .split(rows[3]);
170
171 let key_style = Style::default()
172 .bg(bg)
173 .patch(theme.action())
174 .add_modifier(Modifier::BOLD);
175 let label_style = Style::default().fg(fg).bg(bg);
176
177 self.action_rects.clear();
178 for (col, (key, label, code)) in action_cols.iter().zip([
179 ("[D]", " Delete", KeyCode::Char('d')),
180 ("[R]", " Rename", KeyCode::Char('r')),
181 ("[M]", " Move ", KeyCode::Char('m')),
182 ]) {
183 let text_w = (key.len() + label.trim_end().len()) as u16;
185 let r = Rect::new(col.x + 1, col.y, text_w.min(col.width.saturating_sub(1)), 1);
186 crate::components::clickable::register(r);
187 self.action_rects.push((r, code));
188 let chunks = Layout::default()
189 .direction(Direction::Horizontal)
190 .constraints([
191 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ])
195 .split(*col);
196
197 f.render_widget(Paragraph::new(key).style(key_style), chunks[1]);
198 f.render_widget(Paragraph::new(label).style(label_style), chunks[2]);
199 }
200
201 self.hints
203 .render(f, rows[5], Style::default().fg(gray).bg(bg), theme);
204 }
205}
206
207#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn esc_sends_close_dialog() {
217 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
218 use tokio::sync::mpsc;
219
220 let rt = tokio::runtime::Runtime::new().unwrap();
221 rt.block_on(async {
222 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
223 let mut dialog = FileOpsMenuDialog::new(VaultPath::new("notes/test.md"));
224
225 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
226 let state = dialog.handle_key(key, &tx);
227
228 assert_eq!(state, EventState::Consumed);
229 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
230 assert!(matches!(event, AppEvent::CloseOverlay));
231 });
232 }
233
234 #[test]
235 fn d_sends_show_delete_dialog() {
236 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
237 use tokio::sync::mpsc;
238
239 let rt = tokio::runtime::Runtime::new().unwrap();
240 rt.block_on(async {
241 let path = VaultPath::new("notes/test.md");
242 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
243 let mut dialog = FileOpsMenuDialog::new(path.clone());
244
245 let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
246 let state = dialog.handle_key(key, &tx);
247
248 assert_eq!(state, EventState::Consumed);
249 let event = rx
250 .try_recv()
251 .expect("expected AppEvent::FileOp(FileOp::ShowDelete)");
252 assert!(matches!(event, AppEvent::FileOp(FileOp::ShowDelete(_))));
253 });
254 }
255
256 #[test]
257 fn r_sends_show_rename_dialog() {
258 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
259 use tokio::sync::mpsc;
260
261 let rt = tokio::runtime::Runtime::new().unwrap();
262 rt.block_on(async {
263 let path = VaultPath::new("notes/test.md");
264 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
265 let mut dialog = FileOpsMenuDialog::new(path.clone());
266
267 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
268 let state = dialog.handle_key(key, &tx);
269
270 assert_eq!(state, EventState::Consumed);
271 let event = rx
272 .try_recv()
273 .expect("expected AppEvent::FileOp(FileOp::ShowRename)");
274 assert!(matches!(event, AppEvent::FileOp(FileOp::ShowRename(_))));
275 });
276 }
277
278 #[test]
279 fn m_sends_show_move_dialog() {
280 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
281 use tokio::sync::mpsc;
282
283 let rt = tokio::runtime::Runtime::new().unwrap();
284 rt.block_on(async {
285 let path = VaultPath::new("notes/test.md");
286 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
287 let mut dialog = FileOpsMenuDialog::new(path.clone());
288
289 let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
290 let state = dialog.handle_key(key, &tx);
291
292 assert_eq!(state, EventState::Consumed);
293 let event = rx
294 .try_recv()
295 .expect("expected AppEvent::FileOp(FileOp::ShowMove)");
296 assert!(matches!(event, AppEvent::FileOp(FileOp::ShowMove(_))));
297 });
298 }
299}