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/// 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/// Coarse editor snapshot suitable for serde round-tripping.
431///
432/// Today's shape is intentionally minimal — it carries only the bits
433/// the runtime [`crate::Editor`] knows how to round-trip without the
434/// trait extraction (mode, cursor, lines, viewport top, settings).
435/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
436/// grows to cover full SPEC state: registers, marks, jump list, change
437/// list, undo tree, full options.
438///
439/// Hosts that persist editor state between sessions should:
440///
441/// - Treat the snapshot as opaque. Don't manually mutate fields.
442/// - Always check `version` after deserialization; reject on
443/// mismatch rather than attempt migration. The 0.0.x churn drops
444/// compatibility freely.
445#[derive(Debug, Clone, PartialEq, Eq)]
446#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
447pub struct EditorSnapshot {
448 /// Format version. Bumped on every structural change. Hosts use
449 /// this to detect mismatched persisted state.
450 pub version: u32,
451 /// Mode at snapshot time (status-line granularity).
452 pub mode: SnapshotMode,
453 /// Cursor `(row, col)` in byte indexing.
454 pub cursor: (u32, u32),
455 /// Buffer lines. Trailing `\n` not included.
456 pub lines: Vec<String>,
457 /// Viewport top line at snapshot time.
458 pub viewport_top: u32,
459}
460
461/// Status-line mode summary. Bridges to the legacy
462/// [`crate::VimMode`] without leaking the full FSM type into the
463/// snapshot wire format.
464#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
465#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
466pub enum SnapshotMode {
467 #[default]
468 Normal,
469 Insert,
470 Visual,
471 VisualLine,
472 VisualBlock,
473}
474
475impl EditorSnapshot {
476 /// Current snapshot format version.
477 pub const VERSION: u32 = 1;
478}
479
480/// Errors surfaced from the engine to the host. Intentionally narrow —
481/// callsites that fail in user-facing ways return `Result<_,
482/// EngineError>`; internal invariant breaks use `debug_assert!`.
483#[derive(Debug, thiserror::Error)]
484pub enum EngineError {
485 /// `:s/pat/.../` couldn't compile the pattern. Host displays the
486 /// regex error in the status line.
487 #[error("regex compile error: {0}")]
488 Regex(#[from] regex::Error),
489
490 /// `:[range]` parse failed.
491 #[error("invalid range: {0}")]
492 InvalidRange(String),
493
494 /// Ex command parse failed (unknown command, malformed args).
495 #[error("ex parse: {0}")]
496 Ex(String),
497
498 /// Edit attempted on a read-only buffer.
499 #[error("buffer is read-only")]
500 ReadOnly,
501
502 /// Position passed by the caller pointed outside the buffer.
503 #[error("position out of bounds: {0:?}")]
504 OutOfBounds(Pos),
505
506 /// Snapshot version mismatch. Host should treat as "abandon
507 /// snapshot" rather than attempt migration.
508 #[error("snapshot version mismatch: file={0}, expected={1}")]
509 SnapshotVersion(u32, u32),
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn caret_is_empty() {
518 let sel = Selection::caret(Pos::new(2, 4));
519 assert!(sel.is_empty());
520 assert_eq!(sel.anchor, sel.head);
521 }
522
523 #[test]
524 fn selection_set_default_has_one_caret() {
525 let set = SelectionSet::default();
526 assert_eq!(set.items.len(), 1);
527 assert_eq!(set.primary, 0);
528 assert_eq!(set.primary().anchor, Pos::ORIGIN);
529 }
530
531 #[test]
532 fn edit_constructors() {
533 let p = Pos::new(0, 5);
534 assert_eq!(Edit::insert(p, "x").range, p..p);
535 assert!(Edit::insert(p, "x").replacement == "x");
536 assert!(Edit::delete(p..p).replacement.is_empty());
537 }
538
539 #[test]
540 fn attrs_flags() {
541 let a = Attrs::BOLD | Attrs::UNDERLINE;
542 assert!(a.contains(Attrs::BOLD));
543 assert!(!a.contains(Attrs::ITALIC));
544 }
545
546 #[test]
547 fn options_default_matches_vim() {
548 let o = Options::default();
549 assert_eq!(o.tabstop, 8);
550 assert!(!o.expandtab);
551 assert!(o.hlsearch);
552 assert!(o.wrapscan);
553 assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
554 }
555
556 #[test]
557 fn editor_snapshot_version_const() {
558 assert_eq!(EditorSnapshot::VERSION, 1);
559 }
560
561 #[test]
562 fn editor_snapshot_default_shape() {
563 let s = EditorSnapshot {
564 version: EditorSnapshot::VERSION,
565 mode: SnapshotMode::Normal,
566 cursor: (0, 0),
567 lines: vec!["hello".to_string()],
568 viewport_top: 0,
569 };
570 assert_eq!(s.cursor, (0, 0));
571 assert_eq!(s.lines.len(), 1);
572 }
573
574 #[cfg(feature = "serde")]
575 #[test]
576 fn editor_snapshot_roundtrip() {
577 let s = EditorSnapshot {
578 version: EditorSnapshot::VERSION,
579 mode: SnapshotMode::Insert,
580 cursor: (3, 7),
581 lines: vec!["alpha".into(), "beta".into()],
582 viewport_top: 2,
583 };
584 let json = serde_json::to_string(&s).unwrap();
585 let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
586 assert_eq!(s, back);
587 }
588
589 #[test]
590 fn engine_error_display() {
591 let e = EngineError::ReadOnly;
592 assert_eq!(e.to_string(), "buffer is read-only");
593 let e = EngineError::OutOfBounds(Pos::new(3, 7));
594 assert!(e.to_string().contains("out of bounds"));
595 }
596}