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 info_dialog::InfoDialog;
6pub use modal_shell::ModalShell;
7pub use move_dialog::MoveDialog;
8pub use pinned_notes_dialog::PinnedNotesDialog;
9pub use properties_dialog::PropertiesDialog;
10pub use quick_note_modal::QuickNoteModal;
11pub use rename_dialog::RenameDialog;
12pub use save_search_dialog::SaveSearchDialog;
13pub use sort_dialog::SortDialog;
14pub use theme_picker::ThemePickerDialog;
15pub use update_dialog::UpdateAvailableDialog;
16pub use workspace_switcher::WorkspaceSwitcherModal;
17
18use std::sync::Arc;
19
20use kimun_core::NoteVault;
21use ratatui::Frame;
22use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent};
23use ratatui::layout::Rect;
24use ratatui::style::{Color, Style};
25use ratatui::widgets::{Block, Borders, Paragraph, Widget};
26
27use crate::components::Component;
28use crate::components::event_state::EventState;
29use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, SaveSource, SortTarget};
30use crate::components::hint_row::HintRow;
31use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
32use crate::components::sortable::SortState;
33use crate::settings::themes::Theme;
34
35/// Load every property key in the background; arrives as
36/// [`OverlayData::PropertyKeysLoaded`]. A failed read leaves pickers empty.
37pub(crate) fn spawn_property_keys(vault: Arc<NoteVault>, tx: &AppTx) {
38    let tx = tx.clone();
39    tokio::spawn(async move {
40        if let Ok(keys) = vault.property_keys().await {
41            tx.send(AppEvent::OverlayData(OverlayData::PropertyKeysLoaded(keys)))
42                .ok();
43        }
44    });
45}
46
47// ---------------------------------------------------------------------------
48// ValidationState — shared by RenameDialog and MoveDialog
49// ---------------------------------------------------------------------------
50
51/// Tracks the current state of an async name / destination availability check.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ValidationState {
54    /// No check has been triggered yet (initial state).
55    Idle,
56    /// A check is in progress.
57    Pending,
58    /// The name / destination is available (does not already exist).
59    Available,
60    /// The name / destination is already taken.
61    Taken,
62}
63
64/// `Dialog` for a dialog whose `handle_key` / `handle_mouse` / `render(.., focused)`
65/// are the plain forwarders; `, error` also routes `set_error` to its `error` field.
66/// Defined ahead of the `mod`s so each dialog file can invoke it.
67macro_rules! impl_dialog {
68    ($ty:ty) => {
69        impl_dialog!(@impl $ty, );
70    };
71    ($ty:ty, error) => {
72        impl_dialog!(@impl $ty, fn set_error(&mut self, msg: String) {
73            self.error = Some(msg);
74        });
75    };
76    (@impl $ty:ty, $($extra:tt)*) => {
77        impl $crate::components::dialogs::Dialog for $ty {
78            fn key(
79                &mut self,
80                key: ratatui::crossterm::event::KeyEvent,
81                tx: &$crate::components::events::AppTx,
82            ) -> $crate::components::event_state::EventState {
83                self.handle_key(key, tx)
84            }
85
86            fn mouse(
87                &mut self,
88                m: &ratatui::crossterm::event::MouseEvent,
89                tx: &$crate::components::events::AppTx,
90            ) -> $crate::components::event_state::EventState {
91                self.handle_mouse(m, tx)
92            }
93
94            fn draw(
95                &mut self,
96                f: &mut ratatui::Frame,
97                rect: ratatui::layout::Rect,
98                theme: &$crate::settings::themes::Theme,
99            ) {
100                #[allow(unused_imports)]
101                use $crate::components::Component as _;
102                self.render(f, rect, theme, true)
103            }
104
105            $($extra)*
106        }
107    };
108}
109
110pub mod create_note_dialog;
111pub mod delete_dialog;
112pub mod file_ops_menu;
113pub mod help_dialog;
114pub mod info_dialog;
115pub mod modal_shell;
116pub mod move_dialog;
117pub mod pinned_notes_dialog;
118pub mod properties_dialog;
119pub mod quick_note_modal;
120pub mod rename_dialog;
121pub mod save_search_dialog;
122pub mod sort_dialog;
123pub mod theme_picker;
124pub mod update_dialog;
125pub mod workspace_switcher;
126
127pub enum ActiveDialog {
128    Menu(FileOpsMenuDialog),
129    Delete(DeleteConfirmDialog),
130    Rename(RenameDialog),
131    Move(MoveDialog),
132    CreateNote(CreateNoteDialog),
133    Help(HelpDialog),
134    QuickNote(QuickNoteModal),
135    WorkspaceSwitcher(WorkspaceSwitcherModal),
136    SaveSearch(SaveSearchDialog),
137    Sort(SortDialog),
138    PinnedNotes(PinnedNotesDialog),
139    ThemePicker(Box<ThemePickerDialog>),
140    UpdateAvailable(UpdateAvailableDialog),
141    Info(InfoDialog),
142    Properties(PropertiesDialog),
143}
144
145impl ActiveDialog {
146    /// The one place that maps a variant to its dialog.
147    fn inner(&mut self) -> &mut dyn Dialog {
148        match self {
149            ActiveDialog::Menu(d) => d,
150            ActiveDialog::Delete(d) => d,
151            ActiveDialog::Rename(d) => d,
152            ActiveDialog::Move(d) => d,
153            ActiveDialog::CreateNote(d) => d,
154            ActiveDialog::Help(d) => d,
155            ActiveDialog::QuickNote(d) => d,
156            ActiveDialog::WorkspaceSwitcher(d) => d,
157            ActiveDialog::SaveSearch(d) => d,
158            ActiveDialog::Sort(d) => d,
159            ActiveDialog::PinnedNotes(d) => d,
160            ActiveDialog::ThemePicker(d) => d.as_mut(),
161            ActiveDialog::UpdateAvailable(d) => d,
162            ActiveDialog::Info(d) => d,
163            ActiveDialog::Properties(d) => d,
164        }
165    }
166
167    pub fn set_error(&mut self, msg: String) {
168        self.inner().set_error(msg);
169    }
170
171    // Constructors for the dialogs opened by EditorScreen via OverlayHost.
172    pub fn help(
173        key_bindings: &crate::keys::KeyBindings,
174        tree: &crate::keys::leader::LeaderNode,
175    ) -> Self {
176        ActiveDialog::Help(HelpDialog::new(key_bindings, tree))
177    }
178
179    /// The full leader-tree cheatsheet (leader `?`).
180    pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
181        ActiveDialog::Help(HelpDialog::cheatsheet(settings))
182    }
183
184    /// The search query syntax reference (F1 over the Find drawer view).
185    pub fn query_syntax() -> Self {
186        ActiveDialog::Help(HelpDialog::query_syntax())
187    }
188
189    /// The live theme picker (leader `v c`).
190    pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
191        ActiveDialog::ThemePicker(Box::new(ThemePickerDialog::new(settings)))
192    }
193
194    /// The update-available dialog.
195    pub fn update(status: &crate::update::UpdateStatus) -> Self {
196        ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
197    }
198
199    /// A read-only message dialog: `headline`, wrapped `body`, `[Esc] Close`.
200    pub fn info(
201        title: impl Into<String>,
202        headline: impl Into<String>,
203        body: impl Into<String>,
204    ) -> Self {
205        ActiveDialog::Info(InfoDialog::new(title, headline, body))
206    }
207
208    /// The server-update hint (footer `rag:` segment). The TUI can't tell how
209    /// the server was installed, so it only points at the user's own method.
210    pub fn server_update(update: &crate::server_client::sync::ServerUpdate) -> Self {
211        Self::info(
212            "Server Update",
213            update.summary(),
214            "Update it the same way you installed it (install script, Docker image, package manager, …).",
215        )
216    }
217
218    pub fn quick_note(vault: Arc<NoteVault>) -> Self {
219        ActiveDialog::QuickNote(QuickNoteModal::new(vault))
220    }
221
222    pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
223        ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
224    }
225
226    pub fn create_note(
227        path: kimun_core::nfs::VaultPath,
228        vault: Arc<NoteVault>,
229        content: Option<String>,
230    ) -> Self {
231        ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault, content))
232    }
233
234    /// Open the save-search dialog. `provenance` is the saved-search name the
235    /// query came from (the breadcrumb), pre-filled as the default name. The
236    /// existing names load in the background and arrive via
237    /// [`OverlayData::SavedSearchNamesLoaded`] to drive the dialog's hint.
238    pub fn save_search(
239        query: String,
240        provenance: Option<String>,
241        source: SaveSource,
242        vault: Arc<NoteVault>,
243        tx: &AppTx,
244    ) -> Self {
245        let tx = tx.clone();
246        tokio::spawn(async move {
247            if let Ok(searches) = vault.list_saved_searches().await {
248                let names = searches.into_iter().map(|s| s.name).collect();
249                tx.send(AppEvent::OverlayData(OverlayData::SavedSearchNamesLoaded(
250                    names,
251                )))
252                .ok();
253            }
254        });
255        ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
256    }
257
258    /// The sort dialog for `target`, opened on its `state` (shown as
259    /// "Unsorted" while `unsorted`). With a `vault`, the property keys load
260    /// in the background for the Key picker (pass one exactly when
261    /// `allows_property`).
262    pub fn sort(
263        target: SortTarget,
264        state: SortState,
265        unsorted: bool,
266        allows_property: bool,
267        vault: Option<Arc<NoteVault>>,
268        tx: &AppTx,
269    ) -> Self {
270        if let Some(vault) = vault {
271            spawn_property_keys(vault, tx);
272        }
273        ActiveDialog::Sort(SortDialog::new(target, state, allows_property).unsorted(unsorted))
274    }
275
276    /// The pinned-notes dialog (leader `f p`). Loads in the background and
277    /// arrives via [`OverlayData::PinnedNotesLoaded`].
278    pub fn pinned_notes(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
279        ActiveDialog::PinnedNotes(PinnedNotesDialog::new(vault, tx))
280    }
281
282    pub fn properties(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
283        ActiveDialog::Properties(PropertiesDialog::new(path, vault, tx))
284    }
285
286    pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
287        ActiveDialog::Menu(FileOpsMenuDialog::new(path))
288    }
289
290    pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
291        ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
292    }
293
294    pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
295        ActiveDialog::Rename(RenameDialog::new(path, vault))
296    }
297
298    pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
299        ActiveDialog::Move(MoveDialog::new(path, vault, tx))
300    }
301}
302
303/// What `ActiveDialog` needs from each dialog, so its dispatch is one `inner()`
304/// match instead of one match per method. A modal consumes every mouse event
305/// (a click must never reach the panels behind it); the impls live next to
306/// each dialog.
307pub trait Dialog {
308    fn key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState;
309    fn mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState;
310    fn draw(&mut self, f: &mut Frame, rect: Rect, theme: &Theme);
311    /// Show a failure inside the dialog; most have no error row.
312    fn set_error(&mut self, _msg: String) {}
313}
314
315impl Overlay for ActiveDialog {
316    fn kind(&self) -> OverlayKind {
317        OverlayKind::Dialog
318    }
319
320    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
321        <Self as Component>::handle_input(self, event, tx)
322    }
323
324    fn handle_data(
325        &mut self,
326        data: &OverlayData,
327        _vault: &Arc<NoteVault>,
328        tx: &AppTx,
329    ) -> OverlayMsg {
330        match data {
331            OverlayData::RenameValidation { available } => {
332                if let ActiveDialog::Rename(d) = self {
333                    d.validation_state = if *available {
334                        ValidationState::Available
335                    } else {
336                        ValidationState::Taken
337                    };
338                    d.validation_task = None;
339                }
340                OverlayMsg::Consumed
341            }
342            OverlayData::PropertyKeysLoaded(keys) => {
343                match self {
344                    ActiveDialog::Sort(d) => d.set_keys(keys.clone()),
345                    ActiveDialog::Properties(d) => d.set_keys(keys.clone()),
346                    _ => {}
347                }
348                OverlayMsg::Consumed
349            }
350            OverlayData::PropertiesLoaded { path, result } => {
351                if let ActiveDialog::Properties(d) = self {
352                    d.on_loaded(path, result);
353                }
354                OverlayMsg::Consumed
355            }
356            OverlayData::PropertyWritten { path, result } => {
357                if let ActiveDialog::Properties(d) = self {
358                    d.on_written(path, result, tx);
359                }
360                OverlayMsg::Consumed
361            }
362            OverlayData::MoveDirectoriesLoaded(paths) => {
363                if let ActiveDialog::Move(d) = self {
364                    d.all_dirs = paths.clone();
365                    d.filtered = None;
366                    d.load_task = None;
367                    if d.list_state.selected().is_none() && !d.results().is_empty() {
368                        d.list_state.select(Some(0));
369                    }
370                    d.spawn_validation(tx);
371                }
372                OverlayMsg::Consumed
373            }
374            OverlayData::MoveFilterResults(paths) => {
375                if let ActiveDialog::Move(d) = self {
376                    d.filter_task = None;
377                    d.filtered = Some(paths.clone());
378                    if !d.results().is_empty() {
379                        d.list_state.select(Some(0));
380                    } else {
381                        d.list_state.select(None);
382                    }
383                    d.spawn_validation(tx);
384                }
385                OverlayMsg::Consumed
386            }
387            OverlayData::MoveDestValidation { available } => {
388                if let ActiveDialog::Move(d) = self {
389                    d.dest_validation = if *available {
390                        ValidationState::Available
391                    } else {
392                        ValidationState::Taken
393                    };
394                    d.validation_task = None;
395                }
396                OverlayMsg::Consumed
397            }
398            OverlayData::SavedSearchNamesLoaded(names) => {
399                if let ActiveDialog::SaveSearch(d) = self {
400                    d.set_existing_names(names.clone());
401                }
402                OverlayMsg::Consumed
403            }
404            OverlayData::PinnedNotesLoaded(result) => {
405                if let ActiveDialog::PinnedNotes(d) = self {
406                    d.handle_loaded(result, tx);
407                }
408                OverlayMsg::Consumed
409            }
410            OverlayData::Error(text) => {
411                self.set_error(text.clone());
412                OverlayMsg::Consumed
413            }
414        }
415    }
416
417    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
418        <Self as Component>::render(self, f, area, theme, true);
419    }
420}
421
422impl Component for ActiveDialog {
423    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
424        match event {
425            InputEvent::Key(key) => self.inner().key(*key, tx),
426            InputEvent::Mouse(m) => self.inner().mouse(m, tx),
427            InputEvent::Paste(_) => EventState::NotConsumed,
428        }
429    }
430
431    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
432        self.inner().draw(f, rect, theme);
433    }
434}
435
436// ---------------------------------------------------------------------------
437// Shared render helpers
438// ---------------------------------------------------------------------------
439
440/// Renders a pre-computed path string (should already include leading spaces).
441pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
442    f.render_widget(
443        Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
444        rect,
445    );
446}
447
448/// Renders a single-line horizontal rule (TOP border only).
449pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
450    Block::default()
451        .borders(Borders::TOP)
452        .border_style(Style::default().fg(gray))
453        .style(Style::default().bg(bg))
454        .render(rect, f.buffer_mut());
455}
456
457/// Renders `  Error: {msg}` in the theme's error color on the panel background.
458pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
459    f.render_widget(
460        Paragraph::new(format!("  Error: {msg}")).style(
461            Style::default()
462                .fg(theme.red.to_ratatui())
463                .bg(theme.bg_panel.to_ratatui()),
464        ),
465        rect,
466    );
467}
468
469/// The `[Enter] {action}   [Esc] Cancel` row shared by the form dialogs.
470/// Each chip is clickable and runs its key.
471pub(super) fn confirm_hints(action: &str) -> HintRow {
472    HintRow::new(&[
473        (KeyCode::Enter, "Enter", action),
474        (KeyCode::Esc, "Esc", "Cancel"),
475    ])
476    .with_gap(3)
477}
478
479/// Draw a [`confirm_hints`] row. `enter_active` is only the *look*: `fg`
480/// when ready, gray otherwise. `enter_clickable` must mirror exactly when the
481/// Enter key acts — a dialog whose Enter still runs while it looks not-ready
482/// (Move before validation lands, Save search while names load) passes
483/// `true`, so the click never does less than the key.
484pub(super) fn render_confirm_hints(
485    f: &mut Frame,
486    rect: Rect,
487    hints: &mut HintRow,
488    enter_active: bool,
489    enter_clickable: bool,
490    fg: Color,
491    theme: &Theme,
492) {
493    let gray = theme.gray.to_ratatui();
494    let bg = theme.bg_panel.to_ratatui();
495    hints.set_enabled(0, enter_clickable);
496    hints.set_style(
497        0,
498        Some(if enter_active {
499            Style::default().fg(fg).bg(bg)
500        } else {
501            Style::default().fg(gray).bg(bg)
502        }),
503    );
504    hints.render(f, rect, Style::default().fg(gray).bg(bg), theme);
505}
506
507// ---------------------------------------------------------------------------
508// Layout helper
509// ---------------------------------------------------------------------------
510
511/// Centre a dialog of exactly `width` × `height` characters.
512pub(super) use crate::components::fixed_centered_rect;
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::keys::KeyBindings;
518
519    /// Render `dialog` on a 100×40 screen, left-click the first cell of
520    /// `text`, and return the events the click sent. Panics if `text` is not
521    /// drawn — a chip that moved off-screen should fail loudly.
522    pub(crate) fn click_text(dialog: &mut ActiveDialog, text: &str) -> Vec<AppEvent> {
523        let (col, row) = draw_and_find(dialog, text);
524        click_at(dialog, col, row)
525    }
526
527    /// Render `dialog` and return the cell where `text` starts.
528    pub(crate) fn draw_and_find(dialog: &mut ActiveDialog, text: &str) -> (u16, u16) {
529        use ratatui::{Terminal, backend::TestBackend};
530        let theme = Theme::gruvbox_dark();
531        let mut t = Terminal::new(TestBackend::new(100, 40)).unwrap();
532        t.draw(|f| <ActiveDialog as Component>::render(dialog, f, f.area(), &theme, true))
533            .unwrap();
534        crate::test_support::find_text(t.backend().buffer(), text)
535            .unwrap_or_else(|| panic!("{text:?} not drawn"))
536    }
537
538    /// Left-click (col,row) and return the events sent.
539    pub(crate) fn click_at(dialog: &mut ActiveDialog, col: u16, row: u16) -> Vec<AppEvent> {
540        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
541        Overlay::handle_input(dialog, &crate::test_support::mouse_down_at(col, row), &tx);
542        crate::test_support::drain(&mut rx)
543    }
544
545    fn closed(events: &[AppEvent]) -> bool {
546        events.iter().any(|e| matches!(e, AppEvent::CloseOverlay))
547    }
548
549    fn has(events: &[AppEvent], pred: impl Fn(&AppEvent) -> bool) -> bool {
550        events.iter().any(pred)
551    }
552
553    #[test]
554    fn menu_actions_are_clickable() {
555        use crate::components::events::FileOp;
556        let path = kimun_core::nfs::VaultPath::new("a.md");
557        let mut d = ActiveDialog::Menu(FileOpsMenuDialog::new(path));
558        assert!(has(&click_text(&mut d, "[D]"), |e| matches!(
559            e,
560            AppEvent::FileOp(FileOp::ShowDelete(_))
561        )));
562        assert!(has(&click_text(&mut d, "Rename"), |e| matches!(
563            e,
564            AppEvent::FileOp(FileOp::ShowRename(_))
565        )));
566        assert!(has(&click_text(&mut d, "Move"), |e| matches!(
567            e,
568            AppEvent::FileOp(FileOp::ShowMove(_))
569        )));
570        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
571        assert!(closed(&click_at(&mut d, 0, 0)), "outside press cancels");
572        assert!(
573            !closed(&click_text(&mut d, "a.md")),
574            "inside press does not"
575        );
576    }
577
578    #[tokio::test]
579    async fn rename_chips_are_clickable() {
580        let vault = crate::test_support::temp_vault("dlg-rename-click").await;
581        let mut d = ActiveDialog::Rename(RenameDialog::new(
582            kimun_core::nfs::VaultPath::new("a.md"),
583            vault,
584        ));
585        // Enter is inert until the name validates, like the key.
586        assert!(click_text(&mut d, "[Enter] Rename").is_empty());
587        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
588    }
589
590    #[tokio::test]
591    async fn move_list_rows_select_and_cancel_chip_closes() {
592        let vault = crate::test_support::temp_vault("dlg-move-click").await;
593        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
594        let mut m = MoveDialog::new(kimun_core::nfs::VaultPath::new("a.md"), vault, &tx);
595        m.all_dirs = vec![
596            kimun_core::nfs::VaultPath::root(),
597            kimun_core::nfs::VaultPath::new("projects"),
598        ];
599        m.list_state.select(Some(0));
600        let mut d = ActiveDialog::Move(m);
601        click_text(&mut d, "projects");
602        let ActiveDialog::Move(m) = &d else {
603            unreachable!()
604        };
605        assert_eq!(m.list_state.selected(), Some(1), "click selects the row");
606        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
607    }
608
609    /// The Enter key moves while validation is still Idle/Pending, so the
610    /// chip must too — a click may never do less than the key.
611    #[tokio::test]
612    async fn move_enter_chip_acts_before_validation_lands() {
613        let vault = crate::test_support::temp_vault("dlg-move-enter").await;
614        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
615        let mut m = MoveDialog::new(kimun_core::nfs::VaultPath::new("a.md"), vault, &tx);
616        m.all_dirs = vec![kimun_core::nfs::VaultPath::new("projects")];
617        m.list_state.select(Some(0));
618        assert_eq!(m.dest_validation, ValidationState::Idle);
619        let mut d = ActiveDialog::Move(m);
620        let (col, row) = draw_and_find(&mut d, "[Enter] Move here");
621        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
622        Overlay::handle_input(&mut d, &crate::test_support::mouse_down_at(col, row), &tx);
623        // The move itself runs on a task; `a.md` does not exist, so it
624        // reports an error — proof the click reached the move.
625        let ev = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
626            .await
627            .expect("the click started the move");
628        assert!(matches!(
629            ev,
630            Some(AppEvent::OverlayData(OverlayData::Error(_))) | Some(AppEvent::FileOp(_))
631        ));
632    }
633
634    #[test]
635    fn save_search_chips_are_clickable() {
636        use crate::components::events::SavedSearchFlow;
637        let mut d = ActiveDialog::SaveSearch(SaveSearchDialog::new(
638            "#tag".into(),
639            None,
640            SaveSource::QueryPanel,
641        ));
642        if let ActiveDialog::SaveSearch(s) = &mut d {
643            s.set_existing_names(vec![]);
644        }
645        let ev = click_text(&mut d, "[Enter] Save new");
646        assert!(has(&ev, |e| matches!(
647            e,
648            AppEvent::SavedSearch(SavedSearchFlow::Confirmed { .. })
649        )));
650        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
651    }
652
653    #[tokio::test]
654    async fn create_note_chips_are_clickable() {
655        let vault = crate::test_support::temp_vault("dlg-create-click").await;
656        let mut d = ActiveDialog::CreateNote(CreateNoteDialog::new(
657            kimun_core::nfs::VaultPath::new("new.md"),
658            vault,
659            None,
660        ));
661        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
662    }
663
664    #[tokio::test]
665    async fn quick_note_chips_are_clickable() {
666        let vault = crate::test_support::temp_vault("dlg-quick-click").await;
667        let mut d = ActiveDialog::QuickNote(QuickNoteModal::new(vault));
668        // Empty input: Enter closes, same as the key.
669        assert!(closed(&click_text(&mut d, "[Enter] Save")));
670        assert!(closed(&click_text(&mut d, "[Shift+Enter]")));
671        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
672    }
673
674    #[test]
675    fn workspace_switcher_rows_and_outside_click() {
676        let mut d = ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::with_workspaces(vec![
677            ("alpha".into(), true),
678            ("beta".into(), false),
679        ]));
680        assert!(click_text(&mut d, "beta").is_empty(), "first click selects");
681        let ev = click_text(&mut d, "beta");
682        assert!(
683            has(
684                &ev,
685                |e| matches!(e, AppEvent::WorkspaceSwitched(n) if n == "beta")
686            ),
687            "clicking the selected row switches"
688        );
689        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
690        assert!(closed(&click_at(&mut d, 0, 0)), "outside press cancels");
691    }
692
693    #[test]
694    fn update_actions_are_clickable() {
695        use crate::components::events::UpdateFlow;
696        let status = crate::update::UpdateStatus {
697            current: "0.1.0".into(),
698            latest: "9.9.9".into(),
699            channel: crate::update::InstallChannel::Script,
700            update_available: true,
701            dismissed: false,
702        };
703        let mut d = ActiveDialog::update(&status);
704        assert!(has(&click_text(&mut d, "Update now"), |e| matches!(
705            e,
706            AppEvent::Update(UpdateFlow::Apply)
707        )));
708        assert!(has(&click_text(&mut d, "[S]"), |e| matches!(
709            e,
710            AppEvent::Update(UpdateFlow::Dismiss(_))
711        )));
712        assert!(closed(&click_text(&mut d, "[Esc] Close")));
713        assert!(closed(&click_at(&mut d, 0, 0)), "outside press closes");
714        assert!(
715            !closed(&click_text(&mut d, "Releases:")),
716            "inside press does not"
717        );
718    }
719
720    #[test]
721    fn theme_picker_click_previews_then_keeps() {
722        let settings = crate::settings::AppSettings::default();
723        let mut d = ActiveDialog::theme_picker(&settings);
724        // Any theme other than the current one.
725        let name = {
726            let current = settings.effective_theme_name();
727            settings
728                .theme_list()
729                .into_iter()
730                .map(|t| t.name)
731                .find(|n| *n != current)
732                .expect("more than one theme")
733        };
734        let ev = click_text(&mut d, &name);
735        assert!(has(&ev, |e| matches!(
736            e,
737            AppEvent::ApplyTheme { persist: false, .. }
738        )));
739        assert!(!closed(&ev), "first click only previews");
740        let ev = click_text(&mut d, &name);
741        assert!(has(&ev, |e| matches!(
742            e,
743            AppEvent::ApplyTheme { persist: true, .. }
744        )));
745        assert!(closed(&ev), "clicking the previewed theme keeps it");
746    }
747
748    #[test]
749    fn theme_picker_hints_are_not_truncated() {
750        let mut d = ActiveDialog::theme_picker(&crate::settings::AppSettings::default());
751        for hint in ["Filter", "Move", "Apply", "Cancel"] {
752            draw_and_find(&mut d, hint);
753        }
754    }
755
756    #[test]
757    fn theme_picker_outside_click_reverts() {
758        let settings = crate::settings::AppSettings::default();
759        let mut d = ActiveDialog::theme_picker(&settings);
760        let name = {
761            let current = settings.effective_theme_name();
762            settings
763                .theme_list()
764                .into_iter()
765                .map(|t| t.name)
766                .find(|n| *n != current)
767                .expect("more than one theme")
768        };
769        click_text(&mut d, &name);
770        let ev = click_at(&mut d, 0, 0);
771        assert!(
772            has(&ev, |e| matches!(
773                e,
774                AppEvent::ApplyTheme { persist: false, .. }
775            )),
776            "reverts the preview"
777        );
778        assert!(closed(&ev));
779    }
780
781    #[test]
782    fn help_close_chip_and_outside_click_close() {
783        let mut d = ActiveDialog::cheatsheet(&crate::settings::AppSettings::default());
784        assert!(closed(&click_text(&mut d, "[Esc] Close")));
785        assert!(
786            !closed(&click_text(&mut d, "Scroll")),
787            "the scroll hint is inert"
788        );
789        assert!(closed(&click_at(&mut d, 0, 0)));
790    }
791
792    #[test]
793    fn help_wheel_scrolls() {
794        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
795        let mut d = ActiveDialog::cheatsheet(&crate::settings::AppSettings::default());
796        let (col, row) = draw_and_find(&mut d, "[Esc] Close");
797        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
798        Overlay::handle_input(
799            &mut d,
800            &InputEvent::Mouse(MouseEvent {
801                kind: MouseEventKind::ScrollDown,
802                column: col,
803                row,
804                modifiers: KeyModifiers::NONE,
805            }),
806            &tx,
807        );
808        let ActiveDialog::Help(h) = &d else {
809            unreachable!()
810        };
811        assert!(h.scroll() > 0);
812    }
813
814    #[tokio::test]
815    async fn pinned_rows_and_chips_are_clickable() {
816        use crate::components::events::PinnedRow;
817        use kimun_core::nfs::VaultPath;
818        let vault = crate::test_support::temp_vault("dlg-pinned-click").await;
819        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
820        let mut p = PinnedNotesDialog::new(vault, &tx);
821        p.set_rows(vec![
822            PinnedRow {
823                path: VaultPath::new("alpha.md"),
824                missing: false,
825            },
826            PinnedRow {
827                path: VaultPath::new("beta.md"),
828                missing: false,
829            },
830        ]);
831        let mut d = ActiveDialog::PinnedNotes(p);
832        assert!(
833            click_text(&mut d, "beta.md").is_empty(),
834            "first click selects"
835        );
836        let ActiveDialog::PinnedNotes(p) = &d else {
837            unreachable!()
838        };
839        assert_eq!(p.selected, 1);
840        assert!(
841            !click_text(&mut d, "beta.md").is_empty(),
842            "second click opens"
843        );
844        assert!(closed(&click_text(&mut d, "[Esc] Close")));
845        assert!(closed(&click_at(&mut d, 0, 0)));
846    }
847
848    fn sidebar_sort() -> ActiveDialog {
849        use crate::components::file_list::{SortField, SortOrder};
850        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
851        let state = SortState {
852            field: SortField::Name,
853            order: SortOrder::Ascending,
854            group_dirs: Some(false),
855        };
856        ActiveDialog::sort(SortTarget::Sidebar, state, false, false, None, &tx)
857    }
858
859    #[test]
860    fn sort_footer_chips_save_and_close() {
861        let mut d = sidebar_sort();
862        assert!(has(&click_text(&mut d, "[s] Save default"), |e| matches!(
863            e,
864            AppEvent::SortChanged { persist: true, .. }
865        )));
866        assert!(closed(&click_text(&mut d, "[Esc] Close")));
867        assert!(
868            click_text(&mut d, "[Space] Toggle").is_empty(),
869            "informational chips are inert"
870        );
871    }
872
873    #[tokio::test]
874    async fn delete_cancel_chip_closes() {
875        let vault = crate::test_support::temp_vault("dlg-delete-click").await;
876        let mut d = ActiveDialog::Delete(DeleteConfirmDialog::new(
877            kimun_core::nfs::VaultPath::new("a.md"),
878            vault,
879        ));
880        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
881        assert!(
882            !closed(&click_text(&mut d, "This cannot")),
883            "body text is inert"
884        );
885    }
886
887    /// Dialogs holding typed input or confirming a destructive action must
888    /// survive a stray click outside — only the read-only popups (which use
889    /// `ModalShell`) dismiss on it.
890    #[tokio::test]
891    async fn input_and_destructive_dialogs_ignore_outside_press() {
892        let vault = crate::test_support::temp_vault("dlg-outside-press").await;
893        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
894        let path = || kimun_core::nfs::VaultPath::new("a.md");
895        let mut dialogs = vec![
896            ActiveDialog::delete(path(), vault.clone()),
897            ActiveDialog::rename(path(), vault.clone()),
898            ActiveDialog::move_to(path(), vault.clone(), &tx),
899            ActiveDialog::create_note(path(), vault.clone(), None),
900            ActiveDialog::quick_note(vault.clone()),
901            ActiveDialog::properties(path(), vault.clone(), &tx),
902            ActiveDialog::save_search("q".into(), None, SaveSource::QueryPanel, vault.clone(), &tx),
903            sidebar_sort(),
904        ];
905        for d in &mut dialogs {
906            // Render once so a would-be popup rect exists, then press far outside.
907            draw_and_find(d, "Esc");
908            assert!(!closed(&click_at(d, 0, 0)), "outside press must not close");
909        }
910    }
911
912    #[test]
913    fn active_dialog_help_variant_compiles() {
914        let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
915        let _active: ActiveDialog = ActiveDialog::Help(dialog);
916    }
917
918    #[test]
919    fn active_dialog_sort_variant_compiles() {
920        use crate::components::events::SortTarget;
921        use crate::components::file_list::{SortField, SortOrder};
922        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
923        let state = crate::components::sortable::SortState {
924            field: SortField::Name,
925            order: SortOrder::Ascending,
926            group_dirs: Some(false),
927        };
928        let _active: ActiveDialog =
929            ActiveDialog::sort(SortTarget::Sidebar, state, false, false, None, &tx);
930    }
931
932    /// Every dialog test for `PinnedNotesDialog` calls `set_rows` directly,
933    /// bypassing the routing this module owns. A regression here (e.g. the
934    /// `PinnedNotesLoaded` arm losing its `if let` guard, or matching the
935    /// wrong variant) would leave the dialog permanently empty with none of
936    /// those tests failing — so this drives the real `handle_data` path.
937    #[tokio::test]
938    async fn active_dialog_routes_pinned_notes_loaded_rows() {
939        use crate::components::events::PinnedRow;
940        use kimun_core::nfs::VaultPath;
941        use tokio::sync::mpsc::unbounded_channel;
942
943        let vault = crate::test_support::temp_vault("active-dialog-pinned").await;
944        let (tx, _rx) = unbounded_channel();
945        let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
946
947        let rows = vec![PinnedRow {
948            path: VaultPath::new("a.md"),
949            missing: false,
950        }];
951        let msg = active.handle_data(
952            &OverlayData::PinnedNotesLoaded(Ok(rows.clone())),
953            &vault,
954            &tx,
955        );
956        assert!(matches!(msg, OverlayMsg::Consumed));
957
958        match &active {
959            ActiveDialog::PinnedNotes(d) => assert_eq!(d.rows(), rows.as_slice()),
960            _ => panic!("expected ActiveDialog::PinnedNotes"),
961        }
962    }
963
964    /// A foreign `OverlayData::Error` — a rename or paste task failing after
965    /// its own dialog closed — must not be routed into the pinned-notes
966    /// dialog as if it were the reload it is waiting on: that would clear
967    /// its in-flight guard and reopen the overlapping-write race.
968    #[tokio::test]
969    async fn active_dialog_leaves_pinned_notes_alone_on_a_foreign_error() {
970        use tokio::sync::mpsc::unbounded_channel;
971
972        let vault = crate::test_support::temp_vault("active-dialog-pinned-err").await;
973        let (tx, mut rx) = unbounded_channel();
974        let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
975
976        let msg = active.handle_data(
977            &OverlayData::Error("rename failed".to_string()),
978            &vault,
979            &tx,
980        );
981        assert!(matches!(msg, OverlayMsg::Consumed));
982        assert!(
983            rx.try_recv().is_err(),
984            "a foreign error must not be flashed as a pinned-notes failure"
985        );
986        match &active {
987            ActiveDialog::PinnedNotes(d) => assert!(
988                !d.is_loaded(),
989                "a foreign error must not settle the dialog's own load"
990            ),
991            _ => panic!("expected ActiveDialog::PinnedNotes"),
992        }
993    }
994}