hjkl_vim/vim/state.rs
1//! Vim FSM: state.
2//!
3//! Split out of the monolithic `vim.rs` (#267 follow-up).
4
5use hjkl_vim_types::{
6 InsertSession, LastChange, LastHorizontalMotion, LastVisual, Mode, Operator, Pending,
7};
8
9use hjkl_engine::VimMode;
10
11/// Vim caps count prefixes at 999,999,999 (`:h count`); mirror that cap on
12/// every value that can feed a `0..count` apply loop so a pathological digit
13/// stream (`<20 nines>x`) saturating toward `usize::MAX` can't freeze the
14/// editor. Matches `CountAccumulator::MAX_COUNT` in hjkl-vim.
15pub const MAX_COUNT: usize = 999_999_999;
16/// ROT13 a string: rotate ASCII letters by 13, leave everything else.
17pub fn rot13_str(s: &str) -> String {
18 s.chars()
19 .map(|c| match c {
20 'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char,
21 'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char,
22 _ => c,
23 })
24 .collect()
25}
26#[derive(Debug, Default)]
27pub struct VimState {
28 /// Internal FSM mode. Kept in sync with `current_mode` after every
29 /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
30 /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
31 /// until the migration is complete.
32 pub mode: Mode,
33 /// Two-key chord in progress. `Pending::None` when idle.
34 pub pending: Pending,
35 /// Digit prefix accumulated before an operator or motion. `0` means
36 /// no prefix was typed (treated as 1 by most commands).
37 pub count: usize,
38 /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
39 pub last_find: Option<(char, bool, bool)>,
40 /// Transient: set while resolving a `;` / `,` repeat so the following
41 /// `t`/`T` find skips an immediately-adjacent match (vim's repeat quirk).
42 /// Read and cleared by the `Motion::Find` cursor dispatch.
43 pub find_repeat_skip: bool,
44 /// Most-recent mutating command for `.` dot-repeat.
45 pub last_change: Option<LastChange>,
46 /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
47 pub insert_session: Option<InsertSession>,
48 /// (row, col) anchor for char-wise Visual mode. Set on entry, used
49 /// to compute the highlight range and the operator range without
50 /// relying on tui-textarea's live selection.
51 pub visual_anchor: (usize, usize),
52 /// Row anchor for VisualLine mode.
53 pub visual_line_anchor: usize,
54 /// (row, col) anchor for VisualBlock mode. The live cursor is the
55 /// opposite corner.
56 pub block_anchor: (usize, usize),
57 /// Intended "virtual" column for the block's active corner. j/k
58 /// clamp cursor.col to shorter rows, which would collapse the
59 /// block across ragged content — so we remember the desired column
60 /// separately and use it for block bounds / insert-column
61 /// computations. Updated by h/l only.
62 pub block_vcol: usize,
63 /// `$` pressed in VisualBlock (`:h v_b_$`): makes the block "ragged" —
64 /// every row resolves its own right edge to that row's own EOL
65 /// instead of the block's fixed `right` column. Set by
66 /// `update_block_vcol` on `Motion::LineEnd`; cleared by any OTHER
67 /// horizontal motion that re-establishes a fixed column. Vertical
68 /// motions (j/k/gg/G/…) preserve it, matching vim. Reset to `false`
69 /// on every fresh `<C-v>` entry.
70 pub block_to_eol: bool,
71 /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
72 /// Active register selector — set by `"reg` prefix, consumed by
73 /// the next y / d / c / p. `None` falls back to the unnamed `"`.
74 pub pending_register: Option<char>,
75 /// Recording target — set by `q{reg}`, cleared by a bare `q`.
76 /// While `Some`, every consumed `Input` is appended to
77 /// `recording_keys`.
78 pub recording_macro: Option<char>,
79 /// Keys recorded into the in-progress macro. On `q` finish, these
80 /// are encoded via [`hjkl_engine::input::encode_macro`] and written to
81 /// the matching named register slot, so macros and yanks share a
82 /// single store.
83 pub recording_keys: Vec<hjkl_engine::input::Input>,
84 /// Set during `@reg` replay so the recorder doesn't capture the
85 /// replayed keystrokes a second time.
86 pub replaying_macro: bool,
87 /// Last register played via `@reg`. `@@` re-plays this one.
88 pub last_macro: Option<char>,
89 /// Position where the cursor was when insert mode last exited (Esc).
90 /// Used by `gi` to return to the exact (row, col) where the user
91 /// last typed, matching vim's `:h gi`.
92 pub last_insert_pos: Option<(usize, usize)>,
93 /// Snapshot of the last visual selection for `gv` re-entry.
94 /// Stored on every Visual / VisualLine / VisualBlock exit.
95 pub last_visual: Option<LastVisual>,
96 /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
97 /// pass doesn't override the user's explicit viewport pinning.
98 /// Cleared every step.
99 /// Set by the 7 smooth-scrollable motions (C-d/u/f/b, zz/zt/zb) so the
100 /// app can animate the viewport jump. Drained via Editor::take_scroll_anim_hint.
101 /// Set while replaying `.` / last-change so we don't re-record it.
102 pub replaying: bool,
103 /// Entered Normal from Insert via `Ctrl-o`; after the next complete
104 /// normal-mode command we return to Insert.
105 pub one_shot_normal: bool,
106 /// Live `/` or `?` prompt. `None` outside search-prompt mode.
107 /// Most recent committed search pattern. Surfaced to host apps via
108 /// [`Editor::last_search`] so their status line can render a hint
109 /// and so `n` / `N` have something to repeat.
110 /// Direction of the last committed search. `n` repeats this; `N`
111 /// inverts it. Defaults to forward so a never-searched buffer's
112 /// `n` still walks downward.
113 /// Text of the most recent insert session — vim's `".` register, pasted
114 /// via `<C-r>.` in insert mode (and `".p` in normal mode).
115 pub last_insert_text: Option<String>,
116 /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
117 /// with the pre-motion cursor when a "big jump" motion fires
118 /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
119 /// `?`). Capped at 100 entries.
120 /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
121 /// jump, matching vim's "branch off trims forward history" rule.
122 /// Set by `Ctrl-R` in insert mode while waiting for the register
123 /// selector. The next typed char names the register; its contents
124 /// are inserted inline at the cursor and the flag clears.
125 pub insert_pending_register: bool,
126 /// Stashed start position for the `[` mark on a Change operation.
127 /// Set to `top` before the cut in `run_operator_over_range` (Change
128 /// arm); consumed by `finish_insert_session` on Esc-from-insert
129 /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
130 /// rule that `[` = start of change, `]` = last typed char on exit.
131 pub change_mark_start: Option<(usize, usize)>,
132 /// Bounded history of committed `/` / `?` search patterns. Newest
133 /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
134 /// avoid unbounded growth on long sessions.
135 /// Index into `search_history` while the user walks past patterns
136 /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
137 /// — typing or backspacing in the prompt resets it so the next
138 /// `Ctrl-P` starts from the most recent entry again.
139 /// Wall-clock instant of the last keystroke. Drives the
140 /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
141 /// exceeds the configured budget, any pending prefix is cleared
142 /// before the new key dispatches. `None` before the first key.
143 /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
144 /// [`hjkl_engine::types::Host::now`] via `last_input_host_at`. This
145 /// `Instant`-flavoured field stays for snapshot tests that still
146 /// observe it directly.
147 /// `Host::now()` reading at the last keystroke. Drives
148 /// `:set timeoutlen` so macro replay / headless drivers stay
149 /// deterministic regardless of wall-clock skew.
150 /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
151 /// AND is written by every Phase 6.3 primitive (`set_mode`,
152 /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
153 /// sole source of truth; until then both fields are kept in sync.
154 /// Initialized to `Normal` via `#[derive(Default)]`.
155 pub current_mode: hjkl_engine::VimMode,
156 /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
157 /// Stack of auto-inserted closing characters awaiting skip-over.
158 ///
159 /// Each entry `(row, col, ch)` records where autopair placed a close
160 /// character. When the next typed char matches `ch` AND the cursor is
161 /// immediately before that position, the engine advances past it
162 /// ("skip-over") instead of inserting. The stack is cleared on any
163 /// cursor motion, mode change, or out-of-pair edit.
164 /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
165 /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
166 pub last_sneak: Option<((char, char), bool)>,
167 /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
168 /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
169 pub last_horizontal_motion: LastHorizontalMotion,
170}
171impl VimState {
172 pub fn public_mode(&self) -> VimMode {
173 match self.mode {
174 Mode::Normal => VimMode::Normal,
175 Mode::Insert => VimMode::Insert,
176 Mode::Visual => VimMode::Visual,
177 Mode::VisualLine => VimMode::VisualLine,
178 Mode::VisualBlock => VimMode::VisualBlock,
179 }
180 }
181
182 /// Project the current vim mode onto the discipline-agnostic
183 /// [`hjkl_engine::CoarseMode`] (the seam app chrome reads — #265 G3 / #267).
184 /// Shared by [`hjkl_engine::DisciplineState::coarse_mode`] and
185 /// [`hjkl_engine::Editor::coarse_mode`].
186 pub fn coarse_mode(&self) -> hjkl_engine::CoarseMode {
187 use hjkl_engine::CoarseMode;
188 match self.current_mode {
189 VimMode::Normal => CoarseMode::Normal,
190 VimMode::Insert => CoarseMode::Insert,
191 VimMode::Visual => CoarseMode::Select,
192 VimMode::VisualLine => CoarseMode::SelectLine,
193 VimMode::VisualBlock => CoarseMode::SelectBlock,
194 }
195 }
196
197 pub fn force_normal(&mut self) {
198 self.mode = Mode::Normal;
199 self.pending = Pending::None;
200 self.count = 0;
201 self.insert_session = None;
202 // Phase 6.3: keep current_mode in sync for callers that bypass step().
203 self.current_mode = hjkl_engine::VimMode::Normal;
204 }
205
206 /// Reset every prefix-tracking field so the next keystroke starts
207 /// a fresh sequence. Drives `:set timeoutlen` — when the user
208 /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
209 /// this before dispatching the new key.
210 ///
211 /// Resets: `pending`, `count`, `pending_register`,
212 /// `insert_pending_register`. Does NOT touch `mode`,
213 /// `insert_session`, marks, jump list, or visual anchors —
214 /// those aren't part of the in-flight chord.
215 pub fn clear_pending_prefix(&mut self) {
216 self.pending = Pending::None;
217 self.count = 0;
218 self.pending_register = None;
219 self.insert_pending_register = false;
220 }
221
222 /// Widen the active insert session's row window to include `row`. Called
223 /// by the Phase 6.1 public `Editor::insert_*` methods after each
224 /// mutation so `finish_insert_session` diffs the right range on Esc.
225 /// No-op when no insert session is active (e.g. calling from Normal mode).
226 pub(crate) fn widen_insert_row(&mut self, row: usize) {
227 if let Some(ref mut session) = self.insert_session {
228 session.row_min = session.row_min.min(row);
229 session.row_max = session.row_max.max(row);
230 }
231 }
232
233 pub fn is_visual(&self) -> bool {
234 matches!(
235 self.mode,
236 Mode::Visual | Mode::VisualLine | Mode::VisualBlock
237 )
238 }
239
240 pub fn is_visual_char(&self) -> bool {
241 self.mode == Mode::Visual
242 }
243
244 /// The pending repeat count (typed digits before a motion/operator),
245 /// or `None` when no digits are pending. Zero is treated as absent.
246 pub(crate) fn pending_count_val(&self) -> Option<u32> {
247 if self.count == 0 {
248 None
249 } else {
250 Some(self.count as u32)
251 }
252 }
253
254 /// `true` when an in-flight chord is awaiting more keys. Inverse of
255 /// `matches!(self.pending, Pending::None)`.
256 pub(crate) fn is_chord_pending(&self) -> bool {
257 !matches!(self.pending, Pending::None)
258 }
259
260 /// Return a single char representing the pending operator, if any.
261 /// Used by host apps (status line "showcmd" area) to display e.g.
262 /// `d`, `y`, `c` while waiting for a motion.
263 pub(crate) fn pending_op_char(&self) -> Option<char> {
264 let op = match &self.pending {
265 Pending::Op { op, .. }
266 | Pending::OpTextObj { op, .. }
267 | Pending::OpG { op, .. }
268 | Pending::OpFind { op, .. }
269 | Pending::OpSquareBracketOpen { op, .. }
270 | Pending::OpSquareBracketClose { op, .. } => Some(*op),
271 _ => None,
272 };
273 op.map(|o| match o {
274 Operator::Delete => 'd',
275 Operator::Change => 'c',
276 Operator::Yank => 'y',
277 Operator::Uppercase => 'U',
278 Operator::Lowercase => 'u',
279 Operator::ToggleCase => '~',
280 Operator::Indent => '>',
281 Operator::Outdent => '<',
282 Operator::Fold => 'z',
283 Operator::Reflow => 'q',
284 Operator::ReflowKeepCursor => 'w',
285 Operator::AutoIndent => '=',
286 Operator::Filter => '!',
287 // `gc` prefix — doubled as `gcc`.
288 Operator::Comment => 'c',
289 // `g?` prefix — doubled as `g??`.
290 Operator::Rot13 => '?',
291 })
292 }
293}
294/// The vim FSM state is the vim discipline's [`hjkl_engine::DisciplineState`]: the
295/// engine reaches it type-erased and asks only for its [`hjkl_engine::CoarseMode`]
296/// (#265 G3 / #267). Until `VimState` physically moves into `hjkl-vim`, this
297/// impl lives here alongside the struct.
298impl hjkl_engine::DisciplineState for VimState {
299 fn coarse_mode(&self) -> hjkl_engine::CoarseMode {
300 Self::coarse_mode(self)
301 }
302 /// Vim's idle state is Normal mode with no pending chord, count or insert
303 /// session — exactly what `force_normal` establishes.
304 fn reset_to_idle(&mut self) {
305 self.force_normal();
306 }
307 /// Only the FSM mode — `current_mode`, `pending`, `count` and any open
308 /// insert session are deliberately left alone (see the trait docs).
309 fn reset_mode_after_history(&mut self) {
310 self.mode = Mode::Normal;
311 }
312 fn as_any(&self) -> &dyn std::any::Any {
313 self
314 }
315 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
316 self
317 }
318}