Skip to main content

strop_engine/editor/
pending.rs

1//! One owner for modal input (R7). A text prompt — `:ex`, `/search`,
2//! `?search`, `|pipe` — owns its `LineEdit` plus everything about the
3//! moment it opened: the pane (full selections and viewport), the
4//! document and its revision. Structural operator composition stays in
5//! the `Walker`; prompts never fabricate grammar text.
6
7use strop_core::{id::BufferRevision, Range};
8use strop_picker::LineEdit;
9
10use super::{input::ParserState, panes::Pane, Key};
11
12/// Where a prompt opened: the whole originating pane (document,
13/// complete selections, viewport) and the buffer revision. Cancellation
14/// restores this verbatim; acceptance validates it before executing.
15#[derive(Debug, Clone)]
16pub(crate) struct SearchOrigin {
17    pub pane_index: usize,
18    pub pane: Pane,
19    pub revision: BufferRevision,
20}
21
22/// What the prompt is for. The sigil is derivable; the context is not.
23#[derive(Debug, Clone)]
24pub(crate) enum PromptContext {
25    Ex(SearchOrigin),
26    Search {
27        origin: SearchOrigin,
28        /// The typed entry state (counts/register/operator) that
29        /// survived crossing into the prompt (`2d/foo` keeps 2 and d).
30        state: ParserState,
31        backward: bool,
32    },
33    Pipe {
34        origin: SearchOrigin,
35        range: Range,
36        visual: bool,
37    },
38}
39
40/// One open modal line: the editable text plus its origin context.
41#[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    /// The text after the sigil — the command/pattern body.
75    pub(crate) fn body(&self) -> &str {
76        &self.line.text[1..]
77    }
78
79    /// The full line, sigil included (empty string semantics live on
80    /// `PendingInput`, which knows whether a prompt is open at all).
81    pub(crate) fn text(&self) -> &str {
82        &self.line.text
83    }
84
85    /// Caret byte offset into `text()` (sigil-inclusive).
86    pub(crate) fn cursor(&self) -> usize {
87        self.line.cursor
88    }
89
90    /// True when Esc put the line's own caret into normal mode.
91    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    /// The saved search entry, when this prompt is a `/` or `?` line.
108    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/// The editor's one prompt slot: open or closed, nothing in between.
124#[derive(Debug, Default)]
125pub struct PendingInput {
126    active: Option<TextPrompt>,
127}
128
129/// One input event for the open prompt.
130pub(crate) enum PendingEvent {
131    Key(Key),
132    /// Bracketed paste routed away from the document (literal text).
133    Paste(String),
134    /// Ex completion offered the given body (Tab cycling).
135    CompleteEx(String),
136    Cancel,
137}
138
139/// What the reducer did — the caller owns every side effect.
140pub(crate) enum PendingEffect {
141    None,
142    /// Text changed: re-resolve incsearch.
143    Edited,
144    /// The line's modal mode flipped.
145    ModeChanged,
146    /// Tab on the ex line: cycle completion.
147    CompleteEx,
148    /// Ctrl-L: terminal desync recovery.
149    Repaint,
150    /// The event was refused (e.g. a pasted newline).
151    Rejected(&'static str),
152    /// Enter: consume and execute (Pipe/Search/Ex by context).
153    Accepted(TextPrompt),
154    /// Esc-Esc / sigil deletion / Cancel: consume and restore.
155    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    /// The full line, sigil included; empty when no prompt is open.
168    pub fn text(&self) -> &str {
169        self.prompt().map_or("", TextPrompt::text)
170    }
171
172    /// Caret byte offset into `text()`; 0 when no prompt is open.
173    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    /// The open prompt's sigil (`: / ? |`); None when closed.
182    pub(crate) fn sigil(&self) -> Option<char> {
183        self.prompt().map(TextPrompt::sigil)
184    }
185
186    /// Open a prompt. Opening while another is active is a caller bug:
187    /// the previous origin would be silently discarded.
188    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    /// The one reducer. Every surface funnels here; effects are the
197    /// caller's to apply. Enter and abort consume the prompt and hand
198    /// it back — exactly once (a closed prompt yields `None`).
199    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'); // vim: bs in normal = h
232            }
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        // The sigil is structural: deleting it (backspace at 1, `x` at
251        // 0) closes the prompt — same gesture as Esc-Esc.
252        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        // exactly-once consumption: a second Enter finds nothing open
301        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        // first Esc: the line's caret goes modal, prompt stays open
326        assert!(matches!(
327            pending.reduce(PendingEvent::Key(Key::Esc)),
328            PendingEffect::ModeChanged
329        ));
330        assert!(pending.normal());
331        // modal x at the sigil deletes it: abort
332        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"); // sigil gone, body intact
338        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}