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/// 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}