1use std::ops::Range;
2
3use crate::{ContextMenuContext, ContextMenuItem, EditorBuffer, KeyCode, SearchAction, Selection};
4use crate::{HookEffect, PromptState};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub struct Modifiers {
9 pub ctrl: bool,
11 pub alt: bool,
13 pub shift: bool,
15 pub meta: bool,
17}
18
19impl Modifiers {
20 pub const fn empty() -> Self {
22 Self {
23 ctrl: false,
24 alt: false,
25 shift: false,
26 meta: false,
27 }
28 }
29
30 pub const fn ctrl() -> Self {
32 Self {
33 ctrl: true,
34 ..Self::empty()
35 }
36 }
37
38 pub const fn alt() -> Self {
40 Self {
41 alt: true,
42 ..Self::empty()
43 }
44 }
45
46 pub const fn shift() -> Self {
48 Self {
49 shift: true,
50 ..Self::empty()
51 }
52 }
53
54 pub const fn meta() -> Self {
56 Self {
57 meta: true,
58 ..Self::empty()
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct KeyEvent {
70 pub code: KeyCode,
72 pub modifiers: Modifiers,
74}
75
76impl KeyEvent {
77 pub fn plain(code: KeyCode) -> Self {
79 Self {
80 code,
81 modifiers: Modifiers::empty(),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum CursorStyle {
89 #[default]
91 Bar,
92 Block,
94 Underline,
96 Hidden,
98}
99
100pub struct HookContext<'a> {
107 pub buffer: &'a mut EditorBuffer,
109 pub selection: &'a mut Option<Selection>,
111 pub cursor_style: &'a mut CursorStyle,
113 pub prompt: &'a mut PromptState,
115 pub effects: &'a mut Vec<HookEffect>,
117}
118
119impl<'a> HookContext<'a> {
120 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum HookOutcome {
141 Consumed,
143 PassThrough,
145}
146
147pub trait EditorHook: 'static {
149 fn on_key(&mut self, _ctx: &mut HookContext, _event: &KeyEvent) -> HookOutcome {
151 HookOutcome::PassThrough
152 }
153
154 fn on_search_action(&mut self, _ctx: &mut HookContext, _action: SearchAction) -> HookOutcome {
160 HookOutcome::PassThrough
161 }
162
163 fn before_insert(&mut self, _ctx: &mut HookContext, _text: char) -> HookOutcome {
165 HookOutcome::PassThrough
166 }
167
168 fn after_edit(&mut self, _buffer: &mut EditorBuffer) {}
170
171 fn on_selection_change(&mut self, _buffer: &EditorBuffer, _selection: Option<&Selection>) {}
173
174 fn on_click(&mut self, _ctx: &mut HookContext, _row: usize, _col: usize) -> HookOutcome {
182 HookOutcome::PassThrough
183 }
184
185 fn context_menu_items(&self, _ctx: &ContextMenuContext) -> Vec<ContextMenuItem> {
191 Vec::new()
192 }
193
194 fn on_context_menu_action(&mut self, _ctx: &mut HookContext, _id: &str) -> HookOutcome {
200 HookOutcome::PassThrough
201 }
202
203 fn status_text(&self) -> Option<&str> {
205 None
206 }
207
208 fn search_snapshot(&self) -> Option<SearchSnapshot> {
214 None
215 }
216}
217
218#[derive(Debug, Clone, Default)]
224pub struct SearchSnapshot {
225 pub active: bool,
227 pub case_sensitive: bool,
229 pub whole_word: bool,
231 pub highlight_all: bool,
233 pub matches: Vec<Range<usize>>,
235 pub current: Option<usize>,
237 pub replace_mode: bool,
239 pub is_replace_prompt: bool,
241 pub query: String,
243 pub replacement: String,
245}
246
247#[derive(Debug, Clone, Default)]
249pub struct AutoPairsHook;
250
251impl AutoPairsHook {
252 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 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 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 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 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}