twrite-core 0.9.1

Headless buffer, movement, syntax, and hook primitives for twrite
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
use std::ops::Range;

use crate::{ContextMenuContext, ContextMenuItem, EditorBuffer, KeyCode, SearchAction, Selection};
use crate::{HookEffect, PromptState};

/// Keyboard modifier keys state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Modifiers {
    /// Whether the Control key is pressed.
    pub ctrl: bool,
    /// Whether the Alt key is pressed.
    pub alt: bool,
    /// Whether the Shift key is pressed.
    pub shift: bool,
    /// Whether the Meta / Command / Windows key is pressed.
    pub meta: bool,
}

impl Modifiers {
    /// Creates an empty modifier set with all modifiers disabled.
    pub const fn empty() -> Self {
        Self {
            ctrl: false,
            alt: false,
            shift: false,
            meta: false,
        }
    }

    /// Control only (used for `Ctrl+…` keybinding hints).
    pub const fn ctrl() -> Self {
        Self {
            ctrl: true,
            ..Self::empty()
        }
    }

    /// Alt (Option) only.
    pub const fn alt() -> Self {
        Self {
            alt: true,
            ..Self::empty()
        }
    }

    /// Shift only.
    pub const fn shift() -> Self {
        Self {
            shift: true,
            ..Self::empty()
        }
    }

    /// Meta (Command / Windows) only.
    pub const fn meta() -> Self {
        Self {
            meta: true,
            ..Self::empty()
        }
    }
}

/// A normalized keyboard event passed to editor hooks.
///
/// `code` is the structured key identity (see [`KeyCode`]); printable input
/// arrives as `Char`, so hooks match `KeyCode::Enter` / `KeyCode::Char('d')`
/// instead of stringly key names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyEvent {
    /// The structured key identity.
    pub code: KeyCode,
    /// The active keyboard modifiers during the key event.
    pub modifiers: Modifiers,
}

impl KeyEvent {
    /// Creates a key event without modifiers.
    pub fn plain(code: KeyCode) -> Self {
        Self {
            code,
            modifiers: Modifiers::empty(),
        }
    }
}

/// The visual style of the text cursor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CursorStyle {
    /// Vertical bar / I-beam cursor.
    #[default]
    Bar,
    /// Solid full-character block cursor.
    Block,
    /// Horizontal underline cursor.
    Underline,
    /// Invisible cursor.
    Hidden,
}

/// The mutable editing context passed to editor hooks during events.
///
/// `prompt` is the shared headless input box (bottom bar / command palette):
/// any hook can `open` it, read submitted input, and fill item rows, with no
/// frontend code. `effects` collects app-level requests (save/load/quit)
/// that the host drains after each event.
pub struct HookContext<'a> {
    /// Mutable access to the underlying text buffer.
    pub buffer: &'a mut EditorBuffer,
    /// Mutable access to the active selection range, if any.
    pub selection: &'a mut Option<Selection>,
    /// Mutable access to the cursor visual style.
    pub cursor_style: &'a mut CursorStyle,
    /// Shared headless prompt / input-box state.
    pub prompt: &'a mut PromptState,
    /// App-level requests for the host to drain after the event.
    pub effects: &'a mut Vec<HookEffect>,
}

impl<'a> HookContext<'a> {
    /// Creates a new hook context.
    pub fn new(
        buffer: &'a mut EditorBuffer,
        selection: &'a mut Option<Selection>,
        cursor_style: &'a mut CursorStyle,
        prompt: &'a mut PromptState,
        effects: &'a mut Vec<HookEffect>,
    ) -> Self {
        Self {
            buffer,
            selection,
            cursor_style,
            prompt,
            effects,
        }
    }
}

/// The outcome of an editor hook handling an event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookOutcome {
    /// The hook handled the event; halt propagation to subsequent hooks and default editor handlers.
    Consumed,
    /// The hook did not consume the event; continue propagation to the next hook or default editor handler.
    PassThrough,
}

