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 pinned_notes_dialog::PinnedNotesDialog;
7pub use properties_dialog::PropertiesDialog;
8pub use quick_note_modal::QuickNoteModal;
9pub use rename_dialog::RenameDialog;
10pub use save_search_dialog::SaveSearchDialog;
11pub use sort_dialog::SortDialog;
12pub use theme_picker::ThemePickerDialog;
13pub use update_dialog::UpdateAvailableDialog;
14pub use workspace_switcher::WorkspaceSwitcherModal;
15
16use std::sync::Arc;
17
18use kimun_core::NoteVault;
19use ratatui::Frame;
20use ratatui::crossterm::event::KeyCode;
21use ratatui::layout::Rect;
22use ratatui::style::{Color, Style};
23use ratatui::widgets::{Block, Borders, Paragraph, Widget};
24
25use crate::components::Component;
26use crate::components::event_state::EventState;
27use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, SaveSource, SortTarget};
28use crate::components::hint_row::HintRow;
29use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
30use crate::components::sortable::SortState;
31use crate::settings::themes::Theme;
32
33pub(crate) fn spawn_property_keys(vault: Arc<NoteVault>, tx: &AppTx) {
36 let tx = tx.clone();
37 tokio::spawn(async move {
38 if let Ok(keys) = vault.property_keys().await {
39 tx.send(AppEvent::OverlayData(OverlayData::PropertyKeysLoaded(keys)))
40 .ok();
41 }
42 });
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ValidationState {
52 Idle,
54 Pending,
56 Available,
58 Taken,
60}
61
62pub mod create_note_dialog;
63pub mod delete_dialog;
64pub mod file_ops_menu;
65pub mod help_dialog;
66pub mod move_dialog;
67pub mod pinned_notes_dialog;
68pub mod properties_dialog;
69pub mod quick_note_modal;
70pub mod rename_dialog;
71pub mod save_search_dialog;
72pub mod sort_dialog;
73pub mod theme_picker;
74pub mod update_dialog;
75pub mod workspace_switcher;
76
77pub enum ActiveDialog {
78 Menu(FileOpsMenuDialog),
79 Delete(DeleteConfirmDialog),
80 Rename(RenameDialog),
81 Move(MoveDialog),
82 CreateNote(CreateNoteDialog),
83 Help(HelpDialog),
84 QuickNote(QuickNoteModal),
85 WorkspaceSwitcher(WorkspaceSwitcherModal),
86 SaveSearch(SaveSearchDialog),
87 Sort(SortDialog),
88 PinnedNotes(PinnedNotesDialog),
89 ThemePicker(ThemePickerDialog),
90 UpdateAvailable(UpdateAvailableDialog),
91 Properties(PropertiesDialog),
92}
93
94impl ActiveDialog {
95 pub fn set_error(&mut self, msg: String) {
96 match self {
97 ActiveDialog::Menu(_) => {} ActiveDialog::Delete(d) => d.error = Some(msg),
99 ActiveDialog::Rename(d) => d.error = Some(msg),
100 ActiveDialog::Move(d) => d.error = Some(msg),
101 ActiveDialog::CreateNote(d) => d.error = Some(msg),
102 ActiveDialog::Help(_) => {}
103 ActiveDialog::QuickNote(d) => d.error = Some(msg),
104 ActiveDialog::WorkspaceSwitcher(_) => {} ActiveDialog::SaveSearch(_) => {} ActiveDialog::Sort(_) => {} ActiveDialog::PinnedNotes(_) => {} ActiveDialog::ThemePicker(_) => {} ActiveDialog::UpdateAvailable(_) => {} ActiveDialog::Properties(d) => d.error = Some(msg),
111 }
112 }
113
114 pub fn help(
116 key_bindings: &crate::keys::KeyBindings,
117 tree: &crate::keys::leader::LeaderNode,
118 ) -> Self {
119 ActiveDialog::Help(HelpDialog::new(key_bindings, tree))
120 }
121
122 pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
124 ActiveDialog::Help(HelpDialog::cheatsheet(settings))
125 }
126
127 pub fn query_syntax() -> Self {
129 ActiveDialog::Help(HelpDialog::query_syntax())
130 }
131
132 pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
134 ActiveDialog::ThemePicker(ThemePickerDialog::new(settings))
135 }
136
137 pub fn update(status: &crate::update::UpdateStatus) -> Self {
139 ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
140 }
141
142 pub fn quick_note(vault: Arc<NoteVault>) -> Self {
143 ActiveDialog::QuickNote(QuickNoteModal::new(vault))
144 }
145
146 pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
147 ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
148 }
149
150 pub fn create_note(
151 path: kimun_core::nfs::VaultPath,
152 vault: Arc<NoteVault>,
153 content: Option<String>,
154 ) -> Self {
155 ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault, content))
156 }
157
158 pub fn save_search(
163 query: String,
164 provenance: Option<String>,
165 source: SaveSource,
166 vault: Arc<NoteVault>,
167 tx: &AppTx,
168 ) -> Self {
169 let tx = tx.clone();
170 tokio::spawn(async move {
171 if let Ok(searches) = vault.list_saved_searches().await {
172 let names = searches.into_iter().map(|s| s.name).collect();
173 tx.send(AppEvent::OverlayData(OverlayData::SavedSearchNamesLoaded(
174 names,
175 )))
176 .ok();
177 }
178 });
179 ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
180 }
181
182 pub fn sort(
187 target: SortTarget,
188 state: SortState,
189 unsorted: bool,
190 allows_property: bool,
191 vault: Option<Arc<NoteVault>>,
192 tx: &AppTx,
193 ) -> Self {
194 if let Some(vault) = vault {
195 spawn_property_keys(vault, tx);
196 }
197 ActiveDialog::Sort(SortDialog::new(target, state, allows_property).unsorted(unsorted))
198 }
199
200 pub fn pinned_notes(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
203 ActiveDialog::PinnedNotes(PinnedNotesDialog::new(vault, tx))
204 }
205
206 pub fn properties(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
207 ActiveDialog::Properties(PropertiesDialog::new(path, vault, tx))
208 }
209
210 pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
211 ActiveDialog::Menu(FileOpsMenuDialog::new(path))
212 }
213
214 pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
215 ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
216 }
217
218 pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
219 ActiveDialog::Rename(RenameDialog::new(path, vault))
220 }
221
222 pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
223 ActiveDialog::Move(MoveDialog::new(path, vault, tx))
224 }
225}
226
227impl Overlay for ActiveDialog {
228 fn kind(&self) -> OverlayKind {
229 OverlayKind::Dialog
230 }
231
232 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
233 <Self as Component>::handle_input(self, event, tx)
234 }
235
236 fn handle_data(
237 &mut self,
238 data: &OverlayData,
239 _vault: &Arc<NoteVault>,
240 tx: &AppTx,
241 ) -> OverlayMsg {
242 match data {
243 OverlayData::RenameValidation { available } => {
244 if let ActiveDialog::Rename(d) = self {
245 d.validation_state = if *available {
246 ValidationState::Available
247 } else {
248 ValidationState::Taken
249 };
250 d.validation_task = None;
251 }
252 OverlayMsg::Consumed
253 }
254 OverlayData::PropertyKeysLoaded(keys) => {
255 match self {
256 ActiveDialog::Sort(d) => d.set_keys(keys.clone()),
257 ActiveDialog::Properties(d) => d.set_keys(keys.clone()),
258 _ => {}
259 }
260 OverlayMsg::Consumed
261 }
262 OverlayData::PropertiesLoaded { path, result } => {
263 if let ActiveDialog::Properties(d) = self {
264 d.on_loaded(path, result);
265 }
266 OverlayMsg::Consumed
267 }
268 OverlayData::PropertyWritten { path, result } => {
269 if let ActiveDialog::Properties(d) = self {
270 d.on_written(path, result, tx);
271 }
272 OverlayMsg::Consumed
273 }
274 OverlayData::MoveDirectoriesLoaded(paths) => {
275 if let ActiveDialog::Move(d) = self {
276 d.all_dirs = paths.clone();
277 d.filtered = None;
278 d.load_task = None;
279 if d.list_state.selected().is_none() && !d.results().is_empty() {
280 d.list_state.select(Some(0));
281 }
282 d.spawn_validation(tx);
283 }
284 OverlayMsg::Consumed
285 }
286 OverlayData::MoveFilterResults(paths) => {
287 if let ActiveDialog::Move(d) = self {
288 d.filter_task = None;
289 d.filtered = Some(paths.clone());
290 if !d.results().is_empty() {
291 d.list_state.select(Some(0));
292 } else {
293 d.list_state.select(None);
294 }
295 d.spawn_validation(tx);
296 }
297 OverlayMsg::Consumed
298 }
299 OverlayData::MoveDestValidation { available } => {
300 if let ActiveDialog::Move(d) = self {
301 d.dest_validation = if *available {
302 ValidationState::Available
303 } else {
304 ValidationState::Taken
305 };
306 d.validation_task = None;
307 }
308 OverlayMsg::Consumed
309 }
310 OverlayData::SavedSearchNamesLoaded(names) => {
311 if let ActiveDialog::SaveSearch(d) = self {
312 d.set_existing_names(names.clone());
313 }
314 OverlayMsg::Consumed
315 }
316 OverlayData::PinnedNotesLoaded(result) => {
317 if let ActiveDialog::PinnedNotes(d) = self {
318 d.handle_loaded(result, tx);
319 }
320 OverlayMsg::Consumed
321 }
322 OverlayData::Error(text) => {
323 self.set_error(text.clone());
324 OverlayMsg::Consumed
325 }
326 }
327 }
328
329 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
330 <Self as Component>::render(self, f, area, theme, true);
331 }
332}
333
334impl Component for ActiveDialog {
335 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
336 let key = match event {
337 InputEvent::Key(key) => key,
338 InputEvent::Mouse(m) => {
342 return match self {
343 ActiveDialog::Sort(d) => d.handle_mouse(m, tx),
344 ActiveDialog::Properties(d) => d.handle_mouse(m, tx),
345 ActiveDialog::Delete(d) => d.handle_mouse(m, tx),
346 ActiveDialog::Menu(d) => d.handle_mouse(m, tx),
347 ActiveDialog::Rename(d) => d.handle_mouse(m, tx),
348 ActiveDialog::Move(d) => d.handle_mouse(m, tx),
349 ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
350 ActiveDialog::CreateNote(d) => d.handle_mouse(m, tx),
351 ActiveDialog::QuickNote(d) => d.handle_mouse(m, tx),
352 ActiveDialog::WorkspaceSwitcher(d) => d.handle_mouse(m, tx),
353 ActiveDialog::UpdateAvailable(d) => d.handle_mouse(m, tx),
354 ActiveDialog::ThemePicker(d) => d.handle_mouse(m, tx),
355 ActiveDialog::Help(d) => d.handle_mouse(m, tx),
356 ActiveDialog::PinnedNotes(d) => d.handle_mouse(m, tx),
357 };
358 }
359 InputEvent::Paste(_) => return EventState::NotConsumed,
360 };
361 match self {
362 ActiveDialog::Menu(d) => d.handle_key(*key, tx),
363 ActiveDialog::Delete(d) => d.handle_key(*key, tx),
364 ActiveDialog::Rename(d) => d.handle_key(*key, tx),
365 ActiveDialog::Move(d) => d.handle_key(*key, tx),
366 ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
367 ActiveDialog::Help(d) => d.handle_key(*key, tx),
368 ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
369 ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
370 ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
371 ActiveDialog::Sort(d) => d.handle_input(event, tx),
372 ActiveDialog::PinnedNotes(d) => d.handle_input(event, tx),
373 ActiveDialog::ThemePicker(d) => d.handle_key(*key, tx),
374 ActiveDialog::UpdateAvailable(d) => d.handle_key(*key, tx),
375 ActiveDialog::Properties(d) => d.handle_key(*key, tx),
376 }
377 }
378
379 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
380 match self {
381 ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
382 ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
383 ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
384 ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
385 ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
386 ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
387 ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
388 ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
389 ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
390 ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
391 ActiveDialog::PinnedNotes(d) => d.render(f, rect, theme, focused),
392 ActiveDialog::ThemePicker(d) => d.render(f, rect, theme, focused),
393 ActiveDialog::UpdateAvailable(d) => d.render(f, rect, theme, focused),
394 ActiveDialog::Properties(d) => d.render(f, rect, theme),
395 }
396 }
397}
398
399pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
405 f.render_widget(
406 Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
407 rect,
408 );
409}
410
411pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
413 Block::default()
414 .borders(Borders::TOP)
415 .border_style(Style::default().fg(gray))
416 .style(Style::default().bg(bg))
417 .render(rect, f.buffer_mut());
418}
419
420pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
422 f.render_widget(
423 Paragraph::new(format!(" Error: {msg}")).style(
424 Style::default()
425 .fg(theme.red.to_ratatui())
426 .bg(theme.bg_panel.to_ratatui()),
427 ),
428 rect,
429 );
430}
431
432pub(super) fn confirm_hints(action: &str) -> HintRow {
435 HintRow::new(&[
436 (KeyCode::Enter, "Enter", action),
437 (KeyCode::Esc, "Esc", "Cancel"),
438 ])
439 .with_gap(3)
440}
441
442pub(super) fn render_confirm_hints(
448 f: &mut Frame,
449 rect: Rect,
450 hints: &mut HintRow,
451 enter_active: bool,
452 enter_clickable: bool,
453 fg: Color,
454 theme: &Theme,
455) {
456 let gray = theme.gray.to_ratatui();
457 let bg = theme.bg_panel.to_ratatui();
458 hints.set_enabled(0, enter_clickable);
459 hints.set_style(
460 0,
461 Some(if enter_active {
462 Style::default().fg(fg).bg(bg)
463 } else {
464 Style::default().fg(gray).bg(bg)
465 }),
466 );
467 hints.render(f, rect, Style::default().fg(gray).bg(bg), theme);
468}
469
470pub(super) use crate::components::fixed_centered_rect;
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::keys::KeyBindings;
481
482 pub(crate) fn click_text(dialog: &mut ActiveDialog, text: &str) -> Vec<AppEvent> {
486 let (col, row) = draw_and_find(dialog, text);
487 click_at(dialog, col, row)
488 }
489
490 pub(crate) fn draw_and_find(dialog: &mut ActiveDialog, text: &str) -> (u16, u16) {
492 use ratatui::{Terminal, backend::TestBackend};
493 let theme = Theme::gruvbox_dark();
494 let mut t = Terminal::new(TestBackend::new(100, 40)).unwrap();
495 t.draw(|f| <ActiveDialog as Component>::render(dialog, f, f.area(), &theme, true))
496 .unwrap();
497 crate::test_support::find_text(t.backend().buffer(), text)
498 .unwrap_or_else(|| panic!("{text:?} not drawn"))
499 }
500
501 pub(crate) fn click_at(dialog: &mut ActiveDialog, col: u16, row: u16) -> Vec<AppEvent> {
503 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
504 Overlay::handle_input(dialog, &crate::test_support::mouse_down_at(col, row), &tx);
505 crate::test_support::drain(&mut rx)
506 }
507
508 fn closed(events: &[AppEvent]) -> bool {
509 events.iter().any(|e| matches!(e, AppEvent::CloseOverlay))
510 }
511
512 fn has(events: &[AppEvent], pred: impl Fn(&AppEvent) -> bool) -> bool {
513 events.iter().any(pred)
514 }
515
516 #[test]
517 fn menu_actions_are_clickable() {
518 use crate::components::events::FileOp;
519 let path = kimun_core::nfs::VaultPath::new("a.md");
520 let mut d = ActiveDialog::Menu(FileOpsMenuDialog::new(path));
521 assert!(has(&click_text(&mut d, "[D]"), |e| matches!(
522 e,
523 AppEvent::FileOp(FileOp::ShowDelete(_))
524 )));
525 assert!(has(&click_text(&mut d, "Rename"), |e| matches!(
526 e,
527 AppEvent::FileOp(FileOp::ShowRename(_))
528 )));
529 assert!(has(&click_text(&mut d, "Move"), |e| matches!(
530 e,
531 AppEvent::FileOp(FileOp::ShowMove(_))
532 )));
533 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
534 assert!(closed(&click_at(&mut d, 0, 0)), "outside press cancels");
535 assert!(
536 !closed(&click_text(&mut d, "a.md")),
537 "inside press does not"
538 );
539 }
540
541 #[tokio::test]
542 async fn rename_chips_are_clickable() {
543 let vault = crate::test_support::temp_vault("dlg-rename-click").await;
544 let mut d = ActiveDialog::Rename(RenameDialog::new(
545 kimun_core::nfs::VaultPath::new("a.md"),
546 vault,
547 ));
548 assert!(click_text(&mut d, "[Enter] Rename").is_empty());
550 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
551 }
552
553 #[tokio::test]
554 async fn move_list_rows_select_and_cancel_chip_closes() {
555 let vault = crate::test_support::temp_vault("dlg-move-click").await;
556 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
557 let mut m = MoveDialog::new(kimun_core::nfs::VaultPath::new("a.md"), vault, &tx);
558 m.all_dirs = vec![
559 kimun_core::nfs::VaultPath::root(),
560 kimun_core::nfs::VaultPath::new("projects"),
561 ];
562 m.list_state.select(Some(0));
563 let mut d = ActiveDialog::Move(m);
564 click_text(&mut d, "projects");
565 let ActiveDialog::Move(m) = &d else {
566 unreachable!()
567 };
568 assert_eq!(m.list_state.selected(), Some(1), "click selects the row");
569 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
570 }
571
572 #[tokio::test]
575 async fn move_enter_chip_acts_before_validation_lands() {
576 let vault = crate::test_support::temp_vault("dlg-move-enter").await;
577 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
578 let mut m = MoveDialog::new(kimun_core::nfs::VaultPath::new("a.md"), vault, &tx);
579 m.all_dirs = vec![kimun_core::nfs::VaultPath::new("projects")];
580 m.list_state.select(Some(0));
581 assert_eq!(m.dest_validation, ValidationState::Idle);
582 let mut d = ActiveDialog::Move(m);
583 let (col, row) = draw_and_find(&mut d, "[Enter] Move here");
584 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
585 Overlay::handle_input(&mut d, &crate::test_support::mouse_down_at(col, row), &tx);
586 let ev = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
589 .await
590 .expect("the click started the move");
591 assert!(matches!(
592 ev,
593 Some(AppEvent::OverlayData(OverlayData::Error(_))) | Some(AppEvent::FileOp(_))
594 ));
595 }
596
597 #[test]
598 fn save_search_chips_are_clickable() {
599 use crate::components::events::SavedSearchFlow;
600 let mut d = ActiveDialog::SaveSearch(SaveSearchDialog::new(
601 "#tag".into(),
602 None,
603 SaveSource::QueryPanel,
604 ));
605 if let ActiveDialog::SaveSearch(s) = &mut d {
606 s.set_existing_names(vec![]);
607 }
608 let ev = click_text(&mut d, "[Enter] Save new");
609 assert!(has(&ev, |e| matches!(
610 e,
611 AppEvent::SavedSearch(SavedSearchFlow::Confirmed { .. })
612 )));
613 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
614 }
615
616 #[tokio::test]
617 async fn create_note_chips_are_clickable() {
618 let vault = crate::test_support::temp_vault("dlg-create-click").await;
619 let mut d = ActiveDialog::CreateNote(CreateNoteDialog::new(
620 kimun_core::nfs::VaultPath::new("new.md"),
621 vault,
622 None,
623 ));
624 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
625 }
626
627 #[tokio::test]
628 async fn quick_note_chips_are_clickable() {
629 let vault = crate::test_support::temp_vault("dlg-quick-click").await;
630 let mut d = ActiveDialog::QuickNote(QuickNoteModal::new(vault));
631 assert!(closed(&click_text(&mut d, "[Enter] Save")));
633 assert!(closed(&click_text(&mut d, "[Shift+Enter]")));
634 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
635 }
636
637 #[test]
638 fn workspace_switcher_rows_and_outside_click() {
639 let mut d = ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::with_workspaces(vec![
640 ("alpha".into(), true),
641 ("beta".into(), false),
642 ]));
643 assert!(click_text(&mut d, "beta").is_empty(), "first click selects");
644 let ev = click_text(&mut d, "beta");
645 assert!(
646 has(
647 &ev,
648 |e| matches!(e, AppEvent::WorkspaceSwitched(n) if n == "beta")
649 ),
650 "clicking the selected row switches"
651 );
652 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
653 assert!(closed(&click_at(&mut d, 0, 0)), "outside press cancels");
654 }
655
656 #[test]
657 fn update_actions_are_clickable() {
658 use crate::components::events::UpdateFlow;
659 let status = crate::update::UpdateStatus {
660 current: "0.1.0".into(),
661 latest: "9.9.9".into(),
662 channel: crate::update::InstallChannel::Script,
663 update_available: true,
664 dismissed: false,
665 };
666 let mut d = ActiveDialog::update(&status);
667 assert!(has(&click_text(&mut d, "Update now"), |e| matches!(
668 e,
669 AppEvent::Update(UpdateFlow::Apply)
670 )));
671 assert!(has(&click_text(&mut d, "[S]"), |e| matches!(
672 e,
673 AppEvent::Update(UpdateFlow::Dismiss(_))
674 )));
675 assert!(closed(&click_text(&mut d, "[Esc] Close")));
676 assert!(closed(&click_at(&mut d, 0, 0)), "outside press closes");
677 assert!(
678 !closed(&click_text(&mut d, "Releases:")),
679 "inside press does not"
680 );
681 }
682
683 #[test]
684 fn theme_picker_click_previews_then_keeps() {
685 let settings = crate::settings::AppSettings::default();
686 let mut d = ActiveDialog::theme_picker(&settings);
687 let name = {
689 let current = settings.effective_theme_name();
690 settings
691 .theme_list()
692 .into_iter()
693 .map(|t| t.name)
694 .find(|n| *n != current)
695 .expect("more than one theme")
696 };
697 let ev = click_text(&mut d, &name);
698 assert!(has(&ev, |e| matches!(
699 e,
700 AppEvent::ApplyTheme { persist: false, .. }
701 )));
702 assert!(!closed(&ev), "first click only previews");
703 let ev = click_text(&mut d, &name);
704 assert!(has(&ev, |e| matches!(
705 e,
706 AppEvent::ApplyTheme { persist: true, .. }
707 )));
708 assert!(closed(&ev), "clicking the previewed theme keeps it");
709 }
710
711 #[test]
712 fn theme_picker_outside_click_reverts() {
713 let settings = crate::settings::AppSettings::default();
714 let mut d = ActiveDialog::theme_picker(&settings);
715 let name = {
716 let current = settings.effective_theme_name();
717 settings
718 .theme_list()
719 .into_iter()
720 .map(|t| t.name)
721 .find(|n| *n != current)
722 .expect("more than one theme")
723 };
724 click_text(&mut d, &name);
725 let ev = click_at(&mut d, 0, 0);
726 assert!(
727 has(&ev, |e| matches!(
728 e,
729 AppEvent::ApplyTheme { persist: false, .. }
730 )),
731 "reverts the preview"
732 );
733 assert!(closed(&ev));
734 }
735
736 #[test]
737 fn help_close_chip_and_outside_click_close() {
738 let mut d = ActiveDialog::cheatsheet(&crate::settings::AppSettings::default());
739 assert!(closed(&click_text(&mut d, "[Esc] Close")));
740 assert!(
741 !closed(&click_text(&mut d, "Scroll")),
742 "the scroll hint is inert"
743 );
744 assert!(closed(&click_at(&mut d, 0, 0)));
745 }
746
747 #[test]
748 fn help_wheel_scrolls() {
749 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
750 let mut d = ActiveDialog::cheatsheet(&crate::settings::AppSettings::default());
751 let (col, row) = draw_and_find(&mut d, "[Esc] Close");
752 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
753 Overlay::handle_input(
754 &mut d,
755 &InputEvent::Mouse(MouseEvent {
756 kind: MouseEventKind::ScrollDown,
757 column: col,
758 row,
759 modifiers: KeyModifiers::NONE,
760 }),
761 &tx,
762 );
763 let ActiveDialog::Help(h) = &d else {
764 unreachable!()
765 };
766 assert!(h.scroll() > 0);
767 }
768
769 #[tokio::test]
770 async fn pinned_rows_and_chips_are_clickable() {
771 use crate::components::events::PinnedRow;
772 use kimun_core::nfs::VaultPath;
773 let vault = crate::test_support::temp_vault("dlg-pinned-click").await;
774 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
775 let mut p = PinnedNotesDialog::new(vault, &tx);
776 p.set_rows(vec![
777 PinnedRow {
778 path: VaultPath::new("alpha.md"),
779 missing: false,
780 },
781 PinnedRow {
782 path: VaultPath::new("beta.md"),
783 missing: false,
784 },
785 ]);
786 let mut d = ActiveDialog::PinnedNotes(p);
787 assert!(
788 click_text(&mut d, "beta.md").is_empty(),
789 "first click selects"
790 );
791 let ActiveDialog::PinnedNotes(p) = &d else {
792 unreachable!()
793 };
794 assert_eq!(p.selected, 1);
795 assert!(
796 !click_text(&mut d, "beta.md").is_empty(),
797 "second click opens"
798 );
799 assert!(closed(&click_text(&mut d, "[Esc] Close")));
800 assert!(closed(&click_at(&mut d, 0, 0)));
801 }
802
803 fn sidebar_sort() -> ActiveDialog {
804 use crate::components::file_list::{SortField, SortOrder};
805 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
806 let state = SortState {
807 field: SortField::Name,
808 order: SortOrder::Ascending,
809 group_dirs: Some(false),
810 };
811 ActiveDialog::sort(SortTarget::Sidebar, state, false, false, None, &tx)
812 }
813
814 #[test]
815 fn sort_footer_chips_save_and_close() {
816 let mut d = sidebar_sort();
817 assert!(has(&click_text(&mut d, "[s] Save default"), |e| matches!(
818 e,
819 AppEvent::SortChanged { persist: true, .. }
820 )));
821 assert!(closed(&click_text(&mut d, "[Esc] Close")));
822 assert!(
823 click_text(&mut d, "[Space] Toggle").is_empty(),
824 "informational chips are inert"
825 );
826 }
827
828 #[tokio::test]
829 async fn delete_cancel_chip_closes() {
830 let vault = crate::test_support::temp_vault("dlg-delete-click").await;
831 let mut d = ActiveDialog::Delete(DeleteConfirmDialog::new(
832 kimun_core::nfs::VaultPath::new("a.md"),
833 vault,
834 ));
835 assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
836 assert!(
837 !closed(&click_text(&mut d, "This cannot")),
838 "body text is inert"
839 );
840 }
841
842 #[test]
843 fn active_dialog_help_variant_compiles() {
844 let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
845 let _active: ActiveDialog = ActiveDialog::Help(dialog);
846 }
847
848 #[test]
849 fn active_dialog_sort_variant_compiles() {
850 use crate::components::events::SortTarget;
851 use crate::components::file_list::{SortField, SortOrder};
852 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
853 let state = crate::components::sortable::SortState {
854 field: SortField::Name,
855 order: SortOrder::Ascending,
856 group_dirs: Some(false),
857 };
858 let _active: ActiveDialog =
859 ActiveDialog::sort(SortTarget::Sidebar, state, false, false, None, &tx);
860 }
861
862 #[tokio::test]
868 async fn active_dialog_routes_pinned_notes_loaded_rows() {
869 use crate::components::events::PinnedRow;
870 use kimun_core::nfs::VaultPath;
871 use tokio::sync::mpsc::unbounded_channel;
872
873 let vault = crate::test_support::temp_vault("active-dialog-pinned").await;
874 let (tx, _rx) = unbounded_channel();
875 let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
876
877 let rows = vec![PinnedRow {
878 path: VaultPath::new("a.md"),
879 missing: false,
880 }];
881 let msg = active.handle_data(
882 &OverlayData::PinnedNotesLoaded(Ok(rows.clone())),
883 &vault,
884 &tx,
885 );
886 assert!(matches!(msg, OverlayMsg::Consumed));
887
888 match &active {
889 ActiveDialog::PinnedNotes(d) => assert_eq!(d.rows(), rows.as_slice()),
890 _ => panic!("expected ActiveDialog::PinnedNotes"),
891 }
892 }
893
894 #[tokio::test]
899 async fn active_dialog_leaves_pinned_notes_alone_on_a_foreign_error() {
900 use tokio::sync::mpsc::unbounded_channel;
901
902 let vault = crate::test_support::temp_vault("active-dialog-pinned-err").await;
903 let (tx, mut rx) = unbounded_channel();
904 let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
905
906 let msg = active.handle_data(
907 &OverlayData::Error("rename failed".to_string()),
908 &vault,
909 &tx,
910 );
911 assert!(matches!(msg, OverlayMsg::Consumed));
912 assert!(
913 rx.try_recv().is_err(),
914 "a foreign error must not be flashed as a pinned-notes failure"
915 );
916 match &active {
917 ActiveDialog::PinnedNotes(d) => assert!(
918 !d.is_loaded(),
919 "a foreign error must not settle the dialog's own load"
920 ),
921 _ => panic!("expected ActiveDialog::PinnedNotes"),
922 }
923 }
924}