Skip to main content

kimun_notes/components/
dialog_manager.rs

1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::layout::Rect;
7
8use crate::components::Component;
9use crate::components::dialogs::{
10    ActiveDialog, CreateNoteDialog, DeleteConfirmDialog, FileOpsMenuDialog, HelpDialog, MoveDialog,
11    QuickNoteModal, RenameDialog, ValidationState, WorkspaceSwitcherModal,
12};
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, InputEvent};
15use crate::keys::KeyBindings;
16use crate::settings::AppSettings;
17use crate::settings::themes::Theme;
18
19/// Manages dialog lifecycle: open/close, focus save/restore, input routing,
20/// rendering, and dialog-related `AppEvent` handling.
21///
22/// The `focus_token` is an opaque `u8` that the owning screen maps to/from its
23/// own focus enum. This keeps `DialogManager` screen-agnostic.
24pub struct DialogManager {
25    active: Option<ActiveDialog>,
26    /// The focus token saved when the *first* dialog in a chain opens.
27    /// Subsequent chained dialogs (e.g. Menu → Delete) don't overwrite it.
28    saved_focus: Option<u8>,
29}
30
31impl Default for DialogManager {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl DialogManager {
38    pub fn new() -> Self {
39        Self {
40            active: None,
41            saved_focus: None,
42        }
43    }
44
45    pub fn is_open(&self) -> bool {
46        self.active.is_some()
47    }
48
49    /// Open a dialog, saving the current focus token if this is the first dialog
50    /// in a chain (i.e. no dialog is currently open).
51    pub fn open(&mut self, dialog: ActiveDialog, current_focus: u8) {
52        if self.saved_focus.is_none() {
53            self.saved_focus = Some(current_focus);
54        }
55        self.active = Some(dialog);
56    }
57
58    /// Close the active dialog and return the saved focus token to restore.
59    pub fn close(&mut self) -> Option<u8> {
60        self.active = None;
61        self.saved_focus.take()
62    }
63
64    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
65        if let Some(dialog) = &mut self.active {
66            dialog.handle_input(event, tx)
67        } else {
68            EventState::NotConsumed
69        }
70    }
71
72    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
73        if let Some(dialog) = &mut self.active {
74            dialog.render(f, area, theme, true);
75        }
76    }
77
78    /// Try to handle a dialog-related `AppEvent`.
79    /// Returns `true` if the event was consumed, `false` otherwise.
80    pub fn handle_app_message(
81        &mut self,
82        msg: &AppEvent,
83        vault: &Arc<NoteVault>,
84        tx: &AppTx,
85        current_focus: u8,
86    ) -> bool {
87        match msg {
88            AppEvent::ShowFileOpsMenu(path) => {
89                self.open(
90                    ActiveDialog::Menu(FileOpsMenuDialog::new(path.clone())),
91                    current_focus,
92                );
93                true
94            }
95            AppEvent::ShowDeleteDialog(path) => {
96                self.open(
97                    ActiveDialog::Delete(DeleteConfirmDialog::new(path.clone(), vault.clone())),
98                    current_focus,
99                );
100                true
101            }
102            AppEvent::ShowRenameDialog(path) => {
103                self.open(
104                    ActiveDialog::Rename(RenameDialog::new(path.clone(), vault.clone())),
105                    current_focus,
106                );
107                true
108            }
109            AppEvent::ShowMoveDialog(path) => {
110                self.open(
111                    ActiveDialog::Move(MoveDialog::new(path.clone(), vault.clone(), tx)),
112                    current_focus,
113                );
114                true
115            }
116            AppEvent::RenameValidation { available } => {
117                if let Some(ActiveDialog::Rename(d)) = &mut self.active {
118                    d.validation_state = if *available {
119                        ValidationState::Available
120                    } else {
121                        ValidationState::Taken
122                    };
123                    d.validation_task = None;
124                }
125                true
126            }
127            AppEvent::MoveDirectoriesLoaded(paths) => {
128                if let Some(ActiveDialog::Move(d)) = &mut self.active {
129                    d.all_dirs = paths.clone();
130                    d.filtered = None;
131                    d.load_task = None;
132                    if d.list_state.selected().is_none() && !d.results().is_empty() {
133                        d.list_state.select(Some(0));
134                    }
135                    d.spawn_validation(tx);
136                }
137                true
138            }
139            AppEvent::MoveFilterResults(paths) => {
140                if let Some(ActiveDialog::Move(d)) = &mut self.active {
141                    d.filter_task = None;
142                    d.filtered = Some(paths.clone());
143                    if !d.results().is_empty() {
144                        d.list_state.select(Some(0));
145                    } else {
146                        d.list_state.select(None);
147                    }
148                    d.spawn_validation(tx);
149                }
150                true
151            }
152            AppEvent::MoveDestValidation { available } => {
153                if let Some(ActiveDialog::Move(d)) = &mut self.active {
154                    d.dest_validation = if *available {
155                        ValidationState::Available
156                    } else {
157                        ValidationState::Taken
158                    };
159                    d.validation_task = None;
160                }
161                true
162            }
163            AppEvent::DialogError(msg) => {
164                if let Some(dialog) = &mut self.active {
165                    dialog.set_error(msg.clone());
166                }
167                true
168            }
169            AppEvent::CloseDialog => {
170                self.close();
171                true
172            }
173            _ => false,
174        }
175    }
176
177    /// Convenience: open the help dialog.
178    pub fn open_help(&mut self, key_bindings: &KeyBindings, current_focus: u8) {
179        self.open(
180            ActiveDialog::Help(HelpDialog::new(key_bindings)),
181            current_focus,
182        );
183    }
184
185    pub fn open_quick_note(&mut self, vault: Arc<NoteVault>, current_focus: u8) {
186        self.open(
187            ActiveDialog::QuickNote(QuickNoteModal::new(vault)),
188            current_focus,
189        );
190    }
191
192    pub fn open_workspace_switcher(&mut self, settings: &AppSettings, current_focus: u8) {
193        self.open(
194            ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings)),
195            current_focus,
196        );
197    }
198
199    /// Convenience: open the create-note dialog.
200    pub fn open_create_note(&mut self, path: VaultPath, vault: Arc<NoteVault>, current_focus: u8) {
201        self.open(
202            ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault)),
203            current_focus,
204        );
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use kimun_core::VaultConfig;
212
213    #[test]
214    fn new_is_not_open() {
215        let dm = DialogManager::new();
216        assert!(!dm.is_open());
217    }
218
219    #[test]
220    fn open_then_close_returns_focus() {
221        let mut dm = DialogManager::new();
222        let kb = KeyBindings::empty();
223        dm.open_help(&kb, 42);
224        assert!(dm.is_open());
225        let restored = dm.close();
226        assert_eq!(restored, Some(42));
227        assert!(!dm.is_open());
228    }
229
230    #[tokio::test]
231    async fn chained_dialogs_preserve_original_focus() {
232        let mut dm = DialogManager::new();
233        let path = VaultPath::new("test");
234        dm.open(ActiveDialog::Menu(FileOpsMenuDialog::new(path.clone())), 1);
235        // Chained dialog (e.g. from menu → delete) should not overwrite saved focus
236        let dir = std::env::temp_dir().join("kimun_dm_test");
237        std::fs::create_dir_all(&dir).ok();
238        let vault = Arc::new(NoteVault::new(VaultConfig::new(&dir)).await.unwrap());
239        dm.open(
240            ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault)),
241            99, // this focus should be ignored
242        );
243        let restored = dm.close();
244        assert_eq!(restored, Some(1)); // original focus preserved
245    }
246
247    #[test]
248    fn close_when_empty_returns_none() {
249        let mut dm = DialogManager::new();
250        assert_eq!(dm.close(), None);
251    }
252}