Skip to main content

wisp/components/
input_prompt.rs

1use 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    /// Cursor row within `lines` (0-based).
19    pub cursor_row: usize,
20    /// Cursor column on that row (0-based).
21    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        // Both should produce 3 lines but different widths
179        assert_eq!(narrow_lines.len(), 3);
180        assert_eq!(wide_lines.len(), 3);
181        // Wide border should be longer than narrow
182        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}