/// Trait for intercepting input events, mutating buffer state, and extending editor behaviors.
pub trait EditorHook: 'static {
    /// Intercepts a key press before standard editor processing.
    fn on_key(&mut self, _ctx: &mut HookContext, _event: &KeyEvent) -> HookOutcome {
        HookOutcome::PassThrough
    }

    /// Handles a synthetic search-panel action (prompt-bar clicks).
    ///
    /// Pointer chrome cannot produce a [`KeyCode`], so actions travel here
    /// instead of through [`on_key`][Self::on_key]. The default passes
    /// through; [`SearchHook`][crate::SearchHook] implements this.
    fn on_search_action(&mut self, _ctx: &mut HookContext, _action: SearchAction) -> HookOutcome {
        HookOutcome::PassThrough
    }

    /// Intercepts text insertion before it is written to the buffer.
    fn before_insert(&mut self, _ctx: &mut HookContext, _text: char) -> HookOutcome {
        HookOutcome::PassThrough
    }

    /// Called immediately after any buffer mutation (typing, deletions, paste, undo/redo).
    fn after_edit(&mut self, _buffer: &mut EditorBuffer) {}

    /// Called whenever the cursor offset or selection range changes.
    fn on_selection_change(&mut self, _buffer: &EditorBuffer, _selection: Option<&Selection>) {}

    /// Intercepts a mouse click at a given buffer line and source byte column.
    ///
    /// `row` is the zero-based buffer row. `col` is the source byte offset within
    /// that line's text (pre-concealment coordinates, clamped to the line length).
    /// Hooks handling interactive elements (e.g. Markdown task checkboxes) should
    /// operate on `row` directly rather than the current cursor row, preserve the
    /// cursor offset when appropriate, and return `Consumed` to halt propagation.
    fn on_click(&mut self, _ctx: &mut HookContext, _row: usize, _col: usize) -> HookOutcome {
        HookOutcome::PassThrough
    }

    /// Contributes right-click context menu rows for the given click.
    ///
    /// Rows append after the built-in edit actions (Cut/Copy/Paste/...);
    /// returning an item with a well-known id (e.g. `"copy"`) overrides
    /// that default in place. The default is no rows.
    fn context_menu_items(&self, _ctx: &ContextMenuContext) -> Vec<ContextMenuItem> {
        Vec::new()
    }

    /// Handles activation of a context menu row by `id`.
    ///
    /// Runs before the built-in edit dispatch; return `Consumed` to halt
    /// (including to suppress a default with the same id). The default
    /// passes through to built-in handling.
    fn on_context_menu_action(&mut self, _ctx: &mut HookContext, _id: &str) -> HookOutcome {
        HookOutcome::PassThrough
    }

    /// Returns a human-readable status or active mode name, if any.
    fn status_text(&self) -> Option<&str> {
        None
    }

    /// Returns a live search-panel snapshot for renderers (toggles, matches).
    ///
    /// Hooks without a prompt-driven search return `None` (the default), in
    /// which case editors hide search chrome. [`crate::SearchHook`]
    /// implements this; composite hooks (e.g. vim) forward their owned hook.
    fn search_snapshot(&self) -> Option<SearchSnapshot> {
        None
    }
}

/// Owned snapshot of a hook's search panel for renderers.
///
/// Returned by [`EditorHook::search_snapshot`]; editors poll it after input
/// events to draw toggle chips and the highlight-all wash. Owned (not
/// borrowed) so hosts can retain it across frames without pinning hooks.
#[derive(Debug, Clone, Default)]
pub struct SearchSnapshot {
    /// Whether the hook currently owns the open prompt.
    pub active: bool,
    /// Whether matching is case-sensitive.
    pub case_sensitive: bool,
    /// Whether matches must span whole words.
    pub whole_word: bool,
    /// Whether all matches wash the viewport (vs current match only).
    pub highlight_all: bool,
    /// All match byte ranges in ascending order.
    pub matches: Vec<Range<usize>>,
    /// Index of the current match, if navigation has occurred.
    pub current: Option<usize>,
    /// Whether replace mode is active in the search panel.
    pub replace_mode: bool,
    /// Whether the active input prompt is currently the replace field.
    pub is_replace_prompt: bool,
    /// Current search query string.
    pub query: String,
    /// Current replacement string.
    pub replacement: String,
}

