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}