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