1use strop_core::{id::BufferRevision, Range};
8use strop_picker::LineEdit;
9
10use super::{input::ParserState, panes::Pane, Key};
11
12#[derive(Debug, Clone)]
16pub(crate) struct SearchOrigin {
17 pub pane_index: usize,
18 pub pane: Pane,
19 pub revision: BufferRevision,
20}
21
22#[derive(Debug, Clone)]
24pub(crate) enum PromptContext {
25 Ex(SearchOrigin),
26 Search {
27 origin: SearchOrigin,
28 state: ParserState,
31 backward: bool,
32 },
33 Pipe {
34 origin: SearchOrigin,
35 range: Range,
36 visual: bool,
37 },
38}
39
40#[derive(Debug, Clone)]
42pub(crate) struct TextPrompt {
43 line: LineEdit,
44 context: PromptContext,
45}
46
47impl TextPrompt {
48 pub(crate) fn new(context: PromptContext) -> Self {
49 let sigil = match &context {
50 PromptContext::Ex(_) => ':',
51 PromptContext::Search {
52 backward: false, ..
53 } => '/',
54 PromptContext::Search { backward: true, .. } => '?',
55 PromptContext::Pipe { .. } => '|',
56 };
57 Self {
58 line: LineEdit::new(sigil.to_string()),
59 context,
60 }
61 }
62
63 pub(crate) fn sigil(&self) -> char {
64 match &self.context {
65 PromptContext::Ex(_) => ':',
66 PromptContext::Search {
67 backward: false, ..
68 } => '/',
69 PromptContext::Search { backward: true, .. } => '?',
70 PromptContext::Pipe { .. } => '|',
71 }
72 }
73
74 pub(crate) fn body(&self) -> &str {
76 &self.line.text[1..]
77 }
78
79 pub(crate) fn text(&self) -> &str {
82 &self.line.text
83 }
84
85 pub(crate) fn cursor(&self) -> usize {
87 self.line.cursor
88 }
89
90 pub(crate) fn normal(&self) -> bool {
92 self.line.normal
93 }
94
95 pub(crate) fn context(&self) -> &PromptContext {
96 &self.context
97 }
98
99 pub(crate) fn origin(&self) -> &SearchOrigin {
100 match &self.context {
101 PromptContext::Ex(origin)
102 | PromptContext::Search { origin, .. }
103 | PromptContext::Pipe { origin, .. } => origin,
104 }
105 }
106
107 pub(crate) fn search(&self) -> Option<(&SearchOrigin, &ParserState)> {
109 match &self.context {
110 PromptContext::Search { origin, state, .. } => Some((origin, state)),
111 _ => None,
112 }
113 }
114
115 pub(crate) fn backward(&self) -> Option<bool> {
116 match &self.context {
117 PromptContext::Search { backward, .. } => Some(*backward),
118 _ => None,
119 }
120 }
121}
122
123#[derive(Debug, Default)]
125pub struct PendingInput {
126 active: Option<TextPrompt>,
127}
128
129pub(crate) enum PendingEvent {
131 Key(Key),
132 Paste(String),
134 CompleteEx(String),
136 Cancel,
137}
138
139pub(crate) enum PendingEffect {
141 None,
142 Edited,
144 ModeChanged,
146 CompleteEx,
148 Repaint,
150 Rejected(&'static str),
152 Accepted(TextPrompt),
154 Aborted(TextPrompt),
156}
157
158impl PendingInput {
159 pub(crate) fn prompt(&self) -> Option<&TextPrompt> {
160 self.active.as_ref()
161 }
162
163 pub fn is_active(&self) -> bool {
164 self.active.is_some()
165 }
166
167 pub fn text(&self) -> &str {
169 self.prompt().map_or("", TextPrompt::text)
170 }
171
172 pub fn cursor(&self) -> usize {
174 self.prompt().map_or(0, TextPrompt::cursor)
175 }
176
177 pub(crate) fn normal(&self) -> bool {
178 self.prompt().is_some_and(TextPrompt::normal)
179 }
180
181 pub(crate) fn sigil(&self) -> Option<char> {
183 self.prompt().map(TextPrompt::sigil)
184 }
185
186 pub(crate) fn open(&mut self, prompt: TextPrompt) {
189 assert!(
190 self.active.is_none(),
191 "cancel the previous prompt before opening another"
192 );
193 self.active = Some(prompt);
194 }
195
196 pub(crate) fn reduce(&mut self, event: PendingEvent) -> PendingEffect {
200 let Some(prompt) = self.active.as_mut() else {
201 return PendingEffect::None;
202 };
203 if matches!(event, PendingEvent::Cancel)
204 || matches!(event, PendingEvent::Key(Key::Esc)) && prompt.line.normal
205 {
206 return PendingEffect::Aborted(self.active.take().expect("active prompt"));
207 }
208 if matches!(event, PendingEvent::Key(Key::Enter)) {
209 return PendingEffect::Accepted(self.active.take().expect("active prompt"));
210 }
211 let old_len = prompt.line.text.len();
212 let old_normal = prompt.line.normal;
213 match event {
214 PendingEvent::Paste(text) => {
215 if text.contains(['\r', '\n']) {
216 return PendingEffect::Rejected("input line cannot contain a newline");
217 }
218 prompt.line.text.insert_str(prompt.line.cursor, &text);
219 prompt.line.cursor += text.len();
220 }
221 PendingEvent::CompleteEx(body) if prompt.sigil() == ':' => {
222 prompt.line.set_text(format!(":{body}"));
223 return PendingEffect::Edited;
224 }
225 PendingEvent::CompleteEx(_) | PendingEvent::Cancel => return PendingEffect::None,
226 PendingEvent::Key(Key::Esc) => {
227 prompt.line.normal = true;
228 prompt.line.cursor = prompt.line.text.len();
229 }
230 PendingEvent::Key(Key::Backspace) if prompt.line.normal => {
231 let _ = prompt.line.normal_key('h'); }
233 PendingEvent::Key(Key::Backspace) => {
234 prompt.line.backspace();
235 }
236 PendingEvent::Key(Key::Char(c)) if prompt.line.normal => {
237 let _ = prompt.line.normal_key(c);
238 }
239 PendingEvent::Key(Key::Char(c)) => {
240 prompt.line.insert_char(c);
241 }
242 PendingEvent::Key(Key::Left) => prompt.line.move_left(),
243 PendingEvent::Key(Key::Right) => prompt.line.move_right(),
244 PendingEvent::Key(Key::Tab) if prompt.sigil() == ':' => {
245 return PendingEffect::CompleteEx;
246 }
247 PendingEvent::Key(Key::CtrlL) => return PendingEffect::Repaint,
248 PendingEvent::Key(_) => return PendingEffect::None,
249 }
250 if !prompt.line.text.starts_with(prompt.sigil()) {
253 return PendingEffect::Aborted(self.active.take().expect("active prompt"));
254 }
255 if old_len != prompt.line.text.len() {
256 PendingEffect::Edited
257 } else if old_normal != prompt.line.normal {
258 PendingEffect::ModeChanged
259 } else {
260 PendingEffect::None
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::editor::Editor;
269 use strop_core::Buffer;
270
271 #[test]
272 fn accepting_a_visual_pipe_returns_its_original_range_and_literal_command() {
273 let e = Editor::new(Buffer::from_text("first\nsecond\n"));
274 let origin = SearchOrigin {
275 pane_index: e.active_pane,
276 pane: e.view().clone(),
277 revision: e.buf().revision(),
278 };
279 let mut pending = PendingInput::default();
280 pending.open(TextPrompt::new(PromptContext::Pipe {
281 origin,
282 range: Range::charwise(0, 13),
283 visual: true,
284 }));
285 for c in "cat".chars() {
286 pending.reduce(PendingEvent::Key(Key::Char(c)));
287 }
288 let PendingEffect::Accepted(prompt) = pending.reduce(PendingEvent::Key(Key::Enter)) else {
289 panic!("pipe not accepted");
290 };
291 assert_eq!(prompt.body(), "cat");
292 match prompt.context() {
293 PromptContext::Pipe { range, visual, .. } => {
294 assert_eq!(*range, Range::charwise(0, 13));
295 assert!(*visual);
296 }
297 _ => panic!("lost pipe effect"),
298 }
299 assert!(!pending.is_active());
300 assert!(matches!(
302 pending.reduce(PendingEvent::Key(Key::Enter)),
303 PendingEffect::None
304 ));
305 }
306
307 #[test]
308 fn esc_once_is_modal_twice_aborts_and_sigil_deletion_aborts() {
309 let e = Editor::new(Buffer::from_text("x\n"));
310 let origin = SearchOrigin {
311 pane_index: e.active_pane,
312 pane: e.view().clone(),
313 revision: e.buf().revision(),
314 };
315 let mut pending = PendingInput::default();
316 pending.open(TextPrompt::new(PromptContext::Search {
317 origin,
318 state: ParserState::default(),
319 backward: false,
320 }));
321 for c in "ab".chars() {
322 pending.reduce(PendingEvent::Key(Key::Char(c)));
323 }
324 assert_eq!(pending.text(), "/ab");
325 assert!(matches!(
327 pending.reduce(PendingEvent::Key(Key::Esc)),
328 PendingEffect::ModeChanged
329 ));
330 assert!(pending.normal());
331 let _ = pending.reduce(PendingEvent::Key(Key::Char('0')));
333 let PendingEffect::Aborted(prompt) = pending.reduce(PendingEvent::Key(Key::Char('x')))
334 else {
335 panic!("sigil deletion must abort");
336 };
337 assert_eq!(prompt.text(), "ab"); assert!(!pending.is_active());
339 assert_eq!(pending.text(), "");
340 }
341
342 #[test]
343 fn paste_is_literal_and_newlines_are_rejected() {
344 let e = Editor::new(Buffer::from_text("x\n"));
345 let origin = SearchOrigin {
346 pane_index: e.active_pane,
347 pane: e.view().clone(),
348 revision: e.buf().revision(),
349 };
350 let mut pending = PendingInput::default();
351 pending.open(TextPrompt::new(PromptContext::Ex(origin)));
352 assert!(matches!(
353 pending.reduce(PendingEvent::Paste("w q".into())),
354 PendingEffect::Edited
355 ));
356 assert_eq!(pending.text(), ":w q");
357 assert!(matches!(
358 pending.reduce(PendingEvent::Paste("\nx".into())),
359 PendingEffect::Rejected("input line cannot contain a newline")
360 ));
361 assert_eq!(pending.text(), ":w q");
362 }
363}