kimun_notes/components/events.rs
1use std::num::NonZeroU64;
2use std::sync::Arc;
3use std::time::Duration;
4
5use ratatui::crossterm::event::{KeyEvent, MouseEvent};
6use tokio::sync::mpsc::UnboundedSender;
7
8use kimun_core::{NoteVault, nfs::VaultPath};
9
10use crate::components::file_list::{SortField, SortOrder};
11
12/// Which panel a sort selection applies to.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SortTarget {
15 Sidebar,
16 Query,
17}
18
19/// All events that flow through the system — both input events (from crossterm)
20/// and app-level messages sent by components / screens to the main loop.
21#[derive(Debug, Clone)]
22pub enum AppEvent {
23 Input(InputEvent),
24 OpenScreen(ScreenEvent),
25
26 // ── App-level messages ───────────────────────────────────────────────────
27 Quit,
28 Redraw,
29 Autosave,
30 /// Background autosave task finished. `saved_revision` carries the
31 /// editor's `content_revision` at the moment the save was *issued*
32 /// on success, `None` if the write failed. The editor screen uses
33 /// `path` to ignore stale completions for notes the user has
34 /// already navigated away from, and `saved_revision` to clear the
35 /// dirty flag iff the buffer is still at that revision (i.e. no
36 /// edits during the save). `NonZeroU64` because the editor's
37 /// `content_revision` is never zero.
38 AutosaveCompleted {
39 path: VaultPath,
40 saved_revision: Option<NonZeroU64>,
41 },
42 OpenPath(VaultPath),
43 FocusEditor,
44 FocusSidebar,
45 /// Sent by SettingsScreen when user confirms Save. The shared settings
46 /// reference already contains the updated values.
47 SettingsSaved,
48 /// Sent by SettingsScreen when user discards or closes unchanged.
49 CloseSettings,
50 /// Sent by VaultSection; SettingsScreen::handle_app_message intercepts.
51 OpenFileBrowser,
52 /// Sent by IndexingSection; SettingsScreen intercepts.
53 TriggerFastReindex,
54 TriggerFullReindex,
55 /// Sent by indexing tokio task on completion.
56 IndexingDone(Result<Duration, String>),
57 /// Open (or create) today's journal entry and switch to it in the editor.
58 OpenJournal,
59 /// Dismiss the active editor overlay (note browser, Saved Searches modal,
60 /// or dialog). The single close path for everything owned by `OverlayHost`.
61 CloseOverlay,
62 /// Follow the link under the editor cursor: note name/path or external URL.
63 FollowLink(String),
64 /// Open the search modal pre-filled with `#<name>` to browse notes by label.
65 FollowLabel(String),
66 /// Insert raw text at the editor's cursor (replacing any active selection).
67 /// Used by the screen layer to deliver async results back to the editor —
68 /// e.g. the markdown link generated after a clipboard image is saved as an attachment.
69 InsertAtCursor(String),
70
71 // ── File-operation dialog messages ───────────────────────────────────────
72 /// Request to show the file-operations menu (delete / rename / move).
73 ShowFileOpsMenu(VaultPath),
74 /// Request to show the delete confirmation dialog for the given entry.
75 ShowDeleteDialog(VaultPath),
76 /// Request to show the rename dialog for the given entry.
77 ShowRenameDialog(VaultPath),
78 /// Request to show the move dialog for the given entry.
79 ShowMoveDialog(VaultPath),
80 /// Confirmation that the given entry was successfully deleted.
81 EntryDeleted(VaultPath),
82 /// Confirmation that an entry was successfully renamed.
83 EntryRenamed {
84 from: VaultPath,
85 to: VaultPath,
86 },
87 /// Confirmation that an entry was successfully moved.
88 EntryMoved {
89 from: VaultPath,
90 to: VaultPath,
91 },
92 /// A new note was just created and should be opened; sidebar should reflect it.
93 EntryCreated(VaultPath),
94 /// A dialog operation failed; carries a human-readable error message.
95 DialogError(String),
96
97 /// A vault was found to be structurally unusable (conflicts, invalid layout, etc.).
98 /// Carries a formatted, human-readable error message.
99 ///
100 /// Handled by `handle_app_message` in `main.rs`, which clears the workspace,
101 /// saves settings, and opens the settings screen with an error overlay.
102 /// To add a new conflict source: emit this event from the detection site; no
103 /// other files need to change.
104 VaultConflict(String),
105
106 // ── Dialog async result messages ─────────────────────────────────────────
107 /// Rename dialog: name availability check result.
108 RenameValidation {
109 available: bool,
110 },
111 /// Move dialog: directory list has loaded.
112 MoveDirectoriesLoaded(Vec<VaultPath>),
113 /// Move dialog: fuzzy filter results are ready.
114 MoveFilterResults(Vec<VaultPath>),
115 /// Move dialog: destination existence check result.
116 MoveDestValidation {
117 available: bool,
118 },
119
120 // ── Workspace messages ──────────────────────────────────────────────
121 /// User switched to a different workspace. Carries the workspace name.
122 /// Handled by main.rs to rebuild the vault and navigate to StartScreen.
123 WorkspaceSwitched(String),
124
125 /// Persist a saved search (emitted by the save-search dialog on submit).
126 SaveSearchConfirmed {
127 name: String,
128 query: String,
129 },
130
131 /// A saved search was chosen in the Saved Searches modal.
132 SavedSearchSelected {
133 query: String,
134 name: String,
135 },
136
137 /// Sort selection changed in the sort dialog — apply live to `target`.
138 /// When `persist` is set (sidebar's "save as default"), also write the
139 /// choice to settings. `group_directories` is sidebar-only (the query panel
140 /// ignores it).
141 SortChanged {
142 target: SortTarget,
143 field: SortField,
144 order: SortOrder,
145 group_directories: bool,
146 persist: bool,
147 },
148}
149
150impl AppEvent {
151 pub fn send_input(event: InputEvent) -> Self {
152 AppEvent::Input(event)
153 }
154}
155
156// ── Input events ────────────────────────────────────────────────────────
157#[derive(Debug, Clone)]
158pub enum InputEvent {
159 Key(KeyEvent),
160 Mouse(MouseEvent),
161 /// Bracketed-paste payload from the terminal. On macOS this is what
162 /// Cmd+V delivers, since the terminal intercepts Cmd combos before they
163 /// reach the TUI. The string may be empty when the clipboard holds only
164 /// non-text content (e.g. an image).
165 Paste(String),
166}
167
168// ── Screen events ────────────────────────────────────────────────────────
169#[derive(Debug, Clone)]
170pub enum ScreenEvent {
171 Start,
172 OpenSettings,
173 /// Open the settings screen with an error overlay already shown.
174 OpenSettingsWithError(String),
175 /// Navigate to the editor for the given vault root path.
176 OpenEditor(Arc<NoteVault>, VaultPath),
177 /// Navigate to the browse screen for the given vault root and directory path.
178 OpenBrowse(Arc<NoteVault>, VaultPath),
179}
180
181/// Convenience alias used throughout the codebase.
182pub type AppTx = UnboundedSender<AppEvent>;
183
184/// Build a `Send + Sync` callback that fires `AppEvent::Redraw` on the
185/// app event bus. Used by long-lived components (autocomplete query
186/// task, etc.) that need to wake the render loop from a background
187/// thread but should not be aware of `AppEvent` themselves.
188pub fn redraw_callback(tx: AppTx) -> Arc<dyn Fn() + Send + Sync + 'static> {
189 Arc::new(move || {
190 let _ = tx.send(AppEvent::Redraw);
191 })
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn _assert_new_variants_exist(e: AppEvent) {
199 match e {
200 AppEvent::ShowDeleteDialog(_) => {}
201 AppEvent::ShowRenameDialog(_) => {}
202 AppEvent::ShowMoveDialog(_) => {}
203 AppEvent::EntryDeleted(_) => {}
204 AppEvent::EntryRenamed { from: _, to: _ } => {}
205 AppEvent::EntryMoved { from: _, to: _ } => {}
206 AppEvent::DialogError(_) => {}
207 _ => {}
208 }
209 }
210
211 #[test]
212 fn sort_events_construct() {
213 use crate::components::file_list::{SortField, SortOrder};
214 let _ = AppEvent::SortChanged {
215 target: SortTarget::Sidebar,
216 field: SortField::Name,
217 order: SortOrder::Ascending,
218 group_directories: true,
219 persist: false,
220 };
221 let _ = AppEvent::SortChanged {
222 target: SortTarget::Query,
223 field: SortField::Title,
224 order: SortOrder::Descending,
225 group_directories: false,
226 persist: true,
227 };
228 }
229}