/// Built-in hook that automatically inserts closing quotes, brackets, and braces, wraps selected text, and steps over closing pairs.
#[derive(Debug, Clone, Default)]
pub struct AutoPairsHook;

impl AutoPairsHook {
    /// Creates a new auto-pairs hook.
    pub fn new() -> Self {
        Self
    }

    fn matching_close(c: char) -> Option<char> {
        match c {
            '(' => Some(')'),
            '[' => Some(']'),
            '{' => Some('}'),
            '"' => Some('"'),
            '\'' => Some('\''),
            '`' => Some('`'),
            _ => None,
        }
    }

    fn is_pair(open: char, close: char) -> bool {
        Self::matching_close(open) == Some(close)
    }
}

impl EditorHook for AutoPairsHook {
    fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
        if event.modifiers.ctrl || event.modifiers.alt || event.modifiers.meta {
            return HookOutcome::PassThrough;
        }

        if event.code == KeyCode::Backspace && ctx.selection.is_none() {
            let cursor = ctx.buffer.cursor_offset();
            if cursor > 0 && cursor < ctx.buffer.len_bytes() {
                let text = ctx.buffer.text();
                let prev_char = text.char_at_byte_offset(cursor - 1);
                let next_char = text.char_at_byte_offset(cursor);
                if let (Some(p), Some(n)) = (prev_char, next_char)
                    && Self::is_pair(p, n)
                {
                    ctx.buffer.delete();
                    ctx.buffer.backspace();
                    return HookOutcome::Consumed;
                }
            }
            return HookOutcome::PassThrough;
        }

        if let KeyCode::Char(ch) = event.code {
            if let Some(close) = Self::matching_close(ch) {
                if let Some(sel) = ctx.selection.take() {
                    let range = sel.byte_range();
                    let selected_text = ctx.buffer.text().byte_slice(range.clone()).to_string();
                    let wrapped = format!("{}{}{}", ch, selected_text, close);
                    ctx.buffer.replace_range(range.clone(), &wrapped);
                    *ctx.selection = Some(Selection::range(range.start + 1, range.end + 1));
                    return HookOutcome::Consumed;
                }

                let cursor = ctx.buffer.cursor_offset();
                let text = ctx.buffer.text();
                let next_char = text.char_at_byte_offset(cursor);

                if (ch == '"' || ch == '\'' || ch == '`') && next_char == Some(ch) {
                    ctx.buffer.move_cursor_right();
                    return HookOutcome::Consumed;
                }

                ctx.buffer.insert(&format!("{}{}", ch, close));
                ctx.buffer.move_cursor_left();
                return HookOutcome::Consumed;
            }

            if ch == ')' || ch == ']' || ch == '}' {
                let cursor = ctx.buffer.cursor_offset();
                let text = ctx.buffer.text();
                let next_char = text.char_at_byte_offset(cursor);
                if next_char == Some(ch) {
                    ctx.buffer.move_cursor_right();
                    return HookOutcome::Consumed;
                }
            }
        }

        HookOutcome::PassThrough
    }
}

trait CharAtByteOffset {
    fn char_at_byte_offset(&self, offset: usize) -> Option<char>;
}

