zero_tui/widgets/
prompt.rs1use 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
27pub const PROMPT_CUE: &str = "> ";
29pub const PROMPT_CONTINUATION: &str = ". ";
31
32const 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 #[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 #[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 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 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 assert_eq!(w.cursor_position(), (6, 1));
177 }
178}