Skip to main content

kimun_notes/components/dialogs/
mod.rs

1pub use create_note_dialog::CreateNoteDialog;
2pub use delete_dialog::DeleteConfirmDialog;
3pub use file_ops_menu::FileOpsMenuDialog;
4pub use help_dialog::HelpDialog;
5pub use move_dialog::MoveDialog;
6pub use quick_note_modal::QuickNoteModal;
7pub use rename_dialog::RenameDialog;
8pub use save_search_dialog::SaveSearchDialog;
9pub use sort_dialog::SortDialog;
10pub use theme_picker::ThemePickerDialog;
11pub use workspace_switcher::WorkspaceSwitcherModal;
12
13use std::sync::Arc;
14
15use kimun_core::NoteVault;
16use ratatui::Frame;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::widgets::{Block, Borders, Paragraph, Widget};
20
21use crate::components::Component;
22use crate::components::event_state::EventState;
23use crate::components::events::{AppEvent, AppTx, InputEvent, SaveSource, SortTarget};
24use crate::components::file_list::{SortField, SortOrder};
25use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
26use crate::settings::themes::Theme;
27
28// ---------------------------------------------------------------------------
29// ValidationState — shared by RenameDialog and MoveDialog
30// ---------------------------------------------------------------------------
31
32/// Tracks the current state of an async name / destination availability check.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ValidationState {
35    /// No check has been triggered yet (initial state).
36    Idle,
37    /// A check is in progress.
38    Pending,
39    /// The name / destination is available (does not already exist).
40    Available,
41    /// The name / destination is already taken.
42    Taken,
43}
44
45pub mod create_note_dialog;
46pub mod delete_dialog;
47pub mod file_ops_menu;
48pub mod help_dialog;
49pub mod move_dialog;
50pub mod quick_note_modal;
51pub mod rename_dialog;
52pub mod save_search_dialog;
53pub mod sort_dialog;
54pub mod theme_picker;
55pub mod workspace_switcher;
56
57pub enum ActiveDialog {
58    Menu(FileOpsMenuDialog),
59    Delete(DeleteConfirmDialog),
60    Rename(RenameDialog),
61    Move(MoveDialog),
62    CreateNote(CreateNoteDialog),
63    Help(HelpDialog),
64    QuickNote(QuickNoteModal),
65    WorkspaceSwitcher(WorkspaceSwitcherModal),
66    SaveSearch(SaveSearchDialog),
67    Sort(SortDialog),
68    ThemePicker(ThemePickerDialog),
69}
70
71impl ActiveDialog {
72    pub fn set_error(&mut self, msg: String) {
73        match self {
74            ActiveDialog::Menu(_) => {} // menu has no error state
75            ActiveDialog::Delete(d) => d.error = Some(msg),
76            ActiveDialog::Rename(d) => d.error = Some(msg),
77            ActiveDialog::Move(d) => d.error = Some(msg),
78            ActiveDialog::CreateNote(d) => d.error = Some(msg),
79            ActiveDialog::Help(_) => {}
80            ActiveDialog::QuickNote(d) => d.error = Some(msg),
81            ActiveDialog::WorkspaceSwitcher(_) => {} // no error state
82            ActiveDialog::SaveSearch(_) => {}        // no error state
83            ActiveDialog::Sort(_) => {}              // no error state
84            ActiveDialog::ThemePicker(_) => {}       // no error state
85        }
86    }
87
88    // Constructors for the dialogs opened by EditorScreen via OverlayHost.
89    pub fn help(key_bindings: &crate::keys::KeyBindings) -> Self {
90        ActiveDialog::Help(HelpDialog::new(key_bindings))
91    }
92
93    /// The full leader-tree cheatsheet (leader `?`).
94    pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
95        ActiveDialog::Help(HelpDialog::cheatsheet(settings))
96    }
97
98    /// The search query syntax reference (F1 over the Find drawer view).
99    pub fn query_syntax() -> Self {
100        ActiveDialog::Help(HelpDialog::query_syntax())
101    }
102
103    /// The live theme picker (leader `v c`).
104    pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
105        ActiveDialog::ThemePicker(ThemePickerDialog::new(settings))
106    }
107
108    pub fn quick_note(vault: Arc<NoteVault>) -> Self {
109        ActiveDialog::QuickNote(QuickNoteModal::new(vault))
110    }
111
112    pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
113        ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
114    }
115
116    pub fn create_note(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
117        ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault))
118    }
119
120    /// Open the save-search dialog. `provenance` is the saved-search name the
121    /// query came from (the breadcrumb), pre-filled as the default name. The
122    /// existing names load in the background and arrive via
123    /// [`AppEvent::SavedSearchNamesLoaded`] to drive the dialog's hint.
124    pub fn save_search(
125        query: String,
126        provenance: Option<String>,
127        source: SaveSource,
128        vault: Arc<NoteVault>,
129        tx: &AppTx,
130    ) -> Self {
131        let tx = tx.clone();
132        tokio::spawn(async move {
133            if let Ok(searches) = vault.list_saved_searches().await {
134                let names = searches.into_iter().map(|s| s.name).collect();
135                tx.send(AppEvent::SavedSearchNamesLoaded(names)).ok();
136            }
137        });
138        ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
139    }
140
141    pub fn sort(
142        target: SortTarget,
143        field: SortField,
144        order: SortOrder,
145        group_directories: bool,
146    ) -> Self {
147        ActiveDialog::Sort(SortDialog::new(target, field, order, group_directories))
148    }
149
150    pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
151        ActiveDialog::Menu(FileOpsMenuDialog::new(path))
152    }
153
154    pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
155        ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
156    }
157
158    pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
159        ActiveDialog::Rename(RenameDialog::new(path, vault))
160    }
161
162    pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
163        ActiveDialog::Move(MoveDialog::new(path, vault, tx))
164    }
165}
166
167impl Overlay for ActiveDialog {
168    fn kind(&self) -> OverlayKind {
169        OverlayKind::Dialog
170    }
171
172    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
173        <Self as Component>::handle_input(self, event, tx)
174    }
175
176    fn handle_app_message(
177        &mut self,
178        msg: &AppEvent,
179        _vault: &Arc<NoteVault>,
180        tx: &AppTx,
181    ) -> OverlayMsg {
182        match msg {
183            AppEvent::RenameValidation { available } => {
184                if let ActiveDialog::Rename(d) = self {
185                    d.validation_state = if *available {
186                        ValidationState::Available
187                    } else {
188                        ValidationState::Taken
189                    };
190                    d.validation_task = None;
191                }
192                OverlayMsg::Consumed
193            }
194            AppEvent::MoveDirectoriesLoaded(paths) => {
195                if let ActiveDialog::Move(d) = self {
196                    d.all_dirs = paths.clone();
197                    d.filtered = None;
198                    d.load_task = None;
199                    if d.list_state.selected().is_none() && !d.results().is_empty() {
200                        d.list_state.select(Some(0));
201                    }
202                    d.spawn_validation(tx);
203                }
204                OverlayMsg::Consumed
205            }
206            AppEvent::MoveFilterResults(paths) => {
207                if let ActiveDialog::Move(d) = self {
208                    d.filter_task = None;
209                    d.filtered = Some(paths.clone());
210                    if !d.results().is_empty() {
211                        d.list_state.select(Some(0));
212                    } else {
213                        d.list_state.select(None);
214                    }
215                    d.spawn_validation(tx);
216                }
217                OverlayMsg::Consumed
218            }
219            AppEvent::MoveDestValidation { available } => {
220                if let ActiveDialog::Move(d) = self {
221                    d.dest_validation = if *available {
222                        ValidationState::Available
223                    } else {
224                        ValidationState::Taken
225                    };
226                    d.validation_task = None;
227                }
228                OverlayMsg::Consumed
229            }
230            AppEvent::SavedSearchNamesLoaded(names) => {
231                if let ActiveDialog::SaveSearch(d) = self {
232                    d.set_existing_names(names.clone());
233                }
234                OverlayMsg::Consumed
235            }
236            AppEvent::DialogError(text) => {
237                self.set_error(text.clone());
238                OverlayMsg::Consumed
239            }
240            _ => OverlayMsg::NotConsumed,
241        }
242    }
243
244    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
245        <Self as Component>::render(self, f, area, theme, true);
246    }
247}
248
249impl Component for ActiveDialog {
250    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
251        let InputEvent::Key(key) = event else {
252            return EventState::NotConsumed;
253        };
254        match self {
255            ActiveDialog::Menu(d) => d.handle_key(*key, tx),
256            ActiveDialog::Delete(d) => d.handle_key(*key, tx),
257            ActiveDialog::Rename(d) => d.handle_key(*key, tx),
258            ActiveDialog::Move(d) => d.handle_key(*key, tx),
259            ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
260            ActiveDialog::Help(d) => d.handle_key(*key, tx),
261            ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
262            ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
263            ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
264            ActiveDialog::Sort(d) => d.handle_input(event, tx),
265            ActiveDialog::ThemePicker(d) => d.handle_key(*key, tx),
266        }
267    }
268
269    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
270        match self {
271            ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
272            ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
273            ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
274            ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
275            ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
276            ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
277            ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
278            ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
279            ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
280            ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
281            ActiveDialog::ThemePicker(d) => d.render(f, rect, theme, focused),
282        }
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Shared render helpers
288// ---------------------------------------------------------------------------
289
290/// Renders a pre-computed path string (should already include leading spaces).
291pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
292    f.render_widget(
293        Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
294        rect,
295    );
296}
297
298/// Renders a single-line horizontal rule (TOP border only).
299pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
300    Block::default()
301        .borders(Borders::TOP)
302        .border_style(Style::default().fg(gray))
303        .style(Style::default().bg(bg))
304        .render(rect, f.buffer_mut());
305}
306
307/// Renders `  Error: {msg}` in the theme's error color on the panel background.
308pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
309    f.render_widget(
310        Paragraph::new(format!("  Error: {msg}")).style(
311            Style::default()
312                .fg(theme.red.to_ratatui())
313                .bg(theme.bg_panel.to_ratatui()),
314        ),
315        rect,
316    );
317}
318
319/// Renders `{enter_text}  [Esc] Cancel` split into two horizontal columns.
320/// The Enter part is dimmed when `enter_active` is `false`.
321pub(super) fn render_confirm_hint(
322    f: &mut Frame,
323    rect: Rect,
324    enter_text: &str,
325    enter_active: bool,
326    fg: Color,
327    gray: Color,
328    bg: Color,
329) {
330    let enter_style = if enter_active {
331        Style::default().fg(fg).bg(bg)
332    } else {
333        Style::default().fg(gray).bg(bg).add_modifier(Modifier::DIM)
334    };
335    let chunks = Layout::default()
336        .direction(Direction::Horizontal)
337        .constraints([
338            Constraint::Length(enter_text.len() as u16 + 1),
339            Constraint::Min(1),
340        ])
341        .split(rect);
342    f.render_widget(Paragraph::new(enter_text).style(enter_style), chunks[0]);
343    f.render_widget(
344        Paragraph::new("  [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
345        chunks[1],
346    );
347}
348
349// ---------------------------------------------------------------------------
350// Layout helper
351// ---------------------------------------------------------------------------
352
353/// Centre a dialog of exactly `width` × `height` characters.
354pub(super) use crate::components::fixed_centered_rect;
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::keys::KeyBindings;
360
361    #[test]
362    fn active_dialog_help_variant_compiles() {
363        let dialog = HelpDialog::new(&KeyBindings::empty());
364        let _active: ActiveDialog = ActiveDialog::Help(dialog);
365    }
366
367    #[test]
368    fn active_dialog_sort_variant_compiles() {
369        use crate::components::events::SortTarget;
370        use crate::components::file_list::{SortField, SortOrder};
371        let _active: ActiveDialog = ActiveDialog::sort(
372            SortTarget::Sidebar,
373            SortField::Name,
374            SortOrder::Ascending,
375            false,
376        );
377    }
378}