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;
4use ratatui::layout::{Constraint, Direction, Layout, Rect};
5use ratatui::style::{Modifier, Style};
6use ratatui::widgets::Paragraph;
7
8use crate::components::Component;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppEvent, AppTx};
11use crate::components::panel::{ModalSpec, modal_chrome};
12use crate::settings::themes::Theme;
13
14// ---------------------------------------------------------------------------
15// FileOpsMenuDialog
16// ---------------------------------------------------------------------------
17
18/// Small menu dialog that lets the user pick a file operation.
19///
20/// ```text
21/// ┌─ File Operations ────────────────────────────┐
22/// │                                              │
23/// │  notes/projects/kimun.md                     │
24/// │                                              │
25/// │  [D] Delete   [R] Rename   [M] Move          │
26/// │                                              │
27/// │  [Esc] Cancel                                │
28/// └──────────────────────────────────────────────┘
29/// ```
30pub struct FileOpsMenuDialog {
31    /// The vault entry this menu was opened for.
32    pub path: VaultPath,
33    /// Pre-computed `"  {path}"` for zero-allocation rendering.
34    pub path_display: String,
35}
36
37impl FileOpsMenuDialog {
38    pub fn new(path: VaultPath) -> Self {
39        let path_display = format!("  {}", path);
40        Self { path, path_display }
41    }
42
43    /// Handle a raw key event. Returns `Consumed` for all recognised keys so
44    /// the event never leaks to the underlying panel.
45    pub fn handle_key(
46        &mut self,
47        key: ratatui::crossterm::event::KeyEvent,
48        tx: &AppTx,
49    ) -> EventState {
50        match key.code {
51            KeyCode::Char('d') | KeyCode::Char('D') => {
52                tx.send(AppEvent::ShowDeleteDialog(self.path.clone())).ok();
53                EventState::Consumed
54            }
55            KeyCode::Char('r') | KeyCode::Char('R') => {
56                tx.send(AppEvent::ShowRenameDialog(self.path.clone())).ok();
57                EventState::Consumed
58            }
59            KeyCode::Char('m') | KeyCode::Char('M') => {
60                tx.send(AppEvent::ShowMoveDialog(self.path.clone())).ok();
61                EventState::Consumed
62            }
63            KeyCode::Esc => {
64                tx.send(AppEvent::CloseOverlay).ok();
65                EventState::Consumed
66            }
67            _ => EventState::Consumed, // swallow unknown keys while menu is open
68        }
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Component trait
74// ---------------------------------------------------------------------------
75
76impl Component for FileOpsMenuDialog {
77    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
78        // Fixed size: 46 wide × 9 tall
79        // Border (2) + spacer + path + separator + actions + spacer + hint + spacer = 9 inner rows → 11 total
80        // But keep it tight: border(2) + 7 inner rows = 9
81        let popup_area = super::fixed_centered_rect(46, 9, rect);
82
83        let inner = modal_chrome(
84            f,
85            popup_area,
86            theme,
87            ModalSpec {
88                title: Some(" File Operations "),
89                border: Some(Style::default().fg(theme.fg.to_ratatui())),
90                ..Default::default()
91            },
92        );
93
94        // ── Layout ────────────────────────────────────────────────────────────
95        // Row 0: spacer
96        // Row 1: path display
97        // Row 2: separator (horizontal line)
98        // Row 3: action row  [D] Delete  [R] Rename  [M] Move
99        // Row 4: spacer
100        // Row 5: hint row    [Esc] Cancel
101        // Row 6: spacer
102
103        let rows = Layout::default()
104            .direction(Direction::Vertical)
105            .constraints([
106                Constraint::Length(1), // 0: spacer
107                Constraint::Length(1), // 1: path
108                Constraint::Length(1), // 2: separator
109                Constraint::Length(1), // 3: actions
110                Constraint::Length(1), // 4: spacer
111                Constraint::Length(1), // 5: hint
112                Constraint::Min(0),    // 6: remainder
113            ])
114            .split(inner);
115
116        let bg = theme.bg_panel.to_ratatui();
117        let fg = theme.fg.to_ratatui();
118        let gray = theme.gray.to_ratatui();
119        let fg_accent = theme.selection_fg.to_ratatui();
120
121        // Row 1: path
122        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
123
124        // Row 2: separator
125        super::render_separator(f, rows[2], gray, bg);
126
127        // Row 3: action shortcuts — key letter highlighted, description muted
128        //
129        // Split into three equal columns.
130        let action_cols = Layout::default()
131            .direction(Direction::Horizontal)
132            .constraints([
133                Constraint::Ratio(1, 3),
134                Constraint::Ratio(1, 3),
135                Constraint::Ratio(1, 3),
136            ])
137            .split(rows[3]);
138
139        let key_style = Style::default()
140            .fg(fg_accent)
141            .bg(bg)
142            .add_modifier(Modifier::BOLD);
143        let label_style = Style::default().fg(fg).bg(bg);
144
145        for (col, (key, label)) in
146            action_cols
147                .iter()
148                .zip([("[D]", " Delete"), ("[R]", " Rename"), ("[M]", " Move  ")])
149        {
150            let chunks = Layout::default()
151                .direction(Direction::Horizontal)
152                .constraints([
153                    Constraint::Length(1), // left padding
154                    Constraint::Length(3), // "[D]"
155                    Constraint::Min(1),    // " Delete"
156                ])
157                .split(*col);
158
159            f.render_widget(Paragraph::new(key).style(key_style), chunks[1]);
160            f.render_widget(Paragraph::new(label).style(label_style), chunks[2]);
161        }
162
163        // Row 5: hint
164        f.render_widget(
165            Paragraph::new("  [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
166            rows[5],
167        );
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Tests
173// ---------------------------------------------------------------------------
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn esc_sends_close_dialog() {
181        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
182        use tokio::sync::mpsc;
183
184        let rt = tokio::runtime::Runtime::new().unwrap();
185        rt.block_on(async {
186            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
187            let mut dialog = FileOpsMenuDialog::new(VaultPath::new("notes/test.md"));
188
189            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
190            let state = dialog.handle_key(key, &tx);
191
192            assert_eq!(state, EventState::Consumed);
193            let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
194            assert!(matches!(event, AppEvent::CloseOverlay));
195        });
196    }
197
198    #[test]
199    fn d_sends_show_delete_dialog() {
200        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
201        use tokio::sync::mpsc;
202
203        let rt = tokio::runtime::Runtime::new().unwrap();
204        rt.block_on(async {
205            let path = VaultPath::new("notes/test.md");
206            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
207            let mut dialog = FileOpsMenuDialog::new(path.clone());
208
209            let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
210            let state = dialog.handle_key(key, &tx);
211
212            assert_eq!(state, EventState::Consumed);
213            let event = rx.try_recv().expect("expected AppEvent::ShowDeleteDialog");
214            assert!(matches!(event, AppEvent::ShowDeleteDialog(_)));
215        });
216    }
217
218    #[test]
219    fn r_sends_show_rename_dialog() {
220        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
221        use tokio::sync::mpsc;
222
223        let rt = tokio::runtime::Runtime::new().unwrap();
224        rt.block_on(async {
225            let path = VaultPath::new("notes/test.md");
226            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
227            let mut dialog = FileOpsMenuDialog::new(path.clone());
228
229            let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
230            let state = dialog.handle_key(key, &tx);
231
232            assert_eq!(state, EventState::Consumed);
233            let event = rx.try_recv().expect("expected AppEvent::ShowRenameDialog");
234            assert!(matches!(event, AppEvent::ShowRenameDialog(_)));
235        });
236    }
237
238    #[test]
239    fn m_sends_show_move_dialog() {
240        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
241        use tokio::sync::mpsc;
242
243        let rt = tokio::runtime::Runtime::new().unwrap();
244        rt.block_on(async {
245            let path = VaultPath::new("notes/test.md");
246            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
247            let mut dialog = FileOpsMenuDialog::new(path.clone());
248
249            let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
250            let state = dialog.handle_key(key, &tx);
251
252            assert_eq!(state, EventState::Consumed);
253            let event = rx.try_recv().expect("expected AppEvent::ShowMoveDialog");
254            assert!(matches!(event, AppEvent::ShowMoveDialog(_)));
255        });
256    }
257}