Skip to main content

hjkl_engine/
types.rs

1//! Core types for the planned 0.1.0 trait surface (per `SPEC.md`).
2//!
3//! These are introduced alongside the legacy sqeel-vim public API. The
4//! trait extraction (phase 5) progressively rewires the existing FSM and
5//! Editor to operate on `Selection` / `SelectionSet` / `Edit` / `Pos`.
6//! Until that work lands, the legacy types in [`crate::editor`] and
7//! [`crate::vim`] remain authoritative.
8
9use std::ops::Range;
10
11/// Grapheme-indexed position. `line` is zero-based row; `col` is zero-based
12/// grapheme column within that line.
13///
14/// Note that `col` counts graphemes, not bytes or chars. Motions and
15/// rendering both honor grapheme boundaries.
16#[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/// What kind of region a [`Selection`] covers.
31///
32/// - `Char`: classic vim `v` selection — closed range on the inline character
33///   axis.
34/// - `Line`: linewise (`V`) — anchor/head columns ignored, full lines covered
35///   between `min(anchor.line, head.line)` and `max(...)`.
36/// - `Block`: blockwise (`Ctrl-V`) — rectangle from `min(col)` to `max(col)`,
37///   each line a sub-range. Falls out of multi-cursor model: implementations
38///   may expand a `Block` selection into N sub-selections during edit
39///   dispatch.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
41pub enum SelectionKind {
42    #[default]
43    Char,
44    Line,
45    Block,
46}
47
48/// A single anchored selection. Empty (caret-only) when `anchor == head`.
49#[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    /// Caret at `pos` with no extent.
58    pub const fn caret(pos: Pos) -> Self {
59        Selection {
60            anchor: pos,
61            head: pos,
62            kind: SelectionKind::Char,
63        }
64    }
65
66    /// Inclusive range `[anchor, head]` (or reversed) as a `Char` selection.
67    pub const fn char_range(anchor: Pos, head: Pos) -> Self {
68        Selection {
69            anchor,
70            head,
71            kind: SelectionKind::Char,
72        }
73    }
74
75    /// True if `anchor == head`.
76    pub fn is_empty(&self) -> bool {
77        self.anchor == self.head
78    }
79}
80
81/// Ordered set of selections. Always non-empty in valid states; `primary`
82/// indexes the cursor visible to vim mode.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SelectionSet {
85    pub items: Vec<Selection>,
86    pub primary: usize,
87}
88
89impl SelectionSet {
90    /// Single caret at `pos`.
91    pub fn caret(pos: Pos) -> Self {
92        SelectionSet {
93            items: vec![Selection::caret(pos)],
94            primary: 0,
95        }
96    }
97
98    /// Returns the primary selection, or the first if `primary` is out of
99    /// bounds.
100    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/// A pending or applied edit. Multi-cursor edits fan out to `Vec<Edit>`
115/// ordered in **reverse byte offset** so each entry's positions remain valid
116/// after the prior entry applies.
117#[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/// Vim editor mode. Distinct from the legacy [`crate::VimMode`] — that one
147/// is the host-facing status-line summary; this is the engine's internal
148/// state machine.
149#[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/// Cursor shape intent emitted on mode transitions. Hosts honor it via
161/// `Host::emit_cursor_shape` once the trait extraction lands.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
163pub enum CursorShape {
164    #[default]
165    Block,
166    Bar,
167    Underline,
168}
169
170/// Engine-native style. Replaces direct ratatui `Style` use in the public
171/// API once phase 5 trait extraction completes; until then both coexist.
172#[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/// Highlight kind emitted by the engine's render pass. The host's style
195/// resolver picks colors for `Selection`/`SearchMatch`/etc.; `Syntax(id)`
196/// carries an opaque host-supplied id whose styling lives in the host.
197#[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/// Editor settings surfaced via `:set`. Per SPEC. Consumed once trait
213/// extraction lands; today's legacy `Settings` (in [`crate::editor`])
214/// continues to drive runtime behaviour.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct Options {
217    /// Display width of `\t` for column math + render. Default 8.
218    pub tabstop: u32,
219    /// Spaces per shift step (`>>`, `<<`, `Ctrl-T`, `Ctrl-D`).
220    pub shiftwidth: u32,
221    /// Insert spaces (`true`) or literal `\t` (`false`) for the Tab key.
222    pub expandtab: bool,
223    /// Characters considered part of a "word" for `w`/`b`/`*`/`#`.
224    /// Default `"@,48-57,_,192-255"` (ASCII letters, digits, `_`, plus
225    /// extended Latin); host may override per language.
226    pub iskeyword: String,
227    /// Default `false`: search is case-sensitive.
228    pub ignorecase: bool,
229    /// When `true` and `ignorecase` is `true`, an uppercase letter in the
230    /// pattern flips back to case-sensitive for that search.
231    pub smartcase: bool,
232    /// Highlight all matches of the last search.
233    pub hlsearch: bool,
234    /// Incrementally highlight matches while typing the search pattern.
235    pub incsearch: bool,
236    /// Wrap searches around the buffer ends.
237    pub wrapscan: bool,
238    /// Copy previous line's leading whitespace on Enter in insert mode.
239    pub autoindent: bool,
240    /// Multi-key sequence timeout (e.g., `<C-w>v`). Vim's `timeoutlen`.
241    pub timeout_len: core::time::Duration,
242    /// Maximum undo-tree depth. Older entries pruned.
243    pub undo_levels: u32,
244    /// Break the current undo group on cursor motion in insert mode.
245    /// Matches vim default; turn off to merge multi-segment edits.
246    pub undo_break_on_motion: bool,
247    /// Reject every edit. `:set ro` sets this; `:w!` clears it.
248    pub readonly: bool,
249}
250
251impl Default for Options {
252    fn default() -> Self {
253        Options {
254            tabstop: 8,
255            shiftwidth: 8,
256            expandtab: false,
257            iskeyword: "@,48-57,_,192-255".to_string(),
258            ignorecase: false,
259            smartcase: false,
260            hlsearch: true,
261            incsearch: true,
262            wrapscan: true,
263            autoindent: true,
264            timeout_len: core::time::Duration::from_millis(1000),
265            undo_levels: 1000,
266            undo_break_on_motion: true,
267            readonly: false,
268        }
269    }
270}
271
272/// Visible region of a buffer. The host writes `top_line` and `height`
273/// per render frame; the engine reads to decide where the cursor must
274/// land for visibility (cf. `scroll_off`).
275#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
276pub struct Viewport {
277    pub top_line: u32,
278    pub height: u32,
279    pub scroll_off: u32,
280}
281
282/// Opaque buffer identifier owned by the host. Engine echoes it back
283/// in [`Host::Intent`] variants for buffer-list operations
284/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
285#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
286pub struct BufferId(pub u64);
287
288/// Modifier bits accompanying every keystroke.
289#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
290pub struct Modifiers {
291    pub ctrl: bool,
292    pub shift: bool,
293    pub alt: bool,
294    pub super_: bool,
295}
296
297/// Special key codes — anything that isn't a printable character.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
299#[non_exhaustive]
300pub enum SpecialKey {
301    Esc,
302    Enter,
303    Backspace,
304    Tab,
305    BackTab,
306    Up,
307    Down,
308    Left,
309    Right,
310    Home,
311    End,
312    PageUp,
313    PageDown,
314    Insert,
315    Delete,
316    F(u8),
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
320pub enum MouseKind {
321    Press,
322    Release,
323    Drag,
324    ScrollUp,
325    ScrollDown,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
329pub struct MouseEvent {
330    pub kind: MouseKind,
331    pub pos: Pos,
332    pub mods: Modifiers,
333}
334
335/// Single input event handed to the engine.
336///
337/// `Paste` content bypasses insert-mode mappings, abbreviations, and
338/// autoindent; the engine inserts the bracketed-paste payload as-is.
339#[derive(Debug, Clone, PartialEq, Eq)]
340#[non_exhaustive]
341pub enum Input {
342    Char(char, Modifiers),
343    Key(SpecialKey, Modifiers),
344    Mouse(MouseEvent),
345    Paste(String),
346    FocusGained,
347    FocusLost,
348    Resize(u16, u16),
349}
350
351/// Host adapter consumed by the engine. Lives behind the planned
352/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
353/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
354/// align against.
355///
356/// Methods with default impls return safe no-ops so hosts that don't
357/// need a feature (cancellation, wrap-aware motion, syntax highlights)
358/// can ignore them.
359pub trait Host: Send {
360    /// Custom intent type. Hosts that don't fan out actions back to
361    /// themselves can use the unit type via the default impl approach
362    /// (set associated type explicitly).
363    type Intent;
364
365    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
366
367    /// Fire-and-forget clipboard write. Engine never blocks; the host
368    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
369    /// `pbcopy`, …).
370    fn write_clipboard(&mut self, text: String);
371
372    /// Returns the last-known cached clipboard value. May be stale —
373    /// matches the OSC52/wl-paste model neovim and helix both ship.
374    fn read_clipboard(&mut self) -> Option<String>;
375
376    // ── Time + cancellation ──
377
378    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
379    /// reads this; engine never reads `Instant::now()` directly so
380    /// macro replay stays deterministic.
381    fn now(&self) -> core::time::Duration;
382
383    /// Cooperative cancellation. Engine polls during long search /
384    /// regex / multi-cursor edit loops. Default returns `false`.
385    fn should_cancel(&self) -> bool {
386        false
387    }
388
389    // ── Search prompt ──
390
391    /// Synchronously prompt the user for a search pattern. Returning
392    /// `None` aborts the search.
393    fn prompt_search(&mut self) -> Option<String>;
394
395    // ── Wrap-aware motion (default: wrap is identity) ──
396
397    /// Map a logical position to its display line for `gj`/`gk`. Hosts
398    /// without wrapping may use the default identity impl.
399    fn display_line_for(&self, pos: Pos) -> u32 {
400        pos.line
401    }
402
403    /// Inverse of [`display_line_for`]. Default identity.
404    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
405        Pos { line, col }
406    }
407
408    // ── Syntax highlights (default: none) ──
409
410    /// Host-supplied syntax highlights for `range`. Empty by default;
411    /// hosts wire tree-sitter or LSP semantic tokens here.
412    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
413        let _ = range;
414        Vec::new()
415    }
416
417    // ── Cursor shape ──
418
419    /// Engine emits this on every mode transition. Hosts repaint the
420    /// cursor in the requested shape.
421    fn emit_cursor_shape(&mut self, shape: CursorShape);
422
423    // ── Custom intent fan-out ──
424
425    /// Host-defined event the engine raises (LSP request, fold op,
426    /// buffer switch, …).
427    fn emit_intent(&mut self, intent: Self::Intent);
428}
429
430/// Engine render frame consumed by the host once per redraw.
431///
432/// Borrow-style — the engine builds it on demand from its internal
433/// state without allocating clones of large fields. Hosts diff across
434/// frames to decide what to repaint.
435///
436/// Coarse today: covers mode + cursor + cursor shape + viewport top
437/// + a snapshot of the current line count (to size the gutter). The
438/// SPEC-target fields (`selections`, `highlights`,
439/// `command_line`, `search_prompt`, `status_line`) land once trait
440/// extraction wires the FSM through `SelectionSet` and the highlight
441/// pipeline.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
443pub struct RenderFrame {
444    pub mode: SnapshotMode,
445    pub cursor_row: u32,
446    pub cursor_col: u32,
447    pub cursor_shape: CursorShape,
448    pub viewport_top: u32,
449    pub line_count: u32,
450}
451
452/// Coarse editor snapshot suitable for serde round-tripping.
453///
454/// Today's shape is intentionally minimal — it carries only the bits
455/// the runtime [`crate::Editor`] knows how to round-trip without the
456/// trait extraction (mode, cursor, lines, viewport top, settings).
457/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
458/// grows to cover full SPEC state: registers, marks, jump list, change
459/// list, undo tree, full options.
460///
461/// Hosts that persist editor state between sessions should:
462///
463/// - Treat the snapshot as opaque. Don't manually mutate fields.
464/// - Always check `version` after deserialization; reject on
465///   mismatch rather than attempt migration. The 0.0.x churn drops
466///   compatibility freely.
467#[derive(Debug, Clone)]
468#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
469pub struct EditorSnapshot {
470    /// Format version. Bumped on every structural change. Hosts use
471    /// this to detect mismatched persisted state.
472    pub version: u32,
473    /// Mode at snapshot time (status-line granularity).
474    pub mode: SnapshotMode,
475    /// Cursor `(row, col)` in byte indexing.
476    pub cursor: (u32, u32),
477    /// Buffer lines. Trailing `\n` not included.
478    pub lines: Vec<String>,
479    /// Viewport top line at snapshot time.
480    pub viewport_top: u32,
481    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
482    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
483    /// doesn't derive them today.
484    pub registers: crate::Registers,
485}
486
487/// Status-line mode summary. Bridges to the legacy
488/// [`crate::VimMode`] without leaking the full FSM type into the
489/// snapshot wire format.
490#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
491#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
492pub enum SnapshotMode {
493    #[default]
494    Normal,
495    Insert,
496    Visual,
497    VisualLine,
498    VisualBlock,
499}
500
501impl EditorSnapshot {
502    /// Current snapshot format version.
503    ///
504    /// Bumped to 2 in v0.0.8: registers added.
505    pub const VERSION: u32 = 2;
506}
507
508/// Errors surfaced from the engine to the host. Intentionally narrow —
509/// callsites that fail in user-facing ways return `Result<_,
510/// EngineError>`; internal invariant breaks use `debug_assert!`.
511#[derive(Debug, thiserror::Error)]
512pub enum EngineError {
513    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
514    /// regex error in the status line.
515    #[error("regex compile error: {0}")]
516    Regex(#[from] regex::Error),
517
518    /// `:[range]` parse failed.
519    #[error("invalid range: {0}")]
520    InvalidRange(String),
521
522    /// Ex command parse failed (unknown command, malformed args).
523    #[error("ex parse: {0}")]
524    Ex(String),
525
526    /// Edit attempted on a read-only buffer.
527    #[error("buffer is read-only")]
528    ReadOnly,
529
530    /// Position passed by the caller pointed outside the buffer.
531    #[error("position out of bounds: {0:?}")]
532    OutOfBounds(Pos),
533
534    /// Snapshot version mismatch. Host should treat as "abandon
535    /// snapshot" rather than attempt migration.
536    #[error("snapshot version mismatch: file={0}, expected={1}")]
537    SnapshotVersion(u32, u32),
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn caret_is_empty() {
546        let sel = Selection::caret(Pos::new(2, 4));
547        assert!(sel.is_empty());
548        assert_eq!(sel.anchor, sel.head);
549    }
550
551    #[test]
552    fn selection_set_default_has_one_caret() {
553        let set = SelectionSet::default();
554        assert_eq!(set.items.len(), 1);
555        assert_eq!(set.primary, 0);
556        assert_eq!(set.primary().anchor, Pos::ORIGIN);
557    }
558
559    #[test]
560    fn edit_constructors() {
561        let p = Pos::new(0, 5);
562        assert_eq!(Edit::insert(p, "x").range, p..p);
563        assert!(Edit::insert(p, "x").replacement == "x");
564        assert!(Edit::delete(p..p).replacement.is_empty());
565    }
566
567    #[test]
568    fn attrs_flags() {
569        let a = Attrs::BOLD | Attrs::UNDERLINE;
570        assert!(a.contains(Attrs::BOLD));
571        assert!(!a.contains(Attrs::ITALIC));
572    }
573
574    #[test]
575    fn options_default_matches_vim() {
576        let o = Options::default();
577        assert_eq!(o.tabstop, 8);
578        assert!(!o.expandtab);
579        assert!(o.hlsearch);
580        assert!(o.wrapscan);
581        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
582    }
583
584    #[test]
585    fn editor_snapshot_version_const() {
586        assert_eq!(EditorSnapshot::VERSION, 2);
587    }
588
589    #[test]
590    fn editor_snapshot_default_shape() {
591        let s = EditorSnapshot {
592            version: EditorSnapshot::VERSION,
593            mode: SnapshotMode::Normal,
594            cursor: (0, 0),
595            lines: vec!["hello".to_string()],
596            viewport_top: 0,
597            registers: crate::Registers::default(),
598        };
599        assert_eq!(s.cursor, (0, 0));
600        assert_eq!(s.lines.len(), 1);
601    }
602
603    #[cfg(feature = "serde")]
604    #[test]
605    fn editor_snapshot_roundtrip() {
606        let s = EditorSnapshot {
607            version: EditorSnapshot::VERSION,
608            mode: SnapshotMode::Insert,
609            cursor: (3, 7),
610            lines: vec!["alpha".into(), "beta".into()],
611            viewport_top: 2,
612            registers: crate::Registers::default(),
613        };
614        let json = serde_json::to_string(&s).unwrap();
615        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
616        assert_eq!(s.cursor, back.cursor);
617        assert_eq!(s.lines, back.lines);
618        assert_eq!(s.viewport_top, back.viewport_top);
619    }
620
621    #[test]
622    fn engine_error_display() {
623        let e = EngineError::ReadOnly;
624        assert_eq!(e.to_string(), "buffer is read-only");
625        let e = EngineError::OutOfBounds(Pos::new(3, 7));
626        assert!(e.to_string().contains("out of bounds"));
627    }
628}