Skip to main content

zero_tui/app/
prompt.rs

1//! Prompt buffer — backing state for the prompt widget.
2//!
3//! The buffer is **multi-line**: `Shift+Enter` inserts a newline,
4//! plain `Enter` submits the joined text. Cursor is `(row, col)`,
5//! both 0-based char positions (not bytes), so multi-byte input
6//! does not corrupt navigation.
7//!
8//! The buffer also owns a [`PromptHistory`] — a small ring of
9//! previously submitted lines that the operator can recall with
10//! `Up`/`Down`. Recalling preserves the in-flight draft: stepping
11//! past the newest history entry restores whatever the operator
12//! was typing before they started navigating.
13//!
14//! Design constraints (kept narrow on purpose):
15//!
16//! - No syntax highlighting or word-wrap. ratatui's render path
17//!   does the visible-width math; if a line is wider than the
18//!   prompt area, it crops at the right edge. The fix for
19//!   long-line ergonomics is "use `Shift+Enter`", not a wrapping
20//!   engine that has to track logical vs visual position.
21//! - Char-position cursor only. ratatui currently expects ASCII
22//!   columns; wide chars in command text are rare. The day we
23//!   need grapheme cluster math, this module is the place to
24//!   add it — every other widget reads `cursor_row()` /
25//!   `cursor_column()` and the joined `as_string()`.
26
27use std::collections::VecDeque;
28
29/// History ring size — large enough for a long live trading
30/// session, small enough that recall is constant-time even when
31/// every keystroke walks the buffer.
32pub const HISTORY_CAP: usize = 256;
33
34/// Maximum prompt rows the buffer will accept. The render layer
35/// caps the *visible* prompt height separately (see
36/// `app::render`); this constant just guards against a runaway
37/// `Shift+Enter` repeat eating memory in pathological cases.
38pub const MAX_LINES: usize = 64;
39
40/// History of submitted prompt lines.
41///
42/// `cursor` semantics:
43/// - `None` → operator is at the live draft (newest).
44/// - `Some(i)` → operator is recalling `entries[i]`.
45///
46/// Stepping past index 0 (Up at oldest) clamps; stepping past
47/// the live draft (Down at newest) leaves `cursor = None` and
48/// restores the saved draft.
49#[derive(Debug, Default, Clone)]
50pub struct PromptHistory {
51    entries: VecDeque<String>,
52    cap: usize,
53    cursor: Option<usize>,
54}
55
56impl PromptHistory {
57    #[must_use]
58    pub fn new() -> Self {
59        Self::with_capacity(HISTORY_CAP)
60    }
61
62    #[must_use]
63    pub fn with_capacity(cap: usize) -> Self {
64        Self {
65            entries: VecDeque::new(),
66            cap,
67            cursor: None,
68        }
69    }
70
71    /// Append a submitted line. Empty lines are ignored. Two
72    /// consecutive identical entries are deduped (bash-style) so
73    /// hammering Enter doesn't bury everything else under copies
74    /// of `/status`.
75    pub fn push(&mut self, line: &str) {
76        if line.trim().is_empty() {
77            return;
78        }
79        if self.entries.back().is_some_and(|last| last == line) {
80            self.cursor = None;
81            return;
82        }
83        if self.cap > 0 && self.entries.len() >= self.cap {
84            self.entries.pop_front();
85        }
86        self.entries.push_back(line.to_string());
87        self.cursor = None;
88    }
89
90    /// Cursor reset — call after a successful submission so the
91    /// next Up starts from the newest entry again.
92    pub fn reset_cursor(&mut self) {
93        self.cursor = None;
94    }
95
96    /// Up arrow — step toward older history. Returns the recalled
97    /// entry, or `None` if history is empty / already at oldest.
98    pub fn recall_prev(&mut self) -> Option<&str> {
99        if self.entries.is_empty() {
100            return None;
101        }
102        let next = match self.cursor {
103            None => self.entries.len().saturating_sub(1),
104            Some(0) => 0,
105            Some(i) => i - 1,
106        };
107        self.cursor = Some(next);
108        self.entries.get(next).map(String::as_str)
109    }
110
111    /// Down arrow — step toward newer history. Returns the
112    /// recalled entry, or `None` to signal "you've stepped past
113    /// the newest entry; restore the live draft" (the buffer
114    /// owns the saved draft and handles that branch).
115    pub fn recall_next(&mut self) -> Option<&str> {
116        let cur = self.cursor?;
117        if cur + 1 >= self.entries.len() {
118            self.cursor = None;
119            return None;
120        }
121        let next = cur + 1;
122        self.cursor = Some(next);
123        self.entries.get(next).map(String::as_str)
124    }
125
126    /// True when the operator is currently navigating history
127    /// (not editing the live draft).
128    #[must_use]
129    pub const fn is_recalling(&self) -> bool {
130        self.cursor.is_some()
131    }
132
133    #[must_use]
134    pub fn len(&self) -> usize {
135        self.entries.len()
136    }
137
138    #[must_use]
139    pub fn is_empty(&self) -> bool {
140        self.entries.is_empty()
141    }
142}
143
144#[derive(Debug)]
145pub struct PromptBuffer {
146    lines: Vec<Vec<char>>,
147    row: usize,
148    col: usize,
149    /// Sticky column intent: when moving up/down across short
150    /// lines, the cursor remembers the column the operator wanted
151    /// even if a passing line was too short. Reset on any
152    /// horizontal motion or insert.
153    desired_col: Option<usize>,
154    history: PromptHistory,
155    /// Snapshot of the live draft taken at the moment the
156    /// operator began recalling history. Restored when they step
157    /// past the newest entry. `None` between recalls.
158    saved_draft: Option<Vec<Vec<char>>>,
159}
160
161impl Default for PromptBuffer {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl PromptBuffer {
168    #[must_use]
169    pub fn new() -> Self {
170        Self {
171            lines: vec![Vec::new()],
172            row: 0,
173            col: 0,
174            desired_col: None,
175            history: PromptHistory::new(),
176            saved_draft: None,
177        }
178    }
179
180    // ─── Read accessors ────────────────────────────────────────
181
182    #[must_use]
183    pub fn is_empty(&self) -> bool {
184        self.lines.iter().all(Vec::is_empty)
185    }
186
187    /// Joined string with embedded `\n` between rows. Allocates.
188    #[must_use]
189    pub fn as_string(&self) -> String {
190        let mut out = String::new();
191        for (i, line) in self.lines.iter().enumerate() {
192            if i > 0 {
193                out.push('\n');
194            }
195            for c in line {
196                out.push(*c);
197            }
198        }
199        out
200    }
201
202    /// Number of rows in the current buffer. Always ≥ 1.
203    #[must_use]
204    pub fn height(&self) -> usize {
205        self.lines.len()
206    }
207
208    /// Current cursor row (0-based).
209    #[must_use]
210    pub const fn cursor_row(&self) -> usize {
211        self.row
212    }
213
214    /// Current cursor column on the active row.
215    #[must_use]
216    pub const fn cursor(&self) -> usize {
217        self.col
218    }
219
220    /// Same as [`cursor`] but as a `u16` for ratatui's column API.
221    #[must_use]
222    pub fn cursor_column(&self) -> u16 {
223        u16::try_from(self.col).unwrap_or(u16::MAX)
224    }
225
226    /// Read a row's contents — used by the widget for rendering.
227    #[must_use]
228    pub fn line(&self, row: usize) -> Option<&[char]> {
229        self.lines.get(row).map(Vec::as_slice)
230    }
231
232    /// Borrowed access to the history ring (read-only).
233    #[must_use]
234    pub const fn history(&self) -> &PromptHistory {
235        &self.history
236    }
237
238    // ─── Edits ─────────────────────────────────────────────────
239
240    /// Insert a literal character at the cursor. Discards the
241    /// history-recall flag — once you start typing on a recalled
242    /// entry it's part of your draft.
243    pub fn insert(&mut self, c: char) {
244        self.history.reset_cursor();
245        self.saved_draft = None;
246        self.desired_col = None;
247        let line = &mut self.lines[self.row];
248        line.insert(self.col, c);
249        self.col += 1;
250    }
251
252    /// `Shift+Enter` — break the current line at the cursor and
253    /// move down. Capped at [`MAX_LINES`]; further newlines are
254    /// silently dropped so a stuck Repeat key cannot grow the
255    /// prompt indefinitely.
256    pub fn insert_newline(&mut self) {
257        if self.lines.len() >= MAX_LINES {
258            return;
259        }
260        self.history.reset_cursor();
261        self.saved_draft = None;
262        self.desired_col = None;
263        let tail = self.lines[self.row].split_off(self.col);
264        self.lines.insert(self.row + 1, tail);
265        self.row += 1;
266        self.col = 0;
267    }
268
269    /// Backspace — delete the char to the left of the cursor; if
270    /// at column 0 of a non-first row, merge with the previous
271    /// row instead.
272    pub fn backspace(&mut self) {
273        self.desired_col = None;
274        if self.col > 0 {
275            self.col -= 1;
276            self.lines[self.row].remove(self.col);
277            return;
278        }
279        if self.row == 0 {
280            return;
281        }
282        // Merge current line into the one above.
283        let tail = std::mem::take(&mut self.lines[self.row]);
284        self.lines.remove(self.row);
285        self.row -= 1;
286        self.col = self.lines[self.row].len();
287        self.lines[self.row].extend(tail);
288    }
289
290    /// Delete — char at the cursor, or if at end of line, splice
291    /// the next line up.
292    pub fn delete(&mut self) {
293        self.desired_col = None;
294        let line_len = self.lines[self.row].len();
295        if self.col < line_len {
296            self.lines[self.row].remove(self.col);
297            return;
298        }
299        if self.row + 1 >= self.lines.len() {
300            return;
301        }
302        let next = self.lines.remove(self.row + 1);
303        self.lines[self.row].extend(next);
304    }
305
306    // ─── Movement ──────────────────────────────────────────────
307
308    pub fn move_left(&mut self) {
309        self.desired_col = None;
310        if self.col > 0 {
311            self.col -= 1;
312            return;
313        }
314        if self.row > 0 {
315            self.row -= 1;
316            self.col = self.lines[self.row].len();
317        }
318    }
319
320    pub fn move_right(&mut self) {
321        self.desired_col = None;
322        if self.col < self.lines[self.row].len() {
323            self.col += 1;
324            return;
325        }
326        if self.row + 1 < self.lines.len() {
327            self.row += 1;
328            self.col = 0;
329        }
330    }
331
332    pub fn move_home(&mut self) {
333        self.desired_col = None;
334        self.col = 0;
335    }
336
337    pub fn move_end(&mut self) {
338        self.desired_col = None;
339        self.col = self.lines[self.row].len();
340    }
341
342    /// Up arrow — *intra-buffer* movement. The caller (input.rs)
343    /// is responsible for routing Up to history recall when the
344    /// cursor is at the first row; this method only moves up
345    /// inside a multi-line buffer.
346    pub fn move_up(&mut self) {
347        if self.row == 0 {
348            return;
349        }
350        let want = self.desired_col.unwrap_or(self.col);
351        self.row -= 1;
352        self.col = want.min(self.lines[self.row].len());
353        self.desired_col = Some(want);
354    }
355
356    pub fn move_down(&mut self) {
357        if self.row + 1 >= self.lines.len() {
358            return;
359        }
360        let want = self.desired_col.unwrap_or(self.col);
361        self.row += 1;
362        self.col = want.min(self.lines[self.row].len());
363        self.desired_col = Some(want);
364    }
365
366    /// True when the cursor is on the *visual* top row of the
367    /// buffer. Input router uses this to decide whether Up should
368    /// recall history or move within the buffer.
369    #[must_use]
370    pub const fn cursor_on_first_row(&self) -> bool {
371        self.row == 0
372    }
373
374    /// True when the cursor is on the *visual* last row.
375    #[must_use]
376    pub fn cursor_on_last_row(&self) -> bool {
377        self.row + 1 == self.lines.len()
378    }
379
380    // ─── History ───────────────────────────────────────────────
381
382    /// Recall the previous (older) history entry. Stashes the
383    /// live draft on first use so a subsequent `recall_next`
384    /// past the newest can restore it.
385    pub fn recall_prev(&mut self) {
386        if !self.history.is_recalling() {
387            self.saved_draft = Some(self.lines.clone());
388        }
389        if let Some(line) = self.history.recall_prev().map(str::to_string) {
390            self.replace_with(&line);
391        }
392    }
393
394    /// Recall the next (newer) history entry, or restore the
395    /// saved draft when stepping past the newest entry.
396    pub fn recall_next(&mut self) {
397        if !self.history.is_recalling() {
398            return;
399        }
400        match self.history.recall_next() {
401            Some(line) => {
402                let line = line.to_string();
403                self.replace_with(&line);
404            }
405            None => {
406                // Past newest → restore the in-flight draft.
407                if let Some(draft) = self.saved_draft.take() {
408                    self.lines = draft;
409                    self.move_to_buffer_end();
410                }
411            }
412        }
413    }
414
415    fn replace_with(&mut self, s: &str) {
416        self.lines = s.split('\n').map(|l| l.chars().collect()).collect();
417        if self.lines.is_empty() {
418            self.lines.push(Vec::new());
419        }
420        self.move_to_buffer_end();
421    }
422
423    fn move_to_buffer_end(&mut self) {
424        self.row = self.lines.len() - 1;
425        self.col = self.lines[self.row].len();
426        self.desired_col = None;
427    }
428
429    /// Submit the buffer. Returns `None` for an all-whitespace
430    /// buffer so the caller can short-circuit. Also pushes the
431    /// joined text onto history and resets the recall cursor.
432    pub fn take(&mut self) -> Option<String> {
433        if self.is_empty() {
434            return None;
435        }
436        let s = self.as_string();
437        if s.trim().is_empty() {
438            self.clear();
439            return None;
440        }
441        self.history.push(&s);
442        self.clear();
443        Some(s)
444    }
445
446    /// Discard the buffer and reset the cursor, but keep history
447    /// intact. Used by Esc.
448    pub fn clear(&mut self) {
449        self.lines = vec![Vec::new()];
450        self.row = 0;
451        self.col = 0;
452        self.desired_col = None;
453        self.saved_draft = None;
454        self.history.reset_cursor();
455    }
456
457    /// Replace the whole buffer with a literal value (used by
458    /// the slash-command picker on Tab-complete). Cursor lands at
459    /// end-of-buffer.
460    pub fn replace_all(&mut self, s: &str) {
461        self.replace_with(s);
462        self.history.reset_cursor();
463        self.saved_draft = None;
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::{PromptBuffer, PromptHistory};
470
471    fn type_str(p: &mut PromptBuffer, s: &str) {
472        for c in s.chars() {
473            p.insert(c);
474        }
475    }
476
477    #[test]
478    fn insert_and_backspace() {
479        let mut p = PromptBuffer::new();
480        type_str(&mut p, "hello");
481        assert_eq!(p.as_string(), "hello");
482        assert_eq!(p.cursor(), 5);
483        p.backspace();
484        p.backspace();
485        assert_eq!(p.as_string(), "hel");
486        assert_eq!(p.cursor(), 3);
487    }
488
489    #[test]
490    fn move_and_delete_midway() {
491        let mut p = PromptBuffer::new();
492        type_str(&mut p, "foobar");
493        p.move_home();
494        p.move_right();
495        p.move_right();
496        p.delete();
497        assert_eq!(p.as_string(), "fobar");
498    }
499
500    #[test]
501    fn take_clears_and_pushes_history() {
502        let mut p = PromptBuffer::new();
503        type_str(&mut p, "/status");
504        assert_eq!(p.take().as_deref(), Some("/status"));
505        assert!(p.is_empty());
506        assert!(p.take().is_none());
507        assert_eq!(p.history().len(), 1);
508    }
509
510    #[test]
511    fn shift_enter_creates_new_row() {
512        let mut p = PromptBuffer::new();
513        type_str(&mut p, "first");
514        p.insert_newline();
515        type_str(&mut p, "second");
516        assert_eq!(p.height(), 2);
517        assert_eq!(p.as_string(), "first\nsecond");
518        assert_eq!(p.cursor_row(), 1);
519        assert_eq!(p.cursor(), 6);
520    }
521
522    #[test]
523    fn newline_in_middle_splits_line() {
524        let mut p = PromptBuffer::new();
525        type_str(&mut p, "abcdef");
526        p.move_home();
527        p.move_right();
528        p.move_right();
529        p.move_right();
530        p.insert_newline();
531        assert_eq!(p.as_string(), "abc\ndef");
532        assert_eq!(p.cursor_row(), 1);
533        assert_eq!(p.cursor(), 0);
534    }
535
536    #[test]
537    fn backspace_at_col0_merges_lines() {
538        let mut p = PromptBuffer::new();
539        type_str(&mut p, "foo");
540        p.insert_newline();
541        type_str(&mut p, "bar");
542        p.move_home();
543        p.backspace();
544        assert_eq!(p.as_string(), "foobar");
545        assert_eq!(p.height(), 1);
546        assert_eq!(p.cursor_row(), 0);
547        assert_eq!(p.cursor(), 3);
548    }
549
550    #[test]
551    fn delete_at_eol_merges_with_next_line() {
552        let mut p = PromptBuffer::new();
553        type_str(&mut p, "foo");
554        p.insert_newline();
555        type_str(&mut p, "bar");
556        // Move to end of first line.
557        p.move_up();
558        p.move_end();
559        p.delete();
560        assert_eq!(p.as_string(), "foobar");
561        assert_eq!(p.height(), 1);
562    }
563
564    #[test]
565    fn move_up_keeps_desired_column_across_short_lines() {
566        let mut p = PromptBuffer::new();
567        type_str(&mut p, "longest line");
568        p.insert_newline();
569        type_str(&mut p, "x");
570        p.insert_newline();
571        type_str(&mut p, "another long line");
572        // Cursor at row=2, col=17. Move up to row=1 (col clamps
573        // to 1 because line is "x"); then up to row=0 — desired
574        // column should snap back to 17.
575        p.move_up();
576        assert_eq!(p.cursor_row(), 1);
577        assert_eq!(p.cursor(), 1);
578        p.move_up();
579        assert_eq!(p.cursor_row(), 0);
580        assert_eq!(
581            p.cursor(),
582            12,
583            "desired col not preserved across short lines"
584        );
585    }
586
587    #[test]
588    fn history_dedupe_and_recall() {
589        let mut h = PromptHistory::with_capacity(8);
590        h.push("a");
591        h.push("b");
592        h.push("b");
593        h.push("c");
594        assert_eq!(h.len(), 3, "consecutive duplicates are deduped");
595        assert_eq!(h.recall_prev(), Some("c"));
596        assert_eq!(h.recall_prev(), Some("b"));
597        assert_eq!(h.recall_prev(), Some("a"));
598        assert_eq!(h.recall_prev(), Some("a"), "clamps at oldest");
599        assert_eq!(h.recall_next(), Some("b"));
600        assert_eq!(h.recall_next(), Some("c"));
601        assert_eq!(
602            h.recall_next(),
603            None,
604            "stepping past newest signals draft restore"
605        );
606    }
607
608    #[test]
609    fn recall_round_trip_preserves_draft() {
610        let mut p = PromptBuffer::new();
611        // Pre-populate history.
612        type_str(&mut p, "/status");
613        let _ = p.take();
614        type_str(&mut p, "/risk");
615        let _ = p.take();
616
617        // Type a draft, then recall up twice and back down twice.
618        type_str(&mut p, "abc");
619        assert_eq!(p.as_string(), "abc");
620        p.recall_prev();
621        assert_eq!(p.as_string(), "/risk");
622        p.recall_prev();
623        assert_eq!(p.as_string(), "/status");
624        p.recall_next();
625        assert_eq!(p.as_string(), "/risk");
626        p.recall_next();
627        assert_eq!(
628            p.as_string(),
629            "abc",
630            "draft must be restored at end of history walk"
631        );
632    }
633
634    #[test]
635    fn typing_on_recalled_entry_drops_recall_state() {
636        let mut p = PromptBuffer::new();
637        type_str(&mut p, "/status");
638        let _ = p.take();
639        p.recall_prev();
640        assert!(p.history().is_recalling());
641        p.insert('x');
642        assert!(
643            !p.history().is_recalling(),
644            "edits commit the recalled line"
645        );
646        // recall_next now does nothing — there is no draft to
647        // restore because the recalled entry has been adopted.
648        p.recall_next();
649        assert_eq!(p.as_string(), "/statusx");
650    }
651
652    #[test]
653    fn empty_submit_does_not_pollute_history() {
654        let mut p = PromptBuffer::new();
655        for c in "   ".chars() {
656            p.insert(c);
657        }
658        assert!(p.take().is_none());
659        assert_eq!(p.history().len(), 0);
660    }
661
662    #[test]
663    fn replace_all_lands_cursor_at_end() {
664        let mut p = PromptBuffer::new();
665        type_str(&mut p, "ab");
666        p.replace_all("/positions ");
667        assert_eq!(p.as_string(), "/positions ");
668        assert_eq!(p.cursor(), 11);
669    }
670
671    #[test]
672    fn newline_capped_at_max_lines() {
673        let mut p = PromptBuffer::new();
674        // MAX_LINES = 64. Pump enough Shift+Enters and verify it
675        // never exceeds the cap.
676        for _ in 0..200 {
677            p.insert_newline();
678        }
679        assert_eq!(p.height(), super::MAX_LINES);
680    }
681}