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