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, 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
16// ---------------------------------------------------------------------------
17// FileOpsMenuDialog
18// ---------------------------------------------------------------------------
19
20/// Small menu dialog that lets the user pick a file operation.
21///
22/// ```text
23/// ┌─ File Operations ────────────────────────────┐
24/// │                                              │
25/// │  notes/projects/kimun.md                     │
26/// │                                              │
27/// │  [D] Delete   [R] Rename   [M] Move          │
28/// │                                              │
29/// │  [Esc] Cancel                                │
30/// └──────────────────────────────────────────────┘
31/// ```
32pub struct FileOpsMenuDialog {
33    /// The vault entry this menu was opened for.
34    pub path: VaultPath,
35    /// Pre-computed `"  {path}"` for zero-allocation rendering.
36    pub path_display: String,
37    /// The popup's outer rect from the last render; a press outside it
38    /// cancels the menu.
39    popup_rect: Rect,
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            popup_rect: Rect::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 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    /// Handle a raw key event. Returns `Consumed` for all recognised keys so
73    /// the event never leaks to the underlying panel.
74    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, // swallow unknown keys while menu is open
100        }
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Component trait
106// ---------------------------------------------------------------------------
107
108impl Component for FileOpsMenuDialog {
109    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
110        // Fixed size: 46 wide × 9 tall
111        // Border (2) + spacer + path + separator + actions + spacer + hint + spacer = 9 inner rows → 11 total
112        // But keep it tight: border(2) + 7 inner rows = 9
113        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        // ── Layout ────────────────────────────────────────────────────────────
128        // Row 0: spacer
129        // Row 1: path display
130        // Row 2: separator (horizontal line)
131        // Row 3: action row  [D] Delete  [R] Rename  [M] Move
132        // Row 4: spacer
133        // Row 5: hint row    [Esc] Cancel
134        // Row 6: spacer
135
136        let rows = Layout::default()
137            .direction(Direction::Vertical)
138            .constraints([
139                Constraint::Length(1), // 0: spacer
140                Constraint::Length(1), // 1: path
141                Constraint::Length(1), // 2: separator
142                Constraint::Length(1), // 3: actions
143                Constraint::Length(1), // 4: spacer
144                Constraint::Length(1), // 5: hint
145                Constraint::Min(0),    // 6: remainder
146            ])
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        // Row 1: path
154        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
155
156        // Row 2: separator
157        super::render_separator(f, rows[2], gray, bg);
158
159        // Row 3: action shortcuts — key letter highlighted, description muted
160        //
161        // Split into three equal columns.
162        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            // The drawn text is the target — what lights up is what clicks.
184            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), // left padding
192                    Constraint::Length(3), // "[D]"
193                    Constraint::Min(1),    // " Delete"
194                ])
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        // Row 5: hint
202        self.hints
203            .render(f, rows[5], Style::default().fg(gray).bg(bg), theme);
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Tests
209// ---------------------------------------------------------------------------
210
211#[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}