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