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 the shared `layout_rows` helper so the wrapping decisions match
32 /// `wrap_input_with_prompt` exactly (the two would silently drift
33 /// otherwise — `cursor_and_wrap_agree_on_line_structure` guards this).
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 rows = layout_rows(input, line_width);
52 for (idx, row) in rows.iter().enumerate() {
53 let content_end = row.start + row.len;
54 let gap_end = content_end + row.gap;
55 let is_last = idx + 1 == rows.len();
56
57 // Cursor belongs to this row if it falls within the row chars or
58 // the whitespace/newline gap after it, or if this is the last row.
59 if cursor_pos < gap_end || is_last {
60 // Cap at the row's content so trailing/gap whitespace doesn't
61 // overflow past the visible line.
62 let cursor_byte_in_line = cursor_pos.saturating_sub(row.start).min(row.len);
63 let line_text = &input[row.start..content_end];
64 let col_cells = line_text[..cursor_byte_in_line.min(line_text.len())].width();
65 return (idx as u16, col_cells as u16);
66 }
67 }
68 (0, 0)
69 }
70}
71
72impl Default for InputState {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78/// Props for InputWidget. The slash-command palette is rendered
79/// separately as `SlashPaletteWidget` in the bottom region (see
80/// `render.rs`); this widget just draws the bordered input box.
81pub struct InputWidget<'a> {
82 pub input: &'a str,
83 /// True when a slash command is in flight (input starts with `/`).
84 /// Drives the warning-yellow border color so the user has a visual
85 /// cue that they're in command-entry mode.
86 pub showing_command_hints: bool,
87 pub theme: &'a Theme,
88 /// Reasoning is currently enabled (any non-`None` level). Drives the
89 /// cyan/sage border color cue.
90 pub reasoning_active: bool,
91}
92
93impl<'a> StatefulWidget for InputWidget<'a> {
94 type State = InputState;
95
96 fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
97 let input_style = Style::new().fg(self.theme.colors.text_primary.to_color());
98
99 // Manually wrap input text with proper indentation (Claude Code style)
100 // First line: "> text"
101 // Continuation lines: " text" (2 spaces to align with first line content)
102 // Always show "> " prompt, even when input is empty
103 let input_text = {
104 let width = area.width.saturating_sub(2) as usize; // Account for top/bottom borders
105 wrap_input_with_prompt(self.input, width)
106 };
107
108 // Border color priority: command-entry mode wins (yellow),
109 // then reasoning-enabled (cyan), then default gray.
110 let border_color = if self.showing_command_hints {
111 self.theme.colors.warning.to_color()
112 } else if self.reasoning_active {
113 // Mermaid sage blue - same as the path color in status bar
114 self.theme.colors.info.to_color() // cyan
115 } else {
116 self.theme.colors.border.to_color() // gray
117 };
118
119 let block = if self.showing_command_hints {
120 Block::default()
121 .borders(Borders::TOP | Borders::BOTTOM)
122 .border_style(Style::new().fg(border_color))
123 .title(" Enter Command ")
124 } else {
125 Block::default()
126 .borders(Borders::TOP | Borders::BOTTOM)
127 .border_style(Style::new().fg(border_color))
128 };
129
130 let input = Paragraph::new(input_text).style(input_style).block(block);
131
132 input.render(area, buf);
133
134 // Note: Cursor positioning is handled in the main render loop after all widgets are rendered
135 // The Frame::set_cursor_position() is called there with the calculated position
136 }
137}
138
139/// Given a tail of input and a max line width (in **display cells**, not
140/// bytes), return the byte offset where this line should end.
141///
142/// Walks `remaining` char-by-char accumulating `UnicodeWidthChar::width`
143/// so CJK / emoji break at the visual edge instead of after ~1/3 of the
144/// space (the byte length of multi-byte chars exceeds their cell width).
145/// Prefers a whitespace break within the accepted range; falls back to a
146/// hard break at the char boundary if no whitespace exists. Always makes
147/// progress: if even the first character exceeds `line_width`, returns
148/// the byte offset *after* it so the caller can't infinite-loop.
149///
150/// Shared between `InputState::calculate_cursor_position` and
151/// `wrap_input_with_prompt` so both make identical wrapping decisions.
152fn find_line_break(remaining: &str, line_width: usize) -> usize {
153 if remaining.is_empty() {
154 return 0;
155 }
156
157 // Walk chars, accumulating display width, to find the byte offset at
158 // which the running cell-count would exceed `line_width`. If the whole
159 // string fits, we're done.
160 let mut acc_width = 0usize;
161 let mut hard_break = remaining.len();
162 for (byte_idx, ch) in remaining.char_indices() {
163 let ch_width = ch.width().unwrap_or(0);
164 if acc_width + ch_width > line_width {
165 hard_break = byte_idx;
166 break;
167 }
168 acc_width += ch_width;
169 }
170
171 if hard_break == remaining.len() {
172 return remaining.len();
173 }
174
175 // If the very first character is wider than the entire line (e.g. a
176 // double-width emoji on a 1-cell viewport), force progress by taking
177 // exactly one char — otherwise the caller loops forever.
178 if hard_break == 0 {
179 return remaining
180 .char_indices()
181 .nth(1)
182 .map(|(idx, _)| idx)
183 .unwrap_or(remaining.len());
184 }
185
186 // Prefer a whitespace break within the accepted byte range. (`pos + 1`
187 // assumes 1-byte ASCII whitespace, which is overwhelmingly the common
188 // case in source text and matches the prior behavior.)
189 remaining[..hard_break]
190 .rfind(char::is_whitespace)
191 .map(|pos| pos + 1)
192 .unwrap_or(hard_break)
193}
194
195/// One rendered row's span within the original input, in bytes.
196///
197/// `start..start+len` is the row's visible text. `gap` is the
198/// whitespace/newline consumed after it before the next row begins (trimmed
199/// inter-word whitespace from a soft wrap, plus the `\n` byte of a hard
200/// break). Shared by `wrap_input_with_prompt` and `calculate_cursor_position`
201/// so they never disagree on line structure.
202struct RowSpan {
203 start: usize,
204 len: usize,
205 gap: usize,
206}
207
208/// Lay `input` out into rendered rows at `line_width` (display cells).
209/// Explicit `\n` forces a new row (so pasted/Shift+Enter newlines render as
210/// real lines); each resulting segment is then soft-wrapped on width via
211/// `find_line_break`. A trailing newline yields a final empty row.
212fn layout_rows(input: &str, line_width: usize) -> Vec<RowSpan> {
213 let mut rows: Vec<RowSpan> = Vec::new();
214 if input.is_empty() {
215 return rows;
216 }
217 let total = input.len();
218 let mut seg_start = 0usize;
219 loop {
220 let seg_end = match input[seg_start..].find('\n') {
221 Some(rel) => seg_start + rel,
222 None => total,
223 };
224 let segment = &input[seg_start..seg_end];
225
226 // Soft-wrap this newline-free segment into >=1 rows.
227 let mut local = 0usize;
228 loop {
229 let rem = &segment[local..];
230 let bp = find_line_break(rem, line_width);
231 let after = &rem[bp..];
232 let ws_gap = after.len() - after.trim_start().len();
233 rows.push(RowSpan {
234 start: seg_start + local,
235 len: bp,
236 gap: ws_gap,
237 });
238 local += bp + ws_gap;
239 if local >= segment.len() {
240 break;
241 }
242 }
243
244 if seg_end >= total {
245 break;
246 }
247 // A '\n' follows: count it in the last row's gap so cursor math lands
248 // the caret on the correct side of the break.
249 if let Some(last) = rows.last_mut() {
250 last.gap += 1;
251 }
252 seg_start = seg_end + 1;
253 if seg_start == total {
254 // Trailing newline → a final empty row.
255 rows.push(RowSpan {
256 start: seg_start,
257 len: 0,
258 gap: 0,
259 });
260 break;
261 }
262 }
263 rows
264}
265
266/// Wrap input text with "> " prefix on the first line and " " on
267/// continuation lines (Claude Code style). Always returns at least "> ",
268/// even when input is empty. Embedded newlines render as real rows.
269fn wrap_input_with_prompt(input: &str, width: usize) -> String {
270 if width < 3 {
271 // Not enough space for "> " prefix
272 return input.to_string();
273 }
274 if input.is_empty() {
275 return String::from("> ");
276 }
277
278 // First line and continuation lines both reserve 2 chars for their
279 // respective prefix ("> " or " "), so they share the same line width.
280 let line_width = width.saturating_sub(2);
281
282 let mut result = String::new();
283 for (idx, row) in layout_rows(input, line_width).iter().enumerate() {
284 let text = input[row.start..row.start + row.len].trim_end();
285 if idx == 0 {
286 result.push_str("> ");
287 } else {
288 result.push('\n');
289 result.push_str(" ");
290 }
291 result.push_str(text);
292 }
293 result
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 /// Parity: for every byte offset in `input`, `calculate_cursor_position`
301 /// must return a (row, col) that lands in the same visual line emitted
302 /// by `wrap_input_with_prompt`. Catches silent drift between the two
303 /// functions going forward.
304 #[test]
305 fn cursor_and_wrap_agree_on_line_structure() {
306 let inputs = [
307 "hello world",
308 "the quick brown fox jumps over the lazy dog",
309 "nospacesinthislonginputthatmusthardbreak",
310 "mixed short and verylongcontiguoustoken here",
311 "leading double spaces between words",
312 "",
313 // CJK inputs: each char is 3 bytes / 2 display cells. The wrap
314 // logic must agree on line structure across both functions.
315 "你好世界",
316 "你好 world 世界",
317 "abc你好def世界ghi",
318 // Embedded newlines (pasted / Shift+Enter): hard line breaks.
319 "first line\nsecond line",
320 "para one\n\npara two",
321 "trailing newline\n",
322 "\nleading newline",
323 ];
324 let content_width = 20usize;
325 for input in inputs {
326 let wrapped = wrap_input_with_prompt(input, content_width);
327
328 // Strip prefixes to count content lines (first line "> ",
329 // continuation lines " "). This yields one vec per rendered
330 // line holding the post-prefix content.
331 let rendered_lines: Vec<String> = wrapped
332 .split('\n')
333 .enumerate()
334 .map(|(i, line)| {
335 let prefix = if i == 0 { "> " } else { " " };
336 line.strip_prefix(prefix).unwrap_or(line).to_string()
337 })
338 .collect();
339
340 // For each byte offset in the input, ask the cursor function
341 // which (row, col) it belongs to, then assert the row index is
342 // in range for the wrapped text.
343 for cursor_pos in 0..=input.len() {
344 if !input.is_char_boundary(cursor_pos) {
345 continue;
346 }
347 let (row, _col) =
348 InputState::calculate_cursor_position(input, cursor_pos, content_width);
349 assert!(
350 (row as usize) < rendered_lines.len().max(1),
351 "cursor row {} out of wrap range ({} lines) for input {:?} at byte {}",
352 row,
353 rendered_lines.len(),
354 input,
355 cursor_pos,
356 );
357 }
358 }
359 }
360
361 #[test]
362 fn find_line_break_whitespace_preferred() {
363 assert_eq!(find_line_break("hello world foo", 10), 6);
364 }
365
366 #[test]
367 fn find_line_break_hard_break_without_whitespace() {
368 assert_eq!(find_line_break("abcdefghijklmno", 5), 5);
369 }
370
371 #[test]
372 fn find_line_break_respects_char_boundary() {
373 // 3-byte CJK chars: each is 3 bytes / 2 display cells. With
374 // `line_width = 4` cells we fit exactly two CJK chars (4 cells,
375 // 6 bytes). Old byte-based code returned 3 (only the first char),
376 // wasting half the line.
377 let s = "你好";
378 assert_eq!(find_line_break(s, 4), 6);
379 }
380
381 #[test]
382 fn find_line_break_uses_display_width_for_cjk() {
383 // Cell width of "你好世界abc" = 4*2 + 3 = 11 cells; `line_width=10`
384 // fits "你好世界ab" (10 cells, 14 bytes) and breaks before "c".
385 let s = "你好世界abc";
386 assert_eq!(find_line_break(s, 10), 14);
387 }
388
389 #[test]
390 fn find_line_break_whole_remaining_fits() {
391 assert_eq!(find_line_break("short", 100), "short".len());
392 }
393
394 #[test]
395 fn find_line_break_makes_progress_when_first_char_overflows() {
396 // Double-width char on a 1-cell viewport: must still consume the
397 // char (return offset 3) so the wrap loop can't spin forever.
398 assert_eq!(find_line_break("你hello", 1), 3);
399 }
400
401 #[test]
402 fn wrap_renders_embedded_newlines_as_rows() {
403 // A pasted multi-line block must show as multiple rows, not a single
404 // space-joined paragraph.
405 assert_eq!(wrap_input_with_prompt("a\nb", 20), "> a\n b");
406 // Consecutive newlines keep the blank line.
407 assert_eq!(wrap_input_with_prompt("a\n\nb", 20), "> a\n \n b");
408 // A trailing newline yields an empty continuation row.
409 assert_eq!(wrap_input_with_prompt("a\n", 20), "> a\n ");
410 }
411
412 #[test]
413 fn cursor_tracks_rows_across_newlines() {
414 // "a\nb": byte 0=before a, 1=after a (on \n), 2=before b, 3=after b.
415 assert_eq!(InputState::calculate_cursor_position("a\nb", 0, 20), (0, 0));
416 assert_eq!(InputState::calculate_cursor_position("a\nb", 1, 20), (0, 1));
417 assert_eq!(InputState::calculate_cursor_position("a\nb", 2, 20), (1, 0));
418 assert_eq!(InputState::calculate_cursor_position("a\nb", 3, 20), (1, 1));
419 }
420}