Skip to main content

lattice_host/
cursor_shape.rs

1//! Vim-style cursor shape derived from [`ModalState`].
2//!
3//! Phase 5.8.N: hoisted out of both renderer peers. Pre-5.8.N the
4//! TUI peer had `lattice-ui-tui::runtime::cursor_style_for(modal)
5//! -> SetCursorStyle` and the GPUI peer had
6//! `lattice-ui-gpui::window::CursorShape::for_mode(modal) -> Self`.
7//! Both implemented the same vim convention:
8//!
9//!   - Normal / Visual / Operator-Pending → Block
10//!   - Insert / Command / Search          → Bar
11//!   - Replace                            → Underline
12//!
13//! Now lives once here; renderer peers map [`CursorShape`] →
14//! their native cursor primitive (crossterm `SetCursorStyle` for
15//! TUI; div-border style for GPUI). Renderer-neutral so any future
16//! peer (web, headless) reuses the mapping.
17
18use lattice_grammar::ModalState;
19
20/// Vim-style cursor shape, renderer-neutral. Each renderer peer
21/// translates this to its own primitive at paint time.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum CursorShape {
24    /// Full inverted-cell block — classic vim cursor for command-
25    /// language modes (Normal, Visual, Operator-Pending).
26    Block,
27    /// Thin left-side vertical bar — vim Insert / Command-line
28    /// convention. The cursor sits BEFORE the next character.
29    Bar,
30    /// Thin bottom-side horizontal underline — Replace mode (vim
31    /// convention; signals overwrite).
32    Underline,
33}
34
35impl CursorShape {
36    /// Map a [`ModalState`] to its canonical cursor shape.
37    pub fn for_mode(modal: ModalState) -> Self {
38        match modal {
39            ModalState::Insert
40            | ModalState::Command
41            | ModalState::Search(_)
42            | ModalState::Prompt => Self::Bar,
43            ModalState::Replace => Self::Underline,
44            // SN.3d: Select shares Visual's Block cursor — the
45            // selection conveys the mode; the cursor matches Visual.
46            ModalState::Normal
47            | ModalState::Visual(_)
48            | ModalState::Select(_)
49            | ModalState::OperatorPending => Self::Block,
50        }
51    }
52}
53
54/// Does a **minibuffer** own the caret in this modal state?
55///
56/// True for the three buffer-backed readline surfaces — the `:` command
57/// line, the `/`·`?` search line and `Effect::OpenPrompt`'s prompt. Each
58/// draws its own caret at its own buffer's cursor, so every OTHER surface
59/// must leave the caret alone while one is open.
60///
61/// **Renderer-neutral because both peers got this wrong the same way.**
62/// The TUI drives one hardware caret, so two surfaces placing it means the
63/// last writer wins; GPUI paints carets as elements, so two surfaces mean
64/// two carets. Different symptoms, one question — and the pane path
65/// already asked it locally (`prompt_owns_cursor`) while the popup path
66/// did not ask it at all. Reported in use: with a popup focused, `/` left
67/// the caret sitting in the popup and merely changed its shape to a Bar,
68/// so a read-only popup looked like it had entered Insert.
69///
70/// Note it includes `Prompt`, which the TUI's local copy omitted — the
71/// prompt line draws its own caret exactly as the other two do.
72pub fn minibuffer_owns_caret(modal: ModalState) -> bool {
73    matches!(
74        modal,
75        ModalState::Command | ModalState::Search(_) | ModalState::Prompt
76    )
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use lattice_grammar::{SearchDirection, VisualKind};
83
84    #[test]
85    fn normal_uses_block() {
86        assert_eq!(
87            CursorShape::for_mode(ModalState::Normal),
88            CursorShape::Block
89        );
90    }
91
92    #[test]
93    fn visual_all_kinds_use_block() {
94        for k in [
95            VisualKind::Charwise,
96            VisualKind::Linewise,
97            VisualKind::Blockwise,
98        ] {
99            assert_eq!(
100                CursorShape::for_mode(ModalState::Visual(k)),
101                CursorShape::Block
102            );
103        }
104    }
105
106    /// SN.3d parity: Select mode uses the same Block cursor as Visual
107    /// for every kind — Select must never diverge from Visual here.
108    #[test]
109    fn select_all_kinds_use_block_matching_visual() {
110        for k in [
111            VisualKind::Charwise,
112            VisualKind::Linewise,
113            VisualKind::Blockwise,
114        ] {
115            assert_eq!(
116                CursorShape::for_mode(ModalState::Select(k)),
117                CursorShape::for_mode(ModalState::Visual(k)),
118                "Select({k:?}) cursor must match Visual({k:?})"
119            );
120            assert_eq!(
121                CursorShape::for_mode(ModalState::Select(k)),
122                CursorShape::Block
123            );
124        }
125    }
126
127    #[test]
128    fn operator_pending_uses_block() {
129        assert_eq!(
130            CursorShape::for_mode(ModalState::OperatorPending),
131            CursorShape::Block
132        );
133    }
134
135    #[test]
136    fn insert_uses_bar() {
137        assert_eq!(CursorShape::for_mode(ModalState::Insert), CursorShape::Bar);
138    }
139
140    #[test]
141    fn command_uses_bar() {
142        assert_eq!(CursorShape::for_mode(ModalState::Command), CursorShape::Bar);
143    }
144
145    #[test]
146    fn search_both_directions_use_bar() {
147        assert_eq!(
148            CursorShape::for_mode(ModalState::Search(SearchDirection::Forward)),
149            CursorShape::Bar
150        );
151        assert_eq!(
152            CursorShape::for_mode(ModalState::Search(SearchDirection::Backward)),
153            CursorShape::Bar
154        );
155    }
156
157    #[test]
158    fn replace_uses_underline() {
159        assert_eq!(
160            CursorShape::for_mode(ModalState::Replace),
161            CursorShape::Underline
162        );
163    }
164}
165
166#[cfg(test)]
167mod caret_owner_tests {
168    use super::*;
169    use lattice_grammar::{SearchDirection, VisualKind};
170
171    /// The three readline surfaces own the caret; nothing else does.
172    #[test]
173    fn the_minibuffers_own_the_caret() {
174        for modal in [
175            ModalState::Command,
176            ModalState::Search(SearchDirection::Forward),
177            ModalState::Search(SearchDirection::Backward),
178            ModalState::Prompt,
179        ] {
180            assert!(
181                minibuffer_owns_caret(modal),
182                "{modal:?} draws its own caret at its own buffer's cursor"
183            );
184        }
185    }
186
187    /// The editing states do not — the pane (or a focused popup) places it.
188    #[test]
189    fn the_editing_states_do_not() {
190        for modal in [
191            ModalState::Normal,
192            ModalState::Insert,
193            ModalState::Replace,
194            ModalState::Visual(VisualKind::Charwise),
195            ModalState::Select(VisualKind::Linewise),
196            ModalState::OperatorPending,
197        ] {
198            assert!(
199                !minibuffer_owns_caret(modal),
200                "{modal:?} is an editing state — the surface owns its caret"
201            );
202        }
203    }
204
205    /// Insert and Search both draw a Bar, and that is exactly why the shape
206    /// cannot stand in for the question: a popup whose caret shape came from
207    /// `Search` looked like it had entered Insert.
208    #[test]
209    fn shape_alone_cannot_answer_this() {
210        assert_eq!(
211            CursorShape::for_mode(ModalState::Insert),
212            CursorShape::for_mode(ModalState::Search(SearchDirection::Forward)),
213            "same shape, different owner — hence a separate predicate"
214        );
215    }
216}