Skip to main content

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    /// A first Ctrl+C armed the exit confirmation; show the second-press
92    /// hint in the box title while the window is open (idle-state surface —
93    /// the busy-state hint lives in the status line).
94    pub exit_armed: bool,
95    /// A first idle Esc armed the double-Esc rewind; show the second-press
96    /// hint in the box title while the window is open. Exit arming wins
97    /// when both are somehow live (quitting is the more consequential act).
98    pub rewind_armed: bool,
99}
100
101impl<'a> StatefulWidget for InputWidget<'a> {
102    type State = InputState;
103
104    fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
105        let input_style = Style::new().fg(self.theme.colors.text_primary.to_color());
106
107        // Manually wrap input text with proper indentation (Claude Code style)
108        // First line: "> text"
109        // Continuation lines: "  text" (2 spaces to align with first line content)
110        // Always show "> " prompt, even when input is empty
111        let input_text = {
112            let width = area.width.saturating_sub(2) as usize; // Account for top/bottom borders
113            wrap_input_with_prompt(self.input, width)
114        };
115
116        // Border color priority: command-entry mode wins (yellow),
117        // then reasoning-enabled (cyan), then default gray.
118        let border_color = if self.showing_command_hints {
119            self.theme.colors.warning.to_color()
120        } else if self.reasoning_active {
121            // Mermaid sage blue - same as the path color in status bar
122            self.theme.colors.info.to_color() // cyan
123        } else {
124            self.theme.colors.border.to_color() // gray
125        };
126
127        let block = if self.showing_command_hints {
128            Block::default()
129                .borders(Borders::TOP | Borders::BOTTOM)
130                .border_style(Style::new().fg(border_color))
131                .title(" Enter Command ")
132        } else if self.exit_armed {
133            Block::default()
134                .borders(Borders::TOP | Borders::BOTTOM)
135                .border_style(Style::new().fg(border_color))
136                .title(" press ctrl+c again to exit ")
137        } else if self.rewind_armed {
138            Block::default()
139                .borders(Borders::TOP | Borders::BOTTOM)
140                .border_style(Style::new().fg(border_color))
141                .title(" esc again to rewind ")
142        } else {
143            Block::default()
144                .borders(Borders::TOP | Borders::BOTTOM)
145                .border_style(Style::new().fg(border_color))
146        };
147
148        let input = Paragraph::new(input_text).style(input_style).block(block);
149
150        input.render(area, buf);
151
152        // Note: Cursor positioning is handled in the main render loop after all widgets are rendered
153        // The Frame::set_cursor_position() is called there with the calculated position
154    }
155}
156
157/// Given a tail of input and a max line width (in **display cells**, not
158/// bytes), return the byte offset where this line should end.
159///
160/// Walks `remaining` char-by-char accumulating `UnicodeWidthChar::width`
161/// so CJK / emoji break at the visual edge instead of after ~1/3 of the
162/// space (the byte length of multi-byte chars exceeds their cell width).
163/// Prefers a whitespace break within the accepted range; falls back to a
164/// hard break at the char boundary if no whitespace exists. Always makes
165/// progress: if even the first character exceeds `line_width`, returns
166/// the byte offset *after* it so the caller can't infinite-loop.
167///
168/// Shared between `InputState::calculate_cursor_position` and
169/// `wrap_input_with_prompt` so both make identical wrapping decisions.
170fn find_line_break(remaining: &str, line_width: usize) -> usize {
171    if remaining.is_empty() {
172        return 0;
173    }
174
175    // Walk chars, accumulating display width, to find the byte offset at
176    // which the running cell-count would exceed `line_width`. If the whole
177    // string fits, we're done.
178    let mut acc_width = 0usize;
179    let mut hard_break = remaining.len();
180    for (byte_idx, ch) in remaining.char_indices() {
181        let ch_width = ch.width().unwrap_or(0);
182        if acc_width + ch_width > line_width {
183            hard_break = byte_idx;
184            break;
185        }
186        acc_width += ch_width;
187    }
188
189    if hard_break == remaining.len() {
190        return remaining.len();
191    }
192
193    // If the very first character is wider than the entire line (e.g. a
194    // double-width emoji on a 1-cell viewport), force progress by taking
195    // exactly one char — otherwise the caller loops forever.
196    if hard_break == 0 {
197        return remaining
198            .char_indices()
199            .nth(1)
200            .map(|(idx, _)| idx)
201            .unwrap_or(remaining.len());
202    }
203
204    // Prefer a whitespace break within the accepted byte range. Advance past
205    // the whitespace char by its UTF-8 length so the returned offset is always
206    // a char boundary — `char::is_whitespace` matches multibyte spaces (NBSP
207    // U+00A0 = 2 bytes, ideographic space U+3000 = 3 bytes, U+2028, …) that a
208    // naive `pos + 1` would split mid-codepoint, panicking the renderer on the
209    // subsequent slice. For 1-byte ASCII whitespace this is identical to the
210    // old `pos + 1`.
211    remaining[..hard_break]
212        .char_indices()
213        .rev()
214        .find(|(_, c)| c.is_whitespace())
215        .map(|(pos, c)| pos + c.len_utf8())
216        .unwrap_or(hard_break)
217}
218
219/// One rendered row's span within the original input, in bytes.
220///
221/// `start..start+len` is the row's visible text. `gap` is the
222/// whitespace/newline consumed after it before the next row begins (trimmed
223/// inter-word whitespace from a soft wrap, plus the `\n` byte of a hard
224/// break). Shared by `wrap_input_with_prompt` and `calculate_cursor_position`
225/// so they never disagree on line structure.
226struct RowSpan {
227    start: usize,
228    len: usize,
229    gap: usize,
230}
231
232/// Lay `input` out into rendered rows at `line_width` (display cells).
233/// Explicit `\n` forces a new row (so pasted/Ctrl+J newlines render as
234/// real lines); each resulting segment is then soft-wrapped on width via
235/// `find_line_break`. A trailing newline yields a final empty row.
236fn layout_rows(input: &str, line_width: usize) -> Vec<RowSpan> {
237    let mut rows: Vec<RowSpan> = Vec::new();
238    if input.is_empty() {
239        return rows;
240    }
241    let total = input.len();
242    let mut seg_start = 0usize;
243    loop {
244        let seg_end = match input[seg_start..].find('\n') {
245            Some(rel) => seg_start + rel,
246            None => total,
247        };
248        let segment = &input[seg_start..seg_end];
249
250        // Soft-wrap this newline-free segment into >=1 rows.
251        let mut local = 0usize;
252        loop {
253            let rem = &segment[local..];
254            let bp = find_line_break(rem, line_width);
255            let after = &rem[bp..];
256            let ws_gap = after.len() - after.trim_start().len();
257            rows.push(RowSpan {
258                start: seg_start + local,
259                len: bp,
260                gap: ws_gap,
261            });
262            local += bp + ws_gap;
263            if local >= segment.len() {
264                break;
265            }
266        }
267
268        if seg_end >= total {
269            break;
270        }
271        // A '\n' follows: count it in the last row's gap so cursor math lands
272        // the caret on the correct side of the break.
273        if let Some(last) = rows.last_mut() {
274            last.gap += 1;
275        }
276        seg_start = seg_end + 1;
277        if seg_start == total {
278            // Trailing newline → a final empty row.
279            rows.push(RowSpan {
280                start: seg_start,
281                len: 0,
282                gap: 0,
283            });
284            break;
285        }
286    }
287    rows
288}
289
290/// Wrap input text with "> " prefix on the first line and "  " on
291/// continuation lines (Claude Code style). Always returns at least "> ",
292/// even when input is empty. Embedded newlines render as real rows.
293fn wrap_input_with_prompt(input: &str, width: usize) -> String {
294    if width < 3 {
295        // Not enough space for "> " prefix
296        return input.to_string();
297    }
298    if input.is_empty() {
299        return String::from("> ");
300    }
301
302    // First line and continuation lines both reserve 2 chars for their
303    // respective prefix ("> " or "  "), so they share the same line width.
304    let line_width = width.saturating_sub(2);
305
306    let mut result = String::new();
307    for (idx, row) in layout_rows(input, line_width).iter().enumerate() {
308        let text = input[row.start..row.start + row.len].trim_end();
309        if idx == 0 {
310            result.push_str("> ");
311        } else {
312            result.push('\n');
313            result.push_str("  ");
314        }
315        result.push_str(text);
316    }
317    result
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// Parity: for every byte offset in `input`, `calculate_cursor_position`
325    /// must return a (row, col) that lands in the same visual line emitted
326    /// by `wrap_input_with_prompt`. Catches silent drift between the two
327    /// functions going forward.
328    #[test]
329    fn cursor_and_wrap_agree_on_line_structure() {
330        let inputs = [
331            "hello world",
332            "the quick brown fox jumps over the lazy dog",
333            "nospacesinthislonginputthatmusthardbreak",
334            "mixed short and verylongcontiguoustoken here",
335            "leading  double  spaces  between  words",
336            "",
337            // CJK inputs: each char is 3 bytes / 2 display cells. The wrap
338            // logic must agree on line structure across both functions.
339            "你好世界",
340            "你好 world 世界",
341            "abc你好def世界ghi",
342            // Embedded newlines (pasted / Ctrl+J): hard line breaks.
343            "first line\nsecond line",
344            "para one\n\npara two",
345            "trailing newline\n",
346            "\nleading newline",
347        ];
348        let content_width = 20usize;
349        for input in inputs {
350            let wrapped = wrap_input_with_prompt(input, content_width);
351
352            // Strip prefixes to count content lines (first line "> ",
353            // continuation lines "  "). This yields one vec per rendered
354            // line holding the post-prefix content.
355            let rendered_lines: Vec<String> = wrapped
356                .split('\n')
357                .enumerate()
358                .map(|(i, line)| {
359                    let prefix = if i == 0 { "> " } else { "  " };
360                    line.strip_prefix(prefix).unwrap_or(line).to_string()
361                })
362                .collect();
363
364            // For each byte offset in the input, ask the cursor function
365            // which (row, col) it belongs to, then assert the row index is
366            // in range for the wrapped text.
367            for cursor_pos in 0..=input.len() {
368                if !input.is_char_boundary(cursor_pos) {
369                    continue;
370                }
371                let (row, _col) =
372                    InputState::calculate_cursor_position(input, cursor_pos, content_width);
373                assert!(
374                    (row as usize) < rendered_lines.len().max(1),
375                    "cursor row {} out of wrap range ({} lines) for input {:?} at byte {}",
376                    row,
377                    rendered_lines.len(),
378                    input,
379                    cursor_pos,
380                );
381            }
382        }
383    }
384
385    #[test]
386    fn find_line_break_whitespace_preferred() {
387        assert_eq!(find_line_break("hello world foo", 10), 6);
388    }
389
390    #[test]
391    fn find_line_break_hard_break_without_whitespace() {
392        assert_eq!(find_line_break("abcdefghijklmno", 5), 5);
393    }
394
395    #[test]
396    fn find_line_break_respects_char_boundary() {
397        // 3-byte CJK chars: each is 3 bytes / 2 display cells. With
398        // `line_width = 4` cells we fit exactly two CJK chars (4 cells,
399        // 6 bytes). Old byte-based code returned 3 (only the first char),
400        // wasting half the line.
401        let s = "你好";
402        assert_eq!(find_line_break(s, 4), 6);
403    }
404
405    #[test]
406    fn find_line_break_uses_display_width_for_cjk() {
407        // Cell width of "你好世界abc" = 4*2 + 3 = 11 cells; `line_width=10`
408        // fits "你好世界ab" (10 cells, 14 bytes) and breaks before "c".
409        let s = "你好世界abc";
410        assert_eq!(find_line_break(s, 10), 14);
411    }
412
413    #[test]
414    fn find_line_break_whole_remaining_fits() {
415        assert_eq!(find_line_break("short", 100), "short".len());
416    }
417
418    #[test]
419    fn find_line_break_makes_progress_when_first_char_overflows() {
420        // Double-width char on a 1-cell viewport: must still consume the
421        // char (return offset 3) so the wrap loop can't spin forever.
422        assert_eq!(find_line_break("你hello", 1), 3);
423    }
424
425    #[test]
426    fn find_line_break_multibyte_whitespace_is_char_boundary() {
427        // Regression: `char::is_whitespace` matches multibyte spaces (NBSP
428        // U+00A0 = 2 bytes, ideographic space U+3000 = 3 bytes, U+2028,
429        // U+202F …). A naive `pos + 1` break offset lands mid-codepoint and
430        // the caller's `&rem[bp..]` slice panics the whole renderer. The
431        // break must always be a char boundary, and the full wrap path must
432        // not panic.
433        for s in [
434            "aaaa\u{00A0}bbbbbbbbbbbbbbbb",
435            "\u{3000}\u{3000}wwwwwwwwwwwwwww",
436            "word\u{2028}word\u{202F}wordwordword",
437        ] {
438            for width in 1..=20 {
439                let bp = find_line_break(s, width);
440                assert!(
441                    s.is_char_boundary(bp),
442                    "break {bp} not a char boundary in {s:?} at width {width}",
443                );
444                let _ = &s[bp..]; // must not panic
445            }
446            let _ = wrap_input_with_prompt(s, 8);
447        }
448    }
449
450    #[test]
451    fn wrap_renders_embedded_newlines_as_rows() {
452        // A pasted multi-line block must show as multiple rows, not a single
453        // space-joined paragraph.
454        assert_eq!(wrap_input_with_prompt("a\nb", 20), "> a\n  b");
455        // Consecutive newlines keep the blank line.
456        assert_eq!(wrap_input_with_prompt("a\n\nb", 20), "> a\n  \n  b");
457        // A trailing newline yields an empty continuation row.
458        assert_eq!(wrap_input_with_prompt("a\n", 20), "> a\n  ");
459    }
460
461    #[test]
462    fn cursor_tracks_rows_across_newlines() {
463        // "a\nb": byte 0=before a, 1=after a (on \n), 2=before b, 3=after b.
464        assert_eq!(InputState::calculate_cursor_position("a\nb", 0, 20), (0, 0));
465        assert_eq!(InputState::calculate_cursor_position("a\nb", 1, 20), (0, 1));
466        assert_eq!(InputState::calculate_cursor_position("a\nb", 2, 20), (1, 0));
467        assert_eq!(InputState::calculate_cursor_position("a\nb", 3, 20), (1, 1));
468    }
469}