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 /// The note's recomputed title (first body line) from the save, so the
52 /// sidebar row can be retitled. `None` when the save failed.
53 title: Option<String>,
54 },
55 /// Open a note (or directory) — `emphasis` carries the originating
56 /// query's needles when the open comes from a query result, so the
57 /// editor lights up the matched spans (spec §5.1). Use
58 /// [`AppEvent::open`] for the plain case.
59 OpenPath {
60 path: VaultPath,
61 emphasis: Option<Vec<String>>,
62 },
63 FocusSidebar,
64 /// Switch the drawer to the given view and reveal it (sent by the
65 /// activity rail and, later, by leader paths / mouse clicks).
66 OpenDrawerView(crate::components::drawer::DrawerView),
67 /// Run the query `#<label>` in the FIND drawer (sent by the TAGS drawer).
68 RunTagQuery(String),
69 /// Jump the editor cursor to the first heading with this text (sent by
70 /// the OUTLINE drawer).
71 JumpToHeading(String),
72 /// Run a leader-tree action (sent by the command palette after it has
73 /// closed itself, so the action sees no open overlay).
74 ExecuteLeaderAction(crate::keys::leader::LeaderAction),
75 /// Show a transient footer flash — async tasks report results with it.
76 FlashMessage(String),
77 /// A newer release was found by the background update check. Stored on
78 /// `App` and surfaced as a footer indicator on the editor screen.
79 UpdateAvailable(crate::update::UpdateStatus),
80 /// User chose "Update now" in the update dialog → run the self-update.
81 ApplyUpdate,
82 /// User skipped a version in the update dialog → persist the dismissal and
83 /// clear the indicator. Carries the version being skipped.
84 DismissUpdate(String),
85 /// Open the update dialog for the currently-known update (manual check).
86 ShowUpdateDialog,
87 /// Self-update finished installing → clear the pending notice (restart still
88 /// required to run the new binary).
89 UpdateApplied,
90 /// Apply (and optionally persist) a resolved theme — sent by the theme
91 /// picker: previews on selection move, persists on Enter. Carries the
92 /// full `Theme` so applying never re-reads the themes directory.
93 ApplyTheme {
94 theme: Box<crate::settings::themes::Theme>,
95 persist: bool,
96 },
97 /// Async-loaded backlink count for the link target under the editor
98 /// cursor (status line 2's `→ target · N backlinks` affordance).
99 LinkTargetMeta {
100 target: String,
101 count: usize,
102 },
103 /// Async-loaded backlink count for the note at `path` (status line 2).
104 BacklinkCountLoaded {
105 path: VaultPath,
106 count: usize,
107 },
108 /// Async-loaded workspace git summary for the status bar, `None` when
109 /// the workspace is not a git repository.
110 GitStatusLoaded(Option<String>),
111 /// Sent by PreferencesScreen when user confirms Save. The shared settings
112 /// reference already contains the updated values.
113 PreferencesSaved,
114 /// Sent by OnboardingScreen when the user confirms Finish on the summary
115 /// step. The shared settings already contain the committed draft; main.rs
116 /// rebuilds the vault and navigates to Start (same as PreferencesSaved).
117 OnboardingFinished,
118 /// Sent by PreferencesScreen when user discards or closes unchanged.
119 ClosePreferences,
120 /// Sent by VaultSection; PreferencesScreen::handle_app_message intercepts.
121 OpenFileBrowser,
122 /// Sent by IndexingSection; PreferencesScreen intercepts.
123 TriggerFastReindex,
124 TriggerFullReindex,
125 /// Sent by indexing tokio task on completion.
126 IndexingDone(Result<Duration, String>),
127 /// Open (or create) today's journal entry and switch to it in the editor.
128 OpenJournal,
129 /// Dismiss the active editor overlay (note browser, Saved Searches modal,
130 /// or dialog). The single close path for everything owned by `OverlayHost`.
131 CloseOverlay,
132 /// Follow the link under the editor cursor: note name/path or external URL.
133 FollowLink(String),
134 /// Open the search modal pre-filled with `#<name>` to browse notes by label.
135 FollowLabel(String),
136 /// Insert raw text at the editor's cursor (replacing any active selection).
137 /// Used by the screen layer to deliver async results back to the editor —
138 /// e.g. the markdown link generated after a clipboard image is saved as an attachment.
139 InsertAtCursor(String),
140
141 // ── File-operation dialog messages ───────────────────────────────────────
142 /// Request to show the file-operations menu (delete / rename / move).
143 ShowFileOpsMenu(VaultPath),
144 /// Request to show the delete confirmation dialog for the given entry.
145 ShowDeleteDialog(VaultPath),
146 /// Request to show the rename dialog for the given entry.
147 ShowRenameDialog(VaultPath),
148 /// Request to show the move dialog for the given entry.
149 ShowMoveDialog(VaultPath),
150 /// Confirmation that the given entry was successfully deleted.
151 EntryDeleted(VaultPath),
152 /// Confirmation that an entry was successfully renamed.
153 EntryRenamed {
154 from: VaultPath,
155 to: VaultPath,
156 },
157 /// Confirmation that an entry was successfully moved.
158 EntryMoved {
159 from: VaultPath,
160 to: VaultPath,
161 },
162 /// Notification that a note was just created at this path. The current
163 /// screen refreshes its sidebar if it is browsing the note's directory.
164 /// Opening the note is a separate concern (the creator emits `OpenPath`).
165 EntryCreated(VaultPath),
166 /// A dialog operation failed; carries a human-readable error message.
167 DialogError(String),
168
169 /// A vault was found to be structurally unusable (conflicts, invalid layout, etc.).
170 /// Carries a formatted, human-readable error message.
171 ///
172 /// Handled by `handle_app_message` in `main.rs`, which clears the workspace,
173 /// saves settings, and opens the settings screen with an error overlay.
174 /// To add a new conflict source: emit this event from the detection site; no
175 /// other files need to change.
176 VaultConflict(String),
177
178 // ── Dialog async result messages ─────────────────────────────────────────
179 /// Rename dialog: name availability check result.
180 RenameValidation {
181 available: bool,
182 },
183 /// Move dialog: directory list has loaded.
184 MoveDirectoriesLoaded(Vec<VaultPath>),
185 /// Move dialog: fuzzy filter results are ready.
186 MoveFilterResults(Vec<VaultPath>),
187 /// Move dialog: destination existence check result.
188 MoveDestValidation {
189 available: bool,
190 },
191 /// Save-search dialog: existing saved-search names have loaded (drives
192 /// the update/overwrite/save-new hint).
193 SavedSearchNamesLoaded(Vec<String>),
194
195 // ── Workspace messages ──────────────────────────────────────────────
196 /// User switched to a different workspace. Carries the workspace name.
197 /// Handled by main.rs to rebuild the vault and navigate to StartScreen.
198 WorkspaceSwitched(String),
199
200 /// Persist a saved search (emitted by the save-search dialog on submit).
201 /// `source` is the surface the query was sourced from, decided when the
202 /// dialog opened — it drives whether the panel breadcrumb re-pins.
203 SaveSearchConfirmed {
204 name: String,
205 query: String,
206 source: SaveSource,
207 },
208
209 /// A saved search was written to disk (success path of
210 /// `SaveSearchConfirmed`). The editor re-pins the panel breadcrumb here —
211 /// only once the write actually succeeded.
212 SavedSearchPersisted {
213 name: String,
214 query: String,
215 source: SaveSource,
216 },
217
218 /// The background saved-search write failed; surface it to the user.
219 SavedSearchSaveFailed {
220 name: String,
221 },
222
223 /// A saved search was chosen in the Saved Searches modal.
224 SavedSearchSelected {
225 query: String,
226 name: String,
227 },
228
229 /// Sort selection changed in the sort dialog — apply live to `target`.
230 /// When `persist` is set (sidebar's "save as default"), also write the
231 /// choice to settings. `group_directories` is sidebar-only (the query panel
232 /// ignores it).
233 SortChanged {
234 target: SortTarget,
235 field: SortField,
236 order: SortOrder,
237 group_directories: bool,
238 persist: bool,
239 },
240}
241
242impl AppEvent {
243 pub fn send_input(event: InputEvent) -> Self {
244 AppEvent::Input(event)
245 }
246
247 /// `OpenPath` without query emphasis — the common case.
248 pub fn open(path: kimun_core::nfs::VaultPath) -> Self {
249 AppEvent::OpenPath {
250 path,
251 emphasis: None,
252 }
253 }
254}
255
256// ── Input events ────────────────────────────────────────────────────────
257#[derive(Debug, Clone)]
258pub enum InputEvent {
259 Key(KeyEvent),
260 Mouse(MouseEvent),
261 /// Bracketed-paste payload from the terminal. On macOS this is what
262 /// Cmd+V delivers, since the terminal intercepts Cmd combos before they
263 /// reach the TUI. The string may be empty when the clipboard holds only
264 /// non-text content (e.g. an image).
265 Paste(String),
266}
267
268// ── Screen events ────────────────────────────────────────────────────────
269#[derive(Debug, Clone)]
270pub enum ScreenEvent {
271 Start,
272 OpenPreferences,
273 /// Open the guided-setup (onboarding) screen.
274 OpenOnboarding,
275 /// Open the settings screen with an error overlay already shown.
276 OpenPreferencesWithError(String),
277 /// Navigate to the editor for the given vault root path.
278 OpenEditor(Arc<NoteVault>, VaultPath),
279 /// Navigate to the browse screen for the given vault root and directory path.
280 OpenBrowse(Arc<NoteVault>, VaultPath),
281}
282
283/// Convenience alias used throughout the codebase.
284pub type AppTx = UnboundedSender<AppEvent>;
285
286/// Sender helpers for the create-then-open sequence shared by every
287/// note-creation site (create dialog, quick note, note browser, sidebar,
288/// journal).
289pub trait AppTxExt {
290 /// Announce a freshly created note so sidebars browsing its directory
291 /// refresh, then open it. The notification is gated on `created` (an
292 /// already-existing note needs no refresh); the note is opened regardless.
293 fn announce_and_open(&self, path: VaultPath, created: bool);
294}
295
296impl AppTxExt for AppTx {
297 fn announce_and_open(&self, path: VaultPath, created: bool) {
298 if created {
299 self.send(AppEvent::EntryCreated(path.clone())).ok();
300 }
301 self.send(AppEvent::open(path)).ok();
302 }
303}
304
305/// Build a `Send + Sync` callback that fires `AppEvent::Redraw` on the
306/// app event bus. Used by long-lived components (autocomplete query
307/// task, etc.) that need to wake the render loop from a background
308/// thread but should not be aware of `AppEvent` themselves.
309pub fn redraw_callback(tx: AppTx) -> Arc<dyn Fn() + Send + Sync + 'static> {
310 Arc::new(move || {
311 let _ = tx.send(AppEvent::Redraw);
312 })
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn _assert_new_variants_exist(e: AppEvent) {
320 match e {
321 AppEvent::ShowDeleteDialog(_) => {}
322 AppEvent::ShowRenameDialog(_) => {}
323 AppEvent::ShowMoveDialog(_) => {}
324 AppEvent::EntryDeleted(_) => {}
325 AppEvent::EntryRenamed { from: _, to: _ } => {}
326 AppEvent::EntryMoved { from: _, to: _ } => {}
327 AppEvent::DialogError(_) => {}
328 _ => {}
329 }
330 }
331
332 #[test]
333 fn sort_events_construct() {
334 use crate::components::file_list::{SortField, SortOrder};
335 let _ = AppEvent::SortChanged {
336 target: SortTarget::Sidebar,
337 field: SortField::Name,
338 order: SortOrder::Ascending,
339 group_directories: true,
340 persist: false,
341 };
342 let _ = AppEvent::SortChanged {
343 target: SortTarget::Query,
344 field: SortField::Title,
345 order: SortOrder::Descending,
346 group_directories: false,
347 persist: true,
348 };
349 }
350}