Skip to main content

reovim_module_vim/resolvers/
visual.rs

1// Methods made `pub` for test access from `resolvers::tests::visual`.
2// This module is private, so `pub` is effectively crate-internal.
3#![allow(clippy::missing_panics_doc)]
4
5//! Visual mode key resolver.
6//!
7//! This resolver handles all three visual modes:
8//! - `vim:visual` - Character-wise selection (v)
9//! - `vim:visual-line` - Line-wise selection (V)
10//! - `vim:visual-block` - Block/rectangular selection (Ctrl-V)
11//!
12//! # Key Differences from Normal Mode
13//!
14//! Visual mode differs from normal mode in how motions and operators behave:
15//! - **Motions**: Extend the selection rather than just moving the cursor
16//! - **Operators (d, y, c)**: Operate on the current selection immediately
17//! - **Escape**: Exits visual mode and clears selection
18//!
19//! # Resolution Flow
20//!
21//! 1. Escape → exit visual mode, clear selection
22//! 2. Count digits → accumulate for motion repeat
23//! 3. Motion keys → look up in Normal mode keymap, execute to extend selection
24//! 4. Visual operators (d, y, c) → execute selection-based commands via keybindings
25//!
26//! # Selection Model
27//!
28//! Selection tracking is handled by the kernel's `Selection` API:
29//! - When visual mode is entered, `selection.start()` is called with cursor position
30//! - Each motion extends the selection by moving the cursor
31//! - The selection range is from anchor (start) to cursor (current position)
32
33use std::sync::RwLock;
34
35use {
36    reovim_driver_input::{
37        ExtensionMap, KeyCode, KeyEvent, KeySequence, ModeKeyResolver, ModeState, Modifiers,
38        ResolveContext, ResolveInput, ResolveResult, SessionApiDyn,
39    },
40    reovim_kernel::api::v1::ModeId,
41};
42
43use {
44    super::operator_common::{KeymapAction, apply_keymap_policy, is_count_digit, is_escape},
45    crate::modes::VimMode,
46};
47
48/// Visual mode state owned by the resolver.
49///
50/// Unlike operator modes (delete/yank/change), visual mode doesn't track
51/// operator state. It only tracks:
52/// - Pending count for motion repetition
53/// - Pending keys for multi-key motions (e.g., gg, G)
54#[derive(Debug, Clone)]
55pub struct VisualState {
56    /// Pending count for motions (e.g., `3j` in visual mode).
57    pub motion_count: Option<usize>,
58    /// Accumulated key sequence for multi-key motions.
59    pub pending_keys: KeySequence,
60    /// Whether the resolver has been initialized for this mode entry.
61    pub initialized: bool,
62}
63
64impl VisualState {
65    /// Create new visual mode state.
66    const fn new() -> Self {
67        Self {
68            motion_count: None,
69            pending_keys: KeySequence::new(),
70            initialized: false,
71        }
72    }
73
74    /// Check if we have a motion count.
75    pub const fn has_motion_count(&self) -> bool {
76        self.motion_count.is_some()
77    }
78
79    /// Accumulate a count digit.
80    #[cfg_attr(coverage_nightly, coverage(off))]
81    fn accumulate_motion_count(&mut self, key: &KeyEvent) {
82        if let KeyCode::Char(c @ '0'..='9') = key.code {
83            let digit = c.to_digit(10).expect("valid digit") as usize;
84            self.motion_count = Some(self.motion_count.unwrap_or(0) * 10 + digit);
85        }
86    }
87
88    /// Take the motion count, returning it and clearing it.
89    #[allow(clippy::missing_const_for_fn)] // Option::take is not const
90    fn take_motion_count(&mut self) -> Option<usize> {
91        self.motion_count.take()
92    }
93
94    /// Get the explicit count (None if not specified).
95    const fn explicit_count(&self) -> Option<usize> {
96        self.motion_count
97    }
98
99    /// Push a key to the pending sequence.
100    fn push_key(&mut self, key: KeyEvent) {
101        self.pending_keys.push(key);
102    }
103
104    /// Get a clone of pending keys.
105    fn keys(&self) -> KeySequence {
106        self.pending_keys.clone()
107    }
108
109    /// Clear pending keys.
110    fn clear_keys(&mut self) {
111        self.pending_keys.clear();
112    }
113
114    /// Reset all state for mode re-entry.
115    fn reset(&mut self) {
116        self.motion_count = None;
117        self.pending_keys.clear();
118        self.initialized = false;
119    }
120}
121
122/// Vim visual mode key resolver.
123///
124/// Handles all three visual mode variants:
125/// - `vim:visual` - Character-wise selection
126/// - `vim:visual-line` - Line-wise selection
127/// - `vim:visual-block` - Block selection
128///
129/// # Example
130///
131/// ```ignore
132/// let resolver = VimVisualResolver::new(VimMode::VISUAL_ID);
133/// resolver_registry.register(resolver);
134/// ```
135pub struct VimVisualResolver {
136    /// Mode ID for this visual mode variant.
137    mode_id: ModeId,
138    /// Parent mode ID for motion lookup (always Normal).
139    parent_mode_id: ModeId,
140    /// Resolver state.
141    state: RwLock<VisualState>,
142}
143
144impl VimVisualResolver {
145    /// Create a new visual mode resolver for the given mode.
146    ///
147    /// # Arguments
148    ///
149    /// * `mode_id` - The visual mode variant (`VISUAL_ID`, `VISUAL_LINE_ID`, or `VISUAL_BLOCK_ID`)
150    #[must_use]
151    #[allow(clippy::missing_const_for_fn)] // RwLock::new is not const
152    pub fn new(mode_id: ModeId) -> Self {
153        Self {
154            mode_id,
155            parent_mode_id: VimMode::NORMAL_ID,
156            state: RwLock::new(VisualState::new()),
157        }
158    }
159
160    /// Create a character-wise visual mode resolver.
161    #[must_use]
162    pub fn character_wise() -> Self {
163        Self::new(VimMode::VISUAL_ID)
164    }
165
166    /// Create a line-wise visual mode resolver.
167    #[must_use]
168    pub fn line_wise() -> Self {
169        Self::new(VimMode::VISUAL_LINE_ID)
170    }
171
172    /// Create a block visual mode resolver.
173    #[must_use]
174    pub fn block_wise() -> Self {
175        Self::new(VimMode::VISUAL_BLOCK_ID)
176    }
177
178    /// Clear all internal state.
179    fn clear_state(&self) {
180        self.state.write().expect("lock poisoned").reset();
181    }
182
183    /// Get a clone of the current state (for testing).
184    #[cfg(test)]
185    pub fn state(&self) -> VisualState {
186        self.state.read().expect("lock poisoned").clone()
187    }
188}
189
190impl ModeKeyResolver for VimVisualResolver {
191    #[cfg_attr(coverage_nightly, coverage(off))]
192    fn resolve_with_keymap(
193        &self,
194        key: &KeyEvent,
195        _state: &mut ModeState,
196        input: &ResolveInput<'_>,
197    ) -> ResolveResult {
198        // Escape or Ctrl-C exits visual mode via ExitVisualMode command.
199        // The command clears selection, records selection_changed, and sets NORMAL mode.
200        if is_escape(key)
201            || (key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL))
202        {
203            self.clear_state();
204            return ResolveResult::Execute(crate::ids::EXIT_VISUAL, ResolveContext::new());
205        }
206
207        let mut state = self.state.write().expect("lock poisoned");
208
209        // Check for count digit
210        if is_count_digit(key, state.has_motion_count()) {
211            state.accumulate_motion_count(key);
212            return ResolveResult::Pending;
213        }
214
215        // Add to pending keys
216        state.push_key(*key);
217        let keys = state.keys();
218
219        // Look up in visual mode first, then fall back to normal mode for motions
220        let lookup_state = {
221            let visual_lookup = input.keymap.query(input.mode, &keys);
222            if matches!(visual_lookup, reovim_driver_input::KeyLookupState::NotFound) {
223                // Motion bindings are in normal mode
224                input.keymap.query(&self.parent_mode_id, &keys)
225            } else {
226                visual_lookup
227            }
228        };
229
230        match apply_keymap_policy(&lookup_state) {
231            KeymapAction::Execute(cmd) => {
232                let explicit_count = state.explicit_count();
233                let _motion_count = state.take_motion_count();
234                state.clear_keys();
235                drop(state);
236
237                // Build context with explicit count
238                let ctx = ResolveContext {
239                    count: explicit_count,
240                    register: None,
241                    keys,
242                    metadata: std::collections::HashMap::new(),
243                };
244
245                ResolveResult::Execute(cmd, ctx)
246            }
247            KeymapAction::Pending => {
248                drop(state);
249                ResolveResult::Pending
250            }
251            KeymapAction::Cancel => {
252                state.clear_keys();
253                drop(state);
254                // For unknown keys in visual mode, just ignore them (don't exit)
255                ResolveResult::NotHandled
256            }
257        }
258    }
259
260    #[cfg_attr(coverage_nightly, coverage(off))]
261    fn resolve_with_session(
262        &self,
263        key: &KeyEvent,
264        _mstate: &mut ModeState,
265        input: &ResolveInput<'_>,
266        _session: &mut dyn SessionApiDyn,
267        _shared_extensions: &mut ExtensionMap,
268        client_extensions: &mut ExtensionMap,
269    ) -> ResolveResult {
270        tracing::debug!(key = ?key, mode = ?self.mode_id, "visual resolver: resolve_with_session");
271
272        // Escape or Ctrl-C exits visual mode via ExitVisualMode command.
273        // The command clears selection, records selection_changed, and sets NORMAL mode.
274        if is_escape(key)
275            || (key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL))
276        {
277            tracing::debug!("visual resolver: escape/ctrl-c - executing EXIT_VISUAL command");
278            self.clear_state();
279            return ResolveResult::Execute(crate::ids::EXIT_VISUAL, ResolveContext::new());
280        }
281
282        let mut state = self.state.write().expect("lock poisoned");
283
284        // Initialize state on first key (read any pending count from normal mode)
285        if !state.initialized {
286            if let Some(vim) = client_extensions.get_mut::<crate::VimSessionState>() {
287                // Transfer any pending count from normal mode
288                if let Some(count) = vim.pending_count.take() {
289                    state.motion_count = Some(count);
290                    tracing::debug!(count, "visual resolver: inherited count from normal mode");
291                }
292            }
293            state.initialized = true;
294        }
295
296        // Check for count digit
297        if is_count_digit(key, state.has_motion_count()) {
298            state.accumulate_motion_count(key);
299            tracing::debug!(count = ?state.motion_count, "visual resolver: count digit");
300            return ResolveResult::Pending;
301        }
302
303        // Add to pending keys
304        state.push_key(*key);
305        let keys = state.keys();
306
307        // Look up in visual mode first, then fall back to normal mode for motions
308        let lookup_state = {
309            let visual_lookup = input.keymap.query(input.mode, &keys);
310            if matches!(visual_lookup, reovim_driver_input::KeyLookupState::NotFound) {
311                // Motion bindings are in normal mode
312                input.keymap.query(&self.parent_mode_id, &keys)
313            } else {
314                visual_lookup
315            }
316        };
317
318        tracing::debug!(?lookup_state, ?keys, "visual resolver: keymap lookup");
319
320        match apply_keymap_policy(&lookup_state) {
321            KeymapAction::Execute(cmd) => {
322                let explicit_count = state.explicit_count();
323                let _motion_count = state.take_motion_count();
324                state.clear_keys();
325                drop(state);
326
327                tracing::debug!(
328                    cmd = %cmd,
329                    explicit_count = ?explicit_count,
330                    "visual resolver: executing command"
331                );
332
333                // Build context with explicit count
334                let ctx = ResolveContext {
335                    count: explicit_count,
336                    register: None,
337                    keys,
338                    metadata: std::collections::HashMap::new(),
339                };
340
341                ResolveResult::Execute(cmd, ctx)
342            }
343            KeymapAction::Pending => {
344                drop(state);
345                tracing::debug!("visual resolver: waiting for more keys");
346                ResolveResult::Pending
347            }
348            KeymapAction::Cancel => {
349                state.clear_keys();
350                drop(state);
351                tracing::debug!("visual resolver: key not found, returning NotHandled");
352                // For unknown keys in visual mode, return NotHandled
353                // This allows inheritance to try parent modes
354                ResolveResult::NotHandled
355            }
356        }
357    }
358
359    fn mode_id(&self) -> &ModeId {
360        &self.mode_id
361    }
362
363    fn inherits_from(&self) -> Option<&ModeId> {
364        Some(&self.parent_mode_id)
365    }
366
367    #[cfg_attr(coverage_nightly, coverage(off))]
368    fn pending_keys(&self) -> KeySequence {
369        self.state.read().expect("lock poisoned").keys()
370    }
371
372    #[cfg_attr(coverage_nightly, coverage(off))]
373    fn reset(&mut self) {
374        self.state.write().expect("lock poisoned").reset();
375    }
376}