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 #[must_use]
21 pub fn new() -> Self {
22 Self { cursor_position: 0 }
23 }
24
25 /// Calculate cursor position for wrapped text.
26 ///
27 /// `content_width` is in **display cells**. Returns `(row, col)` where
28 /// `col` is also in display cells — required because `Frame::set_cursor_
29 /// position` is cell-based, not byte-based. CJK / emoji input previously
30 /// mispositioned the cursor because the column was returned in bytes.
31 ///
32 /// Uses the shared `layout_rows` helper so the wrapping decisions match
33 /// `wrap_input_with_prompt` exactly (the two would silently drift
34 /// otherwise — `cursor_and_wrap_agree_on_line_structure` guards this).
35 #[must_use]
36 pub fn calculate_cursor_position(
37 input: &str,
38 cursor_pos: usize,
39 content_width: usize,
40 ) -> (u16, u16) {
41 let cursor_pos = cursor_pos.min(input.len());
42
43 if content_width < 3 || input.is_empty() {
44 return (0, 0);
45 }
46
47 // Available cells per line after the 2-cell prefix ("> " or " ")
48 let line_width = content_width.saturating_sub(2);
49 if line_width == 0 {
50 return (0, 0);
51 }
52
53 let rows = layout_rows(input, line_width);
54 for (idx, row) in rows.iter().enumerate() {
55 let content_end = row.start + row.len;
56 let gap_end = content_end + row.gap;
57 let is_last = idx + 1 == rows.len();
58
59 // Cursor belongs to this row if it falls within the row chars or
60 // the whitespace/newline gap after it, or if this is the last row.
61 if cursor_pos < gap_end || is_last {
62 // Cap at the row's content so trailing/gap whitespace doesn't
63 // overflow past the visible line.
64 let cursor_byte_in_line = cursor_pos.saturating_sub(row.start).min(row.len);
65 let line_text = &input[row.start..content_end];
66 let col_cells = line_text[..cursor_byte_in_line.min(line_text.len())].width();
67 return (idx as u16, col_cells as u16);
68 }
69 }
70 (0, 0)
71 }
72}
73
74impl Default for InputState {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80/// Props for `InputWidget`. The slash-command palette is rendered
81/// separately as `SlashPaletteWidget` in the bottom region (see
82/// `render.rs`); this widget just draws the bordered input box.
83pub struct InputWidget<'a> {
84 pub input: &'a str,
85 /// True when a slash command is in flight (input starts with `/`).
86 /// Drives the warning-yellow border color so the user has a visual
87 /// cue that they're in command-entry mode.
88 pub showing_command_hints: bool,
89 pub theme: &'a Theme,
90 /// Reasoning is currently enabled (any non-`None` level). Drives the
91 /// cyan/sage border color cue.
92 pub reasoning_active: bool,
93 /// A first Ctrl+C armed the exit confirmation; show the second-press
94 /// hint in the box title while the window is open (idle-state surface —
95 /// the busy-state hint lives in the status line).
96 pub exit_armed: bool,
97 /// A first idle Esc armed the double-Esc rewind; show the second-press
98 /// hint in the box title while the window is open. Exit arming wins
99 /// when both are somehow live (quitting is the more consequential act).
100 pub rewind_armed: bool,
101}
102
103impl<'a> StatefulWidget for InputWidget<'a> {
104 type State = InputState;
105
106 fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
107 let input_style = Style::new().fg(self.theme.colors.text_primary.to_color());
108
109 // Manually wrap input text with proper indentation (Claude Code style)
110 // First line: "> text"
111 // Continuation lines: " text" (2 spaces to align with first line content)
112 // Always show "> " prompt, even when input is empty
113 let input_text = {
114 let width = area.width.saturating_sub(2) as usize; // Account for top/bottom borders
115 wrap_input_with_prompt(self.input, width)
116 };
117
118 // Border color priority: command-entry mode wins (yellow),
119 // then reasoning-enabled (cyan), then default gray.
120 let border_color = if self.showing_command_hints {
121 self.theme.colors.warning.to_color()
122 } else if self.reasoning_active {
123 // Mermaid sage blue - same as the path color in status bar
124 self.theme.colors.info.to_color() // cyan
125 } else {
126 self.theme.colors.border.to_color() // gray
127 };
128
129 let block = if self.showing_command_hints {
130 Block::default()
131 .borders(Borders::TOP | Borders::BOTTOM)
132 .border_style(Style::new().fg(border_color))
133 .title(" Enter Command ")
134 } else if self.exit_armed {
135 Block::default()
136 .borders(Borders::TOP | Borders::BOTTOM)
137 .border_style(Style::new().fg(border_color))
138 .title(" press ctrl+c again to exit ")
139 } else if self.rewind_armed {
140 Block::default()
141 .borders(Borders::TOP | Borders::BOTTOM)
142 .border_style(Style::new().fg(border_color))
143 .title(" esc again to rewind ")
144 } else {
145 Block::default()
146 .borders(Borders::TOP | Borders::BOTTOM)
147 .border_style(Style::new().fg(border_color))
148 };
149
150 let input = Paragraph::new(input_text).style(input_style).block(block);
151
152 input.render(area, buf);
153
154 // Note: Cursor positioning is handled in the main render loop after all widgets are rendered
155 // The Frame::set_cursor_position() is called there with the calculated position
156 }
157}
158
159/// Given a tail of input and a max line width (in **display cells**, not
160/// bytes), return the byte offset where this line should end.
161///
162/// Walks `remaining` char-by-char accumulating `UnicodeWidthChar::width`
163/// so CJK / emoji break at the visual edge instead of after ~1/3 of the
164/// space (the byte length of multi-byte chars exceeds their cell width).
165/// Prefers a whitespace break within the accepted range; falls back to a
166/// hard break at the char boundary if no whitespace exists. Always makes
167/// progress: if even the first character exceeds `line_width`, returns
168/// the byte offset *after* it so the caller can't infinite-loop.
169///
170/// Shared between `InputState::calculate_cursor_position` and
171/// `wrap_input_with_prompt` so both make identical wrapping decisions.
172fn find_line_break(remaining: &str, line_width: usize) -> usize {
173 if remaining.is_empty() {
174 return 0;
175 }
176
177 // Walk chars, accumulating display width, to find the byte offset at
178 // which the running cell-count would exceed `line_width`. If the whole
179 // string fits, we're done.
180 let mut acc_width = 0usize;
181 let mut hard_break = remaining.len();
182 for (byte_idx, ch) in remaining.char_indices() {
183 let ch_width = ch.width().unwrap_or(0);
184 if acc_width + ch_width > line_width {
185 hard_break = byte_idx;
186 break;
187 }
188 acc_width += ch_width;
189 }
190
191 if hard_break == remaining.len() {
192 return remaining.len();
193 }
194
195 // If the very first character is wider than the entire line (e.g. a
196 // double-width emoji on a 1-cell viewport), force progress by taking
197 // exactly one char — otherwise the caller loops forever.
198 if hard_break == 0 {
199 return remaining
200 .char_indices()
201 .nth(1)
202 .map(|(idx, _)| idx)
203 .unwrap_or(remaining.len());
204 }
205
206 // Prefer a whitespace break within the accepted byte range. Advance past
207 // the whitespace char by its UTF-8 length so the returned offset is always
208 // a char boundary — `char::is_whitespace` matches multibyte spaces (NBSP
209 // U+00A0 = 2 bytes, ideographic space U+3000 = 3 bytes, U+2028, …) that a
210 // naive `pos + 1` would split mid-codepoint, panicking the renderer on the
211 // subsequent slice. For 1-byte ASCII whitespace this is identical to the
212 // old `pos + 1`.
213 remaining[..hard_break]
214 .char_indices()
215 .rev()
216 .find(|(_, c)| c.is_whitespace())
217 .map(|(pos, c)| pos + c.len_utf8())
218 .unwrap_or(hard_break)
219}
220
221/// One rendered row's span within the original input, in bytes.
222///
223/// `start..start+len` is the row's visible text. `gap` is the
224/// whitespace/newline consumed after it before the next row begins (trimmed
225/// inter-word whitespace from a soft wrap, plus the `\n` byte of a hard
226/// break). Shared by `wrap_input_with_prompt` and `calculate_cursor_position`
227/// so they never disagree on line structure.
228struct RowSpan {
229 start: usize,
230 len: usize,
231 gap: usize,
232}
233
234/// Lay `input` out into rendered rows at `line_width` (display cells).
235/// Explicit `\n` forces a new row (so pasted/Ctrl+J newlines render as
236/// real lines); each resulting segment is then soft-wrapped on width via
237/// `find_line_break`. A trailing newline yields a final empty row.
238fn layout_rows(input: &str, line_width: usize) -> Vec<RowSpan> {
239 let mut rows: Vec<RowSpan> = Vec::new();
240 if input.is_empty() {
241 return rows;
242 }
243 let total = input.len();
244 let mut seg_start = 0usize;
245 loop {
246 let seg_end = match input[seg_start..].find('\n') {
247 Some(rel) => seg_start + rel,
248 None => total,
249 };
250 let segment = &input[seg_start..seg_end];
251
252 // Soft-wrap this newline-free segment into >=1 rows.
253 let mut local = 0usize;
254 loop {
255 let rem = &segment[local..];
256 let bp = find_line_break(rem, line_width);
257 let after = &rem[bp..];
258 let ws_gap = after.len() - after.trim_start().len();
259 rows.push(RowSpan {
260 start: seg_start + local,
261 len: bp,
262 gap: ws_gap,
263 });
264 local += bp + ws_gap;
265 if local >= segment.len() {
266 break;
267 }
268 }
269
270 if seg_end >= total {
271 break;
272 }
273 // A '\n' follows: count it in the last row's gap so cursor math lands
274 // the caret on the correct side of the break.
275 if let Some(last) = rows.last_mut() {
276 last.gap += 1;
277 }
278 seg_start = seg_end + 1;
279 if seg_start == total {
280 // Trailing newline → a final empty row.
281 rows.push(RowSpan {
282 start: seg_start,
283 len: 0,
284 gap: 0,
285 });
286 break;
287 }
288 }
289 rows
290}
291
292/// How many rendered rows `input` occupies at `content_width` display cells.
293///
294/// `content_width` is the box's inner width — the same value
295/// `calculate_cursor_position` takes — and the 2-cell `"> "` / `" "` prefix is
296/// subtracted here, so a caller passes one width and cannot get the two out of
297/// step.
298///
299/// Built on `layout_rows` for the same reason the cursor is: the layout picks
300/// whitespace breaks, so it starts a row *earlier* than counting cells to the
301/// hard edge would. A separate row count that hard-broke instead reported one
302/// row too few for any input whose last word wrapped — the box clipped its
303/// final line and the caret was drawn on the row the box did not have.
304#[must_use]
305pub fn rendered_row_count(input: &str, content_width: usize) -> usize {
306 // Mirrors `calculate_cursor_position`'s degenerate-width guards: below
307 // this the prefix does not fit and the widget stops wrapping at all.
308 if content_width < 3 || input.is_empty() {
309 return 1;
310 }
311 let line_width = content_width.saturating_sub(2);
312 if line_width == 0 {
313 return 1;
314 }
315 layout_rows(input, line_width).len().max(1)
316}
317
318/// Wrap input text with "> " prefix on the first line and " " on
319/// continuation lines (Claude Code style). Always returns at least "> ",
320/// even when input is empty. Embedded newlines render as real rows.
321fn wrap_input_with_prompt(input: &str, width: usize) -> String {
322 if width < 3 {
323 // Not enough space for "> " prefix
324 return input.to_string();
325 }
326 if input.is_empty() {
327 return String::from("> ");
328 }
329
330 // First line and continuation lines both reserve 2 chars for their
331 // respective prefix ("> " or " "), so they share the same line width.
332 let line_width = width.saturating_sub(2);
333
334 let mut result = String::new();
335 for (idx, row) in layout_rows(input, line_width).iter().enumerate() {
336 let text = input[row.start..row.start + row.len].trim_end();
337 if idx == 0 {
338 result.push_str("> ");
339 } else {
340 result.push('\n');
341 result.push_str(" ");
342 }
343 result.push_str(text);
344 }
345 result
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 /// Parity: for every byte offset in `input`, `calculate_cursor_position`
353 /// must return a (row, col) that lands in the same visual line emitted
354 /// by `wrap_input_with_prompt`. Catches silent drift between the two
355 /// functions going forward.
356 #[test]
357 fn cursor_and_wrap_agree_on_line_structure() {
358 let inputs = [
359 "hello world",
360 "the quick brown fox jumps over the lazy dog",
361 "nospacesinthislonginputthatmusthardbreak",
362 "mixed short and verylongcontiguoustoken here",
363 "leading double spaces between words",
364 "",
365 // CJK inputs: each char is 3 bytes / 2 display cells. The wrap
366 // logic must agree on line structure across both functions.
367 "你好世界",
368 "你好 world 世界",
369 "abc你好def世界ghi",
370 // Embedded newlines (pasted / Ctrl+J): hard line breaks.
371 "first line\nsecond line",
372 "para one\n\npara two",
373 "trailing newline\n",
374 "\nleading newline",
375 ];
376 let content_width = 20usize;
377 for input in inputs {
378 let wrapped = wrap_input_with_prompt(input, content_width);
379
380 // Strip prefixes to count content lines (first line "> ",
381 // continuation lines " "). This yields one vec per rendered
382 // line holding the post-prefix content.
383 let rendered_lines: Vec<String> = wrapped
384 .split('\n')
385 .enumerate()
386 .map(|(i, line)| {
387 let prefix = if i == 0 { "> " } else { " " };
388 line.strip_prefix(prefix).unwrap_or(line).to_string()
389 })
390 .collect();
391
392 // For each byte offset in the input, ask the cursor function
393 // which (row, col) it belongs to, then assert the row index is
394 // in range for the wrapped text.
395 for cursor_pos in 0..=input.len() {
396 if !input.is_char_boundary(cursor_pos) {
397 continue;
398 }
399 let (row, _col) =
400 InputState::calculate_cursor_position(input, cursor_pos, content_width);
401 assert!(
402 (row as usize) < rendered_lines.len().max(1),
403 "cursor row {} out of wrap range ({} lines) for input {:?} at byte {}",
404 row,
405 rendered_lines.len(),
406 input,
407 cursor_pos,
408 );
409 }
410 }
411 }
412
413 #[test]
414 fn find_line_break_whitespace_preferred() {
415 assert_eq!(find_line_break("hello world foo", 10), 6);
416 }
417
418 #[test]
419 fn find_line_break_hard_break_without_whitespace() {
420 assert_eq!(find_line_break("abcdefghijklmno", 5), 5);
421 }
422
423 #[test]
424 fn find_line_break_respects_char_boundary() {
425 // 3-byte CJK chars: each is 3 bytes / 2 display cells. With
426 // `line_width = 4` cells we fit exactly two CJK chars (4 cells,
427 // 6 bytes). Old byte-based code returned 3 (only the first char),
428 // wasting half the line.
429 let s = "你好";
430 assert_eq!(find_line_break(s, 4), 6);
431 }
432
433 #[test]
434 fn find_line_break_uses_display_width_for_cjk() {
435 // Cell width of "你好世界abc" = 4*2 + 3 = 11 cells; `line_width=10`
436 // fits "你好世界ab" (10 cells, 14 bytes) and breaks before "c".
437 let s = "你好世界abc";
438 assert_eq!(find_line_break(s, 10), 14);
439 }
440
441 #[test]
442 fn find_line_break_whole_remaining_fits() {
443 assert_eq!(find_line_break("short", 100), "short".len());
444 }
445
446 #[test]
447 fn find_line_break_makes_progress_when_first_char_overflows() {
448 // Double-width char on a 1-cell viewport: must still consume the
449 // char (return offset 3) so the wrap loop can't spin forever.
450 assert_eq!(find_line_break("你hello", 1), 3);
451 }
452
453 #[test]
454 fn find_line_break_multibyte_whitespace_is_char_boundary() {
455 // Regression: `char::is_whitespace` matches multibyte spaces (NBSP
456 // U+00A0 = 2 bytes, ideographic space U+3000 = 3 bytes, U+2028,
457 // U+202F …). A naive `pos + 1` break offset lands mid-codepoint and
458 // the caller's `&rem[bp..]` slice panics the whole renderer. The
459 // break must always be a char boundary, and the full wrap path must
460 // not panic.
461 for s in [
462 "aaaa\u{00A0}bbbbbbbbbbbbbbbb",
463 "\u{3000}\u{3000}wwwwwwwwwwwwwww",
464 "word\u{2028}word\u{202F}wordwordword",
465 ] {
466 for width in 1..=20 {
467 let bp = find_line_break(s, width);
468 assert!(
469 s.is_char_boundary(bp),
470 "break {bp} not a char boundary in {s:?} at width {width}",
471 );
472 let _ = &s[bp..]; // must not panic
473 }
474 let _ = wrap_input_with_prompt(s, 8);
475 }
476 }
477
478 #[test]
479 fn wrap_renders_embedded_newlines_as_rows() {
480 // A pasted multi-line block must show as multiple rows, not a single
481 // space-joined paragraph.
482 assert_eq!(wrap_input_with_prompt("a\nb", 20), "> a\n b");
483 // Consecutive newlines keep the blank line.
484 assert_eq!(wrap_input_with_prompt("a\n\nb", 20), "> a\n \n b");
485 // A trailing newline yields an empty continuation row.
486 assert_eq!(wrap_input_with_prompt("a\n", 20), "> a\n ");
487 }
488
489 #[test]
490 fn cursor_tracks_rows_across_newlines() {
491 // "a\nb": byte 0=before a, 1=after a (on \n), 2=before b, 3=after b.
492 assert_eq!(InputState::calculate_cursor_position("a\nb", 0, 20), (0, 0));
493 assert_eq!(InputState::calculate_cursor_position("a\nb", 1, 20), (0, 1));
494 assert_eq!(InputState::calculate_cursor_position("a\nb", 2, 20), (1, 0));
495 assert_eq!(InputState::calculate_cursor_position("a\nb", 3, 20), (1, 1));
496 }
497
498 /// The box must be exactly as tall as the text it renders. `render/mod.rs`
499 /// sizes it from `rendered_row_count` and the widget wraps with
500 /// `wrap_input_with_prompt`; if those two ever disagree the last line is
501 /// clipped and the caret is drawn on a row that does not exist.
502 ///
503 /// Swept rather than spot-checked: the bug only showed in the window
504 /// between a word wrapping and the cell count catching up, which is a
505 /// couple of characters wide and easy to step over.
506 #[test]
507 fn row_count_matches_the_rendered_line_count() {
508 let inputs = [
509 "Create a language that. Your goal is up to you.",
510 "the quick brown fox jumps over the lazy dog",
511 "supercalifragilisticexpialidocious antidisestablishmentarianism",
512 "a b c d e f g h i j k l m n o p q r s t u v w x y z",
513 "trailing space wraps here ",
514 "explicit\nnewlines\nhere",
515 "mixed 日本語 and ascii text that wraps somewhere",
516 ];
517 for text in inputs {
518 for content_width in 3usize..60 {
519 for n in 0..=text.len() {
520 // `get` yields None on a non-boundary, which is also the
521 // char-boundary check.
522 let Some(prefix) = text.get(..n) else {
523 continue;
524 };
525 let rendered = wrap_input_with_prompt(prefix, content_width);
526 // `wrap_input_with_prompt` bails to the raw string below
527 // width 3, where there is no wrapping to agree about.
528 if content_width < 3 {
529 continue;
530 }
531 let drawn = rendered.lines().count();
532 let counted = rendered_row_count(prefix, content_width);
533 assert_eq!(
534 counted, drawn,
535 "height {counted} != {drawn} drawn rows for {prefix:?} \
536 at content_width {content_width}\nrendered:\n{rendered}"
537 );
538 }
539 }
540 }
541 }
542
543 /// The exact frame from the bug report: at this width `up` is pushed to a
544 /// third row by the whitespace break, while counting cells to the hard
545 /// edge reaches only 2. The old height loop returned 2, so the box clipped
546 /// that row and the caret was drawn below the text.
547 ///
548 /// The width is load-bearing and was found by sweep, not chosen: at a
549 /// `content_width` of 20 both algorithms answer 3 and this test would pass
550 /// against the bug. 22 is the narrowest width above it where they differ.
551 #[test]
552 fn the_row_a_wrapped_last_word_needs_is_counted() {
553 let text = "Create a language that. Your goal is up";
554 let content_width = 22;
555 let rendered = wrap_input_with_prompt(text, content_width);
556 assert_eq!(rendered.lines().count(), 3, "rendered:\n{rendered}");
557 assert_eq!(rendered_row_count(text, content_width), 3);
558 }
559
560 /// Empty input still occupies the one row that holds the bare `"> "`.
561 #[test]
562 fn an_empty_buffer_is_one_row() {
563 assert_eq!(rendered_row_count("", 40), 1);
564 assert_eq!(wrap_input_with_prompt("", 40).lines().count(), 1);
565 }
566}