Skip to main content

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