mermaid_cli/render/widgets/input.rs
1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::Style,
5 widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
6};
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9use crate::render::theme::Theme;
10
11/// State for the input widget
12#[derive(Debug, Clone)]
13pub struct InputState {
14 /// Cursor position in the input string
15 pub cursor_position: usize,
16}
17
18impl InputState {
19 /// Create a new input state
20 pub fn new() -> Self {
21 Self { cursor_position: 0 }
22 }
23
24 /// Calculate cursor position for wrapped text.
25 ///
26 /// `content_width` is in **display cells**. Returns `(row, col)` where
27 /// `col` is also in display cells — required because `Frame::set_cursor_
28 /// position` is cell-based, not byte-based. CJK / emoji input previously
29 /// mispositioned the cursor because the column was returned in bytes.
30 ///
31 /// Uses `find_line_break` so the wrapping decisions match
32 /// `wrap_input_with_prompt` exactly — keep them consistent by routing
33 /// any future line-break logic through that shared helper.
34 pub fn calculate_cursor_position(
35 input: &str,
36 cursor_pos: usize,
37 content_width: usize,
38 ) -> (u16, u16) {
39 let cursor_pos = cursor_pos.min(input.len());
40
41 if content_width < 3 || input.is_empty() {
42 return (0, 0);
43 }
44
45 // Available cells per line after the 2-cell prefix ("> " or " ")
46 let line_width = content_width.saturating_sub(2);
47 if line_width == 0 {
48 return (0, 0);
49 }
50
51 let mut current_line: usize = 0;
52 let mut consumed: usize = 0; // byte offset into `input`
53
54 let mut chars_remaining = input;
55 loop {
56 let break_point = find_line_break(chars_remaining, line_width);
57
58 // Calculate whitespace gap between this line and the next
59 let after = &chars_remaining[break_point..];
60 let next_content = after.trim_start();
61 let ws_gap = after.len() - next_content.len();
62 let is_last_line = next_content.is_empty();
63
64 // Cursor belongs to this line if it falls within the line chars,
65 // or within the whitespace gap (trimmed between lines), or if
66 // this is the last line (cursor must be here).
67 if cursor_pos < consumed + break_point + ws_gap || is_last_line {
68 // Cap at break_point so trailing / gap whitespace doesn't
69 // overflow past the visible line.
70 let cursor_byte_in_line = cursor_pos.saturating_sub(consumed).min(break_point);
71 // Convert the byte offset to display cells by summing widths.
72 let line_text = &chars_remaining[..break_point];
73 let col_cells = line_text[..cursor_byte_in_line.min(line_text.len())].width();
74 return (current_line as u16, col_cells as u16);
75 }
76
77 consumed += break_point + ws_gap;
78 chars_remaining = next_content;
79 current_line += 1;
80 }
81 }
82}
83
84impl Default for InputState {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90/// Props for InputWidget. The slash-command palette is rendered
91/// separately as `SlashPaletteWidget` in the bottom region (see
92/// `render.rs`); this widget just draws the bordered input box.
93pub struct InputWidget<'a> {
94 pub input: &'a str,
95 /// True when a slash command is in flight (input starts with `/`).
96 /// Drives the warning-yellow border color so the user has a visual
97 /// cue that they're in command-entry mode.
98 pub showing_command_hints: bool,
99 pub theme: &'a Theme,
100 /// Reasoning is currently enabled (any non-`None` level). Drives the
101 /// cyan/sage border color cue.
102 pub reasoning_active: bool,
103}
104
105impl<'a> StatefulWidget for InputWidget<'a> {
106 type State = InputState;
107
108 fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
109 let input_style = Style::new().fg(self.theme.colors.text_primary.to_color());
110
111 // Manually wrap input text with proper indentation (Claude Code style)
112 // First line: "> text"
113 // Continuation lines: " text" (2 spaces to align with first line content)
114 // Always show "> " prompt, even when input is empty
115 let input_text = {
116 let width = area.width.saturating_sub(2) as usize; // Account for top/bottom borders
117 wrap_input_with_prompt(self.input, width)
118 };
119
120 // Border color priority: command-entry mode wins (yellow),
121 // then reasoning-enabled (cyan), then default gray.
122 let border_color = if self.showing_command_hints {
123 self.theme.colors.warning.to_color()
124 } else if self.reasoning_active {
125 // Mermaid sage blue - same as the path color in status bar
126 self.theme.colors.info.to_color() // cyan
127 } else {
128 self.theme.colors.border.to_color() // gray
129 };
130
131 let block = if self.showing_command_hints {
132 Block::default()
133 .borders(Borders::TOP | Borders::BOTTOM)
134 .border_style(Style::new().fg(border_color))
135 .title(" Enter Command ")
136 } else {
137 Block::default()
138 .borders(Borders::TOP | Borders::BOTTOM)
139 .border_style(Style::new().fg(border_color))
140 };
141
142 let input = Paragraph::new(input_text).style(input_style).block(block);
143
144 input.render(area, buf);
145
146 // Note: Cursor positioning is handled in the main render loop after all widgets are rendered
147 // The Frame::set_cursor_position() is called there with the calculated position
148 }
149}
150
151/// Given a tail of input and a max line width (in **display cells**, not
152/// bytes), return the byte offset where this line should end.
153///
154/// Walks `remaining` char-by-char accumulating `UnicodeWidthChar::width`
155/// so CJK / emoji break at the visual edge instead of after ~1/3 of the
156/// space (the byte length of multi-byte chars exceeds their cell width).
157/// Prefers a whitespace break within the accepted range; falls back to a
158/// hard break at the char boundary if no whitespace exists. Always makes
159/// progress: if even the first character exceeds `line_width`, returns
160/// the byte offset *after* it so the caller can't infinite-loop.
161///
162/// Shared between `InputState::calculate_cursor_position` and
163/// `wrap_input_with_prompt` so both make identical wrapping decisions.
164fn find_line_break(remaining: &str, line_width: usize) -> usize {
165 if remaining.is_empty() {
166 return 0;
167 }
168
169 // Walk chars, accumulating display width, to find the byte offset at
170 // which the running cell-count would exceed `line_width`. If the whole
171 // string fits, we're done.
172 let mut acc_width = 0usize;
173 let mut hard_break = remaining.len();
174 for (byte_idx, ch) in remaining.char_indices() {
175 let ch_width = ch.width().unwrap_or(0);
176 if acc_width + ch_width > line_width {
177 hard_break = byte_idx;
178 break;
179 }
180 acc_width += ch_width;
181 }
182
183 if hard_break == remaining.len() {
184 return remaining.len();
185 }
186
187 // If the very first character is wider than the entire line (e.g. a
188 // double-width emoji on a 1-cell viewport), force progress by taking
189 // exactly one char — otherwise the caller loops forever.
190 if hard_break == 0 {
191 return remaining
192 .char_indices()
193 .nth(1)
194 .map(|(idx, _)| idx)
195 .unwrap_or(remaining.len());
196 }
197
198 // Prefer a whitespace break within the accepted byte range. (`pos + 1`
199 // assumes 1-byte ASCII whitespace, which is overwhelmingly the common
200 // case in source text and matches the prior behavior.)
201 remaining[..hard_break]
202 .rfind(char::is_whitespace)
203 .map(|pos| pos + 1)
204 .unwrap_or(hard_break)
205}
206
207/// Wrap input text with "> " prefix on first line and " " on continuation lines (Claude Code style)
208/// Always returns at least "> " even when input is empty
209fn wrap_input_with_prompt(input: &str, width: usize) -> String {
210 if width < 3 {
211 // Not enough space for "> " prefix
212 return input.to_string();
213 }
214
215 // Always start with "> " prompt
216 let mut result = String::from("> ");
217
218 // If input is empty, just return the prompt
219 if input.is_empty() {
220 return result;
221 }
222
223 // First line and continuation lines both reserve 2 chars for their
224 // respective prefix ("> " or " "), so they share the same line width.
225 let line_width = width.saturating_sub(2);
226
227 let mut chars_remaining = input;
228 let mut is_first_line = true;
229
230 while !chars_remaining.is_empty() {
231 let break_point = find_line_break(chars_remaining, line_width);
232
233 // Extract this line's text
234 let line_text = &chars_remaining[..break_point];
235
236 // Add line text (prefix already added for first line, or add it for continuation)
237 if is_first_line {
238 // First line: "> " already in result, just add the text
239 result.push_str(line_text.trim_end());
240 } else {
241 // Continuation line: add newline + " " prefix + text
242 result.push('\n');
243 result.push_str(" ");
244 result.push_str(line_text.trim_end());
245 }
246
247 // Move to next line
248 chars_remaining = chars_remaining[break_point..].trim_start();
249 is_first_line = false;
250 }
251
252 result
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 /// Parity: for every byte offset in `input`, `calculate_cursor_position`
260 /// must return a (row, col) that lands in the same visual line emitted
261 /// by `wrap_input_with_prompt`. Catches silent drift between the two
262 /// functions going forward.
263 #[test]
264 fn cursor_and_wrap_agree_on_line_structure() {
265 let inputs = [
266 "hello world",
267 "the quick brown fox jumps over the lazy dog",
268 "nospacesinthislonginputthatmusthardbreak",
269 "mixed short and verylongcontiguoustoken here",
270 "leading double spaces between words",
271 "",
272 // CJK inputs: each char is 3 bytes / 2 display cells. The wrap
273 // logic must agree on line structure across both functions.
274 "你好世界",
275 "你好 world 世界",
276 "abc你好def世界ghi",
277 ];
278 let content_width = 20usize;
279 for input in inputs {
280 let wrapped = wrap_input_with_prompt(input, content_width);
281
282 // Strip prefixes to count content lines (first line "> ",
283 // continuation lines " "). This yields one vec per rendered
284 // line holding the post-prefix content.
285 let rendered_lines: Vec<String> = wrapped
286 .split('\n')
287 .enumerate()
288 .map(|(i, line)| {
289 let prefix = if i == 0 { "> " } else { " " };
290 line.strip_prefix(prefix).unwrap_or(line).to_string()
291 })
292 .collect();
293
294 // For each byte offset in the input, ask the cursor function
295 // which (row, col) it belongs to, then assert the row index is
296 // in range for the wrapped text.
297 for cursor_pos in 0..=input.len() {
298 if !input.is_char_boundary(cursor_pos) {
299 continue;
300 }
301 let (row, _col) =
302 InputState::calculate_cursor_position(input, cursor_pos, content_width);
303 assert!(
304 (row as usize) < rendered_lines.len().max(1),
305 "cursor row {} out of wrap range ({} lines) for input {:?} at byte {}",
306 row,
307 rendered_lines.len(),
308 input,
309 cursor_pos,
310 );
311 }
312 }
313 }
314
315 #[test]
316 fn find_line_break_whitespace_preferred() {
317 assert_eq!(find_line_break("hello world foo", 10), 6);
318 }
319
320 #[test]
321 fn find_line_break_hard_break_without_whitespace() {
322 assert_eq!(find_line_break("abcdefghijklmno", 5), 5);
323 }
324
325 #[test]
326 fn find_line_break_respects_char_boundary() {
327 // 3-byte CJK chars: each is 3 bytes / 2 display cells. With
328 // `line_width = 4` cells we fit exactly two CJK chars (4 cells,
329 // 6 bytes). Old byte-based code returned 3 (only the first char),
330 // wasting half the line.
331 let s = "你好";
332 assert_eq!(find_line_break(s, 4), 6);
333 }
334
335 #[test]
336 fn find_line_break_uses_display_width_for_cjk() {
337 // Cell width of "你好世界abc" = 4*2 + 3 = 11 cells; `line_width=10`
338 // fits "你好世界ab" (10 cells, 14 bytes) and breaks before "c".
339 let s = "你好世界abc";
340 assert_eq!(find_line_break(s, 10), 14);
341 }
342
343 #[test]
344 fn find_line_break_whole_remaining_fits() {
345 assert_eq!(find_line_break("short", 100), "short".len());
346 }
347
348 #[test]
349 fn find_line_break_makes_progress_when_first_char_overflows() {
350 // Double-width char on a 1-cell viewport: must still consume the
351 // char (return offset 3) so the wrap loop can't spin forever.
352 assert_eq!(find_line_break("你hello", 1), 3);
353 }
354}