use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::render::theme::Theme;
#[derive(Debug, Clone)]
pub struct InputState {
pub cursor_position: usize,
}
impl InputState {
pub fn new() -> Self {
Self { cursor_position: 0 }
}
pub fn calculate_cursor_position(
input: &str,
cursor_pos: usize,
content_width: usize,
) -> (u16, u16) {
let cursor_pos = cursor_pos.min(input.len());
if content_width < 3 || input.is_empty() {
return (0, 0);
}
let line_width = content_width.saturating_sub(2);
if line_width == 0 {
return (0, 0);
}
let rows = layout_rows(input, line_width);
for (idx, row) in rows.iter().enumerate() {
let content_end = row.start + row.len;
let gap_end = content_end + row.gap;
let is_last = idx + 1 == rows.len();
if cursor_pos < gap_end || is_last {
let cursor_byte_in_line = cursor_pos.saturating_sub(row.start).min(row.len);
let line_text = &input[row.start..content_end];
let col_cells = line_text[..cursor_byte_in_line.min(line_text.len())].width();
return (idx as u16, col_cells as u16);
}
}
(0, 0)
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
pub struct InputWidget<'a> {
pub input: &'a str,
pub showing_command_hints: bool,
pub theme: &'a Theme,
pub reasoning_active: bool,
}
impl<'a> StatefulWidget for InputWidget<'a> {
type State = InputState;
fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
let input_style = Style::new().fg(self.theme.colors.text_primary.to_color());
let input_text = {
let width = area.width.saturating_sub(2) as usize; wrap_input_with_prompt(self.input, width)
};
let border_color = if self.showing_command_hints {
self.theme.colors.warning.to_color()
} else if self.reasoning_active {
self.theme.colors.info.to_color() } else {
self.theme.colors.border.to_color() };
let block = if self.showing_command_hints {
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::new().fg(border_color))
.title(" Enter Command ")
} else {
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::new().fg(border_color))
};
let input = Paragraph::new(input_text).style(input_style).block(block);
input.render(area, buf);
}
}
fn find_line_break(remaining: &str, line_width: usize) -> usize {
if remaining.is_empty() {
return 0;
}
let mut acc_width = 0usize;
let mut hard_break = remaining.len();
for (byte_idx, ch) in remaining.char_indices() {
let ch_width = ch.width().unwrap_or(0);
if acc_width + ch_width > line_width {
hard_break = byte_idx;
break;
}
acc_width += ch_width;
}
if hard_break == remaining.len() {
return remaining.len();
}
if hard_break == 0 {
return remaining
.char_indices()
.nth(1)
.map(|(idx, _)| idx)
.unwrap_or(remaining.len());
}
remaining[..hard_break]
.rfind(char::is_whitespace)
.map(|pos| pos + 1)
.unwrap_or(hard_break)
}
struct RowSpan {
start: usize,
len: usize,
gap: usize,
}
fn layout_rows(input: &str, line_width: usize) -> Vec<RowSpan> {
let mut rows: Vec<RowSpan> = Vec::new();
if input.is_empty() {
return rows;
}
let total = input.len();
let mut seg_start = 0usize;
loop {
let seg_end = match input[seg_start..].find('\n') {
Some(rel) => seg_start + rel,
None => total,
};
let segment = &input[seg_start..seg_end];
let mut local = 0usize;
loop {
let rem = &segment[local..];
let bp = find_line_break(rem, line_width);
let after = &rem[bp..];
let ws_gap = after.len() - after.trim_start().len();
rows.push(RowSpan {
start: seg_start + local,
len: bp,
gap: ws_gap,
});
local += bp + ws_gap;
if local >= segment.len() {
break;
}
}
if seg_end >= total {
break;
}
if let Some(last) = rows.last_mut() {
last.gap += 1;
}
seg_start = seg_end + 1;
if seg_start == total {
rows.push(RowSpan {
start: seg_start,
len: 0,
gap: 0,
});
break;
}
}
rows
}
fn wrap_input_with_prompt(input: &str, width: usize) -> String {
if width < 3 {
return input.to_string();
}
if input.is_empty() {
return String::from("> ");
}
let line_width = width.saturating_sub(2);
let mut result = String::new();
for (idx, row) in layout_rows(input, line_width).iter().enumerate() {
let text = input[row.start..row.start + row.len].trim_end();
if idx == 0 {
result.push_str("> ");
} else {
result.push('\n');
result.push_str(" ");
}
result.push_str(text);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_and_wrap_agree_on_line_structure() {
let inputs = [
"hello world",
"the quick brown fox jumps over the lazy dog",
"nospacesinthislonginputthatmusthardbreak",
"mixed short and verylongcontiguoustoken here",
"leading double spaces between words",
"",
"你好世界",
"你好 world 世界",
"abc你好def世界ghi",
"first line\nsecond line",
"para one\n\npara two",
"trailing newline\n",
"\nleading newline",
];
let content_width = 20usize;
for input in inputs {
let wrapped = wrap_input_with_prompt(input, content_width);
let rendered_lines: Vec<String> = wrapped
.split('\n')
.enumerate()
.map(|(i, line)| {
let prefix = if i == 0 { "> " } else { " " };
line.strip_prefix(prefix).unwrap_or(line).to_string()
})
.collect();
for cursor_pos in 0..=input.len() {
if !input.is_char_boundary(cursor_pos) {
continue;
}
let (row, _col) =
InputState::calculate_cursor_position(input, cursor_pos, content_width);
assert!(
(row as usize) < rendered_lines.len().max(1),
"cursor row {} out of wrap range ({} lines) for input {:?} at byte {}",
row,
rendered_lines.len(),
input,
cursor_pos,
);
}
}
}
#[test]
fn find_line_break_whitespace_preferred() {
assert_eq!(find_line_break("hello world foo", 10), 6);
}
#[test]
fn find_line_break_hard_break_without_whitespace() {
assert_eq!(find_line_break("abcdefghijklmno", 5), 5);
}
#[test]
fn find_line_break_respects_char_boundary() {
let s = "你好";
assert_eq!(find_line_break(s, 4), 6);
}
#[test]
fn find_line_break_uses_display_width_for_cjk() {
let s = "你好世界abc";
assert_eq!(find_line_break(s, 10), 14);
}
#[test]
fn find_line_break_whole_remaining_fits() {
assert_eq!(find_line_break("short", 100), "short".len());
}
#[test]
fn find_line_break_makes_progress_when_first_char_overflows() {
assert_eq!(find_line_break("你hello", 1), 3);
}
#[test]
fn wrap_renders_embedded_newlines_as_rows() {
assert_eq!(wrap_input_with_prompt("a\nb", 20), "> a\n b");
assert_eq!(wrap_input_with_prompt("a\n\nb", 20), "> a\n \n b");
assert_eq!(wrap_input_with_prompt("a\n", 20), "> a\n ");
}
#[test]
fn cursor_tracks_rows_across_newlines() {
assert_eq!(InputState::calculate_cursor_position("a\nb", 0, 20), (0, 0));
assert_eq!(InputState::calculate_cursor_position("a\nb", 1, 20), (0, 1));
assert_eq!(InputState::calculate_cursor_position("a\nb", 2, 20), (1, 0));
assert_eq!(InputState::calculate_cursor_position("a\nb", 3, 20), (1, 1));
}
}