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, 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/// Modifier bits accompanying every keystroke.
273#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
274pub struct Modifiers {
275    pub ctrl: bool,
276    pub shift: bool,
277    pub alt: bool,
278    pub super_: bool,
279}
280
281/// Special key codes — anything that isn't a printable character.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
283#[non_exhaustive]
284pub enum SpecialKey {
285    Esc,
286    Enter,
287    Backspace,
288    Tab,
289    BackTab,
290    Up,
291    Down,
292    Left,
293    Right,
294    Home,
295    End,
296    PageUp,
297    PageDown,
298    Insert,
299    Delete,
300    F(u8),
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
304pub enum MouseKind {
305    Press,
306    Release,
307    Drag,
308    ScrollUp,
309    ScrollDown,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
313pub struct MouseEvent {
314    pub kind: MouseKind,
315    pub pos: Pos,
316    pub mods: Modifiers,
317}
318
319/// Single input event handed to the engine.
320///
321/// `Paste` content bypasses insert-mode mappings, abbreviations, and
322/// autoindent; the engine inserts the bracketed-paste payload as-is.
323#[derive(Debug, Clone, PartialEq, Eq)]
324#[non_exhaustive]
325pub enum Input {
326    Char(char, Modifiers),
327    Key(SpecialKey, Modifiers),
328    Mouse(MouseEvent),
329    Paste(String),
330    FocusGained,
331    FocusLost,
332    Resize(u16, u16),
333}
334
335/// Host adapter consumed by the engine. Lives behind the planned
336/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
337/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
338/// align against.
339///
340/// Methods with default impls return safe no-ops so hosts that don't
341/// need a feature (cancellation, wrap-aware motion, syntax highlights)
342/// can ignore them.
343pub trait Host: Send {
344    /// Custom intent type. Hosts that don't fan out actions back to
345    /// themselves can use the unit type via the default impl approach
346    /// (set associated type explicitly).
347    type Intent;
348
349    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
350
351    /// Fire-and-forget clipboard write. Engine never blocks; the host
352    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
353    /// `pbcopy`, …).
354    fn write_clipboard(&mut self, text: String);
355
356    /// Returns the last-known cached clipboard value. May be stale —
357    /// matches the OSC52/wl-paste model neovim and helix both ship.
358    fn read_clipboard(&mut self) -> Option<String>;
359
360    // ── Time + cancellation ──
361
362    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
363    /// reads this; engine never reads `Instant::now()` directly so
364    /// macro replay stays deterministic.
365    fn now(&self) -> core::time::Duration;
366
367    /// Cooperative cancellation. Engine polls during long search /
368    /// regex / multi-cursor edit loops. Default returns `false`.
369    fn should_cancel(&self) -> bool {
370        false
371    }
372
373    // ── Search prompt ──
374
375    /// Synchronously prompt the user for a search pattern. Returning
376    /// `None` aborts the search.
377    fn prompt_search(&mut self) -> Option<String>;
378
379    // ── Wrap-aware motion (default: wrap is identity) ──
380
381    /// Map a logical position to its display line for `gj`/`gk`. Hosts
382    /// without wrapping may use the default identity impl.
383    fn display_line_for(&self, pos: Pos) -> u32 {
384        pos.line
385    }
386
387    /// Inverse of [`display_line_for`]. Default identity.
388    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
389        Pos { line, col }
390    }
391
392    // ── Syntax highlights (default: none) ──
393
394    /// Host-supplied syntax highlights for `range`. Empty by default;
395    /// hosts wire tree-sitter or LSP semantic tokens here.
396    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
397        let _ = range;
398        Vec::new()
399    }
400
401    // ── Cursor shape ──
402
403    /// Engine emits this on every mode transition. Hosts repaint the
404    /// cursor in the requested shape.
405    fn emit_cursor_shape(&mut self, shape: CursorShape);
406
407    // ── Custom intent fan-out ──
408
409    /// Host-defined event the engine raises (LSP request, fold op,
410    /// buffer switch, …).
411    fn emit_intent(&mut self, intent: Self::Intent);
412}
413
414/// Errors surfaced from the engine to the host. Intentionally narrow —
415/// callsites that fail in user-facing ways return `Result<_,
416/// EngineError>`; internal invariant breaks use `debug_assert!`.
417#[derive(Debug, thiserror::Error)]
418pub enum EngineError {
419    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
420    /// regex error in the status line.
421    #[error("regex compile error: {0}")]
422    Regex(#[from] regex::Error),
423
424    /// `:[range]` parse failed.
425    #[error("invalid range: {0}")]
426    InvalidRange(String),
427
428    /// Ex command parse failed (unknown command, malformed args).
429    #[error("ex parse: {0}")]
430    Ex(String),
431
432    /// Edit attempted on a read-only buffer.
433    #[error("buffer is read-only")]
434    ReadOnly,
435
436    /// Position passed by the caller pointed outside the buffer.
437    #[error("position out of bounds: {0:?}")]
438    OutOfBounds(Pos),
439
440    /// Snapshot version mismatch. Host should treat as "abandon
441    /// snapshot" rather than attempt migration.
442    #[error("snapshot version mismatch: file={0}, expected={1}")]
443    SnapshotVersion(u32, u32),
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn caret_is_empty() {
452        let sel = Selection::caret(Pos::new(2, 4));
453        assert!(sel.is_empty());
454        assert_eq!(sel.anchor, sel.head);
455    }
456
457    #[test]
458    fn selection_set_default_has_one_caret() {
459        let set = SelectionSet::default();
460        assert_eq!(set.items.len(), 1);
461        assert_eq!(set.primary, 0);
462        assert_eq!(set.primary().anchor, Pos::ORIGIN);
463    }
464
465    #[test]
466    fn edit_constructors() {
467        let p = Pos::new(0, 5);
468        assert_eq!(Edit::insert(p, "x").range, p..p);
469        assert!(Edit::insert(p, "x").replacement == "x");
470        assert!(Edit::delete(p..p).replacement.is_empty());
471    }
472
473    #[test]
474    fn attrs_flags() {
475        let a = Attrs::BOLD | Attrs::UNDERLINE;
476        assert!(a.contains(Attrs::BOLD));
477        assert!(!a.contains(Attrs::ITALIC));
478    }
479
480    #[test]
481    fn options_default_matches_vim() {
482        let o = Options::default();
483        assert_eq!(o.tabstop, 8);
484        assert!(!o.expandtab);
485        assert!(o.hlsearch);
486        assert!(o.wrapscan);
487        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
488    }
489
490    #[test]
491    fn engine_error_display() {
492        let e = EngineError::ReadOnly;
493        assert_eq!(e.to_string(), "buffer is read-only");
494        let e = EngineError::OutOfBounds(Pos::new(3, 7));
495        assert!(e.to_string().contains("out of bounds"));
496    }
497}