Skip to main content

escriba_core/
mode.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Editor modes — vim-inspired with a small ceiling.
5///
6/// More modes (operator-pending, replace, insert-visual) are phase-2 add-ons
7/// layered through pending state, not new enum variants.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
9pub enum Mode {
10    #[default]
11    Normal,
12    Insert,
13    Visual,
14    VisualLine,
15    Command,
16}
17
18impl Mode {
19    #[must_use]
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Normal => "NORMAL",
23            Self::Insert => "INSERT",
24            Self::Visual => "VISUAL",
25            Self::VisualLine => "V-LINE",
26            Self::Command => "COMMAND",
27        }
28    }
29
30    #[must_use]
31    pub const fn is_insertish(self) -> bool {
32        matches!(self, Self::Insert | Self::Command)
33    }
34
35    #[must_use]
36    pub const fn is_visualish(self) -> bool {
37        matches!(self, Self::Visual | Self::VisualLine)
38    }
39
40    /// The [`CursorShape`] this mode renders, per the vim editor
41    /// convention every backend (TUI + GPU) shares. Routing both renderers
42    /// through this one typed function makes "the GPU and TUI cursors
43    /// disagree on shape" unrepresentable — the shape is derived from the
44    /// mode in exactly one place.
45    #[must_use]
46    pub const fn cursor_shape(self) -> CursorShape {
47        match self {
48            // Normal navigation + command-line entry: a solid block.
49            Self::Normal | Self::Command => CursorShape::Block,
50            // Insert: a thin vertical bar between glyphs (vim `i`-mode).
51            Self::Insert => CursorShape::Bar,
52            // Visual selection: an underline so the selected cell stays
53            // legible under the highlight.
54            Self::Visual | Self::VisualLine => CursorShape::Underline,
55        }
56    }
57}
58
59/// The on-screen shape a cursor takes. A typed enum so the per-mode shape
60/// is a total, exhaustively-matched mapping (a new mode forces a new arm)
61/// rather than a renderer-local `if mode == …` ladder that can silently
62/// drift between backends.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
64pub enum CursorShape {
65    /// Full-cell solid block — Normal / Command modes.
66    #[default]
67    Block,
68    /// Thin vertical bar between cells — Insert mode.
69    Bar,
70    /// Underline along the cell's baseline — Visual / VisualLine modes.
71    Underline,
72}
73
74impl CursorShape {
75    /// A short stable label — for `--spec` surfaces, status introspection,
76    /// and tests.
77    #[must_use]
78    pub const fn as_str(self) -> &'static str {
79        match self {
80            Self::Block => "block",
81            Self::Bar => "bar",
82            Self::Underline => "underline",
83        }
84    }
85}
86
87/// A one-shot transition request — emitted by the keymap, consumed by the
88/// editor loop.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90pub struct ModeTransition {
91    pub from: Mode,
92    pub to: Mode,
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn default_is_normal() {
101        assert_eq!(Mode::default(), Mode::Normal);
102    }
103
104    #[test]
105    fn classifiers() {
106        assert!(Mode::Insert.is_insertish());
107        assert!(Mode::Command.is_insertish());
108        assert!(!Mode::Normal.is_insertish());
109        assert!(Mode::Visual.is_visualish());
110        assert!(Mode::VisualLine.is_visualish());
111    }
112
113    #[test]
114    fn display_labels() {
115        assert_eq!(Mode::Normal.as_str(), "NORMAL");
116        assert_eq!(Mode::Insert.as_str(), "INSERT");
117    }
118
119    #[test]
120    fn cursor_shape_per_mode() {
121        // Block in navigation/command, bar in insert, underline in visual.
122        assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
123        assert_eq!(Mode::Command.cursor_shape(), CursorShape::Block);
124        assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
125        assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
126        assert_eq!(Mode::VisualLine.cursor_shape(), CursorShape::Underline);
127    }
128
129    #[test]
130    fn cursor_shape_labels() {
131        assert_eq!(CursorShape::Block.as_str(), "block");
132        assert_eq!(CursorShape::Bar.as_str(), "bar");
133        assert_eq!(CursorShape::Underline.as_str(), "underline");
134    }
135
136    #[test]
137    fn insert_is_the_only_bar_mode() {
138        // Forcing function: exactly one mode renders a bar cursor (Insert).
139        let modes = [
140            Mode::Normal,
141            Mode::Insert,
142            Mode::Visual,
143            Mode::VisualLine,
144            Mode::Command,
145        ];
146        let bars = modes
147            .into_iter()
148            .filter(|m| m.cursor_shape() == CursorShape::Bar)
149            .count();
150        assert_eq!(bars, 1);
151    }
152}