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_discrete_jump(key: &Key) -> bool {
152 matches!(
153 key,
154 Key::Char('n')
155 | Key::Char('N')
156 | Key::Char('*')
157 | Key::Char('#')
158 | Key::Char('.')
164 | Key::Char('u')
167 | Key::Ctrl('r')
168 | Key::Ctrl('o')
169 | Key::Ctrl('i')
170 )
171}
172
173#[derive(Debug, Clone)]
175struct LastChange {
176 action: Action,
178 count: u32,
180 inserted: String,
182}
183
184impl EditorState {
185 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
187 let window = Window {
188 id: WindowId(1),
189 buffer_id: active,
190 viewport: Viewport {
191 top_line: 0,
192 left_column: 0,
193 visible_lines: 40,
194 visible_columns: 160,
195 },
196 rect: Rect {
197 x: 0,
198 y: 0,
199 width: 1200,
200 height: 800,
201 },
202 };
203 Self {
204 buffers: initial,
205 modal: ModalState::new(),
206 search: SearchState::new(escriba_search::CaseMode::Smart),
207 search_at: None,
208 last_change: None,
209 recording_insert: false,
210 jumps: JumpList::new(),
211 keymap: Keymap::default_vim(),
212 commands: CommandRegistry::default_set(),
213 layout: Layout::single(window),
214 active,
215 cursors: Cursors::single(Position::ZERO),
216 quit_requested: false,
217 register: None,
218 op_pending: zenmai::Stateful::new(OpState::Resting),
219 messages: Vec::new(),
220 options: HashMap::new(),
221 lisp_vm: None,
222 pending_keys: Vec::new(),
223 repeat_gate: KeyRepeatGate::new(),
224 plugin_host: PluginHost::default(),
225 edit_gen: EditGen::default(),
226 damage: Damage::None,
227 }
228 }
229
230 #[must_use]
234 pub fn edit_gen(&self) -> EditGen {
235 self.edit_gen
236 }
237
238 fn bump_gen(&mut self) {
240 self.edit_gen = self.edit_gen.next();
241 }
242
243 #[must_use]
245 pub fn damage(&self) -> Damage {
246 self.damage
247 }
248
249 pub fn take_damage(&mut self) -> Damage {
253 std::mem::replace(&mut self.damage, Damage::None)
254 }
255
256 fn active_line_count(&self) -> u32 {
259 self.buffers
260 .get(self.active)
261 .map_or(0, escriba_buffer::Buffer::line_count)
262 }
263
264 pub fn register_lazy_plugin(
270 &mut self,
271 name: impl Into<String>,
272 triggers: Vec<LazyTrigger>,
273 entry_src: impl Into<String>,
274 ) {
275 self.plugin_host.register(name, triggers, entry_src);
276 }
277
278 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
284 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
285 return 0;
286 };
287 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
288 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
289 if let Some(value) = self.options.get("mapleader") {
290 if let Some(key) = escriba_lisp::parse_leader_key(value) {
291 self.keymap.set_leader(key);
292 }
293 }
294 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
295 (cmd.registered + km.keybinds_applied) as usize
296 }
297
298 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
302 let pending = self.plugin_host.pending_for_filetype(filetype);
303 let n = pending.len();
304 for src in pending {
305 self.apply_plugin_entry(&src);
306 }
307 n
308 }
309
310 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
313 let pending = self.plugin_host.pending_for_event(event);
314 let n = pending.len();
315 for src in pending {
316 self.apply_plugin_entry(&src);
317 }
318 n
319 }
320
321 pub fn tick(&mut self, event: &AppEvent) {
326 self.tick_at(event, Instant::now());
327 }
328
329 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
333 match translate_app_event(event) {
334 InputOutcome::Key(k) => {
335 if self.gate_key(&k, now) {
336 self.on_key(&k);
337 }
338 }
339 InputOutcome::Resized { width, height } => {
340 if let Some(w) = self
341 .layout
342 .windows
343 .iter_mut()
344 .find(|w| w.id == self.layout.active)
345 {
346 w.rect.width = width;
347 w.rect.height = height;
348 }
349 self.damage = self.damage.join(Damage::Viewport);
350 self.bump_gen();
351 }
352 InputOutcome::Quit => self.quit_requested = true,
353 InputOutcome::Focus(_) | InputOutcome::None => {}
354 }
355 }
356
357 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
367 match self.modal.mode() {
368 Mode::Normal | Mode::Visual | Mode::VisualLine => {
369 if is_discrete_jump(key) {
375 return true;
376 }
377 self.repeat_gate.try_pass_at(*key, now)
378 }
379 Mode::Insert | Mode::Command => true,
380 }
381 }
382
383 pub fn on_key(&mut self, key: &Key) {
385 match self.step_sequence(key) {
389 SeqStep::Pending => return,
390 SeqStep::Resolved(action) => {
391 let count = self.modal.pending_count().unwrap_or(1);
392 self.modal.clear_count();
393 for _ in 0..count {
394 self.apply(&action);
395 if self.quit_requested {
396 return;
397 }
398 }
399 return;
400 }
401 SeqStep::Passthrough => {}
402 }
403 let counted = self.keymap.dispatch(&self.modal, key);
404 if matches!(counted.action, Action::Pending) {
406 if let Key::Char(c) = key {
407 if c.is_ascii_digit() {
408 let d = u32::from(*c as u8 - b'0');
409 self.modal.append_count(d);
410 }
411 }
412 return;
413 }
414 self.apply_counted(&counted.action, counted.count);
418 self.modal.clear_count();
420 }
421
422 fn step_sequence(&mut self, key: &Key) -> SeqStep {
434 let mode = self.modal.mode();
435 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
436 return SeqStep::Passthrough;
437 }
438 if !self.pending_keys.is_empty() {
439 let mut seq = self.pending_keys.clone();
440 seq.push(key.clone());
441 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
442 let action = b.action.clone();
443 self.pending_keys.clear();
444 return SeqStep::Resolved(action);
445 }
446 if self.keymap.is_sequence_prefix(mode, &seq) {
447 self.pending_keys = seq;
448 return SeqStep::Pending;
449 }
450 self.pending_keys.clear();
453 }
454 let start = [key.clone()];
455 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
456 self.pending_keys = start.to_vec();
457 return SeqStep::Pending;
458 }
459 SeqStep::Passthrough
460 }
461
462 #[must_use]
467 pub fn cursor(&self) -> Position {
468 self.cursors.primary()
469 }
470
471 fn set_cursor(&mut self, pos: Position) {
480 let clamped = if let Some(buf) = self.buffers.get(self.active) {
481 buf.clamp(pos)
482 } else {
483 pos
484 };
485 self.cursors.set_primary(clamped);
486 if let Some(w) = self
487 .layout
488 .windows
489 .iter_mut()
490 .find(|w| w.id == self.layout.active)
491 {
492 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
493 }
494 }
495
496 fn apply(&mut self, action: &Action) {
498 self.apply_counted(action, 1);
499 }
500
501 fn apply_counted(&mut self, action: &Action, count: u32) {
509 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
510 for _ in 0..times {
511 self.apply_resolved(&resolved);
512 if self.quit_requested {
513 return;
514 }
515 }
516 }
517 }
518
519 #[must_use]
523 fn text_rev(&self) -> TextRev {
524 self.buffers
525 .get(self.active)
526 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
527 }
528
529 fn active_text(&self) -> String {
530 self.buffers
531 .get(self.active)
532 .map(escriba_buffer::Buffer::to_string)
533 .unwrap_or_default()
534 }
535
536 fn cursor_char(&self) -> usize {
538 self.buffers
539 .get(self.active)
540 .and_then(|b| b.position_to_char(self.cursor()).ok())
541 .unwrap_or(0)
542 }
543
544 #[must_use]
552 pub fn status_model(&self) -> StatusModel<'_> {
553 let cursor = self.cursor();
554 let prompt = self.search.prompt();
555
556 let kind = match prompt.map(|p| p.direction) {
557 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
558 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
559 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
562 None => PromptKind::None,
563 };
564
565 StatusModel {
566 mode: self.modal.mode(),
567 line: cursor.line.saturating_add(1) as usize,
568 column: cursor.column.saturating_add(1) as usize,
569 prompt: kind,
570 prompt_text: prompt.map_or_else(|| self.modal.minibuffer(), |p| p.text.as_str()),
571 prompt_caret: prompt.map_or_else(
576 || self.modal.minibuffer().chars().count(),
577 escriba_search::Prompt::caret,
578 ),
579 count: self.match_count(),
580 message: self.messages.last().map(String::as_str),
581 }
582 }
583
584 #[must_use]
590 fn match_count(&self) -> MatchCount {
591 if self.search.is_prompting() {
592 let text = self.active_text();
593 let total = self.search.preview_total(&text);
594 return match self.search.preview(&text) {
595 Some(step) => MatchCount::new(step.index, total),
596 None if total == 0 && !self.search.prompt_is_empty() => MatchCount::None,
600 None => MatchCount::Idle,
601 };
602 }
603 if self.search.pattern().is_none() {
604 return MatchCount::Idle;
605 }
606 let total = self.search.matches().len();
607 let rev = self.text_rev();
610 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
611 if total == 0 {
612 MatchCount::None
613 } else {
614 MatchCount::Idle
615 },
616 |&i| MatchCount::new(i, total),
617 )
618 }
619
620 fn repeat_last_change(&mut self) {
626 let Some(change) = self.last_change.clone() else {
627 self.messages
628 .push("E32: No previous change to repeat".to_string());
629 return;
630 };
631
632 for _ in 0..change.count.max(1) {
633 self.apply_resolved(&change.action);
634 }
635 for c in change.inserted.chars() {
636 self.apply_resolved(&Action::InsertChar(c));
637 }
638 if self.modal.mode() == Mode::Insert {
639 self.apply_resolved(&Action::ChangeMode(Mode::Normal));
643 }
644 self.last_change = Some(change);
648 self.recording_insert = false;
649 }
650
651 fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
658 use escriba_core::TextObject as O;
659 let at = self.cursor_char();
660 let matches = self.search.matches();
661
662 let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
674 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
675 match object {
676 O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
677 O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
678 }
679 })?;
680
681 let m = matches.get(idx)?;
682 let buf = self.buffers.get(self.active)?;
683 Some(Range {
684 start: buf.char_to_position(m.start),
685 end: buf.char_to_position(m.end),
686 })
687 }
688
689 fn land_on(&mut self, step: escriba_search::Step) {
690 if let Some(buf) = self.buffers.get(self.active) {
691 let pos = buf.char_to_position(step.target.start);
692 self.set_cursor(pos);
693 }
694 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
698 if let Some(msg) = step.wrapped.message() {
699 self.messages.push(msg.to_string());
700 }
701 }
702
703 fn jump_search(&mut self, reverse: bool) {
707 self.search.relight();
710 self.jumps.push(self.cursor());
712 let at = self.cursor_char();
713 match self.search.repeat(at, reverse) {
714 Some(step) => self.land_on(step),
715 None => {
716 let msg = self.search.pattern().map_or_else(
717 || "E35: No previous regular expression".to_string(),
718 |p| {
719 let mut m = String::from("E486: Pattern not found: ");
720 m.push_str(p.raw());
721 m
722 },
723 );
724 self.messages.push(msg);
725 }
726 }
727 }
728
729 fn preview_search(&mut self) {
736 let text = self.active_text();
737 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
738 return;
739 };
740 let target = self
741 .search
742 .preview(&text)
743 .map_or(origin, |s| s.target.start);
744 if let Some(buf) = self.buffers.get(self.active) {
753 let pos = buf.char_to_position(target);
754 self.set_cursor(pos);
755 }
756 }
757
758 fn submit_search_operated(&mut self, op: Operator) {
767 let text = self.active_text();
768 let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
769 else {
770 return;
771 };
772
773 match self.search.accept(&text) {
774 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
775 self.modal.clear_minibuffer();
776 self.modal.enter(Mode::Normal);
777 match self.search.commit_step_skipping(origin, skip) {
778 Some(step) => {
779 if let Some(buf) = self.buffers.get(self.active) {
781 let from = buf.char_to_position(origin);
782 self.jumps.push(from);
783 let target = buf.char_to_position(step.target.start);
784 self.set_cursor(from);
785 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
786 self.apply_operator_to(op, target);
787 }
788 }
789 None => self.report_pattern_not_found(),
790 }
791 }
792 escriba_search::Accepted::NothingToRepeat => {
793 self.modal.clear_minibuffer();
794 self.modal.enter(Mode::Normal);
795 self.messages
796 .push("E35: No previous regular expression".to_string());
797 }
798 escriba_search::Accepted::Invalid(e) => {
799 let mut m = String::from("E383: Invalid search string: ");
800 m.push_str(&e.to_string());
801 self.messages.push(m);
802 }
803 }
804 }
805
806 fn report_pattern_not_found(&mut self) {
809 let mut m = String::from("E486: Pattern not found");
810 if let Some(p) = self.search.pattern() {
811 m.push_str(": ");
812 m.push_str(p.raw());
813 }
814 self.messages.push(m);
815 }
816
817 fn submit_search(&mut self) {
819 let text = self.active_text();
820 let at = self
835 .search
836 .prompt()
837 .map_or_else(|| self.cursor_char(), |p| p.origin);
838 let skip = self
843 .search
844 .prompt()
845 .map_or(0, escriba_search::Prompt::preview_skip);
846 let outcome = self.search.accept(&text);
847 match outcome {
848 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
849 self.modal.clear_minibuffer();
850 self.modal.enter(Mode::Normal);
851 if let Some(buf) = self.buffers.get(self.active) {
856 let origin = buf.char_to_position(at);
857 self.jumps.push(origin);
858 }
859 match self.search.commit_step_skipping(at, skip) {
860 Some(step) => self.land_on(step),
861 None => {
862 let mut m = String::from("E486: Pattern not found");
863 if let Some(p) = self.search.pattern() {
864 m.push_str(": ");
865 m.push_str(p.raw());
866 }
867 self.messages.push(m);
868 }
869 }
870 }
871 escriba_search::Accepted::NothingToRepeat => {
872 self.modal.clear_minibuffer();
873 self.modal.enter(Mode::Normal);
874 self.messages
875 .push("E35: No previous regular expression".to_string());
876 }
877 escriba_search::Accepted::Invalid(e) => {
880 let mut m = String::from("E383: Invalid search string: ");
881 m.push_str(&e.to_string());
882 self.messages.push(m);
883 }
884 }
885 }
886
887 fn apply_resolved(&mut self, action: &Action) {
888 let lines_before = self.active_line_count();
891 let rev_before = self.text_rev();
894 let cline_before = self.cursor().line;
895 match action {
896 Action::Move(m) => self.apply_motion(*m),
897 Action::SearchOpen(dir) => {
898 let origin = self.cursor_char();
902 self.search.open(*dir, origin);
903 self.modal.enter(Mode::Command);
904 }
905 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
906 Action::SearchWord { reverse } => {
907 let dir = if *reverse {
908 SearchDirection::Backward
909 } else {
910 SearchDirection::Forward
911 };
912 let (text, at) = (self.active_text(), self.cursor_char());
913 self.jumps.push(self.cursor());
915 match self.search.search_word(&text, at, dir) {
916 Some(step) => self.land_on(step),
917 None => self
920 .messages
921 .push("E348: No string under cursor".to_string()),
922 }
923 }
924 Action::ClearSearchHighlight => self.search.clear_highlight(),
925 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
926 Action::TextObject(object) => {
927 if let Some(range) = self.resolve_object(*object) {
933 self.jumps.push(self.cursor());
934 self.set_cursor(range.start);
935 } else {
936 self.report_pattern_not_found();
937 }
938 }
939 Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
940 Some(range) => self.apply_operator_over(*op, range),
941 None => self.report_pattern_not_found(),
942 },
943 Action::RepeatLastChange => self.repeat_last_change(),
944 Action::JumpBack => {
945 let here = self.cursor();
946 if let Some(pos) = self.jumps.back(here) {
947 self.set_cursor(pos);
948 } else {
949 self.messages
950 .push("E662: At start of changelist".to_string());
951 }
952 }
953 Action::JumpForward => {
954 if let Some(pos) = self.jumps.forward() {
955 self.set_cursor(pos);
956 } else {
957 self.messages.push("E663: At end of changelist".to_string());
958 }
959 }
960 Action::ChangeMode(m) => {
961 if *m == Mode::Normal && self.search.is_prompting() {
965 if let Some(origin) = self.search.cancel() {
966 if let Some(buf) = self.buffers.get(self.active) {
967 let pos = buf.char_to_position(origin);
968 self.set_cursor(pos);
969 }
970 }
971 }
972 self.modal.enter(*m);
973 }
974 Action::InsertChar(c) => self.insert_char(*c),
975 Action::Edit(edit) => self.apply_edit(edit),
976 Action::Undo => {
977 if let Some(buf) = self.buffers.get_mut(self.active) {
978 let _ = buf.undo();
979 }
980 self.set_cursor(self.cursor());
983 }
984 Action::Redo => {
985 if let Some(buf) = self.buffers.get_mut(self.active) {
986 let _ = buf.redo();
987 }
988 self.set_cursor(self.cursor());
989 }
990 Action::Save => {
991 if let Some(buf) = self.buffers.get_mut(self.active) {
992 let _ = buf.save();
993 }
994 self.set_cursor(self.cursor());
995 }
996 Action::Quit => self.quit_requested = true,
997 Action::SubmitCommand => {
998 if self.search.is_prompting() {
999 self.submit_search();
1000 } else {
1001 self.submit_command();
1002 }
1003 }
1004 Action::Command { name, args } => self.run_command(name, args),
1005 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
1006 Action::Operator(_) => {}
1009 Action::PromptCaret { to } => {
1010 if self.search.is_prompting() {
1011 self.search.move_caret(*to);
1012 }
1013 }
1014 Action::SearchPreviewStep { forward } => {
1015 if self.search.is_prompting() {
1016 self.search.preview_step(*forward);
1017 self.preview_search();
1018 }
1019 }
1020 Action::PromptDelete => {
1021 if self.search.is_prompting() {
1022 self.search.delete_at_caret();
1023 self.preview_search();
1024 }
1025 }
1026 Action::PromptDeleteWord => {
1027 if self.search.is_prompting() {
1028 self.search.delete_word_before_caret();
1029 self.preview_search();
1030 }
1031 }
1032 Action::PromptClearToStart => {
1033 if self.search.is_prompting() {
1034 self.search.clear_before_caret();
1035 self.preview_search();
1036 }
1037 }
1038 Action::PromptBackspace => {
1039 self.prompt_backspace();
1040 if self.search.is_prompting() {
1044 self.preview_search();
1045 }
1046 }
1047 Action::PromptHistory { back } => {
1048 if self.search.is_prompting() {
1049 self.search.history_step(*back);
1050 self.modal.clear_minibuffer();
1054 if let Some(text) = self.search.prompt().map(|p| p.text.clone()) {
1055 self.modal.push_minibuffer_str(&text);
1056 }
1057 self.preview_search();
1058 }
1059 }
1060 Action::Pending => {}
1061 }
1062 let lines_after = self.active_line_count();
1067 let cline_after = self.cursor().line;
1068 let d = match action {
1069 Action::SearchOpen(_)
1073 | Action::PromptHistory { .. }
1074 | Action::PromptBackspace
1075 | Action::PromptCaret { .. }
1076 | Action::SearchPreviewStep { .. }
1077 | Action::PromptDelete
1078 | Action::PromptDeleteWord
1079 | Action::PromptClearToStart
1080 | Action::SearchRepeat { .. }
1081 | Action::SearchWord { .. }
1082 | Action::ClearSearchHighlight
1083 | Action::SearchSubmitOperated { .. }
1084 | Action::RepeatLastChange
1087 | Action::TextObject(_)
1088 | Action::ApplyOperatorObject { .. }
1089 | Action::JumpBack
1091 | Action::JumpForward => Damage::Full,
1092 Action::InsertChar(_)
1093 | Action::Edit(_)
1094 | Action::Undo
1095 | Action::Redo
1096 | Action::ApplyOperator { .. } => {
1097 if lines_after == lines_before {
1098 Damage::span(cline_before, cline_after)
1099 } else {
1100 Damage::Lines {
1101 from: cline_before.min(cline_after),
1102 to: u32::MAX,
1103 }
1104 }
1105 }
1106 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
1107 Action::Save => Damage::Viewport,
1108 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
1109 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
1110 };
1111 self.damage = self.damage.join(d);
1112 if self.recording_insert {
1133 match action {
1134 Action::InsertChar(c) => {
1135 if let Some(lc) = self.last_change.as_mut() {
1136 lc.inserted.push(*c);
1137 }
1138 }
1139 Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
1141 _ => {}
1142 }
1143 } else if self.text_rev() != rev_before
1144 && !matches!(
1145 action,
1146 Action::RepeatLastChange | Action::Undo | Action::Redo
1147 )
1148 {
1149 self.last_change = Some(LastChange {
1150 action: action.clone(),
1151 count: 1,
1152 inserted: String::new(),
1153 });
1154 self.recording_insert = self.modal.mode() == Mode::Insert;
1155 }
1156
1157 if action.highlight_effect() == HighlightEffect::Clear {
1162 self.search.clear_highlight();
1163 }
1164 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
1173 let text = self.active_text();
1174 self.search.refresh(&text);
1175 }
1180 self.bump_gen();
1185 }
1186
1187 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1197 let buf = self.buffers.get(self.active)?;
1198 let pos = from;
1199 Some(match motion {
1200 Motion::SearchNext | Motion::SearchPrev => {
1205 let at = buf.position_to_char(pos).ok()?;
1206 let step = self
1207 .search
1208 .repeat(at, matches!(motion, Motion::SearchPrev))?;
1209 buf.char_to_position(step.target.start)
1210 }
1211 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1212 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1213 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1214 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1215 Motion::LineStart => Position::new(pos.line, 0),
1216 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1217 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1218 Motion::DocStart => Position::ZERO,
1219 Motion::DocEnd => Position::new(
1220 buf.line_count().saturating_sub(1),
1221 buf.line_len_chars(buf.line_count().saturating_sub(1)),
1222 ),
1223 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1224 Motion::WordStartPrev => word_prev(buf, pos),
1225 Motion::PageDown | Motion::HalfPageDown => {
1226 Position::new(pos.line.saturating_add(10), pos.column)
1227 }
1228 Motion::PageUp | Motion::HalfPageUp => {
1229 Position::new(pos.line.saturating_sub(10), pos.column)
1230 }
1231 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1232 Motion::ForwardSexp
1235 | Motion::BackwardSexp
1236 | Motion::UpList
1237 | Motion::DownList
1238 | Motion::BeginningOfDefun
1239 | Motion::EndOfDefun
1240 | Motion::BeginningOfSexp
1241 | Motion::EndOfSexp => pos,
1242 })
1243 }
1244
1245 fn apply_motion(&mut self, motion: Motion) {
1246 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1254 self.jump_search(matches!(motion, Motion::SearchPrev));
1255 return;
1256 }
1257 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1258 return;
1259 };
1260 self.set_cursor(pos);
1263 }
1264
1265 fn apply_operator(&mut self, op: Operator, motion: Motion) {
1272 let from = self.cursor();
1273 let Some(to) = self.resolve_motion(from, motion) else {
1274 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1279 if self.search.pattern().is_none() {
1280 self.messages
1281 .push("E35: No previous regular expression".to_string());
1282 } else {
1283 self.report_pattern_not_found();
1284 }
1285 }
1286 return;
1287 };
1288 self.apply_operator_to(op, to);
1289 }
1290
1291 fn apply_operator_to(&mut self, op: Operator, to: Position) {
1298 let from = self.cursor();
1299 self.apply_operator_over(
1300 op,
1301 Range {
1302 start: from,
1303 end: to,
1304 },
1305 );
1306 }
1307
1308 fn apply_operator_over(&mut self, op: Operator, range: Range) {
1315 let range = range.normalized();
1316 if range.is_empty() {
1317 return;
1318 }
1319 let text = self
1321 .buffers
1322 .get(self.active)
1323 .and_then(|buf| buf.slice(range).ok());
1324 if op.leaves_register() {
1325 if let Some(t) = &text {
1326 self.register = Some(t.clone());
1327 }
1328 }
1329 match op {
1330 Operator::Delete | Operator::Change => {
1333 if let Some(buf) = self.buffers.get_mut(self.active) {
1334 let _ = buf.apply(&Edit::delete(range));
1335 }
1336 self.set_cursor(range.start);
1337 if op == Operator::Change {
1338 self.modal.enter(Mode::Insert);
1339 }
1340 }
1341 Operator::Yank => {
1344 self.set_cursor(range.start);
1345 }
1346 _ => {
1350 self.messages
1351 .push("operator not yet implemented".to_owned());
1352 }
1353 }
1354 }
1355
1356 #[must_use]
1359 pub fn register(&self) -> Option<&str> {
1360 self.register.as_deref()
1361 }
1362
1363 fn insert_char(&mut self, c: char) {
1364 if self.modal.mode() == Mode::Command {
1365 if self.search.is_prompting() {
1369 self.search.push(c);
1370 self.modal.push_minibuffer(c);
1371 self.preview_search();
1372 } else {
1373 self.modal.push_minibuffer(c);
1374 }
1375 return;
1376 }
1377 let cursor = self.cursor();
1378 let Some(buf) = self.buffers.get_mut(self.active) else {
1379 return;
1380 };
1381 let edit = Edit::insert(cursor, c.to_string());
1382 if buf.apply(&edit).is_ok() {
1383 let next = if c == '\n' {
1384 Position::new(cursor.line.saturating_add(1), 0)
1385 } else {
1386 cursor.shift_right(1)
1387 };
1388 self.set_cursor(next);
1391 }
1392 }
1393
1394 fn prompt_backspace(&mut self) -> bool {
1398 if self.modal.mode() != Mode::Command {
1399 return false;
1400 }
1401 if self.search.is_prompting() {
1402 if self.search.backspace() {
1404 self.modal.clear_minibuffer();
1405 self.modal.enter(Mode::Normal);
1406 return true;
1407 }
1408 }
1409 self.modal.pop_minibuffer();
1410 true
1411 }
1412
1413 fn apply_edit(&mut self, _edit: &Edit) {
1414 }
1418
1419 fn submit_command(&mut self) {
1420 let line = self.modal.minibuffer().to_string();
1424 self.modal.escape();
1425 let (name, args) = parse_command_line(&line);
1426 if name.is_empty() {
1427 return;
1428 }
1429 self.run_command(&name, &args);
1430 }
1431
1432 fn run_command(&mut self, name: &str, args: &[String]) {
1433 if matches!(name, "noh" | "nohl" | "nohlsearch") {
1439 self.search.clear_highlight();
1440 return;
1441 }
1442 if self.plugin_host.pending() > 0 {
1448 let pending = self.plugin_host.pending_for_command(name);
1449 for src in pending {
1450 self.apply_plugin_entry(&src);
1451 }
1452 }
1453 let active = Some(self.active);
1454 let mut quit = false;
1455 {
1456 let mut ctx = EditContext {
1457 buffers: &mut self.buffers,
1458 active,
1459 state: &mut self.modal,
1460 quit_requested: &mut quit,
1461 };
1462 let _ = self.commands.run(name, &mut ctx, args);
1463 }
1464 if quit {
1467 self.quit_requested = true;
1468 }
1469 }
1470
1471 #[must_use]
1476 pub fn snapshot(&self) -> EditorSnapshot {
1477 let current_line = self
1478 .buffers
1479 .get(self.active)
1480 .and_then(|b| b.line(self.cursor().line))
1481 .map(|s| s.trim_end_matches('\n').to_string())
1482 .unwrap_or_default();
1483 let buffer_name = self
1484 .buffers
1485 .get(self.active)
1486 .and_then(|b| b.path.as_ref())
1487 .map(|p| p.display().to_string())
1488 .unwrap_or_else(|| "[scratch]".to_string());
1489 EditorSnapshot {
1490 cursor_line: i64::from(self.cursor().line),
1491 cursor_column: i64::from(self.cursor().column),
1492 current_line,
1493 mode: self.modal.mode().as_str().to_string(),
1494 buffer_name,
1495 }
1496 }
1497
1498 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1514 let mut host = EscribaHost::with_snapshot(self.snapshot());
1515 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1516 vm.eval(src, &mut host)?;
1517 let effects = host.take_effects();
1518 self.apply_host_effects(effects);
1519 Ok(())
1520 }
1521
1522 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1526 for eff in effects {
1527 match eff {
1528 HostEffect::Message(m) => self.messages.push(m),
1529 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1530 HostEffect::SetOption { name, value } => {
1531 self.options.insert(name, value);
1532 }
1533 HostEffect::InsertText(text) => self.insert_text(&text),
1534 }
1535 }
1536 }
1537
1538 fn insert_text(&mut self, text: &str) {
1541 if text.is_empty() {
1542 return;
1543 }
1544 let cursor = self.cursor();
1545 let Some(buf) = self.buffers.get_mut(self.active) else {
1546 return;
1547 };
1548 let edit = Edit::insert(cursor, text.to_string());
1549 if buf.apply(&edit).is_ok() {
1550 let next = if let Some(nl) = text.rfind('\n') {
1551 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1552 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1553 Position::new(cursor.line + added_lines, last_line_len)
1554 } else {
1555 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1556 cursor.shift_right(n)
1557 };
1558 self.set_cursor(next);
1561 }
1562 }
1563}
1564
1565fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1566 let Some(text) = buf.line(line) else {
1567 return Position::new(line, 0);
1568 };
1569 let col = text
1570 .chars()
1571 .take_while(|c| c.is_whitespace() && *c != '\n')
1572 .count();
1573 Position::new(line, u32::try_from(col).unwrap_or(0))
1574}
1575
1576fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1577 let Some(text) = buf.line(pos.line) else {
1578 return pos;
1579 };
1580 let chars: Vec<char> = text.chars().collect();
1581 let start = pos.column as usize;
1582 let mut i = start;
1583 while i < chars.len() && !chars[i].is_whitespace() {
1584 i += 1;
1585 }
1586 while i < chars.len() && chars[i].is_whitespace() {
1587 i += 1;
1588 }
1589 if i >= chars.len() {
1590 if pos.line + 1 < buf.line_count() {
1592 return Position::new(pos.line + 1, 0);
1593 }
1594 }
1595 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1596}
1597
1598fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1599 let Some(text) = buf.line(pos.line) else {
1600 return pos;
1601 };
1602 let chars: Vec<char> = text.chars().collect();
1603 let mut i = (pos.column as usize).min(chars.len());
1604 while i > 0 && chars[i - 1].is_whitespace() {
1605 i -= 1;
1606 }
1607 while i > 0 && !chars[i - 1].is_whitespace() {
1608 i -= 1;
1609 }
1610 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1611}
1612
1613fn parse_command_line(line: &str) -> (String, Vec<String>) {
1614 let mut parts = line.split_whitespace();
1615 let Some(first) = parts.next() else {
1616 return (String::new(), Vec::new());
1617 };
1618 let head = first.strip_prefix(':').unwrap_or(first);
1619 let name = match head {
1620 "w" => "save",
1621 "q" => "quit",
1622 "u" => "undo",
1623 other => other,
1624 };
1625 (name.to_string(), parts.map(str::to_string).collect())
1626}
1627
1628#[cfg(test)]
1629mod tests {
1630 use super::*;
1631 use madori::event::{KeyCode, KeyEvent, Modifiers};
1632
1633 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1641 st.apply(&Action::SearchOpen(dir));
1642 for c in pat.chars() {
1643 st.apply(&Action::InsertChar(c));
1644 }
1645 st.apply(&Action::SubmitCommand);
1646 }
1647
1648 #[test]
1649 fn slash_search_moves_the_cursor_to_the_match() {
1650 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1651 type_search(&mut st, SearchDirection::Forward, "charlie");
1652 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1653 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1654 assert_eq!(st.search.matches().len(), 1);
1655 }
1656
1657 #[test]
1658 fn n_and_N_walk_matches_in_both_directions() {
1659 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1660 type_search(&mut st, SearchDirection::Forward, "foo");
1661 let first = st.cursor().line;
1662 st.apply(&Action::SearchRepeat { reverse: false });
1663 let second = st.cursor().line;
1664 assert!(second > first, "n advances ({first} -> {second})");
1665 st.apply(&Action::SearchRepeat { reverse: true });
1666 assert_eq!(st.cursor().line, first, "N comes back");
1667 }
1668
1669 #[test]
1670 fn star_searches_the_word_under_the_cursor() {
1671 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1672 st.apply(&Action::SearchWord { reverse: false });
1673 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1674 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1675 }
1676
1677 #[test]
1678 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1679 let mut st = new_state_with("foo\nbar\nfoo\n");
1680 type_search(&mut st, SearchDirection::Forward, "foo");
1681 let matches_before = st.search.matches().len();
1682
1683 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1684 st.apply(&Action::InsertChar('z'));
1685 st.apply(&Action::ChangeMode(Mode::Normal));
1686
1687 assert!(!st.search.is_prompting(), "prompt gone");
1688 assert_eq!(
1689 st.search.pattern().unwrap().raw(),
1690 "foo",
1691 "old pattern survives"
1692 );
1693 assert_eq!(
1694 st.search.matches().len(),
1695 matches_before,
1696 "old highlights survive"
1697 );
1698 }
1699
1700 #[test]
1701 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1702 let mut st = new_state_with("foo\n");
1703 st.apply(&Action::ChangeMode(Mode::Command));
1705 assert!(!st.search.is_prompting(), "`:` must not open a search");
1706 st.apply(&Action::InsertChar('w'));
1707 assert!(
1708 st.search.prompt().is_none(),
1709 "typed char went to the ex line"
1710 );
1711 }
1712
1713 #[test]
1714 fn a_missing_pattern_reports_instead_of_failing_silently() {
1715 let mut st = new_state_with("alpha\nbravo\n");
1716 type_search(&mut st, SearchDirection::Forward, "zzz");
1717 assert!(
1718 st.messages.iter().any(|m| m.contains("E486")),
1719 "must report not-found, got {:?}",
1720 st.messages
1721 );
1722 }
1723
1724 #[test]
1725 fn n_without_any_search_reports_rather_than_moving() {
1726 let mut st = new_state_with("alpha\nbravo\n");
1727 let before = st.cursor();
1728 st.apply(&Action::SearchRepeat { reverse: false });
1729 assert_eq!(st.cursor(), before, "cursor must not move");
1730 assert!(
1731 st.messages.iter().any(|m| m.contains("E35")),
1732 "got {:?}",
1733 st.messages
1734 );
1735 }
1736
1737 #[test]
1738 fn search_as_a_motion_composes_with_an_operator() {
1739 let mut st = new_state_with("alpha bravo charlie\n");
1741 type_search(&mut st, SearchDirection::Forward, "charlie");
1742 st.set_cursor(Position::new(0, 0));
1743 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1744 assert!(target.is_some(), "search must resolve as a motion");
1745 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1746 }
1747
1748 #[test]
1749 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1750 let st = new_state_with("alpha bravo\n");
1753 assert!(
1754 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1755 .is_none()
1756 );
1757 }
1758
1759 #[test]
1760 fn clear_highlight_keeps_the_pattern_usable() {
1761 let mut st = new_state_with("foo\nbar\nfoo\n");
1762 type_search(&mut st, SearchDirection::Forward, "foo");
1763 st.apply(&Action::ClearSearchHighlight);
1764 assert!(st.search.highlights().is_empty(), "nothing lit");
1765 st.apply(&Action::SearchRepeat { reverse: false });
1766 assert!(st.search.pattern().is_some(), "but n still works");
1767 }
1768
1769 #[test]
1770 fn typing_previews_incrementally_before_commit() {
1771 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1772 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1773 for c in "charlie".chars() {
1774 st.apply(&Action::InsertChar(c));
1775 }
1776 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1778 assert!(st.search.pattern().is_none(), "but nothing is committed");
1779 }
1780
1781 #[test]
1782 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1783 let mut st = new_state_with("alpha\nbravo\n");
1784 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1785 for c in "bravox".chars() {
1786 st.apply(&Action::InsertChar(c));
1787 }
1788 assert_eq!(st.search.prompt().unwrap().text, "bravox");
1789 st.apply(&Action::PromptBackspace);
1790 assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1791 assert_eq!(st.modal.minibuffer(), "bravo", "display stays in lockstep");
1792 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1793 }
1794
1795 #[test]
1796 fn backspacing_past_the_slash_closes_the_prompt() {
1797 let mut st = new_state_with("alpha\n");
1798 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1799 st.apply(&Action::InsertChar('a'));
1800 st.apply(&Action::PromptBackspace);
1801 st.apply(&Action::PromptBackspace);
1802 assert!(!st.search.is_prompting(), "prompt closed");
1803 assert_eq!(st.modal.mode(), Mode::Normal);
1804 }
1805
1806 #[test]
1807 fn noh_clears_highlights_and_keeps_the_pattern() {
1808 let mut st = new_state_with("foo\nbar\nfoo\n");
1809 type_search(&mut st, SearchDirection::Forward, "foo");
1810 assert!(!st.search.highlights().is_empty());
1811 st.run_command("noh", &[]);
1812 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1813 assert!(st.search.pattern().is_some(), "but n still works");
1814 }
1815
1816 #[test]
1817 fn noh_accepts_the_vim_aliases() {
1818 for name in ["noh", "nohl", "nohlsearch"] {
1819 let mut st = new_state_with("foo\nfoo\n");
1820 type_search(&mut st, SearchDirection::Forward, "foo");
1821 st.run_command(name, &[]);
1822 assert!(st.search.highlights().is_empty(), "{name} must clear");
1823 }
1824 }
1825
1826 #[test]
1827 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1828 let mut st = new_state_with("foo\n");
1829 st.apply(&Action::ChangeMode(Mode::Command));
1830 st.apply(&Action::InsertChar('w'));
1831 st.apply(&Action::InsertChar('q'));
1832 st.apply(&Action::PromptBackspace);
1833 assert_eq!(st.modal.minibuffer(), "w");
1834 assert!(st.search.prompt().is_none(), "no search was involved");
1835 }
1836
1837 #[test]
1838 fn up_arrow_recalls_the_previous_search() {
1839 let mut st = new_state_with("alpha\nbravo\n");
1840 type_search(&mut st, SearchDirection::Forward, "bravo");
1841 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1842 st.apply(&Action::PromptHistory { back: true });
1843 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1844 assert_eq!(st.modal.minibuffer(), "bravo", "display follows the prompt");
1845 }
1846
1847 #[test]
1848 fn arrowing_back_down_restores_the_half_typed_pattern() {
1849 let mut st = new_state_with("alpha\nbravo\n");
1850 type_search(&mut st, SearchDirection::Forward, "bravo");
1851 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1852 st.apply(&Action::InsertChar('a'));
1853 st.apply(&Action::PromptHistory { back: true });
1854 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1855 st.apply(&Action::PromptHistory { back: false });
1856 assert_eq!(
1857 st.search.prompt().unwrap().text,
1858 "a",
1859 "the draft comes back"
1860 );
1861 assert_eq!(st.modal.minibuffer(), "a");
1862 }
1863
1864 #[test]
1865 fn history_arrows_do_nothing_on_the_ex_line() {
1866 let mut st = new_state_with("alpha\n");
1867 st.apply(&Action::ChangeMode(Mode::Command));
1868 st.apply(&Action::InsertChar('w'));
1869 st.apply(&Action::PromptHistory { back: true });
1870 assert_eq!(st.modal.minibuffer(), "w", "ex line untouched");
1871 }
1872
1873 fn new_state_with(text: &str) -> EditorState {
1874 let mut bufs = BufferSet::new();
1875 let id = bufs.scratch(text);
1876 EditorState::new_with_buffer(bufs, id)
1877 }
1878
1879 #[test]
1885 fn edit_gen_advances_on_applied_action_not_on_read() {
1886 let mut s = new_state_with("hello\nworld\n");
1887 let g0 = s.edit_gen();
1888 s.apply(&Action::InsertChar('X'));
1889 assert_ne!(
1890 s.edit_gen(),
1891 g0,
1892 "an applied action must advance the refresh generation",
1893 );
1894 let g1 = s.edit_gen();
1896 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1897 }
1898
1899 #[test]
1904 fn damage_tracks_edit_scope_and_drains() {
1905 let mut s = new_state_with("hello\nworld\n");
1906 assert!(s.damage().is_none(), "a fresh state has no damage");
1907
1908 s.apply(&Action::InsertChar('X')); assert_eq!(
1910 s.damage(),
1911 Damage::Lines { from: 0, to: 0 },
1912 "a local edit damages just its line",
1913 );
1914
1915 let drained = s.take_damage();
1916 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1917 assert!(s.damage().is_none(), "take_damage drains to None");
1918
1919 s.apply(&Action::InsertChar('\n')); assert_eq!(
1921 s.damage(),
1922 Damage::Lines {
1923 from: 0,
1924 to: u32::MAX,
1925 },
1926 "a line-count change damages to end-of-document",
1927 );
1928 }
1929
1930 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1934 let mut s = new_state_with(text);
1935 for w in &mut s.layout.windows {
1936 w.viewport.visible_lines = vis_lines;
1937 w.viewport.visible_columns = vis_cols;
1938 }
1939 s
1940 }
1941
1942 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
1947 let w = s.layout.active_window().expect("active window");
1948 let v = w.viewport;
1949 let c = s.cursor();
1950 assert!(
1951 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
1952 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
1953 c.line,
1954 v.top_line,
1955 v.top_line + v.visible_lines,
1956 );
1957 assert!(
1958 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
1959 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
1960 c.column,
1961 v.left_column,
1962 v.left_column + v.visible_columns,
1963 );
1964 }
1965
1966 fn press(kc: KeyCode) -> AppEvent {
1967 AppEvent::Key(KeyEvent {
1968 key: kc,
1969 pressed: true,
1970 modifiers: Modifiers::default(),
1971 text: None,
1972 })
1973 }
1974
1975 fn line0_len(s: &EditorState) -> u32 {
1978 s.buffers.get(s.active).unwrap().line_len_chars(0)
1979 }
1980
1981 #[test]
1982 fn delete_to_line_end_clears_line_and_fills_register() {
1983 let mut s = new_state_with("hello world");
1984 s.apply(&Action::ApplyOperator {
1985 op: Operator::Delete,
1986 motion: Motion::LineEnd,
1987 });
1988 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
1989 assert_eq!(
1990 s.register(),
1991 Some("hello world"),
1992 "delete fills the register"
1993 );
1994 assert_eq!(
1995 s.cursor(),
1996 Position::ZERO,
1997 "cursor lands at the range start"
1998 );
1999 }
2000
2001 #[test]
2002 fn delete_over_right_motion_removes_one_char() {
2003 let mut s = new_state_with("abc");
2004 s.apply(&Action::ApplyOperator {
2005 op: Operator::Delete,
2006 motion: Motion::Right,
2007 });
2008 assert_eq!(
2009 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2010 Some("bc")
2011 );
2012 assert_eq!(s.register(), Some("a"));
2013 }
2014
2015 #[test]
2016 fn change_to_line_end_deletes_and_enters_insert() {
2017 let mut s = new_state_with("hello world");
2018 assert_eq!(s.modal.mode(), Mode::Normal);
2019 s.apply(&Action::ApplyOperator {
2020 op: Operator::Change,
2021 motion: Motion::LineEnd,
2022 });
2023 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
2024 assert_eq!(
2025 s.modal.mode(),
2026 Mode::Insert,
2027 "change enters Insert to type the replacement"
2028 );
2029 assert_eq!(
2030 s.register(),
2031 Some("hello world"),
2032 "change fills the register"
2033 );
2034 }
2035
2036 #[test]
2037 fn yank_to_line_end_fills_register_without_mutating() {
2038 let mut s = new_state_with("hello world");
2039 s.apply(&Action::ApplyOperator {
2040 op: Operator::Yank,
2041 motion: Motion::LineEnd,
2042 });
2043 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
2044 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
2045 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
2046 }
2047
2048 #[test]
2049 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
2050 let mut s = new_state_with("hello world");
2054 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
2055 assert_eq!(target, Position::new(0, 11));
2056 s.apply_motion(Motion::LineEnd);
2057 assert_eq!(
2058 s.cursor(),
2059 target,
2060 "the move path resolves the same target the operator uses"
2061 );
2062 }
2063
2064 #[test]
2065 fn empty_motion_range_is_a_no_op() {
2066 let mut s = new_state_with("abc");
2069 s.apply(&Action::ApplyOperator {
2070 op: Operator::Delete,
2071 motion: Motion::LineStart,
2072 });
2073 assert_eq!(
2074 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2075 Some("abc")
2076 );
2077 assert_eq!(s.register(), None);
2078 }
2079
2080 #[test]
2081 fn operator_then_motion_composes_through_the_pending_fsm() {
2082 let mut s = new_state_with("hello world");
2086 s.apply(&Action::Operator(Operator::Delete));
2087 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
2088 s.apply(&Action::Move(Motion::LineEnd));
2089 assert_eq!(
2090 line0_len(&s),
2091 0,
2092 "d then $ composes d$ and deletes the line"
2093 );
2094 assert_eq!(s.register(), Some("hello world"));
2095 }
2096
2097 #[test]
2098 fn change_operator_through_fsm_enters_insert() {
2099 let mut s = new_state_with("hello world");
2100 s.apply(&Action::Operator(Operator::Change));
2101 s.apply(&Action::Move(Motion::LineEnd));
2102 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
2103 }
2104
2105 #[test]
2106 fn lone_motion_after_no_operator_just_moves() {
2107 let mut s = new_state_with("hello world");
2109 s.apply(&Action::Move(Motion::LineEnd));
2110 assert_eq!(s.cursor(), Position::new(0, 11));
2111 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
2112 }
2113
2114 #[test]
2115 fn counted_operator_deletes_count_times() {
2116 let mut s = new_state_with("abcdef");
2120 s.apply_counted(&Action::Operator(Operator::Delete), 3);
2121 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
2122 s.apply(&Action::Move(Motion::Right));
2123 assert_eq!(
2124 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2125 Some("def")
2126 );
2127 }
2128
2129 #[test]
2130 fn operator_and_motion_counts_multiply_end_to_end() {
2131 let mut s = new_state_with("abcdefgh");
2133 s.apply_counted(&Action::Operator(Operator::Delete), 2);
2134 s.apply_counted(&Action::Move(Motion::Right), 3);
2135 assert_eq!(
2136 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2137 Some("gh")
2138 );
2139 }
2140
2141 #[test]
2142 fn bare_counted_motion_still_repeats_no_regression() {
2143 let mut s = new_state_with("a\nb\nc\nd\ne");
2146 s.apply_counted(&Action::Move(Motion::Down), 3);
2147 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
2148 }
2149
2150 struct SpacedClock(std::time::Instant);
2156 impl SpacedClock {
2157 fn new() -> Self {
2158 Self(std::time::Instant::now())
2159 }
2160 fn next(&mut self) -> std::time::Instant {
2161 self.0 += std::time::Duration::from_secs(1);
2162 self.0
2163 }
2164 }
2165
2166 #[test]
2167 fn hjkl_moves_cursor() {
2168 let mut s = new_state_with("hello\nworld");
2169 s.tick(&press(KeyCode::Char('l')));
2170 assert_eq!(s.cursor().column, 1);
2171 s.tick(&press(KeyCode::Char('j')));
2172 assert_eq!(s.cursor().line, 1);
2173 s.tick(&press(KeyCode::Char('h')));
2174 assert_eq!(s.cursor().column, 0);
2175 }
2176
2177 #[test]
2178 fn insert_mode_inserts_chars() {
2179 let mut s = new_state_with("");
2180 s.tick(&press(KeyCode::Char('i')));
2181 assert_eq!(s.modal.mode(), Mode::Insert);
2182 s.tick(&press(KeyCode::Char('h')));
2183 s.tick(&press(KeyCode::Char('i')));
2184 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
2185 assert_eq!(s.cursor().column, 2);
2186 }
2187
2188 #[test]
2189 fn esc_returns_to_normal() {
2190 let mut s = new_state_with("");
2191 s.tick(&press(KeyCode::Char('i')));
2192 s.tick(&press(KeyCode::Escape));
2193 assert_eq!(s.modal.mode(), Mode::Normal);
2194 }
2195
2196 #[test]
2197 fn count_prefix_repeats_motion() {
2198 let mut s = new_state_with("abcdefghij");
2199 s.tick(&press(KeyCode::Char('5')));
2200 s.tick(&press(KeyCode::Char('l')));
2201 assert_eq!(s.cursor().column, 5);
2202 }
2203
2204 #[test]
2205 fn close_event_requests_quit() {
2206 let mut s = new_state_with("");
2207 s.tick(&AppEvent::CloseRequested);
2208 assert!(s.quit_requested);
2209 }
2210
2211 #[test]
2212 fn word_next_jumps_past_whitespace() {
2213 let mut s = new_state_with("foo bar baz");
2214 let mut clk = SpacedClock::new();
2217 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2218 assert_eq!(s.cursor().column, 4);
2219 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2220 assert_eq!(s.cursor().column, 8);
2221 }
2222
2223 #[test]
2226 fn leader_sequence_holds_then_resolves() {
2227 let mut s = new_state_with("a\nbb\nccc");
2228 s.keymap.bind_sequence(
2229 Mode::Normal,
2230 vec![Key::Char(','), Key::Char('g')],
2231 Action::Move(Motion::DocEnd),
2232 "doc end",
2233 );
2234 s.on_key(&Key::Char(','));
2236 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2237 assert_eq!(s.cursor(), Position::ZERO);
2238 s.on_key(&Key::Char('g'));
2240 assert!(s.pending_keys.is_empty());
2241 assert_eq!(s.cursor().line, 2);
2242 }
2243
2244 #[test]
2245 fn two_key_gg_jumps_doc_start() {
2246 let mut s = new_state_with("a\nbb\nccc");
2247 s.keymap.bind_sequence(
2248 Mode::Normal,
2249 vec![Key::Char('g'), Key::Char('g')],
2250 Action::Move(Motion::DocStart),
2251 "doc start",
2252 );
2253 let mut clk = SpacedClock::new();
2254 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2255 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2256 assert_eq!(s.cursor().line, 2);
2257 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2259 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
2261 }
2262
2263 #[test]
2264 fn broken_sequence_aborts_and_clears_pending() {
2265 let mut s = new_state_with("hello");
2266 s.keymap.bind_sequence(
2267 Mode::Normal,
2268 vec![Key::Char('g'), Key::Char('g')],
2269 Action::Move(Motion::DocEnd),
2270 "doc end",
2271 );
2272 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2274 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
2276 assert_eq!(s.cursor(), Position::ZERO);
2277 }
2278
2279 #[test]
2280 fn single_binding_wins_over_sequence_prefix() {
2281 let mut s = new_state_with("abcde");
2285 let mut clk = SpacedClock::new();
2286 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2287 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2288 assert_eq!(s.cursor().column, 2);
2289 s.keymap.bind_sequence(
2290 Mode::Normal,
2291 vec![Key::Char('h'), Key::Char('z')],
2292 Action::Move(Motion::DocEnd),
2293 "shadowed",
2294 );
2295 s.on_key(&Key::Char('h'));
2296 assert!(s.pending_keys.is_empty(), "single binding should not pend");
2297 assert_eq!(s.cursor().column, 1, "h moved left immediately");
2298 }
2299
2300 #[test]
2303 fn lisp_set_option_writes_live_options() {
2304 let mut s = new_state_with("");
2305 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2306 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2307 }
2308
2309 #[test]
2310 fn lisp_insert_modifies_buffer_and_advances_cursor() {
2311 let mut s = new_state_with("");
2312 s.run_lisp(r#"(insert "abc")"#).unwrap();
2313 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2314 assert_eq!(s.cursor(), Position::new(0, 3));
2315 }
2316
2317 #[test]
2318 fn lisp_message_appends_to_messages() {
2319 let mut s = new_state_with("");
2320 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2321 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2322 }
2323
2324 #[test]
2325 fn lisp_reads_snapshot_and_branches_to_effect() {
2326 let mut s = new_state_with("one\ntwo\nthree");
2329 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2331 .unwrap();
2332 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2333 }
2334
2335 #[test]
2336 fn lisp_run_command_effect_drives_registry() {
2337 let mut s = new_state_with("");
2341 s.run_lisp(r#"(insert "abc")"#).unwrap();
2342 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2343 s.run_lisp(r#"(run-command "undo")"#).unwrap();
2344 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2345 }
2346
2347 #[test]
2348 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2349 let mut s = new_state_with("");
2354 s.run_lisp(r#"(run-command "quit")"#).unwrap();
2355 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2356 assert_eq!(
2357 s.modal.minibuffer(),
2358 "",
2359 "quit must not pollute any command line — Normal mode has no minibuffer",
2360 );
2361 }
2362
2363 #[test]
2366 fn lazy_plugin_activates_on_command_trigger() {
2367 let mut s = new_state_with("");
2371 s.register_lazy_plugin(
2372 "user-lazy",
2373 vec![LazyTrigger::Command("LazyGo".into())],
2374 r#"(defoption :name "lazy-loaded" :value "yes")
2375 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
2376 );
2377 assert_eq!(s.plugin_host.pending(), 1);
2378 assert!(
2379 s.options.get("lazy-loaded").is_none(),
2380 "entry not applied yet"
2381 );
2382
2383 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
2385
2386 assert_eq!(
2387 s.options.get("lazy-loaded").map(String::as_str),
2388 Some("yes"),
2389 "the command trigger applied the plugin's entry",
2390 );
2391 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
2392 }
2393
2394 #[test]
2395 fn lazy_plugin_activates_on_filetype() {
2396 let mut s = new_state_with("");
2397 s.register_lazy_plugin(
2398 "user-rust",
2399 vec![LazyTrigger::FileType("rust".into())],
2400 r#"(defoption :name "rust-plugin" :value "on")"#,
2401 );
2402 let n = s.activate_filetype_plugins("rust");
2403 assert_eq!(n, 1);
2404 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
2405 assert_eq!(s.activate_filetype_plugins("rust"), 0);
2407 }
2408
2409 #[test]
2410 fn cached_vm_serves_multiple_run_lisp_calls() {
2411 let mut s = new_state_with("");
2412 s.run_lisp(r#"(message "one")"#).unwrap();
2413 assert!(
2414 s.lisp_vm.is_some(),
2415 "VM should be cached after first run_lisp"
2416 );
2417 s.run_lisp(r#"(message "two")"#).unwrap();
2418 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
2419 }
2420
2421 #[test]
2422 fn lisp_define_persists_across_run_lisp_calls() {
2423 let mut s = new_state_with("");
2426 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
2427 s.run_lisp(r#"(message greeting)"#).unwrap();
2428 assert_eq!(s.messages, vec!["hi".to_string()]);
2429 }
2430
2431 #[test]
2432 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2433 let mut s = new_state_with("");
2437 s.run_lisp(
2438 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2439 )
2440 .unwrap();
2441 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2442 assert_eq!(
2443 s.options.get("col").map(String::as_str),
2444 Some("stale-zero"),
2445 "cursor-column within the same call reads the pre-eval snapshot",
2446 );
2447 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2450 .unwrap();
2451 assert_eq!(
2452 s.options.get("col2").map(String::as_str),
2453 Some("live-two"),
2454 "a later call sees the refreshed snapshot",
2455 );
2456 }
2457
2458 #[test]
2459 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2460 let mut s = new_state_with("");
2461 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2462 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2463 assert_eq!(s.cursor(), Position::new(1, 3));
2464 }
2465
2466 #[test]
2467 fn visual_mode_sequence_resolves() {
2468 let mut s = new_state_with("abc");
2469 s.modal.enter(Mode::Visual);
2470 s.keymap.bind_sequence(
2471 Mode::Visual,
2472 vec![Key::Char('g'), Key::Char('e')],
2473 Action::Move(Motion::DocEnd),
2474 "ge",
2475 );
2476 s.on_key(&Key::Char('g'));
2477 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2478 s.on_key(&Key::Char('e'));
2479 assert!(s.pending_keys.is_empty());
2480 assert_eq!(
2481 s.cursor().column,
2482 3,
2483 "ge resolved to doc-end in visual mode"
2484 );
2485 }
2486
2487 #[test]
2488 fn sequence_abort_with_bound_breaking_key_redispatches() {
2489 let mut s = new_state_with("abcde");
2492 s.keymap.bind_sequence(
2493 Mode::Normal,
2494 vec![Key::Char('g'), Key::Char('g')],
2495 Action::Move(Motion::DocEnd),
2496 "gg",
2497 );
2498 s.on_key(&Key::Char('g'));
2499 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2500 s.on_key(&Key::Char('l'));
2501 assert!(s.pending_keys.is_empty());
2502 assert_eq!(
2503 s.cursor().column,
2504 1,
2505 "the breaking key l should re-dispatch as move-right",
2506 );
2507 }
2508
2509 #[test]
2512 fn viewport_contains_cursor_after_every_op() {
2513 let mut s = new_state_small_viewport("", 5, 10);
2517 assert_cursor_in_viewport(&s, "initial");
2518
2519 s.tick(&press(KeyCode::Char('i')));
2522 assert_eq!(s.modal.mode(), Mode::Insert);
2523 for line in 0..30u32 {
2524 for c in "line".chars() {
2525 s.tick(&press(KeyCode::Char(c)));
2526 assert_cursor_in_viewport(&s, "typing chars");
2527 }
2528 s.tick(&press(KeyCode::Enter));
2529 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2530 }
2531
2532 for i in 0..200u32 {
2535 s.tick(&press(KeyCode::Char('x')));
2536 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2537 }
2538
2539 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2541 assert_cursor_in_viewport(&s, "insert_text multiline");
2542
2543 s.tick(&press(KeyCode::Escape));
2545 assert_eq!(s.modal.mode(), Mode::Normal);
2546 for m in [
2547 Motion::DocStart,
2548 Motion::DocEnd,
2549 Motion::Down,
2550 Motion::Down,
2551 Motion::Up,
2552 Motion::Right,
2553 Motion::Right,
2554 Motion::Left,
2555 Motion::LineEnd,
2556 Motion::LineStart,
2557 Motion::GotoLine(1),
2558 Motion::GotoLine(40),
2559 Motion::PageDown,
2560 Motion::PageUp,
2561 ] {
2562 s.apply_motion(m);
2563 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2564 }
2565
2566 for i in 0..50u32 {
2569 s.apply(&Action::Undo);
2570 assert_cursor_in_viewport(&s, &format!("undo {i}"));
2571 }
2572 for i in 0..50u32 {
2574 s.apply(&Action::Redo);
2575 assert_cursor_in_viewport(&s, &format!("redo {i}"));
2576 }
2577 }
2578
2579 #[test]
2580 fn insert_at_eof_keeps_cursor_in_bounds() {
2581 let mut s = new_state_small_viewport("abc", 5, 10);
2584 s.apply_motion(Motion::DocEnd);
2585 s.tick(&press(KeyCode::Char('i')));
2586 s.tick(&press(KeyCode::Char('d')));
2587 let buf = s.buffers.get(s.active).unwrap();
2588 let clamped = buf.clamp(s.cursor());
2589 assert_eq!(
2590 s.cursor(),
2591 clamped,
2592 "cursor must be clamped in-bounds at EOF"
2593 );
2594 assert_cursor_in_viewport(&s, "insert at eof");
2595 }
2596
2597 #[test]
2598 fn count_prefix_then_sequence_repeats() {
2599 let mut s = new_state_with("a\nb\nc\nd\ne");
2601 s.keymap.bind_sequence(
2602 Mode::Normal,
2603 vec![Key::Char('g'), Key::Char('j')],
2604 Action::Move(Motion::Down),
2605 "gj",
2606 );
2607 s.on_key(&Key::Char('2'));
2608 s.on_key(&Key::Char('g'));
2609 s.on_key(&Key::Char('j'));
2610 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2611 }
2612
2613 #[test]
2616 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2617 let mut s = new_state_with(&"x\n".repeat(40));
2623 let t0 = std::time::Instant::now();
2624 let mut delivered = 0u32;
2625 for i in 0..20u32 {
2626 let before = s.cursor().line;
2627 s.tick_at(
2628 &press(KeyCode::Char('j')),
2629 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2630 );
2631 if s.cursor().line != before {
2632 delivered += 1;
2633 }
2634 }
2635 assert!(
2638 (10..=14).contains(&delivered),
2639 "expected the storm debounced to ~13 moves, got {delivered}",
2640 );
2641 assert!(
2642 delivered < 20,
2643 "the gate must drop SOME storm ticks, not pass all 20",
2644 );
2645 }
2646
2647 #[test]
2648 fn spaced_intentional_taps_all_pass() {
2649 let mut s = new_state_with(&"x\n".repeat(10));
2652 let t0 = std::time::Instant::now();
2653 for i in 0..5u32 {
2654 s.tick_at(
2655 &press(KeyCode::Char('j')),
2656 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2658 );
2659 }
2660 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2661 }
2662
2663 #[test]
2664 fn distinct_keys_have_independent_clocks() {
2665 let mut s = new_state_with("abc\ndef\nghi");
2668 let t = std::time::Instant::now();
2669 s.tick_at(&press(KeyCode::Char('j')), t);
2670 s.tick_at(
2672 &press(KeyCode::Char('j')),
2673 t + std::time::Duration::from_millis(10),
2674 );
2675 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2676 s.tick_at(
2678 &press(KeyCode::Char('l')),
2679 t + std::time::Duration::from_millis(10),
2680 );
2681 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2682 }
2683
2684 #[test]
2687 fn cursor_home_preserves_single_cursor_behavior() {
2688 let mut s = new_state_with("hello\nworld\nthere");
2693 assert_eq!(s.cursor(), Position::ZERO);
2694 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2695
2696 s.apply_motion(Motion::Down);
2697 s.apply_motion(Motion::Right);
2698 s.apply_motion(Motion::Right);
2699 assert_eq!(s.cursor(), Position::new(1, 2));
2700 assert_eq!(s.cursors.count(), 1);
2702
2703 let w = s.layout.active_window().unwrap();
2705 assert!(w.viewport.top_line <= s.cursor().line);
2706 }
2707
2708 #[test]
2709 fn insert_mode_is_ungated_so_repeat_typing_works() {
2710 let mut s = new_state_with("");
2714 s.tick(&press(KeyCode::Char('i')));
2715 assert_eq!(s.modal.mode(), Mode::Insert);
2716 let t = std::time::Instant::now();
2717 for _ in 0..10 {
2718 s.tick_at(&press(KeyCode::Char('x')), t);
2719 }
2720 assert_eq!(
2721 s.buffers.get(s.active).unwrap().to_string(),
2722 "xxxxxxxxxx",
2723 "insert-mode repeat typing is ungated",
2724 );
2725 }
2726}