Skip to main content

wisp/components/
input_prompt.rs

1use 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    /// Cursor row within `lines` (0-based).
20    pub cursor_row: usize,
21    /// Cursor column on that row (0-based).
22    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; // space between │ and │
52        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        // Ensure we always render enough rows to place the cursor safely.
60        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            // "│ > " (or "│   ") takes 4 visual columns.
94            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        // Both should produce 3 lines but different widths
237        assert_eq!(narrow_lines.len(), 3);
238        assert_eq!(wide_lines.len(), 3);
239        // Wide border should be longer than narrow
240        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}