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, 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    pub fn save_search(query: String) -> Self {
102        ActiveDialog::SaveSearch(SaveSearchDialog::new(query))
103    }
104
105    pub fn sort(
106        target: SortTarget,
107        field: SortField,
108        order: SortOrder,
109        group_directories: bool,
110    ) -> Self {
111        ActiveDialog::Sort(SortDialog::new(target, field, order, group_directories))
112    }
113
114    pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
115        ActiveDialog::Menu(FileOpsMenuDialog::new(path))
116    }
117
118    pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
119        ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
120    }
121
122    pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
123        ActiveDialog::Rename(RenameDialog::new(path, vault))
124    }
125
126    pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
127        ActiveDialog::Move(MoveDialog::new(path, vault, tx))
128    }
129}
130
131impl Overlay for ActiveDialog {
132    fn kind(&self) -> OverlayKind {
133        OverlayKind::Dialog
134    }
135
136    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
137        <Self as Component>::handle_input(self, event, tx)
138    }
139
140    fn handle_app_message(
141        &mut self,
142        msg: &AppEvent,
143        _vault: &Arc<NoteVault>,
144        tx: &AppTx,
145    ) -> OverlayMsg {
146        match msg {
147            AppEvent::RenameValidation { available } => {
148                if let ActiveDialog::Rename(d) = self {
149                    d.validation_state = if *available {
150                        ValidationState::Available
151                    } else {
152                        ValidationState::Taken
153                    };
154                    d.validation_task = None;
155                }
156                OverlayMsg::Consumed
157            }
158            AppEvent::MoveDirectoriesLoaded(paths) => {
159                if let ActiveDialog::Move(d) = self {
160                    d.all_dirs = paths.clone();
161                    d.filtered = None;
162                    d.load_task = None;
163                    if d.list_state.selected().is_none() && !d.results().is_empty() {
164                        d.list_state.select(Some(0));
165                    }
166                    d.spawn_validation(tx);
167                }
168                OverlayMsg::Consumed
169            }
170            AppEvent::MoveFilterResults(paths) => {
171                if let ActiveDialog::Move(d) = self {
172                    d.filter_task = None;
173                    d.filtered = Some(paths.clone());
174                    if !d.results().is_empty() {
175                        d.list_state.select(Some(0));
176                    } else {
177                        d.list_state.select(None);
178                    }
179                    d.spawn_validation(tx);
180                }
181                OverlayMsg::Consumed
182            }
183            AppEvent::MoveDestValidation { available } => {
184                if let ActiveDialog::Move(d) = self {
185                    d.dest_validation = if *available {
186                        ValidationState::Available
187                    } else {
188                        ValidationState::Taken
189                    };
190                    d.validation_task = None;
191                }
192                OverlayMsg::Consumed
193            }
194            AppEvent::DialogError(text) => {
195                self.set_error(text.clone());
196                OverlayMsg::Consumed
197            }
198            _ => OverlayMsg::NotConsumed,
199        }
200    }
201
202    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
203        <Self as Component>::render(self, f, area, theme, true);
204    }
205}
206
207impl Component for ActiveDialog {
208    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
209        let InputEvent::Key(key) = event else {
210            return EventState::NotConsumed;
211        };
212        match self {
213            ActiveDialog::Menu(d) => d.handle_key(*key, tx),
214            ActiveDialog::Delete(d) => d.handle_key(*key, tx),
215            ActiveDialog::Rename(d) => d.handle_key(*key, tx),
216            ActiveDialog::Move(d) => d.handle_key(*key, tx),
217            ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
218            ActiveDialog::Help(d) => d.handle_key(*key, tx),
219            ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
220            ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
221            ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
222            ActiveDialog::Sort(d) => d.handle_input(event, tx),
223        }
224    }
225
226    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
227        match self {
228            ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
229            ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
230            ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
231            ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
232            ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
233            ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
234            ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
235            ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
236            ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
237            ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
238        }
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Shared render helpers
244// ---------------------------------------------------------------------------
245
246/// Renders a pre-computed path string (should already include leading spaces).
247pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
248    f.render_widget(
249        Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
250        rect,
251    );
252}
253
254/// Renders a single-line horizontal rule (TOP border only).
255pub(super) fn render_separator(f: &mut Frame, rect: Rect, fg_muted: Color, bg: Color) {
256    Block::default()
257        .borders(Borders::TOP)
258        .border_style(Style::default().fg(fg_muted))
259        .style(Style::default().bg(bg))
260        .render(rect, f.buffer_mut());
261}
262
263/// Renders `  Error: {msg}` in red.
264pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, bg: Color) {
265    f.render_widget(
266        Paragraph::new(format!("  Error: {msg}")).style(Style::default().fg(Color::Red).bg(bg)),
267        rect,
268    );
269}
270
271/// Renders `{enter_text}  [Esc] Cancel` split into two horizontal columns.
272/// The Enter part is dimmed when `enter_active` is `false`.
273pub(super) fn render_confirm_hint(
274    f: &mut Frame,
275    rect: Rect,
276    enter_text: &str,
277    enter_active: bool,
278    fg: Color,
279    fg_muted: Color,
280    bg: Color,
281) {
282    let enter_style = if enter_active {
283        Style::default().fg(fg).bg(bg)
284    } else {
285        Style::default()
286            .fg(fg_muted)
287            .bg(bg)
288            .add_modifier(Modifier::DIM)
289    };
290    let chunks = Layout::default()
291        .direction(Direction::Horizontal)
292        .constraints([
293            Constraint::Length(enter_text.len() as u16 + 1),
294            Constraint::Min(1),
295        ])
296        .split(rect);
297    f.render_widget(Paragraph::new(enter_text).style(enter_style), chunks[0]);
298    f.render_widget(
299        Paragraph::new("  [Esc] Cancel").style(Style::default().fg(fg_muted).bg(bg)),
300        chunks[1],
301    );
302}
303
304// ---------------------------------------------------------------------------
305// Layout helper
306// ---------------------------------------------------------------------------
307
308/// Centre a dialog of exactly `width` × `height` characters.
309pub(super) fn fixed_centered_rect(
310    width: u16,
311    height: u16,
312    area: ratatui::layout::Rect,
313) -> ratatui::layout::Rect {
314    let w = width.min(area.width);
315    let h = height.min(area.height);
316    ratatui::layout::Rect {
317        x: area.x + (area.width.saturating_sub(w)) / 2,
318        y: area.y + (area.height.saturating_sub(h)) / 2,
319        width: w,
320        height: h,
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::keys::KeyBindings;
328
329    #[test]
330    fn active_dialog_help_variant_compiles() {
331        let dialog = HelpDialog::new(&KeyBindings::empty());
332        let _active: ActiveDialog = ActiveDialog::Help(dialog);
333    }
334
335    #[test]
336    fn active_dialog_sort_variant_compiles() {
337        use crate::components::events::SortTarget;
338        use crate::components::file_list::{SortField, SortOrder};
339        let _active: ActiveDialog = ActiveDialog::sort(
340            SortTarget::Sidebar,
341            SortField::Name,
342            SortOrder::Ascending,
343            false,
344        );
345    }
346}