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}