wisp/components/
input_prompt.rs1use tui::{Line, ViewContext};
2
3pub fn prompt_content_width(terminal_width: usize) -> usize {
4 terminal_width.saturating_sub(2).max(1)
5}
6
7pub fn prompt_text_start_col(_terminal_width: usize) -> usize {
8 2
9}
10
11pub struct InputPrompt<'a> {
12 pub input: &'a str,
13 pub cursor_index: usize,
14}
15
16pub struct InputPromptLayout {
17 pub lines: Vec<Line>,
18 pub cursor_row: usize,
20 pub cursor_col: u16,
22}
23
24impl InputPrompt<'_> {
25 pub fn layout(&self, context: &ViewContext) -> InputPromptLayout {
26 let width = usize::from(context.size.width);
27 let cursor_index = clamp_to_char_boundary(self.input, self.cursor_index);
28 let styled_input = style_input(self.input, context);
29
30 let content_width = prompt_content_width(width);
31 let content_width_u16 = u16::try_from(content_width).unwrap_or(u16::MAX);
32 let wrapped_chunks = styled_input.soft_wrap(content_width_u16);
33
34 let (cursor_content_row, cursor_content_col) =
35 wrapped_cursor_position(self.input, cursor_index, content_width_u16);
36
37 let content_rows = wrapped_chunks.len().max(cursor_content_row + 1);
38
39 let mut lines = Vec::with_capacity(content_rows + 2);
40 lines.push(Line::styled("─".repeat(width), context.theme.muted()));
41
42 for row in 0..content_rows {
43 let chunk = wrapped_chunks.get(row).cloned().unwrap_or_default();
44 let mut middle = Line::default();
45 if row == 0 {
46 middle.push_styled("> ", context.theme.primary());
47 } else {
48 middle.push_styled(" ", context.theme.muted());
49 }
50 middle.append_line(&chunk);
51 lines.push(middle);
52 }
53
54 lines.push(Line::styled("─".repeat(width), context.theme.muted()));
55
56 InputPromptLayout {
57 lines,
58 cursor_row: 1 + cursor_content_row,
59 cursor_col: u16::try_from(prompt_text_start_col(width) + cursor_content_col).unwrap_or(u16::MAX),
60 }
61 }
62}
63
64impl InputPrompt<'_> {
65 #[cfg(test)]
66 pub fn render(&self, context: &ViewContext) -> Vec<Line> {
67 self.layout(context).lines
68 }
69}
70
71fn style_input(input: &str, context: &ViewContext) -> Line {
72 if !input.contains('@') {
73 return Line::styled(input, context.theme.text_primary());
74 }
75 style_mentions(input, context)
76}
77
78fn style_mentions(input: &str, context: &ViewContext) -> Line {
79 let mut styled = Line::default();
80 let mut last_pos = 0;
81
82 for (at_pos, _) in input.match_indices('@') {
83 if at_pos < last_pos {
84 continue;
85 }
86
87 styled.push_styled(&input[last_pos..at_pos], context.theme.text_primary());
88
89 let mention_end = input[at_pos..].find(' ').map_or(input.len(), |i| at_pos + i);
90 styled.push_styled(&input[at_pos..mention_end], context.theme.info());
91 last_pos = mention_end;
92 }
93
94 if last_pos < input.len() {
95 styled.push_styled(&input[last_pos..], context.theme.text_primary());
96 }
97
98 styled
99}
100
101fn clamp_to_char_boundary(text: &str, mut idx: usize) -> usize {
102 idx = idx.min(text.len());
103 while !text.is_char_boundary(idx) {
104 idx = idx.saturating_sub(1);
105 }
106 idx
107}
108
109fn wrapped_cursor_position(input: &str, cursor_index: usize, content_width: u16) -> (usize, usize) {
110 let cursor_index = clamp_to_char_boundary(input, cursor_index);
111 let wrapped_prefix = Line::new(&input[..cursor_index]).soft_wrap(content_width);
112 let row = wrapped_prefix.len().saturating_sub(1);
113 let col = wrapped_prefix.last().map_or(0, Line::display_width);
114 (row, col)
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn renders_three_lines() {
123 let prompt = InputPrompt { input: "", cursor_index: 0 };
124 let ctx = ViewContext::new((80, 24));
125 let lines = prompt.render(&ctx);
126 assert_eq!(lines.len(), 3);
127 }
128
129 #[test]
130 fn top_rule_is_horizontal_line() {
131 let prompt = InputPrompt { input: "", cursor_index: 0 };
132 let ctx = ViewContext::new((80, 24));
133 let lines = prompt.render(&ctx);
134 assert!(lines[0].plain_text().chars().all(|c| c == '─'));
135 assert_eq!(lines[0].display_width(), 80);
136 }
137
138 #[test]
139 fn bottom_rule_is_horizontal_line() {
140 let prompt = InputPrompt { input: "", cursor_index: 0 };
141 let ctx = ViewContext::new((80, 24));
142 let lines = prompt.render(&ctx);
143 assert!(lines[2].plain_text().chars().all(|c| c == '─'));
144 }
145
146 #[test]
147 fn middle_line_contains_prompt() {
148 let prompt = InputPrompt { input: "", cursor_index: 0 };
149 let ctx = ViewContext::new((80, 24));
150 let lines = prompt.render(&ctx);
151 assert!(lines[1].plain_text().starts_with("> "));
152 }
153
154 #[test]
155 fn renders_input_text() {
156 let prompt = InputPrompt { input: "hello", cursor_index: 5 };
157 let ctx = ViewContext::new((80, 24));
158 let lines = prompt.render(&ctx);
159 assert!(lines[1].plain_text().contains("hello"));
160 }
161
162 #[test]
163 fn renders_consistently() {
164 let prompt = InputPrompt { input: "test", cursor_index: 4 };
165 let ctx = ViewContext::new((80, 24));
166 let a = prompt.render(&ctx);
167 let b = prompt.render(&ctx);
168 assert_eq!(a, b);
169 }
170
171 #[test]
172 fn adapts_to_terminal_width() {
173 let prompt = InputPrompt { input: "", cursor_index: 0 };
174 let narrow = ViewContext::new((40, 24));
175 let wide = ViewContext::new((120, 24));
176 let narrow_lines = prompt.render(&narrow);
177 let wide_lines = prompt.render(&wide);
178 assert_eq!(narrow_lines.len(), 3);
180 assert_eq!(wide_lines.len(), 3);
181 assert!(wide_lines[0].plain_text().len() > narrow_lines[0].plain_text().len());
183 }
184
185 #[test]
186 fn wraps_long_input() {
187 let prompt = InputPrompt { input: "this is a very long input that should wrap", cursor_index: 41 };
188 let ctx = ViewContext::new((20, 24));
189 let lines = prompt.render(&ctx);
190 assert!(lines.len() > 3);
191 }
192
193 #[test]
194 fn mention_and_plain_text_both_render() {
195 let prompt = InputPrompt { input: "@main.rs explain this", cursor_index: 20 };
196 let ctx = ViewContext::new((80, 24));
197 let lines = prompt.render(&ctx);
198 assert!(lines[1].plain_text().contains("@main.rs"));
199 assert!(lines[1].plain_text().contains("explain this"));
200 }
201}