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
179#[derive(Debug, Clone)]
181struct LastChange {
182 action: Action,
184 count: u32,
186 inserted: String,
188}
189
190impl EditorState {
191 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
193 let window = Window {
194 id: WindowId(1),
195 buffer_id: active,
196 viewport: Viewport {
197 top_line: 0,
198 left_column: 0,
199 visible_lines: 40,
200 visible_columns: 160,
201 },
202 rect: Rect {
203 x: 0,
204 y: 0,
205 width: 1200,
206 height: 800,
207 },
208 };
209 Self {
210 buffers: initial,
211 modal: ModalState::new(),
212 search: SearchState::new(escriba_search::CaseMode::Smart),
213 search_at: None,
214 last_change: None,
215 recording_insert: false,
216 jumps: JumpList::new(),
217 keymap: Keymap::default_vim(),
218 commands: CommandRegistry::default_set(),
219 layout: Layout::single(window),
220 active,
221 cursors: Cursors::single(Position::ZERO),
222 quit_requested: false,
223 register: None,
224 op_pending: zenmai::Stateful::new(OpState::Resting),
225 messages: Vec::new(),
226 options: HashMap::new(),
227 lisp_vm: None,
228 pending_keys: Vec::new(),
229 repeat_gate: KeyRepeatGate::new(),
230 plugin_host: PluginHost::default(),
231 edit_gen: EditGen::default(),
232 damage: Damage::None,
233 }
234 }
235
236 #[must_use]
240 pub fn edit_gen(&self) -> EditGen {
241 self.edit_gen
242 }
243
244 fn bump_gen(&mut self) {
246 self.edit_gen = self.edit_gen.next();
247 }
248
249 #[must_use]
251 pub fn damage(&self) -> Damage {
252 self.damage
253 }
254
255 pub fn take_damage(&mut self) -> Damage {
259 std::mem::replace(&mut self.damage, Damage::None)
260 }
261
262 fn active_line_count(&self) -> u32 {
265 self.buffers
266 .get(self.active)
267 .map_or(0, escriba_buffer::Buffer::line_count)
268 }
269
270 pub fn register_lazy_plugin(
276 &mut self,
277 name: impl Into<String>,
278 triggers: Vec<LazyTrigger>,
279 entry_src: impl Into<String>,
280 ) {
281 self.plugin_host.register(name, triggers, entry_src);
282 }
283
284 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
290 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
291 return 0;
292 };
293 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
294 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
295 if let Some(value) = self.options.get("mapleader") {
296 if let Some(key) = escriba_lisp::parse_leader_key(value) {
297 self.keymap.set_leader(key);
298 }
299 }
300 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
301 (cmd.registered + km.keybinds_applied) as usize
302 }
303
304 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
308 let pending = self.plugin_host.pending_for_filetype(filetype);
309 let n = pending.len();
310 for src in pending {
311 self.apply_plugin_entry(&src);
312 }
313 n
314 }
315
316 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
319 let pending = self.plugin_host.pending_for_event(event);
320 let n = pending.len();
321 for src in pending {
322 self.apply_plugin_entry(&src);
323 }
324 n
325 }
326
327 pub fn tick(&mut self, event: &AppEvent) {
332 self.tick_at(event, Instant::now());
333 }
334
335 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
339 match translate_app_event(event) {
340 InputOutcome::Key(k) => {
341 if self.gate_key(&k, now) {
342 self.on_key(&k);
343 }
344 }
345 InputOutcome::Resized { width, height } => {
346 if let Some(w) = self
347 .layout
348 .windows
349 .iter_mut()
350 .find(|w| w.id == self.layout.active)
351 {
352 w.rect.width = width;
353 w.rect.height = height;
354 }
355 self.damage = self.damage.join(Damage::Viewport);
356 self.bump_gen();
357 }
358 InputOutcome::Quit => self.quit_requested = true,
359 InputOutcome::Focus(_) | InputOutcome::None => {}
360 }
361 }
362
363 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
373 match self.modal.mode() {
374 Mode::Normal | Mode::Visual | Mode::VisualLine => {
375 if is_repeat_storm_candidate(key) {
381 return self.repeat_gate.try_pass_at(*key, now);
382 }
383 true
384 }
385 Mode::Insert | Mode::Command => true,
386 }
387 }
388
389 pub fn on_key(&mut self, key: &Key) {
391 match self.step_sequence(key) {
395 SeqStep::Pending => return,
396 SeqStep::Resolved(action) => {
397 let count = self.modal.pending_count().unwrap_or(1);
398 self.modal.clear_count();
399 for _ in 0..count {
400 self.apply(&action);
401 if self.quit_requested {
402 return;
403 }
404 }
405 return;
406 }
407 SeqStep::Passthrough => {}
408 }
409 let counted = self.keymap.dispatch(&self.modal, key);
410 if matches!(counted.action, Action::Pending) {
412 if let Key::Char(c) = key {
413 if c.is_ascii_digit() {
414 let d = u32::from(*c as u8 - b'0');
415 self.modal.append_count(d);
416 }
417 }
418 return;
419 }
420 self.apply_counted(&counted.action, counted.count);
424 self.modal.clear_count();
426 }
427
428 fn step_sequence(&mut self, key: &Key) -> SeqStep {
440 let mode = self.modal.mode();
441 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
442 return SeqStep::Passthrough;
443 }
444 if !self.pending_keys.is_empty() {
445 let mut seq = self.pending_keys.clone();
446 seq.push(key.clone());
447 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
448 let action = b.action.clone();
449 self.pending_keys.clear();
450 return SeqStep::Resolved(action);
451 }
452 if self.keymap.is_sequence_prefix(mode, &seq) {
453 self.pending_keys = seq;
454 return SeqStep::Pending;
455 }
456 self.pending_keys.clear();
459 }
460 let start = [key.clone()];
461 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
462 self.pending_keys = start.to_vec();
463 return SeqStep::Pending;
464 }
465 SeqStep::Passthrough
466 }
467
468 #[must_use]
473 pub fn cursor(&self) -> Position {
474 self.cursors.primary()
475 }
476
477 fn set_cursor(&mut self, pos: Position) {
486 let clamped = if let Some(buf) = self.buffers.get(self.active) {
487 buf.clamp(pos)
488 } else {
489 pos
490 };
491 self.cursors.set_primary(clamped);
492 if let Some(w) = self
493 .layout
494 .windows
495 .iter_mut()
496 .find(|w| w.id == self.layout.active)
497 {
498 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
499 }
500 }
501
502 fn apply(&mut self, action: &Action) {
504 self.apply_counted(action, 1);
505 }
506
507 fn apply_counted(&mut self, action: &Action, count: u32) {
515 if matches!(action, Action::SubmitCommand) {
533 if let Some(e) = self.search.prompt_error() {
534 let mut m = String::from("E383: Invalid search string: ");
535 m.push_str(&e.to_string());
536 self.messages.push(m);
537 return;
538 }
539 }
540
541 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
542 for _ in 0..times {
543 self.apply_resolved(&resolved);
544 if self.quit_requested {
545 return;
546 }
547 }
548 }
549 }
550
551 #[must_use]
555 fn text_rev(&self) -> TextRev {
556 self.buffers
557 .get(self.active)
558 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
559 }
560
561 fn active_text(&self) -> String {
562 self.buffers
563 .get(self.active)
564 .map(escriba_buffer::Buffer::to_string)
565 .unwrap_or_default()
566 }
567
568 fn cursor_char(&self) -> usize {
570 self.buffers
571 .get(self.active)
572 .and_then(|b| b.position_to_char(self.cursor()).ok())
573 .unwrap_or(0)
574 }
575
576 #[must_use]
584 pub fn status_model(&self) -> StatusModel<'_> {
585 let cursor = self.cursor();
586 let prompt = self.search.prompt();
587
588 let kind = match prompt.map(|p| p.direction) {
589 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
590 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
591 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
594 None => PromptKind::None,
595 };
596
597 StatusModel {
598 mode: self.modal.mode(),
599 line: cursor.line.saturating_add(1) as usize,
600 column: cursor.column.saturating_add(1) as usize,
601 prompt: kind,
602 prompt_text: prompt.map_or_else(|| self.modal.minibuffer(), |p| p.text.as_str()),
603 prompt_caret: prompt.map_or_else(
608 || self.modal.minibuffer().chars().count(),
609 escriba_search::Prompt::caret,
610 ),
611 count: self.match_count(),
612 message: self.messages.last().map(String::as_str),
613 }
614 }
615
616 #[must_use]
622 fn match_count(&self) -> MatchCount {
623 if self.search.is_prompting() {
624 let text = self.active_text();
625 let total = self.search.preview_total(&text);
626 return match self.search.preview(&text) {
627 Some(step) => MatchCount::new(step.index, total),
628 None if total == 0 && !self.search.prompt_is_empty() => MatchCount::None,
632 None => MatchCount::Idle,
633 };
634 }
635 if self.search.pattern().is_none() {
636 return MatchCount::Idle;
637 }
638 let total = self.search.matches().len();
639 let rev = self.text_rev();
642 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
643 if total == 0 {
644 MatchCount::None
645 } else {
646 MatchCount::Idle
647 },
648 |&i| MatchCount::new(i, total),
649 )
650 }
651
652 fn repeat_last_change(&mut self) {
658 let Some(change) = self.last_change.clone() else {
659 self.messages
660 .push("E32: No previous change to repeat".to_string());
661 return;
662 };
663
664 for _ in 0..change.count.max(1) {
665 self.apply_resolved(&change.action);
666 }
667 for c in change.inserted.chars() {
668 self.apply_resolved(&Action::InsertChar(c));
669 }
670 if self.modal.mode() == Mode::Insert {
671 self.apply_resolved(&Action::ChangeMode(Mode::Normal));
675 }
676 self.last_change = Some(change);
680 self.recording_insert = false;
681 }
682
683 fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
690 use escriba_core::TextObject as O;
691 let at = self.cursor_char();
692 let matches = self.search.matches();
693
694 let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
706 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
707 match object {
708 O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
709 O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
710 }
711 })?;
712
713 let m = matches.get(idx)?;
714 let buf = self.buffers.get(self.active)?;
715 Some(Range {
716 start: buf.char_to_position(m.start),
717 end: buf.char_to_position(m.end),
718 })
719 }
720
721 fn land_on(&mut self, step: escriba_search::Step) {
722 if let Some(buf) = self.buffers.get(self.active) {
723 let pos = buf.char_to_position(step.target.start);
724 self.set_cursor(pos);
725 }
726 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
730 if let Some(msg) = step.wrapped.message() {
731 self.messages.push(msg.to_string());
732 }
733 }
734
735 fn jump_search(&mut self, reverse: bool) {
739 self.search.relight();
742 self.jumps.push(self.cursor());
744 let at = self.cursor_char();
745 match self.search.repeat(at, reverse) {
746 Some(step) => self.land_on(step),
747 None => {
748 let msg = self.search.pattern().map_or_else(
749 || "E35: No previous regular expression".to_string(),
750 |p| {
751 let mut m = String::from("E486: Pattern not found: ");
752 m.push_str(p.raw());
753 m
754 },
755 );
756 self.messages.push(msg);
757 }
758 }
759 }
760
761 fn preview_search(&mut self) {
768 let text = self.active_text();
769 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
770 return;
771 };
772 let target = self
773 .search
774 .preview(&text)
775 .map_or(origin, |s| s.target.start);
776 if let Some(buf) = self.buffers.get(self.active) {
785 let pos = buf.char_to_position(target);
786 self.set_cursor(pos);
787 }
788 }
789
790 fn submit_search_operated(&mut self, op: Operator) {
799 let text = self.active_text();
800 let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
801 else {
802 return;
803 };
804
805 match self.search.accept(&text) {
806 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
807 self.modal.clear_minibuffer();
808 self.modal.enter(Mode::Normal);
809 match self.search.commit_step_skipping(origin, skip) {
810 Some(step) => {
811 if let Some(buf) = self.buffers.get(self.active) {
813 let from = buf.char_to_position(origin);
814 self.jumps.push(from);
815 let target = buf.char_to_position(step.target.start);
816 self.set_cursor(from);
817 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
818 self.apply_operator_to(op, target);
819 }
820 }
821 None => self.report_pattern_not_found(),
822 }
823 }
824 escriba_search::Accepted::NothingToRepeat => {
825 self.modal.clear_minibuffer();
826 self.modal.enter(Mode::Normal);
827 self.messages
828 .push("E35: No previous regular expression".to_string());
829 }
830 escriba_search::Accepted::Invalid(e) => {
831 let mut m = String::from("E383: Invalid search string: ");
832 m.push_str(&e.to_string());
833 self.messages.push(m);
834 }
835 }
836 }
837
838 fn report_pattern_not_found(&mut self) {
841 let mut m = String::from("E486: Pattern not found");
842 if let Some(p) = self.search.pattern() {
843 m.push_str(": ");
844 m.push_str(p.raw());
845 }
846 self.messages.push(m);
847 }
848
849 fn submit_search(&mut self) {
851 let text = self.active_text();
852 let at = self
867 .search
868 .prompt()
869 .map_or_else(|| self.cursor_char(), |p| p.origin);
870 let skip = self
875 .search
876 .prompt()
877 .map_or(0, escriba_search::Prompt::preview_skip);
878 let outcome = self.search.accept(&text);
879 match outcome {
880 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
881 self.modal.clear_minibuffer();
882 self.modal.enter(Mode::Normal);
883 if let Some(buf) = self.buffers.get(self.active) {
888 let origin = buf.char_to_position(at);
889 self.jumps.push(origin);
890 }
891 match self.search.commit_step_skipping(at, skip) {
892 Some(step) => self.land_on(step),
893 None => {
894 let mut m = String::from("E486: Pattern not found");
895 if let Some(p) = self.search.pattern() {
896 m.push_str(": ");
897 m.push_str(p.raw());
898 }
899 self.messages.push(m);
900 }
901 }
902 }
903 escriba_search::Accepted::NothingToRepeat => {
904 self.modal.clear_minibuffer();
905 self.modal.enter(Mode::Normal);
906 self.messages
907 .push("E35: No previous regular expression".to_string());
908 }
909 escriba_search::Accepted::Invalid(e) => {
912 let mut m = String::from("E383: Invalid search string: ");
913 m.push_str(&e.to_string());
914 self.messages.push(m);
915 }
916 }
917 }
918
919 fn apply_resolved(&mut self, action: &Action) {
920 let lines_before = self.active_line_count();
923 let rev_before = self.text_rev();
926 let cline_before = self.cursor().line;
927 match action {
928 Action::Move(m) => self.apply_motion(*m),
929 Action::SearchOpen(dir) => {
930 let origin = self.cursor_char();
934 self.search.open(*dir, origin);
935 self.modal.enter(Mode::Command);
936 }
937 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
938 Action::SearchWord { reverse } => {
939 let dir = if *reverse {
940 SearchDirection::Backward
941 } else {
942 SearchDirection::Forward
943 };
944 let (text, at) = (self.active_text(), self.cursor_char());
945 self.jumps.push(self.cursor());
947 match self.search.search_word(&text, at, dir) {
948 Some(step) => self.land_on(step),
949 None => self
952 .messages
953 .push("E348: No string under cursor".to_string()),
954 }
955 }
956 Action::ClearSearchHighlight => self.search.clear_highlight(),
957 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
958 Action::TextObject(object) => {
959 if let Some(range) = self.resolve_object(*object) {
965 self.jumps.push(self.cursor());
966 self.set_cursor(range.start);
967 } else {
968 self.report_pattern_not_found();
969 }
970 }
971 Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
972 Some(range) => self.apply_operator_over(*op, range),
973 None => self.report_pattern_not_found(),
974 },
975 Action::RepeatLastChange => self.repeat_last_change(),
976 Action::JumpBack => {
977 let here = self.cursor();
978 if let Some(pos) = self.jumps.back(here) {
979 self.set_cursor(pos);
980 } else {
981 self.messages
982 .push("E662: At start of changelist".to_string());
983 }
984 }
985 Action::JumpForward => {
986 if let Some(pos) = self.jumps.forward() {
987 self.set_cursor(pos);
988 } else {
989 self.messages.push("E663: At end of changelist".to_string());
990 }
991 }
992 Action::ChangeMode(m) => {
993 if *m == Mode::Normal && self.search.is_prompting() {
997 if let Some(origin) = self.search.cancel() {
998 if let Some(buf) = self.buffers.get(self.active) {
999 let pos = buf.char_to_position(origin);
1000 self.set_cursor(pos);
1001 }
1002 }
1003 }
1004 self.modal.enter(*m);
1005 }
1006 Action::InsertChar(c) => self.insert_char(*c),
1007 Action::Edit(edit) => self.apply_edit(edit),
1008 Action::Undo => {
1009 if let Some(buf) = self.buffers.get_mut(self.active) {
1010 let _ = buf.undo();
1011 }
1012 self.set_cursor(self.cursor());
1015 }
1016 Action::Redo => {
1017 if let Some(buf) = self.buffers.get_mut(self.active) {
1018 let _ = buf.redo();
1019 }
1020 self.set_cursor(self.cursor());
1021 }
1022 Action::Save => {
1023 if let Some(buf) = self.buffers.get_mut(self.active) {
1024 let _ = buf.save();
1025 }
1026 self.set_cursor(self.cursor());
1027 }
1028 Action::Quit => self.quit_requested = true,
1029 Action::SubmitCommand => {
1030 if self.search.is_prompting() {
1031 self.submit_search();
1032 } else {
1033 self.submit_command();
1034 }
1035 }
1036 Action::Command { name, args } => self.run_command(name, args),
1037 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
1038 Action::Operator(_) => {}
1041 Action::PromptCaret { to } => {
1042 if self.search.is_prompting() {
1043 self.search.move_caret(*to);
1044 }
1045 }
1046 Action::SearchPreviewStep { forward } => {
1047 if self.search.is_prompting() {
1048 self.search.preview_step(*forward);
1049 self.preview_search();
1050 }
1051 }
1052 Action::PromptDelete => {
1053 if self.search.is_prompting() {
1054 self.search.delete_at_caret();
1055 self.preview_search();
1056 }
1057 }
1058 Action::PromptDeleteWord => {
1059 if self.search.is_prompting() {
1060 self.search.delete_word_before_caret();
1061 self.preview_search();
1062 }
1063 }
1064 Action::PromptClearToStart => {
1065 if self.search.is_prompting() {
1066 self.search.clear_before_caret();
1067 self.preview_search();
1068 }
1069 }
1070 Action::PromptBackspace => {
1071 self.prompt_backspace();
1072 if self.search.is_prompting() {
1076 self.preview_search();
1077 }
1078 }
1079 Action::PromptHistory { back } => {
1080 if self.search.is_prompting() {
1081 self.search.history_step(*back);
1082 self.preview_search();
1086 }
1087 }
1088 Action::Pending => {}
1089 }
1090 let lines_after = self.active_line_count();
1095 let cline_after = self.cursor().line;
1096 let d = match action {
1097 Action::SearchOpen(_)
1101 | Action::PromptHistory { .. }
1102 | Action::PromptBackspace
1103 | Action::PromptCaret { .. }
1104 | Action::SearchPreviewStep { .. }
1105 | Action::PromptDelete
1106 | Action::PromptDeleteWord
1107 | Action::PromptClearToStart
1108 | Action::SearchRepeat { .. }
1109 | Action::SearchWord { .. }
1110 | Action::ClearSearchHighlight
1111 | Action::SearchSubmitOperated { .. }
1112 | Action::RepeatLastChange
1115 | Action::TextObject(_)
1116 | Action::ApplyOperatorObject { .. }
1117 | Action::JumpBack
1119 | Action::JumpForward => Damage::Full,
1120 Action::InsertChar(_)
1121 | Action::Edit(_)
1122 | Action::Undo
1123 | Action::Redo
1124 | Action::ApplyOperator { .. } => {
1125 if lines_after == lines_before {
1126 Damage::span(cline_before, cline_after)
1127 } else {
1128 Damage::Lines {
1129 from: cline_before.min(cline_after),
1130 to: u32::MAX,
1131 }
1132 }
1133 }
1134 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
1135 Action::Save => Damage::Viewport,
1136 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
1137 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
1138 };
1139 self.damage = self.damage.join(d);
1140 if self.recording_insert {
1161 match action {
1162 Action::InsertChar(c) => {
1163 if let Some(lc) = self.last_change.as_mut() {
1164 lc.inserted.push(*c);
1165 }
1166 }
1167 Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
1169 _ => {}
1170 }
1171 } else if self.text_rev() != rev_before
1172 && !matches!(
1173 action,
1174 Action::RepeatLastChange | Action::Undo | Action::Redo
1175 )
1176 {
1177 self.last_change = Some(LastChange {
1178 action: action.clone(),
1179 count: 1,
1180 inserted: String::new(),
1181 });
1182 self.recording_insert = self.modal.mode() == Mode::Insert;
1183 }
1184
1185 if action.highlight_effect() == HighlightEffect::Clear {
1190 self.search.clear_highlight();
1191 }
1192 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
1201 let text = self.active_text();
1202 self.search.refresh(&text);
1203 }
1208 self.bump_gen();
1213 }
1214
1215 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1225 let buf = self.buffers.get(self.active)?;
1226 let pos = from;
1227 Some(match motion {
1228 Motion::SearchNext | Motion::SearchPrev => {
1233 let at = buf.position_to_char(pos).ok()?;
1234 let step = self
1235 .search
1236 .repeat(at, matches!(motion, Motion::SearchPrev))?;
1237 buf.char_to_position(step.target.start)
1238 }
1239 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1240 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1241 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1242 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1243 Motion::LineStart => Position::new(pos.line, 0),
1244 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1245 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1246 Motion::DocStart => Position::ZERO,
1247 Motion::DocEnd => Position::new(
1248 buf.line_count().saturating_sub(1),
1249 buf.line_len_chars(buf.line_count().saturating_sub(1)),
1250 ),
1251 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1252 Motion::WordStartPrev => word_prev(buf, pos),
1253 Motion::PageDown | Motion::HalfPageDown => {
1254 Position::new(pos.line.saturating_add(10), pos.column)
1255 }
1256 Motion::PageUp | Motion::HalfPageUp => {
1257 Position::new(pos.line.saturating_sub(10), pos.column)
1258 }
1259 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1260 Motion::ForwardSexp
1263 | Motion::BackwardSexp
1264 | Motion::UpList
1265 | Motion::DownList
1266 | Motion::BeginningOfDefun
1267 | Motion::EndOfDefun
1268 | Motion::BeginningOfSexp
1269 | Motion::EndOfSexp => pos,
1270 })
1271 }
1272
1273 fn apply_motion(&mut self, motion: Motion) {
1274 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1282 self.jump_search(matches!(motion, Motion::SearchPrev));
1283 return;
1284 }
1285 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1286 return;
1287 };
1288 self.set_cursor(pos);
1291 }
1292
1293 fn apply_operator(&mut self, op: Operator, motion: Motion) {
1300 let from = self.cursor();
1301 let Some(to) = self.resolve_motion(from, motion) else {
1302 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1307 if self.search.pattern().is_none() {
1308 self.messages
1309 .push("E35: No previous regular expression".to_string());
1310 } else {
1311 self.report_pattern_not_found();
1312 }
1313 }
1314 return;
1315 };
1316 self.apply_operator_to(op, to);
1317 }
1318
1319 fn apply_operator_to(&mut self, op: Operator, to: Position) {
1326 let from = self.cursor();
1327 self.apply_operator_over(
1328 op,
1329 Range {
1330 start: from,
1331 end: to,
1332 },
1333 );
1334 }
1335
1336 fn apply_operator_over(&mut self, op: Operator, range: Range) {
1343 let range = range.normalized();
1344 if range.is_empty() {
1345 return;
1346 }
1347 let text = self
1349 .buffers
1350 .get(self.active)
1351 .and_then(|buf| buf.slice(range).ok());
1352 if op.leaves_register() {
1353 if let Some(t) = &text {
1354 self.register = Some(t.clone());
1355 }
1356 }
1357 match op {
1358 Operator::Delete | Operator::Change => {
1361 if let Some(buf) = self.buffers.get_mut(self.active) {
1362 let _ = buf.apply(&Edit::delete(range));
1363 }
1364 self.set_cursor(range.start);
1365 if op == Operator::Change {
1366 self.modal.enter(Mode::Insert);
1367 }
1368 }
1369 Operator::Yank => {
1372 self.set_cursor(range.start);
1373 }
1374 _ => {
1378 self.messages
1379 .push("operator not yet implemented".to_owned());
1380 }
1381 }
1382 }
1383
1384 #[must_use]
1387 pub fn register(&self) -> Option<&str> {
1388 self.register.as_deref()
1389 }
1390
1391 fn insert_char(&mut self, c: char) {
1392 if self.modal.mode() == Mode::Command {
1393 if self.search.is_prompting() {
1397 self.search.push(c);
1411 self.preview_search();
1412 } else {
1413 self.modal.push_minibuffer(c);
1414 }
1415 return;
1416 }
1417 let cursor = self.cursor();
1418 let Some(buf) = self.buffers.get_mut(self.active) else {
1419 return;
1420 };
1421 let edit = Edit::insert(cursor, c.to_string());
1422 if buf.apply(&edit).is_ok() {
1423 let next = if c == '\n' {
1424 Position::new(cursor.line.saturating_add(1), 0)
1425 } else {
1426 cursor.shift_right(1)
1427 };
1428 self.set_cursor(next);
1431 }
1432 }
1433
1434 fn prompt_backspace(&mut self) -> bool {
1438 if self.modal.mode() != Mode::Command {
1439 return false;
1440 }
1441 if self.search.is_prompting() {
1442 if self.search.backspace() {
1447 self.modal.clear_minibuffer();
1448 self.modal.enter(Mode::Normal);
1449 }
1450 return true;
1453 }
1454 self.modal.pop_minibuffer();
1455 true
1456 }
1457
1458 fn apply_edit(&mut self, _edit: &Edit) {
1459 }
1463
1464 fn submit_command(&mut self) {
1465 let line = self.modal.minibuffer().to_string();
1469 self.modal.escape();
1470 let (name, args) = parse_command_line(&line);
1471 if name.is_empty() {
1472 return;
1473 }
1474 self.run_command(&name, &args);
1475 }
1476
1477 fn run_command(&mut self, name: &str, args: &[String]) {
1478 if matches!(name, "noh" | "nohl" | "nohlsearch") {
1484 self.search.clear_highlight();
1485 return;
1486 }
1487 if self.plugin_host.pending() > 0 {
1493 let pending = self.plugin_host.pending_for_command(name);
1494 for src in pending {
1495 self.apply_plugin_entry(&src);
1496 }
1497 }
1498 let active = Some(self.active);
1499 let mut quit = false;
1500 {
1501 let mut ctx = EditContext {
1502 buffers: &mut self.buffers,
1503 active,
1504 state: &mut self.modal,
1505 quit_requested: &mut quit,
1506 };
1507 let _ = self.commands.run(name, &mut ctx, args);
1508 }
1509 if quit {
1512 self.quit_requested = true;
1513 }
1514 }
1515
1516 #[must_use]
1521 pub fn snapshot(&self) -> EditorSnapshot {
1522 let current_line = self
1523 .buffers
1524 .get(self.active)
1525 .and_then(|b| b.line(self.cursor().line))
1526 .map(|s| s.trim_end_matches('\n').to_string())
1527 .unwrap_or_default();
1528 let buffer_name = self
1529 .buffers
1530 .get(self.active)
1531 .and_then(|b| b.path.as_ref())
1532 .map(|p| p.display().to_string())
1533 .unwrap_or_else(|| "[scratch]".to_string());
1534 EditorSnapshot {
1535 cursor_line: i64::from(self.cursor().line),
1536 cursor_column: i64::from(self.cursor().column),
1537 current_line,
1538 mode: self.modal.mode().as_str().to_string(),
1539 buffer_name,
1540 }
1541 }
1542
1543 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1559 let mut host = EscribaHost::with_snapshot(self.snapshot());
1560 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1561 vm.eval(src, &mut host)?;
1562 let effects = host.take_effects();
1563 self.apply_host_effects(effects);
1564 Ok(())
1565 }
1566
1567 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1571 for eff in effects {
1572 match eff {
1573 HostEffect::Message(m) => self.messages.push(m),
1574 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1575 HostEffect::SetOption { name, value } => {
1576 self.options.insert(name, value);
1577 }
1578 HostEffect::InsertText(text) => self.insert_text(&text),
1579 }
1580 }
1581 }
1582
1583 fn insert_text(&mut self, text: &str) {
1586 if text.is_empty() {
1587 return;
1588 }
1589 let cursor = self.cursor();
1590 let Some(buf) = self.buffers.get_mut(self.active) else {
1591 return;
1592 };
1593 let edit = Edit::insert(cursor, text.to_string());
1594 if buf.apply(&edit).is_ok() {
1595 let next = if let Some(nl) = text.rfind('\n') {
1596 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1597 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1598 Position::new(cursor.line + added_lines, last_line_len)
1599 } else {
1600 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1601 cursor.shift_right(n)
1602 };
1603 self.set_cursor(next);
1606 }
1607 }
1608}
1609
1610fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1611 let Some(text) = buf.line(line) else {
1612 return Position::new(line, 0);
1613 };
1614 let col = text
1615 .chars()
1616 .take_while(|c| c.is_whitespace() && *c != '\n')
1617 .count();
1618 Position::new(line, u32::try_from(col).unwrap_or(0))
1619}
1620
1621fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1622 let Some(text) = buf.line(pos.line) else {
1623 return pos;
1624 };
1625 let chars: Vec<char> = text.chars().collect();
1626 let start = pos.column as usize;
1627 let mut i = start;
1628 while i < chars.len() && !chars[i].is_whitespace() {
1629 i += 1;
1630 }
1631 while i < chars.len() && chars[i].is_whitespace() {
1632 i += 1;
1633 }
1634 if i >= chars.len() {
1635 if pos.line + 1 < buf.line_count() {
1637 return Position::new(pos.line + 1, 0);
1638 }
1639 }
1640 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1641}
1642
1643fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1644 let Some(text) = buf.line(pos.line) else {
1645 return pos;
1646 };
1647 let chars: Vec<char> = text.chars().collect();
1648 let mut i = (pos.column as usize).min(chars.len());
1649 while i > 0 && chars[i - 1].is_whitespace() {
1650 i -= 1;
1651 }
1652 while i > 0 && !chars[i - 1].is_whitespace() {
1653 i -= 1;
1654 }
1655 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1656}
1657
1658fn parse_command_line(line: &str) -> (String, Vec<String>) {
1659 let mut parts = line.split_whitespace();
1660 let Some(first) = parts.next() else {
1661 return (String::new(), Vec::new());
1662 };
1663 let head = first.strip_prefix(':').unwrap_or(first);
1664 let name = match head {
1665 "w" => "save",
1666 "q" => "quit",
1667 "u" => "undo",
1668 other => other,
1669 };
1670 (name.to_string(), parts.map(str::to_string).collect())
1671}
1672
1673#[cfg(test)]
1674mod tests {
1675 use super::*;
1676 use madori::event::{KeyCode, KeyEvent, Modifiers};
1677
1678 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1686 st.apply(&Action::SearchOpen(dir));
1687 for c in pat.chars() {
1688 st.apply(&Action::InsertChar(c));
1689 }
1690 st.apply(&Action::SubmitCommand);
1691 }
1692
1693 #[test]
1694 fn slash_search_moves_the_cursor_to_the_match() {
1695 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1696 type_search(&mut st, SearchDirection::Forward, "charlie");
1697 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1698 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1699 assert_eq!(st.search.matches().len(), 1);
1700 }
1701
1702 #[test]
1703 fn n_and_N_walk_matches_in_both_directions() {
1704 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1705 type_search(&mut st, SearchDirection::Forward, "foo");
1706 let first = st.cursor().line;
1707 st.apply(&Action::SearchRepeat { reverse: false });
1708 let second = st.cursor().line;
1709 assert!(second > first, "n advances ({first} -> {second})");
1710 st.apply(&Action::SearchRepeat { reverse: true });
1711 assert_eq!(st.cursor().line, first, "N comes back");
1712 }
1713
1714 #[test]
1715 fn star_searches_the_word_under_the_cursor() {
1716 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1717 st.apply(&Action::SearchWord { reverse: false });
1718 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1719 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1720 }
1721
1722 #[test]
1723 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1724 let mut st = new_state_with("foo\nbar\nfoo\n");
1725 type_search(&mut st, SearchDirection::Forward, "foo");
1726 let matches_before = st.search.matches().len();
1727
1728 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1729 st.apply(&Action::InsertChar('z'));
1730 st.apply(&Action::ChangeMode(Mode::Normal));
1731
1732 assert!(!st.search.is_prompting(), "prompt gone");
1733 assert_eq!(
1734 st.search.pattern().unwrap().raw(),
1735 "foo",
1736 "old pattern survives"
1737 );
1738 assert_eq!(
1739 st.search.matches().len(),
1740 matches_before,
1741 "old highlights survive"
1742 );
1743 }
1744
1745 #[test]
1746 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1747 let mut st = new_state_with("foo\n");
1748 st.apply(&Action::ChangeMode(Mode::Command));
1750 assert!(!st.search.is_prompting(), "`:` must not open a search");
1751 st.apply(&Action::InsertChar('w'));
1752 assert!(
1753 st.search.prompt().is_none(),
1754 "typed char went to the ex line"
1755 );
1756 }
1757
1758 #[test]
1759 fn a_missing_pattern_reports_instead_of_failing_silently() {
1760 let mut st = new_state_with("alpha\nbravo\n");
1761 type_search(&mut st, SearchDirection::Forward, "zzz");
1762 assert!(
1763 st.messages.iter().any(|m| m.contains("E486")),
1764 "must report not-found, got {:?}",
1765 st.messages
1766 );
1767 }
1768
1769 #[test]
1770 fn n_without_any_search_reports_rather_than_moving() {
1771 let mut st = new_state_with("alpha\nbravo\n");
1772 let before = st.cursor();
1773 st.apply(&Action::SearchRepeat { reverse: false });
1774 assert_eq!(st.cursor(), before, "cursor must not move");
1775 assert!(
1776 st.messages.iter().any(|m| m.contains("E35")),
1777 "got {:?}",
1778 st.messages
1779 );
1780 }
1781
1782 #[test]
1783 fn search_as_a_motion_composes_with_an_operator() {
1784 let mut st = new_state_with("alpha bravo charlie\n");
1786 type_search(&mut st, SearchDirection::Forward, "charlie");
1787 st.set_cursor(Position::new(0, 0));
1788 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1789 assert!(target.is_some(), "search must resolve as a motion");
1790 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1791 }
1792
1793 #[test]
1794 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1795 let st = new_state_with("alpha bravo\n");
1798 assert!(
1799 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1800 .is_none()
1801 );
1802 }
1803
1804 #[test]
1805 fn clear_highlight_keeps_the_pattern_usable() {
1806 let mut st = new_state_with("foo\nbar\nfoo\n");
1807 type_search(&mut st, SearchDirection::Forward, "foo");
1808 st.apply(&Action::ClearSearchHighlight);
1809 assert!(st.search.highlights().is_empty(), "nothing lit");
1810 st.apply(&Action::SearchRepeat { reverse: false });
1811 assert!(st.search.pattern().is_some(), "but n still works");
1812 }
1813
1814 #[test]
1815 fn typing_previews_incrementally_before_commit() {
1816 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1817 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1818 for c in "charlie".chars() {
1819 st.apply(&Action::InsertChar(c));
1820 }
1821 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1823 assert!(st.search.pattern().is_none(), "but nothing is committed");
1824 }
1825
1826 #[test]
1827 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1828 let mut st = new_state_with("alpha\nbravo\n");
1829 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1830 for c in "bravox".chars() {
1831 st.apply(&Action::InsertChar(c));
1832 }
1833 assert_eq!(st.search.prompt().unwrap().text, "bravox");
1834 st.apply(&Action::PromptBackspace);
1835 assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1836 assert_eq!(
1837 st.status_model().prompt_text,
1838 "bravo",
1839 "the model reads the PROMPT — the minibuffer is the ex-line's store",
1840 );
1841 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1842 }
1843
1844 #[test]
1845 fn backspacing_past_the_slash_closes_the_prompt() {
1846 let mut st = new_state_with("alpha\n");
1847 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1848 st.apply(&Action::InsertChar('a'));
1849 st.apply(&Action::PromptBackspace);
1850 st.apply(&Action::PromptBackspace);
1851 assert!(!st.search.is_prompting(), "prompt closed");
1852 assert_eq!(st.modal.mode(), Mode::Normal);
1853 }
1854
1855 #[test]
1856 fn noh_clears_highlights_and_keeps_the_pattern() {
1857 let mut st = new_state_with("foo\nbar\nfoo\n");
1858 type_search(&mut st, SearchDirection::Forward, "foo");
1859 assert!(!st.search.highlights().is_empty());
1860 st.run_command("noh", &[]);
1861 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1862 assert!(st.search.pattern().is_some(), "but n still works");
1863 }
1864
1865 #[test]
1866 fn noh_accepts_the_vim_aliases() {
1867 for name in ["noh", "nohl", "nohlsearch"] {
1868 let mut st = new_state_with("foo\nfoo\n");
1869 type_search(&mut st, SearchDirection::Forward, "foo");
1870 st.run_command(name, &[]);
1871 assert!(st.search.highlights().is_empty(), "{name} must clear");
1872 }
1873 }
1874
1875 #[test]
1876 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1877 let mut st = new_state_with("foo\n");
1878 st.apply(&Action::ChangeMode(Mode::Command));
1879 st.apply(&Action::InsertChar('w'));
1880 st.apply(&Action::InsertChar('q'));
1881 st.apply(&Action::PromptBackspace);
1882 assert_eq!(st.status_model().prompt_text, "w");
1883 assert!(st.search.prompt().is_none(), "no search was involved");
1884 }
1885
1886 #[test]
1887 fn up_arrow_recalls_the_previous_search() {
1888 let mut st = new_state_with("alpha\nbravo\n");
1889 type_search(&mut st, SearchDirection::Forward, "bravo");
1890 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1891 st.apply(&Action::PromptHistory { back: true });
1892 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1893 assert_eq!(
1894 st.status_model().prompt_text,
1895 "bravo",
1896 "display follows the prompt"
1897 );
1898 }
1899
1900 #[test]
1901 fn arrowing_back_down_restores_the_half_typed_pattern() {
1902 let mut st = new_state_with("alpha\nbravo\n");
1903 type_search(&mut st, SearchDirection::Forward, "bravo");
1904 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1905 st.apply(&Action::InsertChar('a'));
1906 st.apply(&Action::PromptHistory { back: true });
1907 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1908 st.apply(&Action::PromptHistory { back: false });
1909 assert_eq!(
1910 st.search.prompt().unwrap().text,
1911 "a",
1912 "the draft comes back"
1913 );
1914 assert_eq!(st.status_model().prompt_text, "a");
1915 }
1916
1917 #[test]
1918 fn history_arrows_do_nothing_on_the_ex_line() {
1919 let mut st = new_state_with("alpha\n");
1920 st.apply(&Action::ChangeMode(Mode::Command));
1921 st.apply(&Action::InsertChar('w'));
1922 st.apply(&Action::PromptHistory { back: true });
1923 assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
1924 }
1925
1926 fn new_state_with(text: &str) -> EditorState {
1927 let mut bufs = BufferSet::new();
1928 let id = bufs.scratch(text);
1929 EditorState::new_with_buffer(bufs, id)
1930 }
1931
1932 #[test]
1938 fn edit_gen_advances_on_applied_action_not_on_read() {
1939 let mut s = new_state_with("hello\nworld\n");
1940 let g0 = s.edit_gen();
1941 s.apply(&Action::InsertChar('X'));
1942 assert_ne!(
1943 s.edit_gen(),
1944 g0,
1945 "an applied action must advance the refresh generation",
1946 );
1947 let g1 = s.edit_gen();
1949 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1950 }
1951
1952 #[test]
1957 fn damage_tracks_edit_scope_and_drains() {
1958 let mut s = new_state_with("hello\nworld\n");
1959 assert!(s.damage().is_none(), "a fresh state has no damage");
1960
1961 s.apply(&Action::InsertChar('X')); assert_eq!(
1963 s.damage(),
1964 Damage::Lines { from: 0, to: 0 },
1965 "a local edit damages just its line",
1966 );
1967
1968 let drained = s.take_damage();
1969 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1970 assert!(s.damage().is_none(), "take_damage drains to None");
1971
1972 s.apply(&Action::InsertChar('\n')); assert_eq!(
1974 s.damage(),
1975 Damage::Lines {
1976 from: 0,
1977 to: u32::MAX,
1978 },
1979 "a line-count change damages to end-of-document",
1980 );
1981 }
1982
1983 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1987 let mut s = new_state_with(text);
1988 for w in &mut s.layout.windows {
1989 w.viewport.visible_lines = vis_lines;
1990 w.viewport.visible_columns = vis_cols;
1991 }
1992 s
1993 }
1994
1995 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
2000 let w = s.layout.active_window().expect("active window");
2001 let v = w.viewport;
2002 let c = s.cursor();
2003 assert!(
2004 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
2005 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
2006 c.line,
2007 v.top_line,
2008 v.top_line + v.visible_lines,
2009 );
2010 assert!(
2011 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
2012 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
2013 c.column,
2014 v.left_column,
2015 v.left_column + v.visible_columns,
2016 );
2017 }
2018
2019 fn press(kc: KeyCode) -> AppEvent {
2020 AppEvent::Key(KeyEvent {
2021 key: kc,
2022 pressed: true,
2023 modifiers: Modifiers::default(),
2024 text: None,
2025 })
2026 }
2027
2028 fn line0_len(s: &EditorState) -> u32 {
2031 s.buffers.get(s.active).unwrap().line_len_chars(0)
2032 }
2033
2034 #[test]
2035 fn delete_to_line_end_clears_line_and_fills_register() {
2036 let mut s = new_state_with("hello world");
2037 s.apply(&Action::ApplyOperator {
2038 op: Operator::Delete,
2039 motion: Motion::LineEnd,
2040 });
2041 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
2042 assert_eq!(
2043 s.register(),
2044 Some("hello world"),
2045 "delete fills the register"
2046 );
2047 assert_eq!(
2048 s.cursor(),
2049 Position::ZERO,
2050 "cursor lands at the range start"
2051 );
2052 }
2053
2054 #[test]
2055 fn delete_over_right_motion_removes_one_char() {
2056 let mut s = new_state_with("abc");
2057 s.apply(&Action::ApplyOperator {
2058 op: Operator::Delete,
2059 motion: Motion::Right,
2060 });
2061 assert_eq!(
2062 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2063 Some("bc")
2064 );
2065 assert_eq!(s.register(), Some("a"));
2066 }
2067
2068 #[test]
2069 fn change_to_line_end_deletes_and_enters_insert() {
2070 let mut s = new_state_with("hello world");
2071 assert_eq!(s.modal.mode(), Mode::Normal);
2072 s.apply(&Action::ApplyOperator {
2073 op: Operator::Change,
2074 motion: Motion::LineEnd,
2075 });
2076 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
2077 assert_eq!(
2078 s.modal.mode(),
2079 Mode::Insert,
2080 "change enters Insert to type the replacement"
2081 );
2082 assert_eq!(
2083 s.register(),
2084 Some("hello world"),
2085 "change fills the register"
2086 );
2087 }
2088
2089 #[test]
2090 fn yank_to_line_end_fills_register_without_mutating() {
2091 let mut s = new_state_with("hello world");
2092 s.apply(&Action::ApplyOperator {
2093 op: Operator::Yank,
2094 motion: Motion::LineEnd,
2095 });
2096 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
2097 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
2098 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
2099 }
2100
2101 #[test]
2102 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
2103 let mut s = new_state_with("hello world");
2107 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
2108 assert_eq!(target, Position::new(0, 11));
2109 s.apply_motion(Motion::LineEnd);
2110 assert_eq!(
2111 s.cursor(),
2112 target,
2113 "the move path resolves the same target the operator uses"
2114 );
2115 }
2116
2117 #[test]
2118 fn empty_motion_range_is_a_no_op() {
2119 let mut s = new_state_with("abc");
2122 s.apply(&Action::ApplyOperator {
2123 op: Operator::Delete,
2124 motion: Motion::LineStart,
2125 });
2126 assert_eq!(
2127 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2128 Some("abc")
2129 );
2130 assert_eq!(s.register(), None);
2131 }
2132
2133 #[test]
2134 fn operator_then_motion_composes_through_the_pending_fsm() {
2135 let mut s = new_state_with("hello world");
2139 s.apply(&Action::Operator(Operator::Delete));
2140 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
2141 s.apply(&Action::Move(Motion::LineEnd));
2142 assert_eq!(
2143 line0_len(&s),
2144 0,
2145 "d then $ composes d$ and deletes the line"
2146 );
2147 assert_eq!(s.register(), Some("hello world"));
2148 }
2149
2150 #[test]
2151 fn change_operator_through_fsm_enters_insert() {
2152 let mut s = new_state_with("hello world");
2153 s.apply(&Action::Operator(Operator::Change));
2154 s.apply(&Action::Move(Motion::LineEnd));
2155 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
2156 }
2157
2158 #[test]
2159 fn lone_motion_after_no_operator_just_moves() {
2160 let mut s = new_state_with("hello world");
2162 s.apply(&Action::Move(Motion::LineEnd));
2163 assert_eq!(s.cursor(), Position::new(0, 11));
2164 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
2165 }
2166
2167 #[test]
2168 fn counted_operator_deletes_count_times() {
2169 let mut s = new_state_with("abcdef");
2173 s.apply_counted(&Action::Operator(Operator::Delete), 3);
2174 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
2175 s.apply(&Action::Move(Motion::Right));
2176 assert_eq!(
2177 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2178 Some("def")
2179 );
2180 }
2181
2182 #[test]
2183 fn operator_and_motion_counts_multiply_end_to_end() {
2184 let mut s = new_state_with("abcdefgh");
2186 s.apply_counted(&Action::Operator(Operator::Delete), 2);
2187 s.apply_counted(&Action::Move(Motion::Right), 3);
2188 assert_eq!(
2189 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2190 Some("gh")
2191 );
2192 }
2193
2194 #[test]
2195 fn bare_counted_motion_still_repeats_no_regression() {
2196 let mut s = new_state_with("a\nb\nc\nd\ne");
2199 s.apply_counted(&Action::Move(Motion::Down), 3);
2200 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
2201 }
2202
2203 struct SpacedClock(std::time::Instant);
2209 impl SpacedClock {
2210 fn new() -> Self {
2211 Self(std::time::Instant::now())
2212 }
2213 fn next(&mut self) -> std::time::Instant {
2214 self.0 += std::time::Duration::from_secs(1);
2215 self.0
2216 }
2217 }
2218
2219 #[test]
2220 fn hjkl_moves_cursor() {
2221 let mut s = new_state_with("hello\nworld");
2222 s.tick(&press(KeyCode::Char('l')));
2223 assert_eq!(s.cursor().column, 1);
2224 s.tick(&press(KeyCode::Char('j')));
2225 assert_eq!(s.cursor().line, 1);
2226 s.tick(&press(KeyCode::Char('h')));
2227 assert_eq!(s.cursor().column, 0);
2228 }
2229
2230 #[test]
2231 fn insert_mode_inserts_chars() {
2232 let mut s = new_state_with("");
2233 s.tick(&press(KeyCode::Char('i')));
2234 assert_eq!(s.modal.mode(), Mode::Insert);
2235 s.tick(&press(KeyCode::Char('h')));
2236 s.tick(&press(KeyCode::Char('i')));
2237 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
2238 assert_eq!(s.cursor().column, 2);
2239 }
2240
2241 #[test]
2242 fn esc_returns_to_normal() {
2243 let mut s = new_state_with("");
2244 s.tick(&press(KeyCode::Char('i')));
2245 s.tick(&press(KeyCode::Escape));
2246 assert_eq!(s.modal.mode(), Mode::Normal);
2247 }
2248
2249 #[test]
2250 fn count_prefix_repeats_motion() {
2251 let mut s = new_state_with("abcdefghij");
2252 s.tick(&press(KeyCode::Char('5')));
2253 s.tick(&press(KeyCode::Char('l')));
2254 assert_eq!(s.cursor().column, 5);
2255 }
2256
2257 #[test]
2258 fn close_event_requests_quit() {
2259 let mut s = new_state_with("");
2260 s.tick(&AppEvent::CloseRequested);
2261 assert!(s.quit_requested);
2262 }
2263
2264 #[test]
2265 fn word_next_jumps_past_whitespace() {
2266 let mut s = new_state_with("foo bar baz");
2267 let mut clk = SpacedClock::new();
2270 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2271 assert_eq!(s.cursor().column, 4);
2272 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2273 assert_eq!(s.cursor().column, 8);
2274 }
2275
2276 #[test]
2279 fn leader_sequence_holds_then_resolves() {
2280 let mut s = new_state_with("a\nbb\nccc");
2281 s.keymap.bind_sequence(
2282 Mode::Normal,
2283 vec![Key::Char(','), Key::Char('g')],
2284 Action::Move(Motion::DocEnd),
2285 "doc end",
2286 );
2287 s.on_key(&Key::Char(','));
2289 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2290 assert_eq!(s.cursor(), Position::ZERO);
2291 s.on_key(&Key::Char('g'));
2293 assert!(s.pending_keys.is_empty());
2294 assert_eq!(s.cursor().line, 2);
2295 }
2296
2297 #[test]
2298 fn two_key_gg_jumps_doc_start() {
2299 let mut s = new_state_with("a\nbb\nccc");
2300 s.keymap.bind_sequence(
2301 Mode::Normal,
2302 vec![Key::Char('g'), Key::Char('g')],
2303 Action::Move(Motion::DocStart),
2304 "doc start",
2305 );
2306 let mut clk = SpacedClock::new();
2307 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2308 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2309 assert_eq!(s.cursor().line, 2);
2310 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2312 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
2314 }
2315
2316 #[test]
2317 fn broken_sequence_aborts_and_clears_pending() {
2318 let mut s = new_state_with("hello");
2319 s.keymap.bind_sequence(
2320 Mode::Normal,
2321 vec![Key::Char('g'), Key::Char('g')],
2322 Action::Move(Motion::DocEnd),
2323 "doc end",
2324 );
2325 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2327 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
2329 assert_eq!(s.cursor(), Position::ZERO);
2330 }
2331
2332 #[test]
2333 fn single_binding_wins_over_sequence_prefix() {
2334 let mut s = new_state_with("abcde");
2338 let mut clk = SpacedClock::new();
2339 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2340 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2341 assert_eq!(s.cursor().column, 2);
2342 s.keymap.bind_sequence(
2343 Mode::Normal,
2344 vec![Key::Char('h'), Key::Char('z')],
2345 Action::Move(Motion::DocEnd),
2346 "shadowed",
2347 );
2348 s.on_key(&Key::Char('h'));
2349 assert!(s.pending_keys.is_empty(), "single binding should not pend");
2350 assert_eq!(s.cursor().column, 1, "h moved left immediately");
2351 }
2352
2353 #[test]
2356 fn lisp_set_option_writes_live_options() {
2357 let mut s = new_state_with("");
2358 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2359 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2360 }
2361
2362 #[test]
2363 fn lisp_insert_modifies_buffer_and_advances_cursor() {
2364 let mut s = new_state_with("");
2365 s.run_lisp(r#"(insert "abc")"#).unwrap();
2366 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2367 assert_eq!(s.cursor(), Position::new(0, 3));
2368 }
2369
2370 #[test]
2371 fn lisp_message_appends_to_messages() {
2372 let mut s = new_state_with("");
2373 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2374 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2375 }
2376
2377 #[test]
2378 fn lisp_reads_snapshot_and_branches_to_effect() {
2379 let mut s = new_state_with("one\ntwo\nthree");
2382 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2384 .unwrap();
2385 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2386 }
2387
2388 #[test]
2389 fn lisp_run_command_effect_drives_registry() {
2390 let mut s = new_state_with("");
2394 s.run_lisp(r#"(insert "abc")"#).unwrap();
2395 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2396 s.run_lisp(r#"(run-command "undo")"#).unwrap();
2397 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2398 }
2399
2400 #[test]
2401 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2402 let mut s = new_state_with("");
2407 s.run_lisp(r#"(run-command "quit")"#).unwrap();
2408 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2409 assert_eq!(
2410 s.modal.minibuffer(),
2411 "",
2412 "quit must not pollute any command line — Normal mode has no minibuffer",
2413 );
2414 }
2415
2416 #[test]
2419 fn lazy_plugin_activates_on_command_trigger() {
2420 let mut s = new_state_with("");
2424 s.register_lazy_plugin(
2425 "user-lazy",
2426 vec![LazyTrigger::Command("LazyGo".into())],
2427 r#"(defoption :name "lazy-loaded" :value "yes")
2428 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
2429 );
2430 assert_eq!(s.plugin_host.pending(), 1);
2431 assert!(
2432 s.options.get("lazy-loaded").is_none(),
2433 "entry not applied yet"
2434 );
2435
2436 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
2438
2439 assert_eq!(
2440 s.options.get("lazy-loaded").map(String::as_str),
2441 Some("yes"),
2442 "the command trigger applied the plugin's entry",
2443 );
2444 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
2445 }
2446
2447 #[test]
2448 fn lazy_plugin_activates_on_filetype() {
2449 let mut s = new_state_with("");
2450 s.register_lazy_plugin(
2451 "user-rust",
2452 vec![LazyTrigger::FileType("rust".into())],
2453 r#"(defoption :name "rust-plugin" :value "on")"#,
2454 );
2455 let n = s.activate_filetype_plugins("rust");
2456 assert_eq!(n, 1);
2457 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
2458 assert_eq!(s.activate_filetype_plugins("rust"), 0);
2460 }
2461
2462 #[test]
2463 fn cached_vm_serves_multiple_run_lisp_calls() {
2464 let mut s = new_state_with("");
2465 s.run_lisp(r#"(message "one")"#).unwrap();
2466 assert!(
2467 s.lisp_vm.is_some(),
2468 "VM should be cached after first run_lisp"
2469 );
2470 s.run_lisp(r#"(message "two")"#).unwrap();
2471 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
2472 }
2473
2474 #[test]
2475 fn lisp_define_persists_across_run_lisp_calls() {
2476 let mut s = new_state_with("");
2479 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
2480 s.run_lisp(r#"(message greeting)"#).unwrap();
2481 assert_eq!(s.messages, vec!["hi".to_string()]);
2482 }
2483
2484 #[test]
2485 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2486 let mut s = new_state_with("");
2490 s.run_lisp(
2491 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2492 )
2493 .unwrap();
2494 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2495 assert_eq!(
2496 s.options.get("col").map(String::as_str),
2497 Some("stale-zero"),
2498 "cursor-column within the same call reads the pre-eval snapshot",
2499 );
2500 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2503 .unwrap();
2504 assert_eq!(
2505 s.options.get("col2").map(String::as_str),
2506 Some("live-two"),
2507 "a later call sees the refreshed snapshot",
2508 );
2509 }
2510
2511 #[test]
2512 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2513 let mut s = new_state_with("");
2514 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2515 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2516 assert_eq!(s.cursor(), Position::new(1, 3));
2517 }
2518
2519 #[test]
2520 fn visual_mode_sequence_resolves() {
2521 let mut s = new_state_with("abc");
2522 s.modal.enter(Mode::Visual);
2523 s.keymap.bind_sequence(
2524 Mode::Visual,
2525 vec![Key::Char('g'), Key::Char('e')],
2526 Action::Move(Motion::DocEnd),
2527 "ge",
2528 );
2529 s.on_key(&Key::Char('g'));
2530 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2531 s.on_key(&Key::Char('e'));
2532 assert!(s.pending_keys.is_empty());
2533 assert_eq!(
2534 s.cursor().column,
2535 3,
2536 "ge resolved to doc-end in visual mode"
2537 );
2538 }
2539
2540 #[test]
2541 fn sequence_abort_with_bound_breaking_key_redispatches() {
2542 let mut s = new_state_with("abcde");
2545 s.keymap.bind_sequence(
2546 Mode::Normal,
2547 vec![Key::Char('g'), Key::Char('g')],
2548 Action::Move(Motion::DocEnd),
2549 "gg",
2550 );
2551 s.on_key(&Key::Char('g'));
2552 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2553 s.on_key(&Key::Char('l'));
2554 assert!(s.pending_keys.is_empty());
2555 assert_eq!(
2556 s.cursor().column,
2557 1,
2558 "the breaking key l should re-dispatch as move-right",
2559 );
2560 }
2561
2562 #[test]
2565 fn viewport_contains_cursor_after_every_op() {
2566 let mut s = new_state_small_viewport("", 5, 10);
2570 assert_cursor_in_viewport(&s, "initial");
2571
2572 s.tick(&press(KeyCode::Char('i')));
2575 assert_eq!(s.modal.mode(), Mode::Insert);
2576 for line in 0..30u32 {
2577 for c in "line".chars() {
2578 s.tick(&press(KeyCode::Char(c)));
2579 assert_cursor_in_viewport(&s, "typing chars");
2580 }
2581 s.tick(&press(KeyCode::Enter));
2582 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2583 }
2584
2585 for i in 0..200u32 {
2588 s.tick(&press(KeyCode::Char('x')));
2589 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2590 }
2591
2592 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2594 assert_cursor_in_viewport(&s, "insert_text multiline");
2595
2596 s.tick(&press(KeyCode::Escape));
2598 assert_eq!(s.modal.mode(), Mode::Normal);
2599 for m in [
2600 Motion::DocStart,
2601 Motion::DocEnd,
2602 Motion::Down,
2603 Motion::Down,
2604 Motion::Up,
2605 Motion::Right,
2606 Motion::Right,
2607 Motion::Left,
2608 Motion::LineEnd,
2609 Motion::LineStart,
2610 Motion::GotoLine(1),
2611 Motion::GotoLine(40),
2612 Motion::PageDown,
2613 Motion::PageUp,
2614 ] {
2615 s.apply_motion(m);
2616 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2617 }
2618
2619 for i in 0..50u32 {
2622 s.apply(&Action::Undo);
2623 assert_cursor_in_viewport(&s, &format!("undo {i}"));
2624 }
2625 for i in 0..50u32 {
2627 s.apply(&Action::Redo);
2628 assert_cursor_in_viewport(&s, &format!("redo {i}"));
2629 }
2630 }
2631
2632 #[test]
2633 fn insert_at_eof_keeps_cursor_in_bounds() {
2634 let mut s = new_state_small_viewport("abc", 5, 10);
2637 s.apply_motion(Motion::DocEnd);
2638 s.tick(&press(KeyCode::Char('i')));
2639 s.tick(&press(KeyCode::Char('d')));
2640 let buf = s.buffers.get(s.active).unwrap();
2641 let clamped = buf.clamp(s.cursor());
2642 assert_eq!(
2643 s.cursor(),
2644 clamped,
2645 "cursor must be clamped in-bounds at EOF"
2646 );
2647 assert_cursor_in_viewport(&s, "insert at eof");
2648 }
2649
2650 #[test]
2651 fn count_prefix_then_sequence_repeats() {
2652 let mut s = new_state_with("a\nb\nc\nd\ne");
2654 s.keymap.bind_sequence(
2655 Mode::Normal,
2656 vec![Key::Char('g'), Key::Char('j')],
2657 Action::Move(Motion::Down),
2658 "gj",
2659 );
2660 s.on_key(&Key::Char('2'));
2661 s.on_key(&Key::Char('g'));
2662 s.on_key(&Key::Char('j'));
2663 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2664 }
2665
2666 #[test]
2669 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2670 let mut s = new_state_with(&"x\n".repeat(40));
2676 let t0 = std::time::Instant::now();
2677 let mut delivered = 0u32;
2678 for i in 0..20u32 {
2679 let before = s.cursor().line;
2680 s.tick_at(
2681 &press(KeyCode::Char('j')),
2682 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2683 );
2684 if s.cursor().line != before {
2685 delivered += 1;
2686 }
2687 }
2688 assert!(
2691 (10..=14).contains(&delivered),
2692 "expected the storm debounced to ~13 moves, got {delivered}",
2693 );
2694 assert!(
2695 delivered < 20,
2696 "the gate must drop SOME storm ticks, not pass all 20",
2697 );
2698 }
2699
2700 #[test]
2701 fn spaced_intentional_taps_all_pass() {
2702 let mut s = new_state_with(&"x\n".repeat(10));
2705 let t0 = std::time::Instant::now();
2706 for i in 0..5u32 {
2707 s.tick_at(
2708 &press(KeyCode::Char('j')),
2709 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2711 );
2712 }
2713 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2714 }
2715
2716 #[test]
2717 fn distinct_keys_have_independent_clocks() {
2718 let mut s = new_state_with("abc\ndef\nghi");
2721 let t = std::time::Instant::now();
2722 s.tick_at(&press(KeyCode::Char('j')), t);
2723 s.tick_at(
2725 &press(KeyCode::Char('j')),
2726 t + std::time::Duration::from_millis(10),
2727 );
2728 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2729 s.tick_at(
2731 &press(KeyCode::Char('l')),
2732 t + std::time::Duration::from_millis(10),
2733 );
2734 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2735 }
2736
2737 #[test]
2740 fn cursor_home_preserves_single_cursor_behavior() {
2741 let mut s = new_state_with("hello\nworld\nthere");
2746 assert_eq!(s.cursor(), Position::ZERO);
2747 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2748
2749 s.apply_motion(Motion::Down);
2750 s.apply_motion(Motion::Right);
2751 s.apply_motion(Motion::Right);
2752 assert_eq!(s.cursor(), Position::new(1, 2));
2753 assert_eq!(s.cursors.count(), 1);
2755
2756 let w = s.layout.active_window().unwrap();
2758 assert!(w.viewport.top_line <= s.cursor().line);
2759 }
2760
2761 #[test]
2762 fn insert_mode_is_ungated_so_repeat_typing_works() {
2763 let mut s = new_state_with("");
2767 s.tick(&press(KeyCode::Char('i')));
2768 assert_eq!(s.modal.mode(), Mode::Insert);
2769 let t = std::time::Instant::now();
2770 for _ in 0..10 {
2771 s.tick_at(&press(KeyCode::Char('x')), t);
2772 }
2773 assert_eq!(
2774 s.buffers.get(s.active).unwrap().to_string(),
2775 "xxxxxxxxxx",
2776 "insert-mode repeat typing is ungated",
2777 );
2778 }
2779}