Skip to main content

mermaid_cli/render/widgets/
input.rs

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