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