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)]
559pub struct RenderFrame {
560 pub mode: SnapshotMode,
561 pub cursor_row: u32,
562 pub cursor_col: u32,
563 pub cursor_shape: CursorShape,
564 pub viewport_top: u32,
565 pub line_count: u32,
566}
567
568#[derive(Debug, Clone)]
584#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
585pub struct EditorSnapshot {
586 pub version: u32,
589 pub mode: SnapshotMode,
591 pub cursor: (u32, u32),
593 pub lines: Vec<String>,
595 pub viewport_top: u32,
597 pub registers: crate::Registers,
601 pub file_marks: std::collections::HashMap<char, (u32, u32)>,
605}
606
607#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
611#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
612pub enum SnapshotMode {
613 #[default]
614 Normal,
615 Insert,
616 Visual,
617 VisualLine,
618 VisualBlock,
619}
620
621impl EditorSnapshot {
622 pub const VERSION: u32 = 3;
627}
628
629#[derive(Debug, thiserror::Error)]
633pub enum EngineError {
634 #[error("regex compile error: {0}")]
637 Regex(#[from] regex::Error),
638
639 #[error("invalid range: {0}")]
641 InvalidRange(String),
642
643 #[error("ex parse: {0}")]
645 Ex(String),
646
647 #[error("buffer is read-only")]
649 ReadOnly,
650
651 #[error("position out of bounds: {0:?}")]
653 OutOfBounds(Pos),
654
655 #[error("snapshot version mismatch: file={0}, expected={1}")]
658 SnapshotVersion(u32, u32),
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 #[test]
666 fn caret_is_empty() {
667 let sel = Selection::caret(Pos::new(2, 4));
668 assert!(sel.is_empty());
669 assert_eq!(sel.anchor, sel.head);
670 }
671
672 #[test]
673 fn selection_set_default_has_one_caret() {
674 let set = SelectionSet::default();
675 assert_eq!(set.items.len(), 1);
676 assert_eq!(set.primary, 0);
677 assert_eq!(set.primary().anchor, Pos::ORIGIN);
678 }
679
680 #[test]
681 fn edit_constructors() {
682 let p = Pos::new(0, 5);
683 assert_eq!(Edit::insert(p, "x").range, p..p);
684 assert!(Edit::insert(p, "x").replacement == "x");
685 assert!(Edit::delete(p..p).replacement.is_empty());
686 }
687
688 #[test]
689 fn attrs_flags() {
690 let a = Attrs::BOLD | Attrs::UNDERLINE;
691 assert!(a.contains(Attrs::BOLD));
692 assert!(!a.contains(Attrs::ITALIC));
693 }
694
695 #[test]
696 fn options_set_get_roundtrip() {
697 let mut o = Options::default();
698 o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
699 assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
700 o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
701 assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
702 o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
703 .unwrap();
704 match o.get_by_name("iskeyword") {
705 Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
706 other => panic!("expected String, got {other:?}"),
707 }
708 }
709
710 #[test]
711 fn options_unknown_name_errors_on_set() {
712 let mut o = Options::default();
713 assert!(matches!(
714 o.set_by_name("frobnicate", OptionValue::Int(1)),
715 Err(EngineError::Ex(_))
716 ));
717 assert!(o.get_by_name("frobnicate").is_none());
718 }
719
720 #[test]
721 fn options_type_mismatch_errors() {
722 let mut o = Options::default();
723 assert!(matches!(
724 o.set_by_name("tabstop", OptionValue::String("nope".into())),
725 Err(EngineError::Ex(_))
726 ));
727 assert!(matches!(
728 o.set_by_name("iskeyword", OptionValue::Int(7)),
729 Err(EngineError::Ex(_))
730 ));
731 }
732
733 #[test]
734 fn options_int_to_bool_coercion() {
735 let mut o = Options::default();
738 o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
739 assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
740 o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
741 assert!(matches!(
742 o.get_by_name("ic"),
743 Some(OptionValue::Bool(false))
744 ));
745 }
746
747 #[test]
748 fn options_default_matches_vim() {
749 let o = Options::default();
750 assert_eq!(o.tabstop, 8);
751 assert!(!o.expandtab);
752 assert!(o.hlsearch);
753 assert!(o.wrapscan);
754 assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
755 }
756
757 #[test]
758 fn editor_snapshot_version_const() {
759 assert_eq!(EditorSnapshot::VERSION, 3);
760 }
761
762 #[test]
763 fn editor_snapshot_default_shape() {
764 let s = EditorSnapshot {
765 version: EditorSnapshot::VERSION,
766 mode: SnapshotMode::Normal,
767 cursor: (0, 0),
768 lines: vec!["hello".to_string()],
769 viewport_top: 0,
770 registers: crate::Registers::default(),
771 file_marks: Default::default(),
772 };
773 assert_eq!(s.cursor, (0, 0));
774 assert_eq!(s.lines.len(), 1);
775 }
776
777 #[cfg(feature = "serde")]
778 #[test]
779 fn editor_snapshot_roundtrip() {
780 let mut file_marks = std::collections::HashMap::new();
781 file_marks.insert('A', (5u32, 2u32));
782 let s = EditorSnapshot {
783 version: EditorSnapshot::VERSION,
784 mode: SnapshotMode::Insert,
785 cursor: (3, 7),
786 lines: vec!["alpha".into(), "beta".into()],
787 viewport_top: 2,
788 registers: crate::Registers::default(),
789 file_marks,
790 };
791 let json = serde_json::to_string(&s).unwrap();
792 let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
793 assert_eq!(s.cursor, back.cursor);
794 assert_eq!(s.lines, back.lines);
795 assert_eq!(s.viewport_top, back.viewport_top);
796 }
797
798 #[test]
799 fn engine_error_display() {
800 let e = EngineError::ReadOnly;
801 assert_eq!(e.to_string(), "buffer is read-only");
802 let e = EngineError::OutOfBounds(Pos::new(3, 7));
803 assert!(e.to_string().contains("out of bounds"));
804 }
805}