Skip to main content

kimun_notes/components/dialogs/
file_ops_menu.rs

1use 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
17// ---------------------------------------------------------------------------
18// FileOpsMenuDialog
19// ---------------------------------------------------------------------------
20
21/// Small menu dialog that lets the user pick a file operation.
22///
23/// ```text
24/// ┌─ File Operations ────────────────────────────┐
25/// │                                              │
26/// │  notes/projects/kimun.md                     │
27/// │                                              │
28/// │  [D] Delete   [R] Rename   [M] Move          │
29/// │                                              │
30/// │  [Esc] Cancel                                │
31/// └──────────────────────────────────────────────┘
32/// ```
33pub struct FileOpsMenuDialog {
34    /// The vault entry this menu was opened for.
35    pub path: VaultPath,
36    /// Pre-computed `"  {path}"` for zero-allocation rendering.
37    pub path_display: String,
38    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
39    shell: ModalShell,
40    /// Each action's whole column from the last render, with its key.
41    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    /// Clicking an action runs its key; a press outside the menu cancels it.
58    /// Modal: every mouse event is consumed.
59    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    /// Handle a raw key event. Returns `Consumed` for all recognised keys so
70    /// the event never leaks to the underlying panel.
71    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, // swallow unknown keys while menu is open
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Component trait
103// ---------------------------------------------------------------------------
104
105impl Component for FileOpsMenuDialog {
106    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
107        // Fixed size: 46 wide × 9 tall
108        // Border (2) + spacer + path + separator + actions + spacer + hint + spacer = 9 inner rows → 11 total
109        // But keep it tight: border(2) + 7 inner rows = 9
110        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        // ── Layout ────────────────────────────────────────────────────────────
125        // Row 0: spacer
126        // Row 1: path display
127        // Row 2: separator (horizontal line)
128        // Row 3: action row  [D] Delete  [R] Rename  [M] Move
129        // Row 4: spacer
130        // Row 5: hint row    [Esc] Cancel
131        // Row 6: spacer
132
133        let rows = Layout::default()
134            .direction(Direction::Vertical)
135            .constraints([
136                Constraint::Length(1), // 0: spacer
137                Constraint::Length(1), // 1: path
138                Constraint::Length(1), // 2: separator
139                Constraint::Length(1), // 3: actions
140                Constraint::Length(1), // 4: spacer
141                Constraint::Length(1), // 5: hint
142                Constraint::Min(0),    // 6: remainder
143            ])
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        // Row 1: path
151        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
152
153        // Row 2: separator
154        super::render_separator(f, rows[2], gray, bg);
155
156        // Row 3: action shortcuts — key letter highlighted, description muted
157        //
158        // Split into three equal columns.
159        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            // The drawn text is the target — what lights up is what clicks.
181            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), // left padding
189                    Constraint::Length(3), // "[D]"
190                    Constraint::Min(1),    // " Delete"
191                ])
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        // Row 5: hint
199        self.hints
200            .render(f, rows[5], Style::default().fg(gray).bg(bg), theme);
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Tests
206// ---------------------------------------------------------------------------
207
208impl_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}