1extern crate self as escriba_runtime;
9
10mod plugin_host;
11pub use plugin_host::{LazyTrigger, PluginHost};
12
13mod operator_pending;
14pub mod status;
15
16pub use operator_pending::{OpState, OperatorPending};
17pub use status::{PromptKind, StatusModel};
18
19use std::collections::HashMap;
20
21use awase::KeyRepeatGate;
22use escriba_buffer::BufferSet;
23use escriba_buffer::TextRev;
24use escriba_command::{CommandRegistry, EditContext};
25use escriba_core::{
26 Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList,
27 Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
28};
29use escriba_input::{InputOutcome, translate_app_event};
30use escriba_keymap::{Key, Keymap};
31use escriba_mode::ModalState;
32use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
33use escriba_ui::{Layout, Rect, Viewport, Window};
34use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, HostEffect, VmError};
35use madori::AppEvent;
36use std::time::Instant;
37
38pub struct EditorState {
41 pub buffers: BufferSet,
42 pub modal: ModalState,
43 pub search: SearchState,
47 pub keymap: Keymap,
48 pub commands: CommandRegistry,
49 pub layout: Layout,
50 pub active: BufferId,
51 cursors: Cursors,
56 pub quit_requested: bool,
57 pub messages: Vec<String>,
60 search_at: Option<Anchored<usize, TextRev>>,
69 last_change: Option<LastChange>,
76 recording_insert: bool,
79
80 pub jumps: JumpList,
84 pub options: HashMap<String, String>,
88 lisp_vm: Option<EscribaVm>,
94 pub pending_keys: Vec<Key>,
100 repeat_gate: KeyRepeatGate<Key>,
108 pub plugin_host: PluginHost,
113 register: Option<String>,
118 op_pending: zenmai::Stateful<OperatorPending>,
123 edit_gen: EditGen,
128 damage: Damage,
133}
134
135enum SeqStep {
137 Pending,
139 Resolved(Action),
141 Passthrough,
143}
144
145const fn is_repeat_storm_candidate(key: &Key) -> bool {
163 matches!(
164 key,
165 Key::Char('h')
169 | Key::Char('j')
170 | Key::Char('k')
171 | Key::Char('l')
172 | Key::Left
173 | Key::Right
174 | Key::Up
175 | Key::Down
176 )
177}
178
179enum CommitOutcome {
193 Landed {
195 origin: usize,
196 step: escriba_search::Step,
197 },
198 NotFound,
200 NoPrevious,
202 NoPrompt,
204}
205
206#[derive(Debug, Clone)]
208struct LastChange {
209 action: Action,
211 count: u32,
213 inserted: String,
215}
216
217impl EditorState {
218 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
220 let window = Window {
221 id: WindowId(1),
222 buffer_id: active,
223 viewport: Viewport {
224 top_line: 0,
225 left_column: 0,
226 visible_lines: 40,
227 visible_columns: 160,
228 },
229 rect: Rect {
230 x: 0,
231 y: 0,
232 width: 1200,
233 height: 800,
234 },
235 };
236 Self {
237 buffers: initial,
238 modal: ModalState::new(),
239 search: SearchState::new(escriba_search::CaseMode::Smart),
240 search_at: None,
241 last_change: None,
242 recording_insert: false,
243 jumps: JumpList::new(),
244 keymap: Keymap::default_vim(),
245 commands: CommandRegistry::default_set(),
246 layout: Layout::single(window),
247 active,
248 cursors: Cursors::single(Position::ZERO),
249 quit_requested: false,
250 register: None,
251 op_pending: zenmai::Stateful::new(OpState::Resting),
252 messages: Vec::new(),
253 options: HashMap::new(),
254 lisp_vm: None,
255 pending_keys: Vec::new(),
256 repeat_gate: KeyRepeatGate::new(),
257 plugin_host: PluginHost::default(),
258 edit_gen: EditGen::default(),
259 damage: Damage::None,
260 }
261 }
262
263 #[must_use]
267 pub fn edit_gen(&self) -> EditGen {
268 self.edit_gen
269 }
270
271 fn bump_gen(&mut self) {
273 self.edit_gen = self.edit_gen.next();
274 }
275
276 #[must_use]
278 pub fn damage(&self) -> Damage {
279 self.damage
280 }
281
282 pub fn take_damage(&mut self) -> Damage {
286 std::mem::replace(&mut self.damage, Damage::None)
287 }
288
289 fn active_line_count(&self) -> u32 {
292 self.buffers
293 .get(self.active)
294 .map_or(0, escriba_buffer::Buffer::line_count)
295 }
296
297 pub fn register_lazy_plugin(
303 &mut self,
304 name: impl Into<String>,
305 triggers: Vec<LazyTrigger>,
306 entry_src: impl Into<String>,
307 ) {
308 self.plugin_host.register(name, triggers, entry_src);
309 }
310
311 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
317 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
318 return 0;
319 };
320 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
321 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
322 if let Some(value) = self.options.get("mapleader") {
323 if let Some(key) = escriba_lisp::parse_leader_key(value) {
324 self.keymap.set_leader(key);
325 }
326 }
327 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
328 (cmd.registered + km.keybinds_applied) as usize
329 }
330
331 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
335 let pending = self.plugin_host.pending_for_filetype(filetype);
336 let n = pending.len();
337 for src in pending {
338 self.apply_plugin_entry(&src);
339 }
340 n
341 }
342
343 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
346 let pending = self.plugin_host.pending_for_event(event);
347 let n = pending.len();
348 for src in pending {
349 self.apply_plugin_entry(&src);
350 }
351 n
352 }
353
354 pub fn tick(&mut self, event: &AppEvent) {
359 self.tick_at(event, Instant::now());
360 }
361
362 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
366 match translate_app_event(event) {
367 InputOutcome::Key(k) => {
368 if self.gate_key(&k, now) {
369 self.on_key(&k);
370 }
371 }
372 InputOutcome::Resized { width, height } => {
373 if let Some(w) = self
374 .layout
375 .windows
376 .iter_mut()
377 .find(|w| w.id == self.layout.active)
378 {
379 w.rect.width = width;
380 w.rect.height = height;
381 }
382 self.damage = self.damage.join(Damage::Viewport);
383 self.bump_gen();
384 }
385 InputOutcome::Quit => self.quit_requested = true,
386 InputOutcome::Focus(_) | InputOutcome::None => {}
387 }
388 }
389
390 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
400 match self.modal.mode() {
401 Mode::Normal | Mode::Visual | Mode::VisualLine => {
402 if is_repeat_storm_candidate(key) {
408 return self.repeat_gate.try_pass_at(*key, now);
409 }
410 true
411 }
412 Mode::Insert | Mode::Command => true,
413 }
414 }
415
416 pub fn on_key(&mut self, key: &Key) {
418 match self.step_sequence(key) {
422 SeqStep::Pending => return,
423 SeqStep::Resolved(action) => {
424 let count = self.modal.pending_count().unwrap_or(1);
425 self.modal.clear_count();
426 for _ in 0..count {
427 self.apply(&action);
428 if self.quit_requested {
429 return;
430 }
431 }
432 return;
433 }
434 SeqStep::Passthrough => {}
435 }
436 let counted = self.keymap.dispatch(&self.modal, key);
437 if matches!(counted.action, Action::Pending) {
439 if let Key::Char(c) = key {
440 if c.is_ascii_digit() {
441 let d = u32::from(*c as u8 - b'0');
442 self.modal.append_count(d);
443 }
444 }
445 return;
446 }
447 self.apply_counted(&counted.action, counted.count);
451 self.modal.clear_count();
453 }
454
455 fn step_sequence(&mut self, key: &Key) -> SeqStep {
467 let mode = self.modal.mode();
468 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
469 return SeqStep::Passthrough;
470 }
471 if !self.pending_keys.is_empty() {
472 let mut seq = self.pending_keys.clone();
473 seq.push(key.clone());
474 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
475 let action = b.action.clone();
476 self.pending_keys.clear();
477 return SeqStep::Resolved(action);
478 }
479 if self.keymap.is_sequence_prefix(mode, &seq) {
480 self.pending_keys = seq;
481 return SeqStep::Pending;
482 }
483 self.pending_keys.clear();
486 }
487 let start = [key.clone()];
488 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
489 self.pending_keys = start.to_vec();
490 return SeqStep::Pending;
491 }
492 SeqStep::Passthrough
493 }
494
495 #[must_use]
500 pub fn cursor(&self) -> Position {
501 self.cursors.primary()
502 }
503
504 fn set_cursor(&mut self, pos: Position) {
513 let clamped = if let Some(buf) = self.buffers.get(self.active) {
514 buf.clamp(pos)
515 } else {
516 pos
517 };
518 self.cursors.set_primary(clamped);
519 if let Some(w) = self
520 .layout
521 .windows
522 .iter_mut()
523 .find(|w| w.id == self.layout.active)
524 {
525 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
526 }
527 }
528
529 fn apply(&mut self, action: &Action) {
531 self.apply_counted(action, 1);
532 }
533
534 fn apply_counted(&mut self, action: &Action, count: u32) {
542 if matches!(action, Action::SubmitCommand) {
560 if let Some(e) = self.search.prompt_error() {
561 let mut m = String::from("E383: Invalid search string: ");
562 m.push_str(&e.to_string());
563 self.messages.push(m);
564 return;
565 }
566 }
567
568 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
569 for _ in 0..times {
570 self.apply_resolved(&resolved);
571 if self.quit_requested {
572 return;
573 }
574 }
575 }
576 }
577
578 #[must_use]
582 fn text_rev(&self) -> TextRev {
583 self.buffers
584 .get(self.active)
585 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
586 }
587
588 fn active_text(&self) -> String {
589 self.buffers
590 .get(self.active)
591 .map(escriba_buffer::Buffer::to_string)
592 .unwrap_or_default()
593 }
594
595 fn cursor_char(&self) -> usize {
597 self.buffers
598 .get(self.active)
599 .and_then(|b| b.position_to_char(self.cursor()).ok())
600 .unwrap_or(0)
601 }
602
603 #[must_use]
611 pub fn status_model(&self) -> StatusModel<'_> {
612 let cursor = self.cursor();
613 let prompt = self.search.prompt();
614
615 let kind = match prompt.map(|p| p.direction) {
616 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
617 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
618 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
621 None => PromptKind::None,
622 };
623
624 StatusModel {
625 mode: self.modal.mode(),
626 line: cursor.line.saturating_add(1) as usize,
627 column: cursor.column.saturating_add(1) as usize,
628 prompt: kind,
629 prompt_text: prompt
630 .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
631 prompt_caret: prompt.map_or_else(
632 || self.modal.minibuffer_caret(),
633 escriba_search::Prompt::caret,
634 ),
635 count: self.match_count(),
636 message: self.messages.last().map(String::as_str),
637 }
638 }
639
640 #[must_use]
646 fn match_count(&self) -> MatchCount {
647 if self.search.is_prompting() {
648 let text = self.active_text();
649 return match self.search.preview(&text) {
654 escriba_search::Preview::Landed { step, total } => {
655 MatchCount::new(step.index, total)
656 }
657 escriba_search::Preview::NoMatch => MatchCount::None,
658 escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
659 MatchCount::Idle
660 }
661 };
662 }
663 if self.search.pattern().is_none() {
664 return MatchCount::Idle;
665 }
666 let total = self.search.matches().len();
667 let rev = self.text_rev();
670 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
671 if total == 0 {
672 MatchCount::None
673 } else {
674 MatchCount::Idle
675 },
676 |&i| MatchCount::new(i, total),
677 )
678 }
679
680 fn repeat_last_change(&mut self) {
686 let Some(change) = self.last_change.clone() else {
687 self.messages
688 .push("E32: No previous change to repeat".to_string());
689 return;
690 };
691
692 for _ in 0..change.count.max(1) {
693 self.apply_resolved(&change.action);
694 }
695 for c in change.inserted.chars() {
696 self.apply_resolved(&Action::InsertChar(c));
697 }
698 if self.modal.mode() == Mode::Insert {
699 self.apply_resolved(&Action::ChangeMode(Mode::Normal));
703 }
704 self.last_change = Some(change);
708 self.recording_insert = false;
709 }
710
711 fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
718 use escriba_core::TextObject as O;
719 let at = self.cursor_char();
720 let matches = self.search.matches();
721
722 let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
734 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
735 match object {
736 O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
737 O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
738 }
739 })?;
740
741 let m = matches.get(idx)?;
742 let buf = self.buffers.get(self.active)?;
743 Some(Range {
744 start: buf.char_to_position(m.start),
745 end: buf.char_to_position(m.end),
746 })
747 }
748
749 fn land_on(&mut self, step: escriba_search::Step) {
750 if let Some(buf) = self.buffers.get(self.active) {
751 let pos = buf.char_to_position(step.target.start);
752 self.set_cursor(pos);
753 }
754 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
758 }
759
760 fn report_wrap(&mut self, step: &escriba_search::Step) {
766 if let Some(msg) = step.wrapped.message() {
767 self.messages.push(msg.to_string());
768 }
769 }
770
771 fn jump_search(&mut self, reverse: bool) {
775 self.search.relight();
778 self.jumps.push(self.cursor());
780 let at = self.cursor_char();
781 match self.search.repeat(at, reverse) {
782 Some(step) => {
783 self.report_wrap(&step);
785 self.land_on(step);
786 }
787 None => {
788 let msg = self.search.pattern().map_or_else(
789 || "E35: No previous regular expression".to_string(),
790 |p| {
791 let mut m = String::from("E486: Pattern not found: ");
792 m.push_str(p.raw());
793 m
794 },
795 );
796 self.messages.push(msg);
797 }
798 }
799 }
800
801 fn preview_search(&mut self) {
808 let text = self.active_text();
809 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
810 return;
811 };
812 let target = match self.search.preview(&text) {
813 escriba_search::Preview::Landed { step, .. } => step.target.start,
814 escriba_search::Preview::Idle
818 | escriba_search::Preview::Incomplete
819 | escriba_search::Preview::NoMatch => origin,
820 };
821 if let Some(buf) = self.buffers.get(self.active) {
830 let pos = buf.char_to_position(target);
831 self.set_cursor(pos);
832 }
833 }
834
835 fn commit_search_prompt(&mut self) -> CommitOutcome {
850 let text = self.active_text();
851 let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
852 else {
853 return CommitOutcome::NoPrompt;
854 };
855
856 match self.search.accept(&text) {
857 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
858 self.modal.clear_minibuffer();
859 self.modal.enter(Mode::Normal);
860 match self.search.commit_step_skipping(origin, skip) {
861 Some(step) => {
862 self.report_wrap(&step);
868 CommitOutcome::Landed { origin, step }
869 }
870 None => {
871 self.report_pattern_not_found();
872 CommitOutcome::NotFound
873 }
874 }
875 }
876 escriba_search::Accepted::NothingToRepeat => {
877 self.modal.clear_minibuffer();
878 self.modal.enter(Mode::Normal);
879 self.messages
880 .push("E35: No previous regular expression".to_string());
881 CommitOutcome::NoPrevious
882 }
883 escriba_search::Accepted::Invalid(e) => {
888 let mut m = String::from("E383: Invalid search string: ");
889 m.push_str(&e.to_string());
890 self.messages.push(m);
891 CommitOutcome::NoPrompt
892 }
893 }
894 }
895
896 fn report_pattern_not_found(&mut self) {
899 let mut m = String::from("E486: Pattern not found");
900 if let Some(p) = self.search.pattern() {
901 m.push_str(": ");
902 m.push_str(p.raw());
903 }
904 self.messages.push(m);
905 }
906
907 fn submit_search(&mut self) {
912 match self.commit_search_prompt() {
913 CommitOutcome::Landed { origin, step } => {
914 if let Some(buf) = self.buffers.get(self.active) {
915 let from = buf.char_to_position(origin);
916 self.jumps.push(from);
917 }
918 self.land_on(step);
919 }
920 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
921 }
922 }
923
924 fn submit_search_operated(&mut self, op: Operator) {
931 match self.commit_search_prompt() {
932 CommitOutcome::Landed { origin, step } => {
933 if let Some(buf) = self.buffers.get(self.active) {
934 let from = buf.char_to_position(origin);
935 let target = buf.char_to_position(step.target.start);
936 self.jumps.push(from);
938 self.set_cursor(from);
939 self.apply_operator_to(op, target);
940 }
941 }
942 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
943 }
944 }
945
946 fn apply_resolved(&mut self, action: &Action) {
947 let lines_before = self.active_line_count();
950 let rev_before = self.text_rev();
953 let cline_before = self.cursor().line;
954 match action {
955 Action::Move(m) => self.apply_motion(*m),
956 Action::SearchOpen(dir) => {
957 let origin = self.cursor_char();
961 self.search.open(*dir, origin);
962 self.modal.enter(Mode::Command);
963 }
964 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
965 Action::SearchWord { reverse } => {
966 let dir = if *reverse {
967 SearchDirection::Backward
968 } else {
969 SearchDirection::Forward
970 };
971 let (text, at) = (self.active_text(), self.cursor_char());
972 self.jumps.push(self.cursor());
974 match self.search.search_word(&text, at, dir) {
975 Some(step) => self.land_on(step),
976 None => self
979 .messages
980 .push("E348: No string under cursor".to_string()),
981 }
982 }
983 Action::ClearSearchHighlight => self.search.clear_highlight(),
984 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
985 Action::TextObject(object) => {
986 if let Some(range) = self.resolve_object(*object) {
992 self.jumps.push(self.cursor());
993 self.set_cursor(range.start);
994 } else {
995 self.report_pattern_not_found();
996 }
997 }
998 Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
999 Some(range) => self.apply_operator_over(*op, range),
1000 None => self.report_pattern_not_found(),
1001 },
1002 Action::RepeatLastChange => self.repeat_last_change(),
1003 Action::JumpBack => {
1004 let here = self.cursor();
1005 if let Some(pos) = self.jumps.back(here) {
1006 self.set_cursor(pos);
1007 } else {
1008 self.messages
1009 .push("E662: At start of changelist".to_string());
1010 }
1011 }
1012 Action::JumpForward => {
1013 if let Some(pos) = self.jumps.forward() {
1014 self.set_cursor(pos);
1015 } else {
1016 self.messages.push("E663: At end of changelist".to_string());
1017 }
1018 }
1019 Action::ChangeMode(m) => {
1020 if *m == Mode::Normal && self.search.is_prompting() {
1024 if let Some(origin) = self.search.cancel() {
1025 if let Some(buf) = self.buffers.get(self.active) {
1026 let pos = buf.char_to_position(origin);
1027 self.set_cursor(pos);
1028 }
1029 }
1030 }
1031 self.modal.enter(*m);
1032 }
1033 Action::InsertChar(c) => self.insert_char(*c),
1034 Action::Edit(edit) => self.apply_edit(edit),
1035 Action::Undo => {
1036 if let Some(buf) = self.buffers.get_mut(self.active) {
1037 let _ = buf.undo();
1038 }
1039 self.set_cursor(self.cursor());
1042 }
1043 Action::Redo => {
1044 if let Some(buf) = self.buffers.get_mut(self.active) {
1045 let _ = buf.redo();
1046 }
1047 self.set_cursor(self.cursor());
1048 }
1049 Action::Save => {
1050 if let Some(buf) = self.buffers.get_mut(self.active) {
1051 let _ = buf.save();
1052 }
1053 self.set_cursor(self.cursor());
1054 }
1055 Action::Quit => self.quit_requested = true,
1056 Action::SubmitCommand => {
1057 if self.search.is_prompting() {
1058 self.submit_search();
1059 } else {
1060 self.submit_command();
1061 }
1062 }
1063 Action::Command { name, args } => self.run_command(name, args),
1064 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
1065 Action::Operator(_) => {}
1068 Action::PromptCaret { to } => {
1069 if self.search.is_prompting() {
1071 self.search.move_caret(*to);
1072 } else {
1073 self.modal.move_minibuffer_caret(*to);
1074 }
1075 }
1076 Action::SearchPreviewStep { forward } => {
1077 if self.search.is_prompting() {
1078 self.search.preview_step(*forward);
1079 self.preview_search();
1080 }
1081 }
1082 Action::PromptDelete => {
1083 if self.search.is_prompting() {
1084 self.search.delete_at_caret();
1085 self.preview_search();
1086 } else {
1087 self.modal.delete_minibuffer_at_caret();
1088 }
1089 }
1090 Action::PromptDeleteWord => {
1091 if self.search.is_prompting() {
1092 self.search.delete_word_before_caret();
1093 self.preview_search();
1094 }
1095 }
1096 Action::PromptClearToStart => {
1097 if self.search.is_prompting() {
1098 self.search.clear_before_caret();
1099 self.preview_search();
1100 }
1101 }
1102 Action::PromptBackspace => {
1103 self.prompt_backspace();
1104 if self.search.is_prompting() {
1108 self.preview_search();
1109 }
1110 }
1111 Action::PromptHistory { back } => {
1112 if self.search.is_prompting() {
1113 self.search.history_step(*back);
1114 self.preview_search();
1118 }
1119 }
1120 Action::Pending => {}
1121 }
1122 let lines_after = self.active_line_count();
1127 let cline_after = self.cursor().line;
1128 let d = match action {
1129 Action::SearchOpen(_)
1133 | Action::PromptHistory { .. }
1134 | Action::PromptBackspace
1135 | Action::PromptCaret { .. }
1136 | Action::SearchPreviewStep { .. }
1137 | Action::PromptDelete
1138 | Action::PromptDeleteWord
1139 | Action::PromptClearToStart
1140 | Action::SearchRepeat { .. }
1141 | Action::SearchWord { .. }
1142 | Action::ClearSearchHighlight
1143 | Action::SearchSubmitOperated { .. }
1144 | Action::RepeatLastChange
1147 | Action::TextObject(_)
1148 | Action::ApplyOperatorObject { .. }
1149 | Action::JumpBack
1151 | Action::JumpForward => Damage::Full,
1152 Action::InsertChar(_)
1153 | Action::Edit(_)
1154 | Action::Undo
1155 | Action::Redo
1156 | Action::ApplyOperator { .. } => {
1157 if lines_after == lines_before {
1158 Damage::span(cline_before, cline_after)
1159 } else {
1160 Damage::Lines {
1161 from: cline_before.min(cline_after),
1162 to: u32::MAX,
1163 }
1164 }
1165 }
1166 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
1167 Action::Save => Damage::Viewport,
1168 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
1169 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
1170 };
1171 self.damage = self.damage.join(d);
1172 if self.recording_insert {
1193 match action {
1194 Action::InsertChar(c) => {
1195 if let Some(lc) = self.last_change.as_mut() {
1196 lc.inserted.push(*c);
1197 }
1198 }
1199 Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
1201 _ => {}
1202 }
1203 } else if self.text_rev() != rev_before
1204 && !matches!(
1205 action,
1206 Action::RepeatLastChange | Action::Undo | Action::Redo
1207 )
1208 {
1209 self.last_change = Some(LastChange {
1210 action: action.clone(),
1211 count: 1,
1212 inserted: String::new(),
1213 });
1214 self.recording_insert = self.modal.mode() == Mode::Insert;
1215 }
1216
1217 if action.highlight_effect() == HighlightEffect::Clear {
1222 self.search.clear_highlight();
1223 }
1224 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
1233 let text = self.active_text();
1234 self.search.refresh(&text);
1235 }
1240 self.bump_gen();
1245 }
1246
1247 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1257 let buf = self.buffers.get(self.active)?;
1258 let pos = from;
1259 Some(match motion {
1260 Motion::SearchNext | Motion::SearchPrev => {
1265 let at = buf.position_to_char(pos).ok()?;
1266 let step = self
1267 .search
1268 .repeat(at, matches!(motion, Motion::SearchPrev))?;
1269 buf.char_to_position(step.target.start)
1270 }
1271 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1272 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1273 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1274 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1275 Motion::LineStart => Position::new(pos.line, 0),
1276 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1277 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1278 Motion::DocStart => Position::ZERO,
1279 Motion::DocEnd => Position::new(
1280 buf.line_count().saturating_sub(1),
1281 buf.line_len_chars(buf.line_count().saturating_sub(1)),
1282 ),
1283 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1284 Motion::WordStartPrev => word_prev(buf, pos),
1285 Motion::PageDown | Motion::HalfPageDown => {
1286 Position::new(pos.line.saturating_add(10), pos.column)
1287 }
1288 Motion::PageUp | Motion::HalfPageUp => {
1289 Position::new(pos.line.saturating_sub(10), pos.column)
1290 }
1291 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1292 Motion::ForwardSexp
1295 | Motion::BackwardSexp
1296 | Motion::UpList
1297 | Motion::DownList
1298 | Motion::BeginningOfDefun
1299 | Motion::EndOfDefun
1300 | Motion::BeginningOfSexp
1301 | Motion::EndOfSexp => pos,
1302 })
1303 }
1304
1305 fn apply_motion(&mut self, motion: Motion) {
1306 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1314 self.jump_search(matches!(motion, Motion::SearchPrev));
1315 return;
1316 }
1317 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1318 return;
1319 };
1320 self.set_cursor(pos);
1323 }
1324
1325 fn apply_operator(&mut self, op: Operator, motion: Motion) {
1332 let from = self.cursor();
1333 let Some(to) = self.resolve_motion(from, motion) else {
1334 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1339 if self.search.pattern().is_none() {
1340 self.messages
1341 .push("E35: No previous regular expression".to_string());
1342 } else {
1343 self.report_pattern_not_found();
1344 }
1345 }
1346 return;
1347 };
1348 self.apply_operator_to(op, to);
1349 }
1350
1351 fn apply_operator_to(&mut self, op: Operator, to: Position) {
1358 let from = self.cursor();
1359 self.apply_operator_over(
1360 op,
1361 Range {
1362 start: from,
1363 end: to,
1364 },
1365 );
1366 }
1367
1368 fn apply_operator_over(&mut self, op: Operator, range: Range) {
1375 let range = range.normalized();
1376 if range.is_empty() {
1377 return;
1378 }
1379 let text = self
1381 .buffers
1382 .get(self.active)
1383 .and_then(|buf| buf.slice(range).ok());
1384 if op.leaves_register() {
1385 if let Some(t) = &text {
1386 self.register = Some(t.clone());
1387 }
1388 }
1389 match op {
1390 Operator::Delete | Operator::Change => {
1393 if let Some(buf) = self.buffers.get_mut(self.active) {
1394 let _ = buf.apply(&Edit::delete(range));
1395 }
1396 self.set_cursor(range.start);
1397 if op == Operator::Change {
1398 self.modal.enter(Mode::Insert);
1399 }
1400 }
1401 Operator::Yank => {
1404 self.set_cursor(range.start);
1405 }
1406 _ => {
1410 self.messages
1411 .push("operator not yet implemented".to_owned());
1412 }
1413 }
1414 }
1415
1416 #[must_use]
1419 pub fn register(&self) -> Option<&str> {
1420 self.register.as_deref()
1421 }
1422
1423 fn insert_char(&mut self, c: char) {
1424 if self.modal.mode() == Mode::Command {
1425 if self.search.is_prompting() {
1429 self.search.push(c);
1443 self.preview_search();
1444 } else {
1445 self.modal.push_minibuffer(c);
1446 }
1447 return;
1448 }
1449 let cursor = self.cursor();
1450 let Some(buf) = self.buffers.get_mut(self.active) else {
1451 return;
1452 };
1453 let edit = Edit::insert(cursor, c.to_string());
1454 if buf.apply(&edit).is_ok() {
1455 let next = if c == '\n' {
1456 Position::new(cursor.line.saturating_add(1), 0)
1457 } else {
1458 cursor.shift_right(1)
1459 };
1460 self.set_cursor(next);
1463 }
1464 }
1465
1466 fn prompt_backspace(&mut self) -> bool {
1470 if self.modal.mode() != Mode::Command {
1471 return false;
1472 }
1473 if self.search.is_prompting() {
1474 if self.search.backspace() {
1479 self.modal.clear_minibuffer();
1480 self.modal.enter(Mode::Normal);
1481 }
1482 return true;
1485 }
1486 self.modal.pop_minibuffer();
1487 true
1488 }
1489
1490 fn apply_edit(&mut self, _edit: &Edit) {
1491 }
1495
1496 fn submit_command(&mut self) {
1497 let line = self.modal.minibuffer().to_string();
1501 self.modal.escape();
1502 let (name, args) = parse_command_line(&line);
1503 if name.is_empty() {
1504 return;
1505 }
1506 self.run_command(&name, &args);
1507 }
1508
1509 fn run_command(&mut self, name: &str, args: &[String]) {
1510 if matches!(name, "noh" | "nohl" | "nohlsearch") {
1516 self.search.clear_highlight();
1517 return;
1518 }
1519 if self.plugin_host.pending() > 0 {
1525 let pending = self.plugin_host.pending_for_command(name);
1526 for src in pending {
1527 self.apply_plugin_entry(&src);
1528 }
1529 }
1530 let active = Some(self.active);
1531 let mut quit = false;
1532 {
1533 let mut ctx = EditContext {
1534 buffers: &mut self.buffers,
1535 active,
1536 state: &mut self.modal,
1537 quit_requested: &mut quit,
1538 };
1539 let _ = self.commands.run(name, &mut ctx, args);
1540 }
1541 if quit {
1544 self.quit_requested = true;
1545 }
1546 }
1547
1548 #[must_use]
1553 pub fn snapshot(&self) -> EditorSnapshot {
1554 let current_line = self
1555 .buffers
1556 .get(self.active)
1557 .and_then(|b| b.line(self.cursor().line))
1558 .map(|s| s.trim_end_matches('\n').to_string())
1559 .unwrap_or_default();
1560 let buffer_name = self
1561 .buffers
1562 .get(self.active)
1563 .and_then(|b| b.path.as_ref())
1564 .map(|p| p.display().to_string())
1565 .unwrap_or_else(|| "[scratch]".to_string());
1566 EditorSnapshot {
1567 cursor_line: i64::from(self.cursor().line),
1568 cursor_column: i64::from(self.cursor().column),
1569 current_line,
1570 mode: self.modal.mode().as_str().to_string(),
1571 buffer_name,
1572 }
1573 }
1574
1575 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1591 let mut host = EscribaHost::with_snapshot(self.snapshot());
1592 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1593 vm.eval(src, &mut host)?;
1594 let effects = host.take_effects();
1595 self.apply_host_effects(effects);
1596 Ok(())
1597 }
1598
1599 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1603 for eff in effects {
1604 match eff {
1605 HostEffect::Message(m) => self.messages.push(m),
1606 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1607 HostEffect::SetOption { name, value } => {
1608 self.options.insert(name, value);
1609 }
1610 HostEffect::InsertText(text) => self.insert_text(&text),
1611 }
1612 }
1613 }
1614
1615 fn insert_text(&mut self, text: &str) {
1618 if text.is_empty() {
1619 return;
1620 }
1621 let cursor = self.cursor();
1622 let Some(buf) = self.buffers.get_mut(self.active) else {
1623 return;
1624 };
1625 let edit = Edit::insert(cursor, text.to_string());
1626 if buf.apply(&edit).is_ok() {
1627 let next = if let Some(nl) = text.rfind('\n') {
1628 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1629 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1630 Position::new(cursor.line + added_lines, last_line_len)
1631 } else {
1632 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1633 cursor.shift_right(n)
1634 };
1635 self.set_cursor(next);
1638 }
1639 }
1640}
1641
1642fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1643 let Some(text) = buf.line(line) else {
1644 return Position::new(line, 0);
1645 };
1646 let col = text
1647 .chars()
1648 .take_while(|c| c.is_whitespace() && *c != '\n')
1649 .count();
1650 Position::new(line, u32::try_from(col).unwrap_or(0))
1651}
1652
1653fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1654 let Some(text) = buf.line(pos.line) else {
1655 return pos;
1656 };
1657 let chars: Vec<char> = text.chars().collect();
1658 let start = pos.column as usize;
1659 let mut i = start;
1660 while i < chars.len() && !chars[i].is_whitespace() {
1661 i += 1;
1662 }
1663 while i < chars.len() && chars[i].is_whitespace() {
1664 i += 1;
1665 }
1666 if i >= chars.len() {
1667 if pos.line + 1 < buf.line_count() {
1669 return Position::new(pos.line + 1, 0);
1670 }
1671 }
1672 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1673}
1674
1675fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1676 let Some(text) = buf.line(pos.line) else {
1677 return pos;
1678 };
1679 let chars: Vec<char> = text.chars().collect();
1680 let mut i = (pos.column as usize).min(chars.len());
1681 while i > 0 && chars[i - 1].is_whitespace() {
1682 i -= 1;
1683 }
1684 while i > 0 && !chars[i - 1].is_whitespace() {
1685 i -= 1;
1686 }
1687 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1688}
1689
1690fn parse_command_line(line: &str) -> (String, Vec<String>) {
1691 let mut parts = line.split_whitespace();
1692 let Some(first) = parts.next() else {
1693 return (String::new(), Vec::new());
1694 };
1695 let head = first.strip_prefix(':').unwrap_or(first);
1696 let name = match head {
1697 "w" => "save",
1698 "q" => "quit",
1699 "u" => "undo",
1700 other => other,
1701 };
1702 (name.to_string(), parts.map(str::to_string).collect())
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707 use super::*;
1708 use madori::event::{KeyCode, KeyEvent, Modifiers};
1709
1710 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1718 st.apply(&Action::SearchOpen(dir));
1719 for c in pat.chars() {
1720 st.apply(&Action::InsertChar(c));
1721 }
1722 st.apply(&Action::SubmitCommand);
1723 }
1724
1725 #[test]
1726 fn slash_search_moves_the_cursor_to_the_match() {
1727 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1728 type_search(&mut st, SearchDirection::Forward, "charlie");
1729 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1730 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1731 assert_eq!(st.search.matches().len(), 1);
1732 }
1733
1734 #[test]
1735 fn n_and_N_walk_matches_in_both_directions() {
1736 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1737 type_search(&mut st, SearchDirection::Forward, "foo");
1738 let first = st.cursor().line;
1739 st.apply(&Action::SearchRepeat { reverse: false });
1740 let second = st.cursor().line;
1741 assert!(second > first, "n advances ({first} -> {second})");
1742 st.apply(&Action::SearchRepeat { reverse: true });
1743 assert_eq!(st.cursor().line, first, "N comes back");
1744 }
1745
1746 #[test]
1747 fn star_searches_the_word_under_the_cursor() {
1748 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1749 st.apply(&Action::SearchWord { reverse: false });
1750 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1751 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1752 }
1753
1754 #[test]
1755 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1756 let mut st = new_state_with("foo\nbar\nfoo\n");
1757 type_search(&mut st, SearchDirection::Forward, "foo");
1758 let matches_before = st.search.matches().len();
1759
1760 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1761 st.apply(&Action::InsertChar('z'));
1762 st.apply(&Action::ChangeMode(Mode::Normal));
1763
1764 assert!(!st.search.is_prompting(), "prompt gone");
1765 assert_eq!(
1766 st.search.pattern().unwrap().raw(),
1767 "foo",
1768 "old pattern survives"
1769 );
1770 assert_eq!(
1771 st.search.matches().len(),
1772 matches_before,
1773 "old highlights survive"
1774 );
1775 }
1776
1777 #[test]
1778 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1779 let mut st = new_state_with("foo\n");
1780 st.apply(&Action::ChangeMode(Mode::Command));
1782 assert!(!st.search.is_prompting(), "`:` must not open a search");
1783 st.apply(&Action::InsertChar('w'));
1784 assert!(
1785 st.search.prompt().is_none(),
1786 "typed char went to the ex line"
1787 );
1788 }
1789
1790 #[test]
1791 fn a_missing_pattern_reports_instead_of_failing_silently() {
1792 let mut st = new_state_with("alpha\nbravo\n");
1793 type_search(&mut st, SearchDirection::Forward, "zzz");
1794 assert!(
1795 st.messages.iter().any(|m| m.contains("E486")),
1796 "must report not-found, got {:?}",
1797 st.messages
1798 );
1799 }
1800
1801 #[test]
1802 fn n_without_any_search_reports_rather_than_moving() {
1803 let mut st = new_state_with("alpha\nbravo\n");
1804 let before = st.cursor();
1805 st.apply(&Action::SearchRepeat { reverse: false });
1806 assert_eq!(st.cursor(), before, "cursor must not move");
1807 assert!(
1808 st.messages.iter().any(|m| m.contains("E35")),
1809 "got {:?}",
1810 st.messages
1811 );
1812 }
1813
1814 #[test]
1815 fn search_as_a_motion_composes_with_an_operator() {
1816 let mut st = new_state_with("alpha bravo charlie\n");
1818 type_search(&mut st, SearchDirection::Forward, "charlie");
1819 st.set_cursor(Position::new(0, 0));
1820 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1821 assert!(target.is_some(), "search must resolve as a motion");
1822 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1823 }
1824
1825 #[test]
1826 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1827 let st = new_state_with("alpha bravo\n");
1830 assert!(
1831 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1832 .is_none()
1833 );
1834 }
1835
1836 #[test]
1837 fn clear_highlight_keeps_the_pattern_usable() {
1838 let mut st = new_state_with("foo\nbar\nfoo\n");
1839 type_search(&mut st, SearchDirection::Forward, "foo");
1840 st.apply(&Action::ClearSearchHighlight);
1841 assert!(st.search.highlights().is_empty(), "nothing lit");
1842 st.apply(&Action::SearchRepeat { reverse: false });
1843 assert!(st.search.pattern().is_some(), "but n still works");
1844 }
1845
1846 #[test]
1847 fn typing_previews_incrementally_before_commit() {
1848 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1849 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1850 for c in "charlie".chars() {
1851 st.apply(&Action::InsertChar(c));
1852 }
1853 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1855 assert!(st.search.pattern().is_none(), "but nothing is committed");
1856 }
1857
1858 #[test]
1859 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1860 let mut st = new_state_with("alpha\nbravo\n");
1861 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1862 for c in "bravox".chars() {
1863 st.apply(&Action::InsertChar(c));
1864 }
1865 assert_eq!(st.search.prompt().unwrap().text(), "bravox");
1866 st.apply(&Action::PromptBackspace);
1867 assert_eq!(
1868 st.search.prompt().unwrap().text(),
1869 "bravo",
1870 "typo corrected"
1871 );
1872 assert_eq!(
1873 st.status_model().prompt_text,
1874 "bravo",
1875 "the model reads the PROMPT — the minibuffer is the ex-line's store",
1876 );
1877 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1878 }
1879
1880 #[test]
1881 fn backspacing_past_the_slash_closes_the_prompt() {
1882 let mut st = new_state_with("alpha\n");
1883 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1884 st.apply(&Action::InsertChar('a'));
1885 st.apply(&Action::PromptBackspace);
1886 st.apply(&Action::PromptBackspace);
1887 assert!(!st.search.is_prompting(), "prompt closed");
1888 assert_eq!(st.modal.mode(), Mode::Normal);
1889 }
1890
1891 #[test]
1892 fn noh_clears_highlights_and_keeps_the_pattern() {
1893 let mut st = new_state_with("foo\nbar\nfoo\n");
1894 type_search(&mut st, SearchDirection::Forward, "foo");
1895 assert!(!st.search.highlights().is_empty());
1896 st.run_command("noh", &[]);
1897 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1898 assert!(st.search.pattern().is_some(), "but n still works");
1899 }
1900
1901 #[test]
1902 fn noh_accepts_the_vim_aliases() {
1903 for name in ["noh", "nohl", "nohlsearch"] {
1904 let mut st = new_state_with("foo\nfoo\n");
1905 type_search(&mut st, SearchDirection::Forward, "foo");
1906 st.run_command(name, &[]);
1907 assert!(st.search.highlights().is_empty(), "{name} must clear");
1908 }
1909 }
1910
1911 #[test]
1912 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1913 let mut st = new_state_with("foo\n");
1914 st.apply(&Action::ChangeMode(Mode::Command));
1915 st.apply(&Action::InsertChar('w'));
1916 st.apply(&Action::InsertChar('q'));
1917 st.apply(&Action::PromptBackspace);
1918 assert_eq!(st.status_model().prompt_text, "w");
1919 assert!(st.search.prompt().is_none(), "no search was involved");
1920 }
1921
1922 #[test]
1923 fn up_arrow_recalls_the_previous_search() {
1924 let mut st = new_state_with("alpha\nbravo\n");
1925 type_search(&mut st, SearchDirection::Forward, "bravo");
1926 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1927 st.apply(&Action::PromptHistory { back: true });
1928 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
1929 assert_eq!(
1930 st.status_model().prompt_text,
1931 "bravo",
1932 "display follows the prompt"
1933 );
1934 }
1935
1936 #[test]
1937 fn arrowing_back_down_restores_the_half_typed_pattern() {
1938 let mut st = new_state_with("alpha\nbravo\n");
1939 type_search(&mut st, SearchDirection::Forward, "bravo");
1940 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1941 st.apply(&Action::InsertChar('a'));
1942 st.apply(&Action::PromptHistory { back: true });
1943 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
1944 st.apply(&Action::PromptHistory { back: false });
1945 assert_eq!(
1946 st.search.prompt().unwrap().text(),
1947 "a",
1948 "the draft comes back"
1949 );
1950 assert_eq!(st.status_model().prompt_text, "a");
1951 }
1952
1953 #[test]
1954 fn history_arrows_do_nothing_on_the_ex_line() {
1955 let mut st = new_state_with("alpha\n");
1956 st.apply(&Action::ChangeMode(Mode::Command));
1957 st.apply(&Action::InsertChar('w'));
1958 st.apply(&Action::PromptHistory { back: true });
1959 assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
1960 }
1961
1962 fn new_state_with(text: &str) -> EditorState {
1963 let mut bufs = BufferSet::new();
1964 let id = bufs.scratch(text);
1965 EditorState::new_with_buffer(bufs, id)
1966 }
1967
1968 #[test]
1974 fn edit_gen_advances_on_applied_action_not_on_read() {
1975 let mut s = new_state_with("hello\nworld\n");
1976 let g0 = s.edit_gen();
1977 s.apply(&Action::InsertChar('X'));
1978 assert_ne!(
1979 s.edit_gen(),
1980 g0,
1981 "an applied action must advance the refresh generation",
1982 );
1983 let g1 = s.edit_gen();
1985 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1986 }
1987
1988 #[test]
1993 fn damage_tracks_edit_scope_and_drains() {
1994 let mut s = new_state_with("hello\nworld\n");
1995 assert!(s.damage().is_none(), "a fresh state has no damage");
1996
1997 s.apply(&Action::InsertChar('X')); assert_eq!(
1999 s.damage(),
2000 Damage::Lines { from: 0, to: 0 },
2001 "a local edit damages just its line",
2002 );
2003
2004 let drained = s.take_damage();
2005 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
2006 assert!(s.damage().is_none(), "take_damage drains to None");
2007
2008 s.apply(&Action::InsertChar('\n')); assert_eq!(
2010 s.damage(),
2011 Damage::Lines {
2012 from: 0,
2013 to: u32::MAX,
2014 },
2015 "a line-count change damages to end-of-document",
2016 );
2017 }
2018
2019 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
2023 let mut s = new_state_with(text);
2024 for w in &mut s.layout.windows {
2025 w.viewport.visible_lines = vis_lines;
2026 w.viewport.visible_columns = vis_cols;
2027 }
2028 s
2029 }
2030
2031 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
2036 let w = s.layout.active_window().expect("active window");
2037 let v = w.viewport;
2038 let c = s.cursor();
2039 assert!(
2040 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
2041 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
2042 c.line,
2043 v.top_line,
2044 v.top_line + v.visible_lines,
2045 );
2046 assert!(
2047 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
2048 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
2049 c.column,
2050 v.left_column,
2051 v.left_column + v.visible_columns,
2052 );
2053 }
2054
2055 fn press(kc: KeyCode) -> AppEvent {
2056 AppEvent::Key(KeyEvent {
2057 key: kc,
2058 pressed: true,
2059 modifiers: Modifiers::default(),
2060 text: None,
2061 })
2062 }
2063
2064 fn line0_len(s: &EditorState) -> u32 {
2067 s.buffers.get(s.active).unwrap().line_len_chars(0)
2068 }
2069
2070 #[test]
2071 fn delete_to_line_end_clears_line_and_fills_register() {
2072 let mut s = new_state_with("hello world");
2073 s.apply(&Action::ApplyOperator {
2074 op: Operator::Delete,
2075 motion: Motion::LineEnd,
2076 });
2077 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
2078 assert_eq!(
2079 s.register(),
2080 Some("hello world"),
2081 "delete fills the register"
2082 );
2083 assert_eq!(
2084 s.cursor(),
2085 Position::ZERO,
2086 "cursor lands at the range start"
2087 );
2088 }
2089
2090 #[test]
2091 fn delete_over_right_motion_removes_one_char() {
2092 let mut s = new_state_with("abc");
2093 s.apply(&Action::ApplyOperator {
2094 op: Operator::Delete,
2095 motion: Motion::Right,
2096 });
2097 assert_eq!(
2098 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2099 Some("bc")
2100 );
2101 assert_eq!(s.register(), Some("a"));
2102 }
2103
2104 #[test]
2105 fn change_to_line_end_deletes_and_enters_insert() {
2106 let mut s = new_state_with("hello world");
2107 assert_eq!(s.modal.mode(), Mode::Normal);
2108 s.apply(&Action::ApplyOperator {
2109 op: Operator::Change,
2110 motion: Motion::LineEnd,
2111 });
2112 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
2113 assert_eq!(
2114 s.modal.mode(),
2115 Mode::Insert,
2116 "change enters Insert to type the replacement"
2117 );
2118 assert_eq!(
2119 s.register(),
2120 Some("hello world"),
2121 "change fills the register"
2122 );
2123 }
2124
2125 #[test]
2126 fn yank_to_line_end_fills_register_without_mutating() {
2127 let mut s = new_state_with("hello world");
2128 s.apply(&Action::ApplyOperator {
2129 op: Operator::Yank,
2130 motion: Motion::LineEnd,
2131 });
2132 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
2133 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
2134 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
2135 }
2136
2137 #[test]
2138 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
2139 let mut s = new_state_with("hello world");
2143 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
2144 assert_eq!(target, Position::new(0, 11));
2145 s.apply_motion(Motion::LineEnd);
2146 assert_eq!(
2147 s.cursor(),
2148 target,
2149 "the move path resolves the same target the operator uses"
2150 );
2151 }
2152
2153 #[test]
2154 fn empty_motion_range_is_a_no_op() {
2155 let mut s = new_state_with("abc");
2158 s.apply(&Action::ApplyOperator {
2159 op: Operator::Delete,
2160 motion: Motion::LineStart,
2161 });
2162 assert_eq!(
2163 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2164 Some("abc")
2165 );
2166 assert_eq!(s.register(), None);
2167 }
2168
2169 #[test]
2170 fn operator_then_motion_composes_through_the_pending_fsm() {
2171 let mut s = new_state_with("hello world");
2175 s.apply(&Action::Operator(Operator::Delete));
2176 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
2177 s.apply(&Action::Move(Motion::LineEnd));
2178 assert_eq!(
2179 line0_len(&s),
2180 0,
2181 "d then $ composes d$ and deletes the line"
2182 );
2183 assert_eq!(s.register(), Some("hello world"));
2184 }
2185
2186 #[test]
2187 fn change_operator_through_fsm_enters_insert() {
2188 let mut s = new_state_with("hello world");
2189 s.apply(&Action::Operator(Operator::Change));
2190 s.apply(&Action::Move(Motion::LineEnd));
2191 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
2192 }
2193
2194 #[test]
2195 fn lone_motion_after_no_operator_just_moves() {
2196 let mut s = new_state_with("hello world");
2198 s.apply(&Action::Move(Motion::LineEnd));
2199 assert_eq!(s.cursor(), Position::new(0, 11));
2200 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
2201 }
2202
2203 #[test]
2204 fn counted_operator_deletes_count_times() {
2205 let mut s = new_state_with("abcdef");
2209 s.apply_counted(&Action::Operator(Operator::Delete), 3);
2210 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
2211 s.apply(&Action::Move(Motion::Right));
2212 assert_eq!(
2213 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2214 Some("def")
2215 );
2216 }
2217
2218 #[test]
2219 fn operator_and_motion_counts_multiply_end_to_end() {
2220 let mut s = new_state_with("abcdefgh");
2222 s.apply_counted(&Action::Operator(Operator::Delete), 2);
2223 s.apply_counted(&Action::Move(Motion::Right), 3);
2224 assert_eq!(
2225 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2226 Some("gh")
2227 );
2228 }
2229
2230 #[test]
2231 fn bare_counted_motion_still_repeats_no_regression() {
2232 let mut s = new_state_with("a\nb\nc\nd\ne");
2235 s.apply_counted(&Action::Move(Motion::Down), 3);
2236 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
2237 }
2238
2239 struct SpacedClock(std::time::Instant);
2245 impl SpacedClock {
2246 fn new() -> Self {
2247 Self(std::time::Instant::now())
2248 }
2249 fn next(&mut self) -> std::time::Instant {
2250 self.0 += std::time::Duration::from_secs(1);
2251 self.0
2252 }
2253 }
2254
2255 #[test]
2256 fn hjkl_moves_cursor() {
2257 let mut s = new_state_with("hello\nworld");
2258 s.tick(&press(KeyCode::Char('l')));
2259 assert_eq!(s.cursor().column, 1);
2260 s.tick(&press(KeyCode::Char('j')));
2261 assert_eq!(s.cursor().line, 1);
2262 s.tick(&press(KeyCode::Char('h')));
2263 assert_eq!(s.cursor().column, 0);
2264 }
2265
2266 #[test]
2267 fn insert_mode_inserts_chars() {
2268 let mut s = new_state_with("");
2269 s.tick(&press(KeyCode::Char('i')));
2270 assert_eq!(s.modal.mode(), Mode::Insert);
2271 s.tick(&press(KeyCode::Char('h')));
2272 s.tick(&press(KeyCode::Char('i')));
2273 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
2274 assert_eq!(s.cursor().column, 2);
2275 }
2276
2277 #[test]
2278 fn esc_returns_to_normal() {
2279 let mut s = new_state_with("");
2280 s.tick(&press(KeyCode::Char('i')));
2281 s.tick(&press(KeyCode::Escape));
2282 assert_eq!(s.modal.mode(), Mode::Normal);
2283 }
2284
2285 #[test]
2286 fn count_prefix_repeats_motion() {
2287 let mut s = new_state_with("abcdefghij");
2288 s.tick(&press(KeyCode::Char('5')));
2289 s.tick(&press(KeyCode::Char('l')));
2290 assert_eq!(s.cursor().column, 5);
2291 }
2292
2293 #[test]
2294 fn close_event_requests_quit() {
2295 let mut s = new_state_with("");
2296 s.tick(&AppEvent::CloseRequested);
2297 assert!(s.quit_requested);
2298 }
2299
2300 #[test]
2301 fn word_next_jumps_past_whitespace() {
2302 let mut s = new_state_with("foo bar baz");
2303 let mut clk = SpacedClock::new();
2306 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2307 assert_eq!(s.cursor().column, 4);
2308 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2309 assert_eq!(s.cursor().column, 8);
2310 }
2311
2312 #[test]
2315 fn leader_sequence_holds_then_resolves() {
2316 let mut s = new_state_with("a\nbb\nccc");
2317 s.keymap.bind_sequence(
2318 Mode::Normal,
2319 vec![Key::Char(','), Key::Char('g')],
2320 Action::Move(Motion::DocEnd),
2321 "doc end",
2322 );
2323 s.on_key(&Key::Char(','));
2325 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2326 assert_eq!(s.cursor(), Position::ZERO);
2327 s.on_key(&Key::Char('g'));
2329 assert!(s.pending_keys.is_empty());
2330 assert_eq!(s.cursor().line, 2);
2331 }
2332
2333 #[test]
2334 fn two_key_gg_jumps_doc_start() {
2335 let mut s = new_state_with("a\nbb\nccc");
2336 s.keymap.bind_sequence(
2337 Mode::Normal,
2338 vec![Key::Char('g'), Key::Char('g')],
2339 Action::Move(Motion::DocStart),
2340 "doc start",
2341 );
2342 let mut clk = SpacedClock::new();
2343 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2344 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2345 assert_eq!(s.cursor().line, 2);
2346 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2348 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
2350 }
2351
2352 #[test]
2353 fn broken_sequence_aborts_and_clears_pending() {
2354 let mut s = new_state_with("hello");
2355 s.keymap.bind_sequence(
2356 Mode::Normal,
2357 vec![Key::Char('g'), Key::Char('g')],
2358 Action::Move(Motion::DocEnd),
2359 "doc end",
2360 );
2361 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2363 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
2365 assert_eq!(s.cursor(), Position::ZERO);
2366 }
2367
2368 #[test]
2369 fn single_binding_wins_over_sequence_prefix() {
2370 let mut s = new_state_with("abcde");
2374 let mut clk = SpacedClock::new();
2375 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2376 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2377 assert_eq!(s.cursor().column, 2);
2378 s.keymap.bind_sequence(
2379 Mode::Normal,
2380 vec![Key::Char('h'), Key::Char('z')],
2381 Action::Move(Motion::DocEnd),
2382 "shadowed",
2383 );
2384 s.on_key(&Key::Char('h'));
2385 assert!(s.pending_keys.is_empty(), "single binding should not pend");
2386 assert_eq!(s.cursor().column, 1, "h moved left immediately");
2387 }
2388
2389 #[test]
2392 fn lisp_set_option_writes_live_options() {
2393 let mut s = new_state_with("");
2394 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2395 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2396 }
2397
2398 #[test]
2399 fn lisp_insert_modifies_buffer_and_advances_cursor() {
2400 let mut s = new_state_with("");
2401 s.run_lisp(r#"(insert "abc")"#).unwrap();
2402 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2403 assert_eq!(s.cursor(), Position::new(0, 3));
2404 }
2405
2406 #[test]
2407 fn lisp_message_appends_to_messages() {
2408 let mut s = new_state_with("");
2409 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2410 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2411 }
2412
2413 #[test]
2414 fn lisp_reads_snapshot_and_branches_to_effect() {
2415 let mut s = new_state_with("one\ntwo\nthree");
2418 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2420 .unwrap();
2421 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2422 }
2423
2424 #[test]
2425 fn lisp_run_command_effect_drives_registry() {
2426 let mut s = new_state_with("");
2430 s.run_lisp(r#"(insert "abc")"#).unwrap();
2431 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2432 s.run_lisp(r#"(run-command "undo")"#).unwrap();
2433 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2434 }
2435
2436 #[test]
2437 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2438 let mut s = new_state_with("");
2443 s.run_lisp(r#"(run-command "quit")"#).unwrap();
2444 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2445 assert_eq!(
2446 s.modal.minibuffer(),
2447 "",
2448 "quit must not pollute any command line — Normal mode has no minibuffer",
2449 );
2450 }
2451
2452 #[test]
2455 fn lazy_plugin_activates_on_command_trigger() {
2456 let mut s = new_state_with("");
2460 s.register_lazy_plugin(
2461 "user-lazy",
2462 vec![LazyTrigger::Command("LazyGo".into())],
2463 r#"(defoption :name "lazy-loaded" :value "yes")
2464 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
2465 );
2466 assert_eq!(s.plugin_host.pending(), 1);
2467 assert!(
2468 s.options.get("lazy-loaded").is_none(),
2469 "entry not applied yet"
2470 );
2471
2472 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
2474
2475 assert_eq!(
2476 s.options.get("lazy-loaded").map(String::as_str),
2477 Some("yes"),
2478 "the command trigger applied the plugin's entry",
2479 );
2480 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
2481 }
2482
2483 #[test]
2484 fn lazy_plugin_activates_on_filetype() {
2485 let mut s = new_state_with("");
2486 s.register_lazy_plugin(
2487 "user-rust",
2488 vec![LazyTrigger::FileType("rust".into())],
2489 r#"(defoption :name "rust-plugin" :value "on")"#,
2490 );
2491 let n = s.activate_filetype_plugins("rust");
2492 assert_eq!(n, 1);
2493 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
2494 assert_eq!(s.activate_filetype_plugins("rust"), 0);
2496 }
2497
2498 #[test]
2499 fn cached_vm_serves_multiple_run_lisp_calls() {
2500 let mut s = new_state_with("");
2501 s.run_lisp(r#"(message "one")"#).unwrap();
2502 assert!(
2503 s.lisp_vm.is_some(),
2504 "VM should be cached after first run_lisp"
2505 );
2506 s.run_lisp(r#"(message "two")"#).unwrap();
2507 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
2508 }
2509
2510 #[test]
2511 fn lisp_define_persists_across_run_lisp_calls() {
2512 let mut s = new_state_with("");
2515 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
2516 s.run_lisp(r#"(message greeting)"#).unwrap();
2517 assert_eq!(s.messages, vec!["hi".to_string()]);
2518 }
2519
2520 #[test]
2521 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2522 let mut s = new_state_with("");
2526 s.run_lisp(
2527 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2528 )
2529 .unwrap();
2530 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2531 assert_eq!(
2532 s.options.get("col").map(String::as_str),
2533 Some("stale-zero"),
2534 "cursor-column within the same call reads the pre-eval snapshot",
2535 );
2536 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2539 .unwrap();
2540 assert_eq!(
2541 s.options.get("col2").map(String::as_str),
2542 Some("live-two"),
2543 "a later call sees the refreshed snapshot",
2544 );
2545 }
2546
2547 #[test]
2548 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2549 let mut s = new_state_with("");
2550 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2551 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2552 assert_eq!(s.cursor(), Position::new(1, 3));
2553 }
2554
2555 #[test]
2556 fn visual_mode_sequence_resolves() {
2557 let mut s = new_state_with("abc");
2558 s.modal.enter(Mode::Visual);
2559 s.keymap.bind_sequence(
2560 Mode::Visual,
2561 vec![Key::Char('g'), Key::Char('e')],
2562 Action::Move(Motion::DocEnd),
2563 "ge",
2564 );
2565 s.on_key(&Key::Char('g'));
2566 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2567 s.on_key(&Key::Char('e'));
2568 assert!(s.pending_keys.is_empty());
2569 assert_eq!(
2570 s.cursor().column,
2571 3,
2572 "ge resolved to doc-end in visual mode"
2573 );
2574 }
2575
2576 #[test]
2577 fn sequence_abort_with_bound_breaking_key_redispatches() {
2578 let mut s = new_state_with("abcde");
2581 s.keymap.bind_sequence(
2582 Mode::Normal,
2583 vec![Key::Char('g'), Key::Char('g')],
2584 Action::Move(Motion::DocEnd),
2585 "gg",
2586 );
2587 s.on_key(&Key::Char('g'));
2588 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2589 s.on_key(&Key::Char('l'));
2590 assert!(s.pending_keys.is_empty());
2591 assert_eq!(
2592 s.cursor().column,
2593 1,
2594 "the breaking key l should re-dispatch as move-right",
2595 );
2596 }
2597
2598 #[test]
2601 fn viewport_contains_cursor_after_every_op() {
2602 let mut s = new_state_small_viewport("", 5, 10);
2606 assert_cursor_in_viewport(&s, "initial");
2607
2608 s.tick(&press(KeyCode::Char('i')));
2611 assert_eq!(s.modal.mode(), Mode::Insert);
2612 for line in 0..30u32 {
2613 for c in "line".chars() {
2614 s.tick(&press(KeyCode::Char(c)));
2615 assert_cursor_in_viewport(&s, "typing chars");
2616 }
2617 s.tick(&press(KeyCode::Enter));
2618 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2619 }
2620
2621 for i in 0..200u32 {
2624 s.tick(&press(KeyCode::Char('x')));
2625 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2626 }
2627
2628 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2630 assert_cursor_in_viewport(&s, "insert_text multiline");
2631
2632 s.tick(&press(KeyCode::Escape));
2634 assert_eq!(s.modal.mode(), Mode::Normal);
2635 for m in [
2636 Motion::DocStart,
2637 Motion::DocEnd,
2638 Motion::Down,
2639 Motion::Down,
2640 Motion::Up,
2641 Motion::Right,
2642 Motion::Right,
2643 Motion::Left,
2644 Motion::LineEnd,
2645 Motion::LineStart,
2646 Motion::GotoLine(1),
2647 Motion::GotoLine(40),
2648 Motion::PageDown,
2649 Motion::PageUp,
2650 ] {
2651 s.apply_motion(m);
2652 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2653 }
2654
2655 for i in 0..50u32 {
2658 s.apply(&Action::Undo);
2659 assert_cursor_in_viewport(&s, &format!("undo {i}"));
2660 }
2661 for i in 0..50u32 {
2663 s.apply(&Action::Redo);
2664 assert_cursor_in_viewport(&s, &format!("redo {i}"));
2665 }
2666 }
2667
2668 #[test]
2669 fn insert_at_eof_keeps_cursor_in_bounds() {
2670 let mut s = new_state_small_viewport("abc", 5, 10);
2673 s.apply_motion(Motion::DocEnd);
2674 s.tick(&press(KeyCode::Char('i')));
2675 s.tick(&press(KeyCode::Char('d')));
2676 let buf = s.buffers.get(s.active).unwrap();
2677 let clamped = buf.clamp(s.cursor());
2678 assert_eq!(
2679 s.cursor(),
2680 clamped,
2681 "cursor must be clamped in-bounds at EOF"
2682 );
2683 assert_cursor_in_viewport(&s, "insert at eof");
2684 }
2685
2686 #[test]
2687 fn count_prefix_then_sequence_repeats() {
2688 let mut s = new_state_with("a\nb\nc\nd\ne");
2690 s.keymap.bind_sequence(
2691 Mode::Normal,
2692 vec![Key::Char('g'), Key::Char('j')],
2693 Action::Move(Motion::Down),
2694 "gj",
2695 );
2696 s.on_key(&Key::Char('2'));
2697 s.on_key(&Key::Char('g'));
2698 s.on_key(&Key::Char('j'));
2699 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2700 }
2701
2702 #[test]
2705 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2706 let mut s = new_state_with(&"x\n".repeat(40));
2712 let t0 = std::time::Instant::now();
2713 let mut delivered = 0u32;
2714 for i in 0..20u32 {
2715 let before = s.cursor().line;
2716 s.tick_at(
2717 &press(KeyCode::Char('j')),
2718 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2719 );
2720 if s.cursor().line != before {
2721 delivered += 1;
2722 }
2723 }
2724 assert!(
2727 (10..=14).contains(&delivered),
2728 "expected the storm debounced to ~13 moves, got {delivered}",
2729 );
2730 assert!(
2731 delivered < 20,
2732 "the gate must drop SOME storm ticks, not pass all 20",
2733 );
2734 }
2735
2736 #[test]
2737 fn spaced_intentional_taps_all_pass() {
2738 let mut s = new_state_with(&"x\n".repeat(10));
2741 let t0 = std::time::Instant::now();
2742 for i in 0..5u32 {
2743 s.tick_at(
2744 &press(KeyCode::Char('j')),
2745 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2747 );
2748 }
2749 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2750 }
2751
2752 #[test]
2753 fn distinct_keys_have_independent_clocks() {
2754 let mut s = new_state_with("abc\ndef\nghi");
2757 let t = std::time::Instant::now();
2758 s.tick_at(&press(KeyCode::Char('j')), t);
2759 s.tick_at(
2761 &press(KeyCode::Char('j')),
2762 t + std::time::Duration::from_millis(10),
2763 );
2764 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2765 s.tick_at(
2767 &press(KeyCode::Char('l')),
2768 t + std::time::Duration::from_millis(10),
2769 );
2770 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2771 }
2772
2773 #[test]
2776 fn cursor_home_preserves_single_cursor_behavior() {
2777 let mut s = new_state_with("hello\nworld\nthere");
2782 assert_eq!(s.cursor(), Position::ZERO);
2783 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2784
2785 s.apply_motion(Motion::Down);
2786 s.apply_motion(Motion::Right);
2787 s.apply_motion(Motion::Right);
2788 assert_eq!(s.cursor(), Position::new(1, 2));
2789 assert_eq!(s.cursors.count(), 1);
2791
2792 let w = s.layout.active_window().unwrap();
2794 assert!(w.viewport.top_line <= s.cursor().line);
2795 }
2796
2797 #[test]
2798 fn insert_mode_is_ungated_so_repeat_typing_works() {
2799 let mut s = new_state_with("");
2803 s.tick(&press(KeyCode::Char('i')));
2804 assert_eq!(s.modal.mode(), Mode::Insert);
2805 let t = std::time::Instant::now();
2806 for _ in 0..10 {
2807 s.tick_at(&press(KeyCode::Char('x')), t);
2808 }
2809 assert_eq!(
2810 s.buffers.get(s.active).unwrap().to_string(),
2811 "xxxxxxxxxx",
2812 "insert-mode repeat typing is ungated",
2813 );
2814 }
2815}