Skip to main content

zero_tui/widgets/
prompt.rs

1//! Prompt widget — renders the [`PromptBuffer`] as N rows.
2//!
3//! The widget paints the leading `> ` cue on the first row and a
4//! continuation cue (`. `) on every subsequent row, matching the
5//! convention most readers know from REPLs. Empty trailing rows
6//! still get the continuation cue so the operator sees that
7//! `Shift+Enter` actually opened a new line.
8//!
9//! Cursor placement is the caller's responsibility (ratatui
10//! requires an explicit `Frame::set_cursor_position`). The widget
11//! exposes [`PromptWidget::cursor_position`] for that purpose.
12//!
13//! Styling is uniform across rows — no syntax highlighting. The
14//! widget intentionally does not inspect buffer contents, so
15//! command-name colorization (if added later) lives in a
16//! companion overlay rather than inside the editor.
17
18use ratatui::buffer::Buffer;
19use ratatui::layout::Rect;
20use ratatui::style::Style;
21use ratatui::text::{Line, Span};
22use ratatui::widgets::Widget;
23
24use crate::app::prompt::PromptBuffer;
25use crate::theme::Theme;
26
27/// Leading cue on the first prompt row.
28pub const PROMPT_CUE: &str = "> ";
29/// Continuation cue on subsequent rows of a multi-line prompt.
30pub const PROMPT_CONTINUATION: &str = ". ";
31
32/// Cue width in columns. Both cues are 2 ASCII chars; we hard-code
33/// the constant to avoid a `chars().count()` per render.
34const CUE_WIDTH: u16 = 2;
35
36#[derive(Debug)]
37pub struct PromptWidget<'a> {
38    pub prompt: &'a PromptBuffer,
39    pub theme: Theme,
40}
41
42impl PromptWidget<'_> {
43    /// Cursor position relative to the widget's `area` origin.
44    /// Returns `(col, row)` so it composes naturally with
45    /// `(area.x + col, area.y + row)` at the call site.
46    #[must_use]
47    pub fn cursor_position(&self) -> (u16, u16) {
48        let col = CUE_WIDTH.saturating_add(self.prompt.cursor_column());
49        let row = u16::try_from(self.prompt.cursor_row()).unwrap_or(u16::MAX);
50        (col, row)
51    }
52
53    /// Backwards-compatible single-int cursor (column on the
54    /// active row). Retained because some snapshot tests still
55    /// reference it.
56    #[must_use]
57    pub fn cursor_column(&self) -> u16 {
58        self.cursor_position().0
59    }
60}
61
62impl Widget for PromptWidget<'_> {
63    fn render(self, area: Rect, buf: &mut Buffer) {
64        if area.height == 0 || area.width == 0 {
65            return;
66        }
67        // Clear the area before painting so a shrinking prompt
68        // doesn't leave ghost characters from the previous frame.
69        for y in area.top()..area.bottom() {
70            for x in area.left()..area.right() {
71                buf[(x, y)].set_char(' ');
72            }
73        }
74
75        let cue_style = Style::default().fg(self.theme.primary);
76        let body_style = Style::default().fg(self.theme.primary);
77        let cont_style = Style::default().fg(self.theme.metadata);
78
79        let visible_rows = usize::from(area.height);
80        for visible_row in 0..visible_rows {
81            let buf_row = visible_row;
82            let line_chars = self.prompt.line(buf_row);
83            // Only the rows the buffer actually owns get content.
84            // Beyond that, leave the row blank — `area.height` is
85            // chosen by the layout to match buffer height, so this
86            // branch is mostly defensive.
87            let Some(chars) = line_chars else {
88                break;
89            };
90            let body: String = chars.iter().collect();
91            let cue = if buf_row == 0 {
92                PROMPT_CUE
93            } else {
94                PROMPT_CONTINUATION
95            };
96            let cue_span = Span::styled(cue, if buf_row == 0 { cue_style } else { cont_style });
97            let body_span = Span::styled(body, body_style);
98            let row_area = Rect {
99                x: area.x,
100                y: area.y + u16::try_from(visible_row).unwrap_or(u16::MAX),
101                width: area.width,
102                height: 1,
103            };
104            Line::from(vec![cue_span, body_span]).render(row_area, buf);
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use ratatui::Terminal;
113    use ratatui::backend::TestBackend;
114
115    fn render(prompt: &PromptBuffer, width: u16, height: u16) -> Vec<String> {
116        let backend = TestBackend::new(width, height);
117        let mut term = Terminal::new(backend).expect("terminal");
118        term.draw(|f| {
119            let w = PromptWidget {
120                prompt,
121                theme: Theme::default(),
122            };
123            f.render_widget(w, f.area());
124        })
125        .expect("draw");
126        let buf = term.backend().buffer().clone();
127        (0..buf.area.height)
128            .map(|y| {
129                (0..buf.area.width)
130                    .map(|x| buf[(x, y)].symbol().to_string())
131                    .collect::<String>()
132            })
133            .collect()
134    }
135
136    #[test]
137    fn single_row_prompt_uses_primary_cue() {
138        let mut p = PromptBuffer::new();
139        for c in "/help".chars() {
140            p.insert(c);
141        }
142        let lines = render(&p, 20, 1);
143        assert_eq!(lines[0].trim_end(), "> /help");
144    }
145
146    #[test]
147    fn second_row_uses_continuation_cue() {
148        let mut p = PromptBuffer::new();
149        for c in "abc".chars() {
150            p.insert(c);
151        }
152        p.insert_newline();
153        for c in "def".chars() {
154            p.insert(c);
155        }
156        let lines = render(&p, 20, 2);
157        assert_eq!(lines[0].trim_end(), "> abc");
158        assert_eq!(lines[1].trim_end(), ". def");
159    }
160
161    #[test]
162    fn cursor_position_accounts_for_cue_and_row() {
163        let mut p = PromptBuffer::new();
164        for c in "ab".chars() {
165            p.insert(c);
166        }
167        p.insert_newline();
168        for c in "cdef".chars() {
169            p.insert(c);
170        }
171        let w = PromptWidget {
172            prompt: &p,
173            theme: Theme::default(),
174        };
175        // Cursor is at row=1 col=4 → screen col = 2 + 4 = 6.
176        assert_eq!(w.cursor_position(), (6, 1));
177    }
178}