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 mut current_line: usize = 0;
let mut consumed: usize = 0;
let mut chars_remaining = input;
loop {
let break_point = find_line_break(chars_remaining, line_width);
let after = &chars_remaining[break_point..];
let next_content = after.trim_start();
let ws_gap = after.len() - next_content.len();
let is_last_line = next_content.is_empty();
if cursor_pos < consumed + break_point + ws_gap || is_last_line {
let cursor_byte_in_line = cursor_pos.saturating_sub(consumed).min(break_point);
let line_text = &chars_remaining[..break_point];
let col_cells = line_text[..cursor_byte_in_line.min(line_text.len())].width();
return (current_line as u16, col_cells as u16);
}
consumed += break_point + ws_gap;
chars_remaining = next_content;
current_line += 1;
}
}
}
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)
}
fn wrap_input_with_prompt(input: &str, width: usize) -> String {
if width < 3 {
return input.to_string();
}
let mut result = String::from("> ");
if input.is_empty() {
return result;
}
let line_width = width.saturating_sub(2);
let mut chars_remaining = input;
let mut is_first_line = true;
while !chars_remaining.is_empty() {
let break_point = find_line_break(chars_remaining, line_width);
let line_text = &chars_remaining[..break_point];
if is_first_line {
result.push_str(line_text.trim_end());
} else {
result.push('\n');
result.push_str(" ");
result.push_str(line_text.trim_end());
}
chars_remaining = chars_remaining[break_point..].trim_start();
is_first_line = false;
}
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",
];
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);
}
}