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
35pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ValidationState {
54 Idle,
56 Pending,
58 Available,
60 Taken,
62}
63
64macro_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 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 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 pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
181 ActiveDialog::Help(HelpDialog::cheatsheet(settings))
182 }
183
184 pub fn query_syntax() -> Self {
186 ActiveDialog::Help(HelpDialog::query_syntax())
187 }
188
189 pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
191 ActiveDialog::ThemePicker(Box::new(ThemePickerDialog::new(settings)))
192 }
193
194 pub fn update(status: &crate::update::UpdateStatus) -> Self {
196 ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
197 }
198
199 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 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 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 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 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
303pub 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 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
436pub(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
448pub(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
457pub(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
469pub(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
479pub(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
507pub(super) use crate::components::fixed_centered_rect;
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::keys::KeyBindings;
518
519 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 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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}