wisp/components/
input_prompt.rs1use tui::{Line, ViewContext};
2use unicode_width::UnicodeWidthChar;
3
4pub fn prompt_content_width(terminal_width: usize) -> usize {
5 if terminal_width >= 4 {
6 (terminal_width - 2).saturating_sub(3).max(1)
7 } else {
8 terminal_width.max(1)
9 }
10}
11
12pub struct InputPrompt<'a> {
13 pub input: &'a str,
14 pub cursor_index: usize,
15}
16
17pub struct InputPromptLayout {
18 pub lines: Vec<Line>,
19 pub cursor_row: usize,
21 pub cursor_col: u16,
23}
24
25impl InputPrompt<'_> {
26 pub fn layout(&self, context: &ViewContext) -> InputPromptLayout {
27 let width = usize::from(context.size.width);
28 let cursor_index = clamp_to_char_boundary(self.input, self.cursor_index);
29 let cursor_display_width = plain_display_width(&self.input[..cursor_index]);
30 let styled_input = style_input(self.input, context);
31
32 if width < 4 {
33 let mut line = Line::new("> ");
34 line.append_line(&styled_input);
35 let total_col = 2 + cursor_display_width;
36 let (cursor_row, cursor_col) = if width == 0 {
37 (0, 0)
38 } else {
39 (
40 total_col / width,
41 u16::try_from(total_col % width).unwrap_or(u16::MAX),
42 )
43 };
44 return InputPromptLayout {
45 lines: vec![line],
46 cursor_row,
47 cursor_col,
48 };
49 }
50
51 let inner_width = width - 2; let content_width = prompt_content_width(width);
53 let wrapped_chunks =
54 styled_input.soft_wrap(u16::try_from(content_width).unwrap_or(u16::MAX));
55
56 let cursor_content_row = cursor_display_width / content_width;
57 let cursor_content_col = cursor_display_width % content_width;
58
59 let content_rows = wrapped_chunks.len().max(cursor_content_row + 1);
61
62 let mut lines = Vec::with_capacity(content_rows + 2);
63 lines.push(Line::styled(
64 format!("╭{}╮", "─".repeat(inner_width)),
65 context.theme.muted(),
66 ));
67
68 for row in 0..content_rows {
69 let chunk = wrapped_chunks.get(row).cloned().unwrap_or_default();
70 let pad_len = content_width.saturating_sub(chunk.display_width());
71 let mut middle = Line::default();
72 middle.push_styled("│", context.theme.muted());
73 middle.push_text(" ");
74 if row == 0 {
75 middle.push_styled("> ", context.theme.primary());
76 } else {
77 middle.push_styled(" ", context.theme.muted());
78 }
79 middle.append_line(&chunk);
80 middle.push_text(" ".repeat(pad_len));
81 middle.push_styled("│", context.theme.muted());
82 lines.push(middle);
83 }
84
85 lines.push(Line::styled(
86 format!("╰{}╯", "─".repeat(inner_width)),
87 context.theme.muted(),
88 ));
89
90 InputPromptLayout {
91 lines,
92 cursor_row: 1 + cursor_content_row,
93 cursor_col: u16::try_from(4 + cursor_content_col).unwrap_or(u16::MAX),
95 }
96 }
97}
98
99impl InputPrompt<'_> {
100 #[cfg(test)]
101 pub fn render(&self, context: &ViewContext) -> Vec<Line> {
102 self.layout(context).lines
103 }
104}
105
106fn style_input(input: &str, context: &ViewContext) -> Line {
107 if !input.contains('@') {
108 return Line::styled(input, context.theme.text_primary());
109 }
110 style_mentions(input, context)
111}
112
113fn style_mentions(input: &str, context: &ViewContext) -> Line {
114 let mut styled = Line::default();
115 let mut last_pos = 0;
116
117 for (at_pos, _) in input.match_indices('@') {
118 if at_pos < last_pos {
119 continue;
120 }
121
122 styled.push_styled(&input[last_pos..at_pos], context.theme.text_primary());
123
124 let mention_end = input[at_pos..]
125 .find(' ')
126 .map_or(input.len(), |i| at_pos + i);
127 styled.push_styled(&input[at_pos..mention_end], context.theme.info());
128 last_pos = mention_end;
129 }
130
131 if last_pos < input.len() {
132 styled.push_styled(&input[last_pos..], context.theme.text_primary());
133 }
134
135 styled
136}
137
138fn plain_display_width(text: &str) -> usize {
139 text.chars()
140 .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
141 .sum()
142}
143
144fn clamp_to_char_boundary(text: &str, mut idx: usize) -> usize {
145 idx = idx.min(text.len());
146 while !text.is_char_boundary(idx) {
147 idx = idx.saturating_sub(1);
148 }
149 idx
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn renders_three_lines() {
158 let prompt = InputPrompt {
159 input: "",
160 cursor_index: 0,
161 };
162 let ctx = ViewContext::new((80, 24));
163 let lines = prompt.render(&ctx);
164 assert_eq!(lines.len(), 3);
165 }
166
167 #[test]
168 fn top_border_contains_box_chars() {
169 let prompt = InputPrompt {
170 input: "",
171 cursor_index: 0,
172 };
173 let ctx = ViewContext::new((80, 24));
174 let lines = prompt.render(&ctx);
175 assert!(lines[0].plain_text().contains("╭"));
176 assert!(lines[0].plain_text().contains("╮"));
177 }
178
179 #[test]
180 fn bottom_border_contains_box_chars() {
181 let prompt = InputPrompt {
182 input: "",
183 cursor_index: 0,
184 };
185 let ctx = ViewContext::new((80, 24));
186 let lines = prompt.render(&ctx);
187 assert!(lines[2].plain_text().contains("╰"));
188 assert!(lines[2].plain_text().contains("╯"));
189 }
190
191 #[test]
192 fn middle_line_contains_prompt() {
193 let prompt = InputPrompt {
194 input: "",
195 cursor_index: 0,
196 };
197 let ctx = ViewContext::new((80, 24));
198 let lines = prompt.render(&ctx);
199 assert!(lines[1].plain_text().contains("> "));
200 assert!(lines[1].plain_text().contains("│"));
201 }
202
203 #[test]
204 fn renders_input_text() {
205 let prompt = InputPrompt {
206 input: "hello",
207 cursor_index: 5,
208 };
209 let ctx = ViewContext::new((80, 24));
210 let lines = prompt.render(&ctx);
211 assert!(lines[1].plain_text().contains("hello"));
212 }
213
214 #[test]
215 fn renders_consistently() {
216 let prompt = InputPrompt {
217 input: "test",
218 cursor_index: 4,
219 };
220 let ctx = ViewContext::new((80, 24));
221 let a = prompt.render(&ctx);
222 let b = prompt.render(&ctx);
223 assert_eq!(a, b);
224 }
225
226 #[test]
227 fn adapts_to_terminal_width() {
228 let prompt = InputPrompt {
229 input: "",
230 cursor_index: 0,
231 };
232 let narrow = ViewContext::new((40, 24));
233 let wide = ViewContext::new((120, 24));
234 let narrow_lines = prompt.render(&narrow);
235 let wide_lines = prompt.render(&wide);
236 assert_eq!(narrow_lines.len(), 3);
238 assert_eq!(wide_lines.len(), 3);
239 assert!(wide_lines[0].plain_text().len() > narrow_lines[0].plain_text().len());
241 }
242
243 #[test]
244 fn wraps_long_input_inside_box() {
245 let prompt = InputPrompt {
246 input: "this is a very long input that should wrap",
247 cursor_index: 41,
248 };
249 let ctx = ViewContext::new((20, 24));
250 let lines = prompt.render(&ctx);
251 assert!(lines.len() > 3);
252 assert!(lines.iter().all(|line| line.plain_text().contains("│")
253 || line.plain_text().contains("╭")
254 || line.plain_text().contains("╰")));
255 }
256
257 #[test]
258 fn mention_and_plain_text_both_render() {
259 let prompt = InputPrompt {
260 input: "@main.rs explain this",
261 cursor_index: 20,
262 };
263 let ctx = ViewContext::new((80, 24));
264 let lines = prompt.render(&ctx);
265 assert!(lines[1].plain_text().contains("@main.rs"));
266 assert!(lines[1].plain_text().contains("explain this"));
267 }
268}