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