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