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}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum OptionValue {
258 Bool(bool),
259 Int(i64),
260 String(String),
261}
262
263impl Default for Options {
264 fn default() -> Self {
265 Options {
266 tabstop: 8,
267 shiftwidth: 8,
268 expandtab: false,
269 iskeyword: "@,48-57,_,192-255".to_string(),
270 ignorecase: false,
271 smartcase: false,
272 hlsearch: true,
273 incsearch: true,
274 wrapscan: true,
275 autoindent: true,
276 timeout_len: core::time::Duration::from_millis(1000),
277 undo_levels: 1000,
278 undo_break_on_motion: true,
279 readonly: false,
280 }
281 }
282}
283
284impl Options {
285 pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
292 macro_rules! set_bool {
293 ($field:ident) => {{
294 self.$field = match val {
295 OptionValue::Bool(b) => b,
296 OptionValue::Int(n) => n != 0,
297 other => {
298 return Err(EngineError::Ex(format!(
299 "option `{name}` expects bool, got {other:?}"
300 )));
301 }
302 };
303 Ok(())
304 }};
305 }
306 macro_rules! set_u32 {
307 ($field:ident) => {{
308 self.$field = match val {
309 OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
310 OptionValue::Int(n) => {
311 return Err(EngineError::Ex(format!(
312 "option `{name}` out of u32 range: {n}"
313 )));
314 }
315 other => {
316 return Err(EngineError::Ex(format!(
317 "option `{name}` expects int, got {other:?}"
318 )));
319 }
320 };
321 Ok(())
322 }};
323 }
324 macro_rules! set_string {
325 ($field:ident) => {{
326 self.$field = match val {
327 OptionValue::String(s) => s,
328 other => {
329 return Err(EngineError::Ex(format!(
330 "option `{name}` expects string, got {other:?}"
331 )));
332 }
333 };
334 Ok(())
335 }};
336 }
337 match name {
338 "tabstop" | "ts" => set_u32!(tabstop),
339 "shiftwidth" | "sw" => set_u32!(shiftwidth),
340 "expandtab" | "et" => set_bool!(expandtab),
341 "iskeyword" | "isk" => set_string!(iskeyword),
342 "ignorecase" | "ic" => set_bool!(ignorecase),
343 "smartcase" | "scs" => set_bool!(smartcase),
344 "hlsearch" | "hls" => set_bool!(hlsearch),
345 "incsearch" | "is" => set_bool!(incsearch),
346 "wrapscan" | "ws" => set_bool!(wrapscan),
347 "autoindent" | "ai" => set_bool!(autoindent),
348 "timeoutlen" | "tm" => {
349 self.timeout_len = match val {
350 OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
351 other => {
352 return Err(EngineError::Ex(format!(
353 "option `{name}` expects non-negative int (millis), got {other:?}"
354 )));
355 }
356 };
357 Ok(())
358 }
359 "undolevels" | "ul" => set_u32!(undo_levels),
360 "undobreak" => set_bool!(undo_break_on_motion),
361 "readonly" | "ro" => set_bool!(readonly),
362 other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
363 }
364 }
365
366 pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
368 Some(match name {
369 "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
370 "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
371 "expandtab" | "et" => OptionValue::Bool(self.expandtab),
372 "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
373 "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
374 "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
375 "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
376 "incsearch" | "is" => OptionValue::Bool(self.incsearch),
377 "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
378 "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
379 "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
380 "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
381 "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
382 "readonly" | "ro" => OptionValue::Bool(self.readonly),
383 _ => return None,
384 })
385 }
386}
387
388#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
392pub struct Viewport {
393 pub top_line: u32,
394 pub height: u32,
395 pub scroll_off: u32,
396}
397
398#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
402pub struct BufferId(pub u64);
403
404#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
406pub struct Modifiers {
407 pub ctrl: bool,
408 pub shift: bool,
409 pub alt: bool,
410 pub super_: bool,
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
415#[non_exhaustive]
416pub enum SpecialKey {
417 Esc,
418 Enter,
419 Backspace,
420 Tab,
421 BackTab,
422 Up,
423 Down,
424 Left,
425 Right,
426 Home,
427 End,
428 PageUp,
429 PageDown,
430 Insert,
431 Delete,
432 F(u8),
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
436pub enum MouseKind {
437 Press,
438 Release,
439 Drag,
440 ScrollUp,
441 ScrollDown,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
445pub struct MouseEvent {
446 pub kind: MouseKind,
447 pub pos: Pos,
448 pub mods: Modifiers,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
456#[non_exhaustive]
457pub enum Input {
458 Char(char, Modifiers),
459 Key(SpecialKey, Modifiers),
460 Mouse(MouseEvent),
461 Paste(String),
462 FocusGained,
463 FocusLost,
464 Resize(u16, u16),
465}
466
467pub trait Host: Send {
476 type Intent;
480
481 fn write_clipboard(&mut self, text: String);
487
488 fn read_clipboard(&mut self) -> Option<String>;
491
492 fn now(&self) -> core::time::Duration;
498
499 fn should_cancel(&self) -> bool {
502 false
503 }
504
505 fn prompt_search(&mut self) -> Option<String>;
510
511 fn display_line_for(&self, pos: Pos) -> u32 {
516 pos.line
517 }
518
519 fn pos_for_display(&self, line: u32, col: u32) -> Pos {
521 Pos { line, col }
522 }
523
524 fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
529 let _ = range;
530 Vec::new()
531 }
532
533 fn emit_cursor_shape(&mut self, shape: CursorShape);
538
539 fn emit_intent(&mut self, intent: Self::Intent);
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
558pub struct RenderFrame {
559 pub mode: SnapshotMode,
560 pub cursor_row: u32,
561 pub cursor_col: u32,
562 pub cursor_shape: CursorShape,
563 pub viewport_top: u32,
564 pub line_count: u32,
565}
566
567#[derive(Debug, Clone)]
583#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
584pub struct EditorSnapshot {
585 pub version: u32,
588 pub mode: SnapshotMode,
590 pub cursor: (u32, u32),
592 pub lines: Vec<String>,
594 pub viewport_top: u32,
596 pub registers: crate::Registers,
600 pub file_marks: std::collections::HashMap<char, (u32, u32)>,
604}
605
606#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
610#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
611pub enum SnapshotMode {
612 #[default]
613 Normal,
614 Insert,
615 Visual,
616 VisualLine,
617 VisualBlock,
618}
619
620impl EditorSnapshot {
621 pub const VERSION: u32 = 3;
626}
627
628#[derive(Debug, thiserror::Error)]
632pub enum EngineError {
633 #[error("regex compile error: {0}")]
636 Regex(#[from] regex::Error),
637
638 #[error("invalid range: {0}")]
640 InvalidRange(String),
641
642 #[error("ex parse: {0}")]
644 Ex(String),
645
646 #[error("buffer is read-only")]
648 ReadOnly,
649
650 #[error("position out of bounds: {0:?}")]
652 OutOfBounds(Pos),
653
654 #[error("snapshot version mismatch: file={0}, expected={1}")]
657 SnapshotVersion(u32, u32),
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn caret_is_empty() {
666 let sel = Selection::caret(Pos::new(2, 4));
667 assert!(sel.is_empty());
668 assert_eq!(sel.anchor, sel.head);
669 }
670
671 #[test]
672 fn selection_set_default_has_one_caret() {
673 let set = SelectionSet::default();
674 assert_eq!(set.items.len(), 1);
675 assert_eq!(set.primary, 0);
676 assert_eq!(set.primary().anchor, Pos::ORIGIN);
677 }
678
679 #[test]
680 fn edit_constructors() {
681 let p = Pos::new(0, 5);
682 assert_eq!(Edit::insert(p, "x").range, p..p);
683 assert!(Edit::insert(p, "x").replacement == "x");
684 assert!(Edit::delete(p..p).replacement.is_empty());
685 }
686
687 #[test]
688 fn attrs_flags() {
689 let a = Attrs::BOLD | Attrs::UNDERLINE;
690 assert!(a.contains(Attrs::BOLD));
691 assert!(!a.contains(Attrs::ITALIC));
692 }
693
694 #[test]
695 fn options_set_get_roundtrip() {
696 let mut o = Options::default();
697 o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
698 assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
699 o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
700 assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
701 o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
702 .unwrap();
703 match o.get_by_name("iskeyword") {
704 Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
705 other => panic!("expected String, got {other:?}"),
706 }
707 }
708
709 #[test]
710 fn options_unknown_name_errors_on_set() {
711 let mut o = Options::default();
712 assert!(matches!(
713 o.set_by_name("frobnicate", OptionValue::Int(1)),
714 Err(EngineError::Ex(_))
715 ));
716 assert!(o.get_by_name("frobnicate").is_none());
717 }
718
719 #[test]
720 fn options_type_mismatch_errors() {
721 let mut o = Options::default();
722 assert!(matches!(
723 o.set_by_name("tabstop", OptionValue::String("nope".into())),
724 Err(EngineError::Ex(_))
725 ));
726 assert!(matches!(
727 o.set_by_name("iskeyword", OptionValue::Int(7)),
728 Err(EngineError::Ex(_))
729 ));
730 }
731
732 #[test]
733 fn options_int_to_bool_coercion() {
734 let mut o = Options::default();
737 o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
738 assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
739 o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
740 assert!(matches!(
741 o.get_by_name("ic"),
742 Some(OptionValue::Bool(false))
743 ));
744 }
745
746 #[test]
747 fn options_default_matches_vim() {
748 let o = Options::default();
749 assert_eq!(o.tabstop, 8);
750 assert!(!o.expandtab);
751 assert!(o.hlsearch);
752 assert!(o.wrapscan);
753 assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
754 }
755
756 #[test]
757 fn editor_snapshot_version_const() {
758 assert_eq!(EditorSnapshot::VERSION, 3);
759 }
760
761 #[test]
762 fn editor_snapshot_default_shape() {
763 let s = EditorSnapshot {
764 version: EditorSnapshot::VERSION,
765 mode: SnapshotMode::Normal,
766 cursor: (0, 0),
767 lines: vec!["hello".to_string()],
768 viewport_top: 0,
769 registers: crate::Registers::default(),
770 file_marks: Default::default(),
771 };
772 assert_eq!(s.cursor, (0, 0));
773 assert_eq!(s.lines.len(), 1);
774 }
775
776 #[cfg(feature = "serde")]
777 #[test]
778 fn editor_snapshot_roundtrip() {
779 let mut file_marks = std::collections::HashMap::new();
780 file_marks.insert('A', (5u32, 2u32));
781 let s = EditorSnapshot {
782 version: EditorSnapshot::VERSION,
783 mode: SnapshotMode::Insert,
784 cursor: (3, 7),
785 lines: vec!["alpha".into(), "beta".into()],
786 viewport_top: 2,
787 registers: crate::Registers::default(),
788 file_marks,
789 };
790 let json = serde_json::to_string(&s).unwrap();
791 let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
792 assert_eq!(s.cursor, back.cursor);
793 assert_eq!(s.lines, back.lines);
794 assert_eq!(s.viewport_top, back.viewport_top);
795 }
796
797 #[test]
798 fn engine_error_display() {
799 let e = EngineError::ReadOnly;
800 assert_eq!(e.to_string(), "buffer is read-only");
801 let e = EngineError::OutOfBounds(Pos::new(3, 7));
802 assert!(e.to_string().contains("out of bounds"));
803 }
804}