impl CharAtByteOffset for ropey::Rope {
    fn char_at_byte_offset(&self, offset: usize) -> Option<char> {
        if offset >= self.len_bytes() {
            return None;
        }
        let char_idx = self.byte_to_char(offset);
        Some(self.char(char_idx))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::PromptState;

    struct MockModalHook {
        mode: String,
    }

    impl EditorHook for MockModalHook {
        fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
            if event.code == KeyCode::Escape {
                self.mode = "NORMAL".into();
                *ctx.cursor_style = CursorStyle::Block;
                *ctx.selection = None;
                return HookOutcome::Consumed;
            }

            if self.mode == "NORMAL" {
                match &event.code {
                    KeyCode::Char('i') => {
                        self.mode = "INSERT".into();
                        *ctx.cursor_style = CursorStyle::Bar;
                        HookOutcome::Consumed
                    }
                    KeyCode::Char('v') => {
                        self.mode = "VISUAL".into();
                        *ctx.selection = Some(Selection::point(ctx.buffer.cursor_offset()));
                        HookOutcome::Consumed
                    }
                    KeyCode::Char('x') => {
                        ctx.buffer.delete();
                        HookOutcome::Consumed
                    }
                    _ => HookOutcome::Consumed,
                }
            } else {
                HookOutcome::PassThrough
            }
        }

        fn status_text(&self) -> Option<&str> {
            Some(&self.mode)
        }
    }

