1use std::ops::Range;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
17pub struct Pos {
18 pub line: u32,
19 pub col: u32,
20}
21
22impl Pos {
23 pub const ORIGIN: Pos = Pos { line: 0, col: 0 };
24
25 pub const fn new(line: u32, col: u32) -> Self {
26 Pos { line, col }
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
41pub enum SelectionKind {
42 #[default]
43 Char,
44 Line,
45 Block,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct Selection {
51 pub anchor: Pos,
52 pub head: Pos,
53 pub kind: SelectionKind,
54}
55
56impl Selection {
57 pub const fn caret(pos: Pos) -> Self {
59 Selection {
60 anchor: pos,
61 head: pos,
62 kind: SelectionKind::Char,
63 }
64 }
65
66 pub const fn char_range(anchor: Pos, head: Pos) -> Self {
68 Selection {
69 anchor,
70 head,
71 kind: SelectionKind::Char,
72 }
73 }
74
75 pub fn is_empty(&self) -> bool {
77 self.anchor == self.head
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SelectionSet {
85 pub items: Vec<Selection>,
86 pub primary: usize,
87}
88
89impl SelectionSet {
90 pub fn caret(pos: Pos) -> Self {
92 SelectionSet {
93 items: vec![Selection::caret(pos)],
94 primary: 0,
95 }
96 }
97
98 pub fn primary(&self) -> &Selection {
101 self.items
102 .get(self.primary)
103 .or_else(|| self.items.first())
104 .expect("SelectionSet must contain at least one selection")
105 }
106}
107
108impl Default for SelectionSet {
109 fn default() -> Self {
110 SelectionSet::caret(Pos::ORIGIN)
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Edit {
119 pub range: Range<Pos>,
120 pub replacement: String,
121}
122
123impl Edit {
124 pub fn insert(at: Pos, text: impl Into<String>) -> Self {
125 Edit {
126 range: at..at,
127 replacement: text.into(),
128 }
129 }
130
131 pub fn delete(range: Range<Pos>) -> Self {
132 Edit {
133 range,
134 replacement: String::new(),
135 }
136 }
137
138 pub fn replace(range: Range<Pos>, text: impl Into<String>) -> Self {
139 Edit {
140 range,
141 replacement: text.into(),
142 }
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum Mode {
151 #[default]
152 Normal,
153 Insert,
154 Visual,
155 Replace,
156 Command,
157 OperatorPending,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
163pub enum CursorShape {
164 #[default]
165 Block,
166 Bar,
167 Underline,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
173pub struct Style {
174 pub fg: Option<Color>,
175 pub bg: Option<Color>,
176 pub attrs: Attrs,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
180pub struct Color(pub u8, pub u8, pub u8);
181
182bitflags::bitflags! {
183 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
184 pub struct Attrs: u8 {
185 const BOLD = 1 << 0;
186 const ITALIC = 1 << 1;
187 const UNDERLINE = 1 << 2;
188 const REVERSE = 1 << 3;
189 const DIM = 1 << 4;
190 const STRIKE = 1 << 5;
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum HighlightKind {
199 Selection,
200 SearchMatch,
201 IncSearch,
202 MatchParen,
203 Syntax(u32),
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Highlight {
208 pub range: Range<Pos>,
209 pub kind: HighlightKind,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct Options {
217 pub tabstop: u32,
219 pub shiftwidth: u32,
221 pub expandtab: bool,
223 pub iskeyword: String,
227 pub ignorecase: bool,
229 pub smartcase: bool,
232 pub hlsearch: bool,
234 pub incsearch: bool,
236 pub wrapscan: bool,
238 pub autoindent: bool,
240 pub timeout_len: core::time::Duration,
242 pub undo_levels: u32,
244 pub undo_break_on_motion: bool,
247 pub readonly: bool,
249 pub wrap: WrapMode,
252 pub textwidth: u32,
254}
255
256#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
260#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
261pub enum WrapMode {
262 #[default]
265 None,
266 Char,
269 Word,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum OptionValue {
282 Bool(bool),
283 Int(i64),
284 String(String),
285}
286
287impl Default for Options {
288 fn default() -> Self {
289 Options {
290 tabstop: 8,
291 shiftwidth: 8,
292 expandtab: false,
293 iskeyword: "@,48-57,_,192-255".to_string(),
294 ignorecase: false,
295 smartcase: false,
296 hlsearch: true,
297 incsearch: true,
298 wrapscan: true,
299 autoindent: true,
300 timeout_len: core::time::Duration::from_millis(1000),
301 undo_levels: 1000,
302 undo_break_on_motion: true,
303 readonly: false,
304 wrap: WrapMode::None,
305 textwidth: 79,
306 }
307 }
308}
309
310impl Options {
311 pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
318 macro_rules! set_bool {
319 ($field:ident) => {{
320 self.$field = match val {
321 OptionValue::Bool(b) => b,
322 OptionValue::Int(n) => n != 0,
323 other => {
324 return Err(EngineError::Ex(format!(
325 "option `{name}` expects bool, got {other:?}"
326 )));
327 }
328 };
329 Ok(())
330 }};
331 }
332 macro_rules! set_u32 {
333 ($field:ident) => {{
334 self.$field = match val {
335 OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
336 OptionValue::Int(n) => {
337 return Err(EngineError::Ex(format!(
338 "option `{name}` out of u32 range: {n}"
339 )));
340 }
341 other => {
342 return Err(EngineError::Ex(format!(
343 "option `{name}` expects int, got {other:?}"
344 )));
345 }
346 };
347 Ok(())
348 }};
349 }
350 macro_rules! set_string {
351 ($field:ident) => {{
352 self.$field = match val {
353 OptionValue::String(s) => s,
354 other => {
355 return Err(EngineError::Ex(format!(
356 "option `{name}` expects string, got {other:?}"
357 )));
358 }
359 };
360 Ok(())
361 }};
362 }
363 match name {
364 "tabstop" | "ts" => set_u32!(tabstop),
365 "shiftwidth" | "sw" => set_u32!(shiftwidth),
366 "textwidth" | "tw" => set_u32!(textwidth),
367 "expandtab" | "et" => set_bool!(expandtab),
368 "iskeyword" | "isk" => set_string!(iskeyword),
369 "ignorecase" | "ic" => set_bool!(ignorecase),
370 "smartcase" | "scs" => set_bool!(smartcase),
371 "hlsearch" | "hls" => set_bool!(hlsearch),
372 "incsearch" | "is" => set_bool!(incsearch),
373 "wrapscan" | "ws" => set_bool!(wrapscan),
374 "autoindent" | "ai" => set_bool!(autoindent),
375 "timeoutlen" | "tm" => {
376 self.timeout_len = match val {
377 OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
378 other => {
379 return Err(EngineError::Ex(format!(
380 "option `{name}` expects non-negative int (millis), got {other:?}"
381 )));
382 }
383 };
384 Ok(())
385 }
386 "undolevels" | "ul" => set_u32!(undo_levels),
387 "undobreak" => set_bool!(undo_break_on_motion),
388 "readonly" | "ro" => set_bool!(readonly),
389 "wrap" => {
390 let on = match val {
391 OptionValue::Bool(b) => b,
392 OptionValue::Int(n) => n != 0,
393 other => {
394 return Err(EngineError::Ex(format!(
395 "option `{name}` expects bool, got {other:?}"
396 )));
397 }
398 };
399 self.wrap = match (on, self.wrap) {
400 (false, _) => WrapMode::None,
401 (true, WrapMode::Word) => WrapMode::Word,
402 (true, _) => WrapMode::Char,
403 };
404 Ok(())
405 }
406 "linebreak" | "lbr" => {
407 let on = match val {
408 OptionValue::Bool(b) => b,
409 OptionValue::Int(n) => n != 0,
410 other => {
411 return Err(EngineError::Ex(format!(
412 "option `{name}` expects bool, got {other:?}"
413 )));
414 }
415 };
416 self.wrap = match (on, self.wrap) {
417 (true, _) => WrapMode::Word,
418 (false, WrapMode::Word) => WrapMode::Char,
419 (false, other) => other,
420 };
421 Ok(())
422 }
423 other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
424 }
425 }
426
427 pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
429 Some(match name {
430 "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
431 "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
432 "textwidth" | "tw" => OptionValue::Int(self.textwidth as i64),
433 "expandtab" | "et" => OptionValue::Bool(self.expandtab),
434 "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
435 "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
436 "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
437 "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
438 "incsearch" | "is" => OptionValue::Bool(self.incsearch),
439 "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
440 "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
441 "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
442 "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
443 "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
444 "readonly" | "ro" => OptionValue::Bool(self.readonly),
445 "wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
446 "linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
447 _ => return None,
448 })
449 }
450}
451
452#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
456pub struct Viewport {
457 pub top_line: u32,
458 pub height: u32,
459 pub scroll_off: u32,
460}
461
462#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
466pub struct BufferId(pub u64);
467
468#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
470pub struct Modifiers {
471 pub ctrl: bool,
472 pub shift: bool,
473 pub alt: bool,
474 pub super_: bool,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
479#[non_exhaustive]
480pub enum SpecialKey {
481 Esc,
482 Enter,
483 Backspace,
484 Tab,
485 BackTab,
486 Up,
487 Down,
488 Left,
489 Right,
490 Home,
491 End,
492 PageUp,
493 PageDown,
494 Insert,
495 Delete,
496 F(u8),
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
500pub enum MouseKind {
501 Press,
502 Release,
503 Drag,
504 ScrollUp,
505 ScrollDown,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
509pub struct MouseEvent {
510 pub kind: MouseKind,
511 pub pos: Pos,
512 pub mods: Modifiers,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
520#[non_exhaustive]
521pub enum Input {
522 Char(char, Modifiers),
523 Key(SpecialKey, Modifiers),
524 Mouse(MouseEvent),
525 Paste(String),
526 FocusGained,
527 FocusLost,
528 Resize(u16, u16),
529}
530
531pub trait Host: Send {
540 type Intent;
544
545 fn write_clipboard(&mut self, text: String);
551
552 fn read_clipboard(&mut self) -> Option<String>;
555
556 fn now(&self) -> core::time::Duration;
562
563 fn should_cancel(&self) -> bool {
566 false
567 }
568
569 fn prompt_search(&mut self) -> Option<String>;
574
575 fn display_line_for(&self, pos: Pos) -> u32 {
580 pos.line
581 }
582
583 fn pos_for_display(&self, line: u32, col: u32) -> Pos {
585 Pos { line, col }
586 }
587
588 fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
593 let _ = range;
594 Vec::new()
595 }
596
597 fn emit_cursor_shape(&mut self, shape: CursorShape);
602
603 fn emit_intent(&mut self, intent: Self::Intent);
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
622pub struct RenderFrame {
623 pub mode: SnapshotMode,
624 pub cursor_row: u32,
625 pub cursor_col: u32,
626 pub cursor_shape: CursorShape,
627 pub viewport_top: u32,
628 pub line_count: u32,
629}
630
631#[derive(Debug, Clone)]
647#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
648pub struct EditorSnapshot {
649 pub version: u32,
652 pub mode: SnapshotMode,
654 pub cursor: (u32, u32),
656 pub lines: Vec<String>,
658 pub viewport_top: u32,
660 pub registers: crate::Registers,
664 pub file_marks: std::collections::HashMap<char, (u32, u32)>,
668}
669
670#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
674#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
675pub enum SnapshotMode {
676 #[default]
677 Normal,
678 Insert,
679 Visual,
680 VisualLine,
681 VisualBlock,
682}
683
684impl EditorSnapshot {
685 pub const VERSION: u32 = 3;
690}
691
692#[derive(Debug, thiserror::Error)]
696pub enum EngineError {
697 #[error("regex compile error: {0}")]
700 Regex(#[from] regex::Error),
701
702 #[error("invalid range: {0}")]
704 InvalidRange(String),
705
706 #[error("ex parse: {0}")]
708 Ex(String),
709
710 #[error("buffer is read-only")]
712 ReadOnly,
713
714 #[error("position out of bounds: {0:?}")]
716 OutOfBounds(Pos),
717
718 #[error("snapshot version mismatch: file={0}, expected={1}")]
721 SnapshotVersion(u32, u32),
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn caret_is_empty() {
730 let sel = Selection::caret(Pos::new(2, 4));
731 assert!(sel.is_empty());
732 assert_eq!(sel.anchor, sel.head);
733 }
734
735 #[test]
736 fn selection_set_default_has_one_caret() {
737 let set = SelectionSet::default();
738 assert_eq!(set.items.len(), 1);
739 assert_eq!(set.primary, 0);
740 assert_eq!(set.primary().anchor, Pos::ORIGIN);
741 }
742
743 #[test]
744 fn edit_constructors() {
745 let p = Pos::new(0, 5);
746 assert_eq!(Edit::insert(p, "x").range, p..p);
747 assert!(Edit::insert(p, "x").replacement == "x");
748 assert!(Edit::delete(p..p).replacement.is_empty());
749 }
750
751 #[test]
752 fn attrs_flags() {
753 let a = Attrs::BOLD | Attrs::UNDERLINE;
754 assert!(a.contains(Attrs::BOLD));
755 assert!(!a.contains(Attrs::ITALIC));
756 }
757
758 #[test]
759 fn options_set_get_roundtrip() {
760 let mut o = Options::default();
761 o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
762 assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
763 o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
764 assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
765 o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
766 .unwrap();
767 match o.get_by_name("iskeyword") {
768 Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
769 other => panic!("expected String, got {other:?}"),
770 }
771 }
772
773 #[test]
774 fn options_unknown_name_errors_on_set() {
775 let mut o = Options::default();
776 assert!(matches!(
777 o.set_by_name("frobnicate", OptionValue::Int(1)),
778 Err(EngineError::Ex(_))
779 ));
780 assert!(o.get_by_name("frobnicate").is_none());
781 }
782
783 #[test]
784 fn options_type_mismatch_errors() {
785 let mut o = Options::default();
786 assert!(matches!(
787 o.set_by_name("tabstop", OptionValue::String("nope".into())),
788 Err(EngineError::Ex(_))
789 ));
790 assert!(matches!(
791 o.set_by_name("iskeyword", OptionValue::Int(7)),
792 Err(EngineError::Ex(_))
793 ));
794 }
795
796 #[test]
797 fn options_int_to_bool_coercion() {
798 let mut o = Options::default();
801 o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
802 assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
803 o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
804 assert!(matches!(
805 o.get_by_name("ic"),
806 Some(OptionValue::Bool(false))
807 ));
808 }
809
810 #[test]
811 fn options_wrap_linebreak_roundtrip() {
812 let mut o = Options::default();
813 assert_eq!(o.wrap, WrapMode::None);
814 o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
815 assert_eq!(o.wrap, WrapMode::Char);
816 o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
817 assert_eq!(o.wrap, WrapMode::Word);
818 assert!(matches!(
819 o.get_by_name("wrap"),
820 Some(OptionValue::Bool(true))
821 ));
822 assert!(matches!(
823 o.get_by_name("lbr"),
824 Some(OptionValue::Bool(true))
825 ));
826 o.set_by_name("linebreak", OptionValue::Bool(false))
827 .unwrap();
828 assert_eq!(o.wrap, WrapMode::Char);
829 o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
830 assert_eq!(o.wrap, WrapMode::None);
831 }
832
833 #[test]
834 fn options_default_matches_vim() {
835 let o = Options::default();
836 assert_eq!(o.tabstop, 8);
837 assert!(!o.expandtab);
838 assert!(o.hlsearch);
839 assert!(o.wrapscan);
840 assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
841 }
842
843 #[test]
844 fn editor_snapshot_version_const() {
845 assert_eq!(EditorSnapshot::VERSION, 3);
846 }
847
848 #[test]
849 fn editor_snapshot_default_shape() {
850 let s = EditorSnapshot {
851 version: EditorSnapshot::VERSION,
852 mode: SnapshotMode::Normal,
853 cursor: (0, 0),
854 lines: vec!["hello".to_string()],
855 viewport_top: 0,
856 registers: crate::Registers::default(),
857 file_marks: Default::default(),
858 };
859 assert_eq!(s.cursor, (0, 0));
860 assert_eq!(s.lines.len(), 1);
861 }
862
863 #[cfg(feature = "serde")]
864 #[test]
865 fn editor_snapshot_roundtrip() {
866 let mut file_marks = std::collections::HashMap::new();
867 file_marks.insert('A', (5u32, 2u32));
868 let s = EditorSnapshot {
869 version: EditorSnapshot::VERSION,
870 mode: SnapshotMode::Insert,
871 cursor: (3, 7),
872 lines: vec!["alpha".into(), "beta".into()],
873 viewport_top: 2,
874 registers: crate::Registers::default(),
875 file_marks,
876 };
877 let json = serde_json::to_string(&s).unwrap();
878 let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
879 assert_eq!(s.cursor, back.cursor);
880 assert_eq!(s.lines, back.lines);
881 assert_eq!(s.viewport_top, back.viewport_top);
882 }
883
884 #[test]
885 fn engine_error_display() {
886 let e = EngineError::ReadOnly;
887 assert_eq!(e.to_string(), "buffer is read-only");
888 let e = EngineError::OutOfBounds(Pos::new(3, 7));
889 assert!(e.to_string().contains("out of bounds"));
890 }
891}