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 { (terminal_width - 2).saturating_sub(3).max(1) } else { terminal_width.max(1) }
6}
7
8pub fn prompt_text_start_col(terminal_width: usize) -> usize {
9    if terminal_width >= 4 { 4 } else { 2.min(terminal_width) }
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                (total_col / width, u16::try_from(total_col % width).unwrap_or(u16::MAX))
40            };
41            return InputPromptLayout { lines: vec![line], cursor_row, cursor_col };
42        }
43
44        let inner_width = width - 2; // space between │ and │
45        let content_width = prompt_content_width(width);
46        let wrapped_chunks = styled_input.soft_wrap(u16::try_from(content_width).unwrap_or(u16::MAX));
47
48        let cursor_content_row = cursor_display_width / content_width;
49        let cursor_content_col = cursor_display_width % content_width;
50
51        // Ensure we always render enough rows to place the cursor safely.
52        let content_rows = wrapped_chunks.len().max(cursor_content_row + 1);
53
54        let mut lines = Vec::with_capacity(content_rows + 2);
55        lines.push(Line::styled(format!("╭{}╮", "─".repeat(inner_width)), context.theme.muted()));
56
57        for row in 0..content_rows {
58            let chunk = wrapped_chunks.get(row).cloned().unwrap_or_default();
59            let pad_len = content_width.saturating_sub(chunk.display_width());
60            let mut middle = Line::default();
61            middle.push_styled("│", context.theme.muted());
62            middle.push_text(" ");
63            if row == 0 {
64                middle.push_styled("> ", context.theme.primary());
65            } else {
66                middle.push_styled("  ", context.theme.muted());
67            }
68            middle.append_line(&chunk);
69            middle.push_text(" ".repeat(pad_len));
70            middle.push_styled("│", context.theme.muted());
71            lines.push(middle);
72        }
73
74        lines.push(Line::styled(format!("╰{}╯", "─".repeat(inner_width)), context.theme.muted()));
75
76        InputPromptLayout {
77            lines,
78            cursor_row: 1 + cursor_content_row,
79            cursor_col: u16::try_from(prompt_text_start_col(width) + cursor_content_col).unwrap_or(u16::MAX),
80        }
81    }
82}
83
84impl InputPrompt<'_> {
85    #[cfg(test)]
86    pub fn render(&self, context: &ViewContext) -> Vec<Line> {
87        self.layout(context).lines
88    }
89}
90
91fn style_input(input: &str, context: &ViewContext) -> Line {
92    if !input.contains('@') {
93        return Line::styled(input, context.theme.text_primary());
94    }
95    style_mentions(input, context)
96}
97
98fn style_mentions(input: &str, context: &ViewContext) -> Line {
99    let mut styled = Line::default();
100    let mut last_pos = 0;
101
102    for (at_pos, _) in input.match_indices('@') {
103        if at_pos < last_pos {
104            continue;
105        }
106
107        styled.push_styled(&input[last_pos..at_pos], context.theme.text_primary());
108
109        let mention_end = input[at_pos..].find(' ').map_or(input.len(), |i| at_pos + i);
110        styled.push_styled(&input[at_pos..mention_end], context.theme.info());
111        last_pos = mention_end;
112    }
113
114    if last_pos < input.len() {
115        styled.push_styled(&input[last_pos..], context.theme.text_primary());
116    }
117
118    styled
119}
120
121fn plain_display_width(text: &str) -> usize {
122    text.chars().map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)).sum()
123}
124
125fn clamp_to_char_boundary(text: &str, mut idx: usize) -> usize {
126    idx = idx.min(text.len());
127    while !text.is_char_boundary(idx) {
128        idx = idx.saturating_sub(1);
129    }
130    idx
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn renders_three_lines() {
139        let prompt = InputPrompt { input: "", cursor_index: 0 };
140        let ctx = ViewContext::new((80, 24));
141        let lines = prompt.render(&ctx);
142        assert_eq!(lines.len(), 3);
143    }
144
145    #[test]
146    fn top_border_contains_box_chars() {
147        let prompt = InputPrompt { input: "", cursor_index: 0 };
148        let ctx = ViewContext::new((80, 24));
149        let lines = prompt.render(&ctx);
150        assert!(lines[0].plain_text().contains("╭"));
151        assert!(lines[0].plain_text().contains("╮"));
152    }
153
154    #[test]
155    fn bottom_border_contains_box_chars() {
156        let prompt = InputPrompt { input: "", cursor_index: 0 };
157        let ctx = ViewContext::new((80, 24));
158        let lines = prompt.render(&ctx);
159        assert!(lines[2].plain_text().contains("╰"));
160        assert!(lines[2].plain_text().contains("╯"));
161    }
162
163    #[test]
164    fn middle_line_contains_prompt() {
165        let prompt = InputPrompt { input: "", cursor_index: 0 };
166        let ctx = ViewContext::new((80, 24));
167        let lines = prompt.render(&ctx);
168        assert!(lines[1].plain_text().contains("> "));
169        assert!(lines[1].plain_text().contains("│"));
170    }
171
172    #[test]
173    fn renders_input_text() {
174        let prompt = InputPrompt { input: "hello", cursor_index: 5 };
175        let ctx = ViewContext::new((80, 24));
176        let lines = prompt.render(&ctx);
177        assert!(lines[1].plain_text().contains("hello"));
178    }
179
180    #[test]
181    fn renders_consistently() {
182        let prompt = InputPrompt { input: "test", cursor_index: 4 };
183        let ctx = ViewContext::new((80, 24));
184        let a = prompt.render(&ctx);
185        let b = prompt.render(&ctx);
186        assert_eq!(a, b);
187    }
188
189    #[test]
190    fn adapts_to_terminal_width() {
191        let prompt = InputPrompt { input: "", cursor_index: 0 };
192        let narrow = ViewContext::new((40, 24));
193        let wide = ViewContext::new((120, 24));
194        let narrow_lines = prompt.render(&narrow);
195        let wide_lines = prompt.render(&wide);
196        // Both should produce 3 lines but different widths
197        assert_eq!(narrow_lines.len(), 3);
198        assert_eq!(wide_lines.len(), 3);
199        // Wide border should be longer than narrow
200        assert!(wide_lines[0].plain_text().len() > narrow_lines[0].plain_text().len());
201    }
202
203    #[test]
204    fn wraps_long_input_inside_box() {
205        let prompt = InputPrompt { input: "this is a very long input that should wrap", cursor_index: 41 };
206        let ctx = ViewContext::new((20, 24));
207        let lines = prompt.render(&ctx);
208        assert!(lines.len() > 3);
209        assert!(lines.iter().all(|line| line.plain_text().contains("│")
210            || line.plain_text().contains("╭")
211            || line.plain_text().contains("╰")));
212    }
213
214    #[test]
215    fn mention_and_plain_text_both_render() {
216        let prompt = InputPrompt { input: "@main.rs explain this", cursor_index: 20 };
217        let ctx = ViewContext::new((80, 24));
218        let lines = prompt.render(&ctx);
219        assert!(lines[1].plain_text().contains("@main.rs"));
220        assert!(lines[1].plain_text().contains("explain this"));
221    }
222}