Skip to main content

lattice_grammar/
modal.rs

1//! Modal state -- a buffer-level state machine in front of the buffer
2//! (DESIGN.md §5.2). Orthogonal to major / minor modes.
3//!
4//! This crate owns the state *type* only. Transitions are not methods here:
5//! a command asks for one by returning [`Effect::EnterMode`](crate::Effect::EnterMode)
6//! (or [`AppEffect::EnterMode`](crate::AppEffect::EnterMode)), and the host
7//! applies it to the focused buffer's modal field. That keeps the state
8//! machine's *policy* (which chord enters which state) in the keymap and the
9//! command bodies, and its *storage* in the host, while every layer agrees on
10//! one vocabulary.
11//!
12//! The usual vim transitions, for orientation:
13//!
14//! | From | Key | To |
15//! |---|---|---|
16//! | Normal | `i` `a` `o` … | [`ModalState::Insert`] |
17//! | Normal | `v` / `V` / `<C-v>` | [`ModalState::Visual`] (charwise / linewise / blockwise) |
18//! | Normal | `gh` / `gH` / `g<C-h>` | [`ModalState::Select`] |
19//! | Normal | an operator (`d`, `c`, `y` …) | [`ModalState::OperatorPending`] |
20//! | Normal | `:` | [`ModalState::Command`] |
21//! | Normal | `/` / `?` | [`ModalState::Search`] |
22//! | Normal | `R` | [`ModalState::Replace`] |
23//! | any | `<Esc>` | [`ModalState::Normal`] |
24//!
25//! # Examples
26//!
27//! ```
28//! use lattice_grammar::{ModalState, VisualKind};
29//!
30//! let state = ModalState::default();
31//! assert_eq!(state, ModalState::Normal);
32//!
33//! // `V` in Normal: the host applies `EnterMode(Visual(Linewise))`.
34//! let state = ModalState::Visual(VisualKind::Linewise);
35//! assert!(state.is_visual());
36//! assert!(!state.is_select()); // same geometry, different dispatch
37//! assert!(!state.is_operator_pending());
38//! ```
39
40use serde::{Deserialize, Serialize};
41
42/// The vim modal state of a buffer — which grammar a keystroke is read in.
43///
44/// Buffer-level and orthogonal to major / minor modes (a `rust` buffer is
45/// in exactly one of these at a time; the axes never collapse). Keymap
46/// lookups are filtered by it, and [`Range::Selection`](crate::Range::Selection)
47/// defaults from it while Visual is active. Serializable so it can cross
48/// the core protocol and be recorded in snapshots.
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub enum ModalState {
51    /// Vim Normal mode: keys are operators, motions and commands. The
52    /// initial state of every buffer and the target of `<Esc>`.
53    #[default]
54    Normal,
55    /// Vim Insert mode: printable keys insert text at the cursor.
56    Insert,
57    /// Vim Visual mode with the given selection shape; the selection is the
58    /// active region and the default range for operators and ex-commands.
59    Visual(VisualKind),
60    /// Vim Select mode (SN.3d). Same selection *geometry* as
61    /// [`Self::Visual`] (the `VisualKind` is reused verbatim), but
62    /// inverted typing semantics: a printable key replaces the whole
63    /// selection and drops into Insert. See
64    /// `docs/dev/architecture/select-mode.md`.
65    Select(VisualKind),
66    /// An operator has been typed and a motion or text object is awaited
67    /// (`d` in `dw`). Repeating the operator key operates linewise on the
68    /// current line (`dd`, `cc`, `yy`).
69    OperatorPending,
70    /// The `:` command line (the `*command-line*` minibuffer) is focused.
71    Command,
72    /// The `/` or `?` search line (the `*search-line*` minibuffer) is
73    /// focused, searching in the given direction.
74    Search(SearchDirection),
75    /// Vim Replace mode (`R`): printable keys overtype existing characters
76    /// instead of inserting.
77    Replace,
78    /// A generic one-line minibuffer text prompt is focused (see
79    /// `Effect::OpenPrompt`) — distinct from `Command`/`Search`
80    /// because those are tied to the specific `*command-line*` /
81    /// `*search-line*` buffers and their own submit semantics; a
82    /// prompt's buffer/label/submit-action varies per invocation.
83    Prompt,
84}
85
86/// The shape of a Visual / Select selection.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88pub enum VisualKind {
89    /// Character-wise (`v`): from anchor to cursor, inclusive.
90    Charwise,
91    /// Line-wise (`V`): whole lines from the anchor's line to the cursor's.
92    Linewise,
93    /// Block-wise (`<C-v>`): the rectangle spanned by anchor and cursor.
94    Blockwise,
95}
96
97/// Which way a search runs: `/` searches forward, `?` backward. `n`
98/// repeats in the same direction, `N` in the opposite one.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub enum SearchDirection {
101    /// Towards the end of the buffer (`/`).
102    Forward,
103    /// Towards the start of the buffer (`?`).
104    Backward,
105}
106
107impl ModalState {
108    /// Whether this state is currently consuming a Visual-mode selection (in
109    /// any of charwise / linewise / blockwise). Used by callers that want to
110    /// supply `Range::Selection` as a default when no explicit range is given.
111    pub fn is_visual(self) -> bool {
112        matches!(self, ModalState::Visual(_))
113    }
114
115    /// Whether this state is Select mode (SN.3d), in any of charwise /
116    /// linewise / blockwise. Select shares Visual's selection geometry
117    /// but overtypes on a printable key. Kept distinct from
118    /// [`Self::is_visual`] because the *dispatch* differs; callers that
119    /// care only about "is a selection live" should gain an explicit
120    /// `selection_is_active` helper when one is first needed (no
121    /// production caller exists yet — see select-mode.md §2).
122    pub fn is_select(self) -> bool {
123        matches!(self, ModalState::Select(_))
124    }
125
126    /// Whether this state expects more input to complete a pending operator.
127    /// In Op-Pending, a motion or text object is awaited; pressing an operator
128    /// key here resolves to "operate on the current line" (vim's `dd`, `cc`,
129    /// `yy` semantics).
130    pub fn is_operator_pending(self) -> bool {
131        matches!(self, ModalState::OperatorPending)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    #![allow(clippy::unwrap_used, clippy::panic)]
138    use super::*;
139
140    #[test]
141    fn visual_predicate_recognises_each_kind() {
142        for kind in [
143            VisualKind::Charwise,
144            VisualKind::Linewise,
145            VisualKind::Blockwise,
146        ] {
147            assert!(ModalState::Visual(kind).is_visual());
148        }
149    }
150
151    #[test]
152    fn non_visual_states_are_not_visual() {
153        for s in [
154            ModalState::Normal,
155            ModalState::Insert,
156            ModalState::Select(VisualKind::Charwise),
157            ModalState::OperatorPending,
158            ModalState::Command,
159            ModalState::Search(SearchDirection::Forward),
160            ModalState::Replace,
161        ] {
162            assert!(!s.is_visual(), "{s:?} should not be visual");
163        }
164    }
165
166    #[test]
167    fn select_predicate_recognises_each_kind_and_excludes_others() {
168        for kind in [
169            VisualKind::Charwise,
170            VisualKind::Linewise,
171            VisualKind::Blockwise,
172        ] {
173            let s = ModalState::Select(kind);
174            assert!(s.is_select());
175            // Select is NOT Visual — the dispatch differs even though
176            // the geometry is shared.
177            assert!(!s.is_visual());
178        }
179        for s in [
180            ModalState::Normal,
181            ModalState::Visual(VisualKind::Charwise),
182            ModalState::Insert,
183        ] {
184            assert!(!s.is_select(), "{s:?} should not be select");
185        }
186    }
187
188    #[test]
189    fn operator_pending_predicate() {
190        assert!(ModalState::OperatorPending.is_operator_pending());
191        assert!(!ModalState::Normal.is_operator_pending());
192    }
193
194    #[test]
195    fn states_are_serializable() {
196        let s = ModalState::Visual(VisualKind::Linewise);
197        let json = serde_json::to_string(&s).unwrap_or_else(|_| panic!("serialize"));
198        let back: ModalState = serde_json::from_str(&json).unwrap();
199        assert_eq!(back, s);
200    }
201}