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