    #[test]
    fn test_modal_hook_transitions() {
        let mut buffer = EditorBuffer::new("hello");
        let mut selection = None;
        let mut cursor_style = CursorStyle::Block;
        let mut prompt = PromptState::new();
        let mut effects = Vec::new();
        let mut hook = MockModalHook {
            mode: "NORMAL".into(),
        };

        let mut ctx = HookContext::new(
            &mut buffer,
            &mut selection,
            &mut cursor_style,
            &mut prompt,
            &mut effects,
        );

        assert_eq!(hook.status_text(), Some("NORMAL"));

        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('i')));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(hook.status_text(), Some("INSERT"));
        assert_eq!(*ctx.cursor_style, CursorStyle::Bar);

        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('a')));
        assert_eq!(outcome, HookOutcome::PassThrough);

        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Escape));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(hook.status_text(), Some("NORMAL"));
        assert_eq!(*ctx.cursor_style, CursorStyle::Block);

        let outcome = hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('v')));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(hook.status_text(), Some("VISUAL"));
        assert!(ctx.selection.is_some());
    }

    #[test]
    fn test_autopairs_insert_and_wrap() {
        let mut buffer = EditorBuffer::new("");
        let mut selection = None;
        let mut cursor_style = CursorStyle::Bar;
        let mut prompt = PromptState::new();
        let mut effects = Vec::new();
        let mut autopairs = AutoPairsHook::new();

        let mut ctx = HookContext::new(
            &mut buffer,
            &mut selection,
            &mut cursor_style,
            &mut prompt,
            &mut effects,
        );

        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('(')));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(ctx.buffer.text().to_string(), "()");
        assert_eq!(ctx.buffer.cursor_offset(), 1);

        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char(')')));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(ctx.buffer.text().to_string(), "()");
        assert_eq!(ctx.buffer.cursor_offset(), 2);

        ctx.buffer.set_cursor_offset(1);
        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Backspace));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(ctx.buffer.text().to_string(), "");
        assert_eq!(ctx.buffer.cursor_offset(), 0);

        ctx.buffer.insert("word");
        *ctx.selection = Some(Selection::range(0, 4));
        let outcome = autopairs.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('"')));
        assert_eq!(outcome, HookOutcome::Consumed);
        assert_eq!(ctx.buffer.text().to_string(), "\"word\"");
    }

    /// Proof that a full vim-style search + ex-command loop runs on hooks +
    /// core alone: no frontend, no GPUI. `/` opens a live search prompt,
    /// `Enter` jumps to the match; `:` opens an ex prompt, `w`/`q` push
    /// app-level effects for the host to drain.
    struct MiniVim {
        search: crate::SearchState,
    }

    impl EditorHook for MiniVim {
        fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
            use crate::{HookEffect, PromptAction, PromptPlacement, PromptSpec, SearchQuery};

            if ctx.prompt.is_open() {
                let is_search = ctx.prompt.spec().is_some_and(|s| s.id == "search");
                match ctx.prompt.handle_key(event) {
                    PromptAction::Editing => {
                        if is_search && !ctx.prompt.input().is_empty() {
                            let input = ctx.prompt.input().to_string();
                            self.search.set_query(SearchQuery::literal(&input));
                            let _ = self.search.refresh(ctx.buffer);
                        }
                        return HookOutcome::Consumed;
                    }
                    PromptAction::Submitted(input) => {
                        if is_search {
                            let from = ctx.buffer.cursor_offset();
                            if let Ok(Some(m)) = self.search.next(ctx.buffer, from, true) {
                                ctx.buffer.set_cursor_offset(m.start);
                                *ctx.selection = Some(Selection::range(m.start, m.end));
                            }
                        } else {
                            match input.as_str() {
                                "w" => ctx.effects.push(HookEffect::Save { path: None }),
                                "q" => ctx.effects.push(HookEffect::Quit { force: false }),
                                "q!" => ctx.effects.push(HookEffect::Quit { force: true }),
                                other => ctx.effects.push(HookEffect::Message(format!(
                                    "E492: Not an editor command: {other}"
                                ))),
                            }
                        }
                        ctx.prompt.close();
                        return HookOutcome::Consumed;
                    }
                    PromptAction::Cancelled | PromptAction::Ignored => {
                        return HookOutcome::Consumed;
                    }
                }
            }
            match &event.code {
                KeyCode::Char('/') => {
                    ctx.prompt.open(
                        PromptSpec::new("search", "/", "Search", PromptPlacement::BottomBar, true),
                        "",
                    );
                    HookOutcome::Consumed
                }
                KeyCode::Char(':') => {
                    ctx.prompt.open(
                        PromptSpec::new("vim-ex", ":", "", PromptPlacement::BottomBar, false),
                        "",
                    );
                    HookOutcome::Consumed
                }
                _ => HookOutcome::PassThrough,
            }
        }
    }

    #[test]
    fn test_hook_only_vim_search_and_ex_commands() {
        use crate::{HookEffect, PromptState};

        let mut buffer = EditorBuffer::new("foo bar foo");
        let mut selection = None;
        let mut cursor_style = CursorStyle::Bar;
        let mut prompt = PromptState::new();
        let mut effects = Vec::new();
        let mut vim = MiniVim {
            search: crate::SearchState::new(),
        };
        let mut ctx = HookContext::new(
            &mut buffer,
            &mut selection,
            &mut cursor_style,
            &mut prompt,
            &mut effects,
        );

        // `/foo` + Enter jumps to the first match and selects it.
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('/'))),
            HookOutcome::Consumed
        );
        assert!(ctx.prompt.is_open());
        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
            assert_eq!(
                vim.on_key(&mut ctx, &KeyEvent::plain(k)),
                HookOutcome::Consumed
            );
        }
        assert_eq!(vim.search.match_count(), 2);
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
            HookOutcome::Consumed
        );
        assert!(!ctx.prompt.is_open());
        assert_eq!(ctx.selection.unwrap().byte_range(), 0..3);

        // `:w` queues a save effect for the host.
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char(':'))),
            HookOutcome::Consumed
        );
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('w'))),
            HookOutcome::Consumed
        );
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
            HookOutcome::Consumed
        );
        assert_eq!(ctx.effects.as_slice(), &[HookEffect::Save { path: None }]);

        // `:q` queues a quit effect.
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char(':'))),
            HookOutcome::Consumed
        );
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Char('q'))),
            HookOutcome::Consumed
        );
        assert_eq!(
            vim.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
            HookOutcome::Consumed
        );
        assert_eq!(
            ctx.effects.as_slice(),
            &[
                HookEffect::Save { path: None },
                HookEffect::Quit { force: false },
            ]
        );
    }
}