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