Skip to main content

twrite_core/
hook.rs

1use std::ops::Range;
2
3use crate::{ContextMenuContext, ContextMenuItem, EditorBuffer, KeyCode, SearchAction, Selection};
4use crate::{HookEffect, PromptState};
5
6/// Keyboard modifier keys state.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub struct Modifiers {
9    /// Whether the Control key is pressed.
10    pub ctrl: bool,
11    /// Whether the Alt key is pressed.
12    pub alt: bool,
13    /// Whether the Shift key is pressed.
14    pub shift: bool,
15    /// Whether the Meta / Command / Windows key is pressed.
16    pub meta: bool,
17}
18
19impl Modifiers {
20    /// Creates an empty modifier set with all modifiers disabled.
21    pub const fn empty() -> Self {
22        Self {
23            ctrl: false,
24            alt: false,
25            shift: false,
26            meta: false,
27        }
28    }
29
30    /// Control only (used for `Ctrl+…` keybinding hints).
31    pub const fn ctrl() -> Self {
32        Self {
33            ctrl: true,
34            ..Self::empty()
35        }
36    }
37
38    /// Alt (Option) only.
39    pub const fn alt() -> Self {
40        Self {
41            alt: true,
42            ..Self::empty()
43        }
44    }
45
46    /// Shift only.
47    pub const fn shift() -> Self {
48        Self {
49            shift: true,
50            ..Self::empty()
51        }
52    }
53
54    /// Meta (Command / Windows) only.
55    pub const fn meta() -> Self {
56        Self {
57            meta: true,
58            ..Self::empty()
59        }
60    }
61}
62
63/// A normalized keyboard event passed to editor hooks.
64///
65/// `code` is the structured key identity (see [`KeyCode`]); printable input
66/// arrives as `Char`, so hooks match `KeyCode::Enter` / `KeyCode::Char('d')`
67/// instead of stringly key names.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct KeyEvent {
70    /// The structured key identity.
71    pub code: KeyCode,
72    /// The active keyboard modifiers during the key event.
73    pub modifiers: Modifiers,
74}
75
76impl KeyEvent {
77    /// Creates a key event without modifiers.
78    pub fn plain(code: KeyCode) -> Self {
79        Self {
80            code,
81            modifiers: Modifiers::empty(),
82        }
83    }
84}
85
86/// The visual style of the text cursor.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum CursorStyle {
89    /// Vertical bar / I-beam cursor.
90    #[default]
91    Bar,
92    /// Solid full-character block cursor.
93    Block,
94    /// Horizontal underline cursor.
95    Underline,
96    /// Invisible cursor.
97    Hidden,
98}
99
100/// The mutable editing context passed to editor hooks during events.
101///
102/// `prompt` is the shared headless input box (bottom bar / command palette):
103/// any hook can `open` it, read submitted input, and fill item rows, with no
104/// frontend code. `effects` collects app-level requests (save/load/quit)
105/// that the host drains after each event.
106pub struct HookContext<'a> {
107    /// Mutable access to the underlying text buffer.
108    pub buffer: &'a mut EditorBuffer,
109    /// Mutable access to the active selection range, if any.
110    pub selection: &'a mut Option<Selection>,
111    /// Mutable access to the cursor visual style.
112    pub cursor_style: &'a mut CursorStyle,
113    /// Shared headless prompt / input-box state.
114    pub prompt: &'a mut PromptState,
115    /// App-level requests for the host to drain after the event.
116    pub effects: &'a mut Vec<HookEffect>,
117}
118
119impl<'a> HookContext<'a> {
120    /// Creates a new hook context.
121    pub fn new(
122        buffer: &'a mut EditorBuffer,
123        selection: &'a mut Option<Selection>,
124        cursor_style: &'a mut CursorStyle,
125        prompt: &'a mut PromptState,
126        effects: &'a mut Vec<HookEffect>,
127    ) -> Self {
128        Self {
129            buffer,
130            selection,
131            cursor_style,
132            prompt,
133            effects,
134        }
135    }
136}
137
138/// The outcome of an editor hook handling an event.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum HookOutcome {
141    /// The hook handled the event; halt propagation to subsequent hooks and default editor handlers.
142    Consumed,
143    /// The hook did not consume the event; continue propagation to the next hook or default editor handler.
144    PassThrough,
145}
146
147/// Trait for intercepting input events, mutating buffer state, and extending editor behaviors.
148pub trait EditorHook: 'static {
149    /// Intercepts a key press before standard editor processing.
150    fn on_key(&mut self, _ctx: &mut HookContext, _event: &KeyEvent) -> HookOutcome {
151        HookOutcome::PassThrough
152    }
153
154    /// Handles a synthetic search-panel action (prompt-bar clicks).
155    ///
156    /// Pointer chrome cannot produce a [`KeyCode`], so actions travel here
157    /// instead of through [`on_key`][Self::on_key]. The default passes
158    /// through; [`SearchHook`][crate::SearchHook] implements this.
159    fn on_search_action(&mut self, _ctx: &mut HookContext, _action: SearchAction) -> HookOutcome {
160        HookOutcome::PassThrough
161    }
162
163    /// Intercepts text insertion before it is written to the buffer.
164    fn before_insert(&mut self, _ctx: &mut HookContext, _text: char) -> HookOutcome {
165        HookOutcome::PassThrough
166    }
167
168    /// Called immediately after any buffer mutation (typing, deletions, paste, undo/redo).
169    fn after_edit(&mut self, _buffer: &mut EditorBuffer) {}
170
171    /// Called whenever the cursor offset or selection range changes.
172    fn on_selection_change(&mut self, _buffer: &EditorBuffer, _selection: Option<&Selection>) {}
173
174    /// Intercepts a mouse click at a given buffer line and source byte column.
175    ///
176    /// `row` is the zero-based buffer row. `col` is the source byte offset within
177    /// that line's text (pre-concealment coordinates, clamped to the line length).
178    /// Hooks handling interactive elements (e.g. Markdown task checkboxes) should
179    /// operate on `row` directly rather than the current cursor row, preserve the
180    /// cursor offset when appropriate, and return `Consumed` to halt propagation.
181    fn on_click(&mut self, _ctx: &mut HookContext, _row: usize, _col: usize) -> HookOutcome {
182        HookOutcome::PassThrough
183    }
184
185    /// Contributes right-click context menu rows for the given click.
186    ///
187    /// Rows append after the built-in edit actions (Cut/Copy/Paste/...);
188    /// returning an item with a well-known id (e.g. `"copy"`) overrides
189    /// that default in place. The default is no rows.
190    fn context_menu_items(&self, _ctx: &ContextMenuContext) -> Vec<ContextMenuItem> {
191        Vec::new()
192    }
193
194    /// Handles activation of a context menu row by `id`.
195    ///
196    /// Runs before the built-in edit dispatch; return `Consumed` to halt
197    /// (including to suppress a default with the same id). The default
198    /// passes through to built-in handling.
199    fn on_context_menu_action(&mut self, _ctx: &mut HookContext, _id: &str) -> HookOutcome {
200        HookOutcome::PassThrough
201    }
202
203    /// Returns a human-readable status or active mode name, if any.
204    fn status_text(&self) -> Option<&str> {
205        None
206    }
207
208    /// Returns a live search-panel snapshot for renderers (toggles, matches).
209    ///
210    /// Hooks without a prompt-driven search return `None` (the default), in
211    /// which case editors hide search chrome. [`crate::SearchHook`]
212    /// implements this; composite hooks (e.g. vim) forward their owned hook.
213    fn search_snapshot(&self) -> Option<SearchSnapshot> {
214        None
215    }
216}
217
218/// Owned snapshot of a hook's search panel for renderers.
219///
220/// Returned by [`EditorHook::search_snapshot`]; editors poll it after input
221/// events to draw toggle chips and the highlight-all wash. Owned (not
222/// borrowed) so hosts can retain it across frames without pinning hooks.
223#[derive(Debug, Clone, Default)]
224pub struct SearchSnapshot {
225    /// Whether the hook currently owns the open prompt.
226    pub active: bool,
227    /// Whether matching is case-sensitive.
228    pub case_sensitive: bool,
229    /// Whether matches must span whole words.
230    pub whole_word: bool,
231    /// Whether all matches wash the viewport (vs current match only).
232    pub highlight_all: bool,
233    /// All match byte ranges in ascending order.
234    pub matches: Vec<Range<usize>>,
235    /// Index of the current match, if navigation has occurred.
236    pub current: Option<usize>,
237    /// Whether replace mode is active in the search panel.
238    pub replace_mode: bool,
239    /// Whether the active input prompt is currently the replace field.
240    pub is_replace_prompt: bool,
241    /// Current search query string.
242    pub query: String,
243    /// Current replacement string.
244    pub replacement: String,
245}
246
247/// Built-in hook that automatically inserts closing quotes, brackets, and braces, wraps selected text, and steps over closing pairs.
248#[derive(Debug, Clone, Default)]
249pub struct AutoPairsHook;
250
251impl AutoPairsHook {
252    /// Creates a new auto-pairs hook.
253    pub fn new() -> Self {
254        Self
255    }
256
257    fn matching_close(c: char) -> Option<char> {
258        match c {
259            '(' => Some(')'),
260            '[' => Some(']'),
261            '{' => Some('}'),
262            '"' => Some('"'),
263            '\'' => Some('\''),
264            '`' => Some('`'),
265            _ => None,
266        }
267    }
268
269    fn is_pair(open: char, close: char) -> bool {
270        Self::matching_close(open) == Some(close)
271    }
272}
273
274impl EditorHook for AutoPairsHook {
275    fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
276        if event.modifiers.ctrl || event.modifiers.alt || event.modifiers.meta {
277            return HookOutcome::PassThrough;
278        }
279
280        if event.code == KeyCode::Backspace && ctx.selection.is_none() {
281            let cursor = ctx.buffer.cursor_offset();
282            if cursor > 0 && cursor < ctx.buffer.len_bytes() {
283                let text = ctx.buffer.text();
284                let prev_char = text.char_at_byte_offset(cursor - 1);
285                let next_char = text.char_at_byte_offset(cursor);
286                if let (Some(p), Some(n)) = (prev_char, next_char)
287                    && Self::is_pair(p, n)
288                {
289                    ctx.buffer.delete();
290                    ctx.buffer.backspace();
291                    return HookOutcome::Consumed;
292                }
293            }
294            return HookOutcome::PassThrough;
295        }
296
297        if let KeyCode::Char(ch) = event.code {
298            if let Some(close) = Self::matching_close(ch) {
299                if let Some(sel) = ctx.selection.take() {
300                    let range = sel.byte_range();
301                    let selected_text = ctx.buffer.text().byte_slice(range.clone()).to_string();
302                    let wrapped = format!("{}{}{}", ch, selected_text, close);
303                    ctx.buffer.replace_range(range.clone(), &wrapped);
304                    *ctx.selection = Some(Selection::range(range.start + 1, range.end + 1));
305                    return HookOutcome::Consumed;
306                }
307
308                let cursor = ctx.buffer.cursor_offset();
309                let text = ctx.buffer.text();
310                let next_char = text.char_at_byte_offset(cursor);
311
312                if (ch == '"' || ch == '\'' || ch == '`') && next_char == Some(ch) {
313                    ctx.buffer.move_cursor_right();
314                    return HookOutcome::Consumed;
315                }
316
317                ctx.buffer.insert(&format!("{}{}", ch, close));
318                ctx.buffer.move_cursor_left();
319                return HookOutcome::Consumed;
320            }
321
322            if ch == ')' || ch == ']' || ch == '}' {
323                let cursor = ctx.buffer.cursor_offset();
324                let text = ctx.buffer.text();
325                let next_char = text.char_at_byte_offset(cursor);
326                if next_char == Some(ch) {
327                    ctx.buffer.move_cursor_right();
328                    return HookOutcome::Consumed;
329                }
330            }
331        }
332
333        HookOutcome::PassThrough
334    }
335}
336
337trait CharAtByteOffset {
338    fn char_at_byte_offset(&self, offset: usize) -> Option<char>;
339}
340
341impl CharAtByteOffset for ropey::Rope {
342    fn char_at_byte_offset(&self, offset: usize) -> Option<char> {
343        if offset >= self.len_bytes() {
344            return None;
345        }
346        let char_idx = self.byte_to_char(offset);
347        Some(self.char(char_idx))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::PromptState;
355
356    struct MockModalHook {
357        mode: String,
358    }
359
360    impl EditorHook for MockModalHook {
361        fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
362            if event.code == KeyCode::Escape {
363                self.mode = "NORMAL".into();
364                *ctx.cursor_style = CursorStyle::Block;
365                *ctx.selection = None;
366                return HookOutcome::Consumed;
367            }
368
369            if self.mode == "NORMAL" {
370                match &event.code {
371                    KeyCode::Char('i') => {
372                        self.mode = "INSERT".into();
373                        *ctx.cursor_style = CursorStyle::Bar;
374                        HookOutcome::Consumed
375                    }
376                    KeyCode::Char('v') => {
377                        self.mode = "VISUAL".into();
378                        *ctx.selection = Some(Selection::point(ctx.buffer.cursor_offset()));
379                        HookOutcome::Consumed
380                    }
381                    KeyCode::Char('x') => {
382                        ctx.buffer.delete();
383                        HookOutcome::Consumed
384                    }
385                    _ => HookOutcome::Consumed,
386                }
387            } else {
388                HookOutcome::PassThrough
389            }
390        }
391
392        fn status_text(&self) -> Option<&str> {
393            Some(&self.mode)
394        }
395    }
396
397    #[test]
398    fn test_modal_hook_transitions() {
399        let mut buffer = EditorBuffer::new("hello");
400        let mut selection = None;
401        let mut cursor_style = CursorStyle::Block;
402        let mut prompt = PromptState::new();
403        let mut effects = Vec::new();
404        let mut hook = MockModalHook {
405            mode: "NORMAL".into(),
406        };
407
408        let mut ctx = HookContext::new(
409            &mut buffer,
410            &mut selection,
411            &mut cursor_style,
412            &mut prompt,
413            &mut effects,
414        );
415
416        assert_eq!(hook.status_text(), Some("NORMAL"));
417
418        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('i')));
419        assert_eq!(outcome, HookOutcome::Consumed);
420        assert_eq!(hook.status_text(), Some("INSERT"));
421        assert_eq!(*ctx.cursor_style, CursorStyle::Bar);
422
423        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('a')));
424        assert_eq!(outcome, HookOutcome::PassThrough);
425
426        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Escape));
427        assert_eq!(outcome, HookOutcome::Consumed);
428        assert_eq!(hook.status_text(), Some("NORMAL"));
429        assert_eq!(*ctx.cursor_style, CursorStyle::Block);
430
431        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('v')));
432        assert_eq!(outcome, HookOutcome::Consumed);
433        assert_eq!(hook.status_text(), Some("VISUAL"));
434        assert!(ctx.selection.is_some());
435    }
436
437    #[test]
438    fn test_autopairs_insert_and_wrap() {
439        let mut buffer = EditorBuffer::new("");
440        let mut selection = None;
441        let mut cursor_style = CursorStyle::Bar;
442        let mut prompt = PromptState::new();
443        let mut effects = Vec::new();
444        let mut autopairs = AutoPairsHook::new();
445
446        let mut ctx = HookContext::new(
447            &mut buffer,
448            &mut selection,
449            &mut cursor_style,
450            &mut prompt,
451            &mut effects,
452        );
453
454        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('(')));
455        assert_eq!(outcome, HookOutcome::Consumed);
456        assert_eq!(ctx.buffer.text().to_string(), "()");
457        assert_eq!(ctx.buffer.cursor_offset(), 1);
458
459        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char(')')));
460        assert_eq!(outcome, HookOutcome::Consumed);
461        assert_eq!(ctx.buffer.text().to_string(), "()");
462        assert_eq!(ctx.buffer.cursor_offset(), 2);
463
464        ctx.buffer.set_cursor_offset(1);
465        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Backspace));
466        assert_eq!(outcome, HookOutcome::Consumed);
467        assert_eq!(ctx.buffer.text().to_string(), "");
468        assert_eq!(ctx.buffer.cursor_offset(), 0);
469
470        ctx.buffer.insert("word");
471        *ctx.selection = Some(Selection::range(0, 4));
472        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('"')));
473        assert_eq!(outcome, HookOutcome::Consumed);
474        assert_eq!(ctx.buffer.text().to_string(), "\"word\"");
475    }
476
477    /// Proof that a full vim-style search + ex-command loop runs on hooks +
478    /// core alone: no frontend, no GPUI. `/` opens a live search prompt,
479    /// `Enter` jumps to the match; `:` opens an ex prompt, `w`/`q` push
480    /// app-level effects for the host to drain.
481    struct MiniVim {
482        search: crate::SearchState,
483    }
484
485    impl EditorHook for MiniVim {
486        fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
487            use crate::{HookEffect, PromptAction, PromptPlacement, PromptSpec, SearchQuery};
488
489            if ctx.prompt.is_open() {
490                let is_search = ctx.prompt.spec().is_some_and(|s| s.id == "search");
491                match ctx.prompt.handle_key(event) {
492                    PromptAction::Editing => {
493                        if is_search && !ctx.prompt.input().is_empty() {
494                            let input = ctx.prompt.input().to_string();
495                            self.search.set_query(SearchQuery::literal(&input));
496                            let _ = self.search.refresh(ctx.buffer);
497                        }
498                        return HookOutcome::Consumed;
499                    }
500                    PromptAction::Submitted(input) => {
501                        if is_search {
502                            let from = ctx.buffer.cursor_offset();
503                            if let Ok(Some(m)) = self.search.next(ctx.buffer, from, true) {
504                                ctx.buffer.set_cursor_offset(m.start);
505                                *ctx.selection = Some(Selection::range(m.start, m.end));
506                            }
507                        } else {
508                            match input.as_str() {
509                                "w" => ctx.effects.push(HookEffect::Save { path: None }),
510                                "q" => ctx.effects.push(HookEffect::Quit { force: false }),
511                                "q!" => ctx.effects.push(HookEffect::Quit { force: true }),
512                                other => ctx.effects.push(HookEffect::Message(format!(
513                                    "E492: Not an editor command: {other}"
514                                ))),
515                            }
516                        }
517                        ctx.prompt.close();
518                        return HookOutcome::Consumed;
519                    }
520                    PromptAction::Cancelled | PromptAction::Ignored => {
521                        return HookOutcome::Consumed;
522                    }
523                }
524            }
525            match &event.code {
526                KeyCode::Char('/') => {
527                    ctx.prompt.open(
528                        PromptSpec::new("search", "/", "Search", PromptPlacement::BottomBar, true),
529                        "",
530                    );
531                    HookOutcome::Consumed
532                }
533                KeyCode::Char(':') => {
534                    ctx.prompt.open(
535                        PromptSpec::new("vim-ex", ":", "", PromptPlacement::BottomBar, false),
536                        "",
537                    );
538                    HookOutcome::Consumed
539                }
540                _ => HookOutcome::PassThrough,
541            }
542        }
543    }
544
545    #[test]
546    fn test_hook_only_vim_search_and_ex_commands() {
547        use crate::{HookEffect, PromptState};
548
549        let mut buffer = EditorBuffer::new("foo bar foo");
550        let mut selection = None;
551        let mut cursor_style = CursorStyle::Bar;
552        let mut prompt = PromptState::new();
553        let mut effects = Vec::new();
554        let mut vim = MiniVim {
555            search: crate::SearchState::new(),
556        };
557        let mut ctx = HookContext::new(
558            &mut buffer,
559            &mut selection,
560            &mut cursor_style,
561            &mut prompt,
562            &mut effects,
563        );
564
565        // `/foo` + Enter jumps to the first match and selects it.
566        assert_eq!(
567            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('/'))),
568            HookOutcome::Consumed
569        );
570        assert!(ctx.prompt.is_open());
571        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
572            assert_eq!(
573                vim.on_key(&mut ctx, &KeyEvent::plain(k)),
574                HookOutcome::Consumed
575            );
576        }
577        assert_eq!(vim.search.match_count(), 2);
578        assert_eq!(
579            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
580            HookOutcome::Consumed
581        );
582        assert!(!ctx.prompt.is_open());
583        assert_eq!(ctx.selection.unwrap().byte_range(), 0..3);
584
585        // `:w` queues a save effect for the host.
586        assert_eq!(
587            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char(':'))),
588            HookOutcome::Consumed
589        );
590        assert_eq!(
591            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('w'))),
592            HookOutcome::Consumed
593        );
594        assert_eq!(
595            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
596            HookOutcome::Consumed
597        );
598        assert_eq!(ctx.effects.as_slice(), &[HookEffect::Save { path: None }]);
599
600        // `:q` queues a quit effect.
601        assert_eq!(
602            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char(':'))),
603            HookOutcome::Consumed
604        );
605        assert_eq!(
606            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('q'))),
607            HookOutcome::Consumed
608        );
609        assert_eq!(
610            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
611            HookOutcome::Consumed
612        );
613        assert_eq!(
614            ctx.effects.as_slice(),
615            &[
616                HookEffect::Save { path: None },
617                HookEffect::Quit { force: false },
618            ]
619        );
620    }
621}